diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml index 0e6207340..092e27785 100644 --- a/.github/workflows/pre-merge.yml +++ b/.github/workflows/pre-merge.yml @@ -43,7 +43,7 @@ jobs: # - name: Install Rust toolchain # uses: dtolnay/rust-toolchain@stable # with: - # toolchain: nightly + # toolchain: nightly-2026-04-27 # components: rustfmt, clippy, rust-src # # - name: Install RISC-V target @@ -87,10 +87,11 @@ jobs: repository: light-player/lp-regalloc2 path: lp-regalloc2 + # Toolchain is pinned in lp2025/rust-toolchain.toml; keep this in sync. - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: nightly + toolchain: nightly-2026-04-27 components: rustfmt, clippy, rust-src - name: Install RISC-V target diff --git a/.idea/lp2025.iml b/.idea/lp2025.iml index 5de7212fb..27f10b7b8 100644 --- a/.idea/lp2025.iml +++ b/.idea/lp2025.iml @@ -96,10 +96,17 @@ + + + + + + + - + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 46922eb8b..8779970b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,31 @@ just build-rv32 # cargo build --target riscv32imac-... -p ... just build # parallel: host + rv32 ``` +### ESP32 linked-build pitfall + +For `fw-esp32`, **linked firmware builds, size measurements, and bloat +analysis must run from `lp-fw/fw-esp32/`** (or through a just recipe that +`cd`s there first, such as `just build-fw-esp32`). The crate-local +`.cargo/config.toml` and linker setup are part of the build. + +This is fine from the workspace root because it does not final-link: + +```bash +cargo check -p fw-esp32 --target riscv32imac-unknown-none-elf --profile release-esp32 --features esp32c6,server +``` + +For a real linked ELF or size numbers, do this instead: + +```bash +cd lp-fw/fw-esp32 +cargo build --target riscv32imac-unknown-none-elf --profile release-esp32 --features esp32c6,server +rust-size ../../target/riscv32imac-unknown-none-elf/release-esp32/fw-esp32 +``` + +Running `cargo build -p fw-esp32 ...` from the workspace root can fail at final +link with `memory region not defined: ROTEXT`, because it bypasses the +crate-local firmware build context. + For targeted host validation of specific crates: ```bash diff --git a/Cargo.lock b/Cargo.lock index 07c8ffe02..0fde21ea6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1070,7 +1070,7 @@ dependencies = [ "cranelift-isle 0.127.0", "hashbrown 0.15.5", "log", - "lp-collection", + "lp-collection 1.0.0 (git+https://github.com/light-player/lp-common?branch=main)", "regalloc2 0.13.6", "rustc-hash 2.1.1", "serde", @@ -2851,6 +2851,7 @@ dependencies = [ "lp-riscv-emu-guest", "lpa-client", "lpa-server", + "lpc-hardware", "lpc-model", "lpc-shared", "lpc-view", @@ -2886,9 +2887,11 @@ dependencies = [ "libm", "littlefs-rust", "log", + "lp-collection 1.0.0", "lp-perf", "lp-shader", "lpa-server", + "lpc-hardware", "lpc-model", "lpc-shared", "lpc-wire", @@ -3902,6 +3905,7 @@ dependencies = [ "futures-util", "fw-checks", "log", + "lp-collection 1.0.0", "lp-perf", "lp-riscv-elf", "lp-riscv-emu", @@ -3910,6 +3914,7 @@ dependencies = [ "lpa-client", "lpa-server", "lpc-engine", + "lpc-hardware", "lpc-model", "lpc-shared", "lpc-view", @@ -3933,6 +3938,15 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "lp-collection" +version = "1.0.0" +dependencies = [ + "rustc-hash 2.1.1", + "serde", + "serde_json", +] + [[package]] name = "lp-collection" version = "1.0.0" @@ -4023,6 +4037,7 @@ dependencies = [ name = "lp-shader" version = "40.0.0" dependencies = [ + "lp-collection 1.0.0", "lpir", "lps-frontend", "lps-glsl", @@ -4063,7 +4078,9 @@ dependencies = [ "log", "lp-perf", "lpc-engine", + "lpc-hardware", "lpc-model", + "lpc-registry", "lpc-shared", "lpc-view", "lpc-wire", @@ -4078,9 +4095,12 @@ dependencies = [ "hashbrown 0.15.5", "libm", "log", + "lp-collection 1.0.0", "lp-perf", "lp-shader", + "lpc-hardware", "lpc-model", + "lpc-registry", "lpc-shared", "lpc-view", "lpc-wire", @@ -4099,12 +4119,23 @@ dependencies = [ "unwinding", ] +[[package]] +name = "lpc-hardware" +version = "40.0.0" +dependencies = [ + "lp-collection 1.0.0", + "lpc-model", + "serde", + "toml", +] + [[package]] name = "lpc-model" version = "40.0.0" dependencies = [ "base64 0.22.1", "hashbrown 0.15.5", + "lp-collection 1.0.0", "lpc-slot-codegen", "lpc-slot-macros", "lpfs", @@ -4115,18 +4146,31 @@ dependencies = [ "toml", ] +[[package]] +name = "lpc-registry" +version = "40.0.0" +dependencies = [ + "lp-collection 1.0.0", + "lpc-model", + "lpc-wire", + "lpfs", + "serde", + "serde_json", +] + [[package]] name = "lpc-shared" version = "40.0.0" dependencies = [ "libm", "log", + "lp-collection 1.0.0", + "lpc-hardware", "lpc-model", "lpc-wire", "lpfs", "lps-shared", "serde", - "toml", ] [[package]] @@ -4146,17 +4190,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "lpc-slot-mockup" -version = "40.0.0" -dependencies = [ - "lpc-model", - "lpc-slot-codegen", - "lpc-view", - "lpc-wire", - "toml", -] - [[package]] name = "lpc-view" version = "40.0.0" @@ -4171,6 +4204,7 @@ name = "lpc-wire" version = "40.0.0" dependencies = [ "base64 0.22.1", + "lp-collection 1.0.0", "lpc-model", "schemars", "ser-write-json", @@ -4226,7 +4260,7 @@ name = "lpir" version = "40.0.0" dependencies = [ "libm", - "lp-collection", + "lp-collection 1.0.0", "lps-q32", ] @@ -4276,6 +4310,7 @@ dependencies = [ "anyhow", "glob", "glsl", + "lp-collection 1.0.0", "lp-riscv-elf", "lp-riscv-emu", "lp-riscv-inst", @@ -4321,6 +4356,7 @@ name = "lps-frontend" version = "40.0.0" dependencies = [ "libm", + "lp-collection 1.0.0", "lpir", "lps-builtin-ids", "lps-shared", @@ -4332,7 +4368,7 @@ dependencies = [ name = "lps-glsl" version = "40.0.0" dependencies = [ - "lp-collection", + "lp-collection 1.0.0", "lpir", "lps-shared", ] @@ -4348,6 +4384,7 @@ dependencies = [ name = "lps-shared" version = "40.0.0" dependencies = [ + "lp-collection 1.0.0", "lps-q32", "schemars", "serde", @@ -4358,6 +4395,7 @@ dependencies = [ name = "lpvm" version = "40.0.0" dependencies = [ + "lp-collection 1.0.0", "lpir", "lps-q32", "lps-shared", @@ -4374,6 +4412,7 @@ dependencies = [ "cranelift-native 0.127.0", "cranelift-object", "libm", + "lp-collection 1.0.0", "lp-riscv-elf", "lpir", "lps-builtin-ids", @@ -4407,6 +4446,7 @@ version = "40.0.0" dependencies = [ "cranelift-codegen 0.127.0", "log", + "lp-collection 1.0.0", "lp-perf", "lp-riscv-elf", "lp-riscv-emu", @@ -5671,7 +5711,7 @@ dependencies = [ "bumpalo", "hashbrown 0.15.5", "log", - "lp-collection", + "lp-collection 1.0.0 (git+https://github.com/light-player/lp-common?branch=main)", "rustc-hash 2.1.1", "smallvec", ] diff --git a/Cargo.toml b/Cargo.toml index 007a3c1d5..0320754ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,10 +2,12 @@ members = [ # lp-base workspace members "lp-base/lp-perf", + "lp-base/lp-collection", "lp-base/lpfs", # lp-app workspace members "lp-core/lpc-engine", "lp-core/lpc-view", + "lp-core/lpc-hardware", "lp-core/lpc-shared", "lp-app/lpa-server", "lp-app/lpa-client", @@ -18,7 +20,7 @@ members = [ "lp-core/lpc-model", "lp-core/lpc-slot-codegen", "lp-core/lpc-slot-macros", - "lp-core/lpc-slot-mockup", + "lp-core/lpc-registry", "lp-core/lpc-wire", # lps workspace members "lp-shader/lps-builtin-ids", @@ -62,6 +64,7 @@ default-members = [ # lp-app workspace members "lp-core/lpc-engine", "lp-core/lpc-view", + "lp-core/lpc-hardware", "lp-core/lpc-shared", "lp-app/lpa-server", "lp-app/lpa-client", @@ -70,6 +73,7 @@ default-members = [ "lp-fw/fw-tests", "lp-cli", "lp-core/lpc-model", + "lp-core/lpc-registry", "lp-core/lpc-wire", # lps workspace members (excluding lps-builtins-emu-app) "lp-shader/lps-builtin-ids", @@ -143,7 +147,7 @@ cranelift-interpreter = { git = "https://github.com/light-player/lp-cranelift", # regalloc2 - fork (ChunkedVec for OOM mitigation, feature-gating ION allocator) regalloc2 = { git = "https://github.com/light-player/lp-regalloc2", branch = "lightplayer", default-features = false, features = ["std"] } -lp-collection = { git = "https://github.com/light-player/lp-common", branch = "main", default-features = false } +lp-collection = { path = "lp-base/lp-collection", default-features = false } # Common dependencies diff --git a/docs/adr/2026-06-10-project-edit-vocabulary.md b/docs/adr/2026-06-10-project-edit-vocabulary.md new file mode 100644 index 000000000..034f75ee8 --- /dev/null +++ b/docs/adr/2026-06-10-project-edit-vocabulary.md @@ -0,0 +1,73 @@ +# ADR 2026-06-10: Canonical Project Overlay Vocabulary + +## Status + +Accepted + +## Context + +The registry branch needs a future UI and engine cutover path that edits +authored project artifacts, not only immediate engine memory. At the time this +ADR was written, the temporary `WireSlotMutation*` API was intentionally +narrow: it set value leaves on runtime slot roots like `node..def`, applied +immediately, and had no overlay or commit concept. That temporary API was +removed during the M4 wire/server cutover. + +The first registry wire POC proved useful behavior, but it duplicated the same +ideas across layers with command-shaped types such as `ArtifactEdit`, +`ArtifactEditOp`, and `ProjectEditBatch`, while the registry still had its own +`ArtifactEdits` / `AssetEdit` overlay model. + +We want the model layer to own the durable vocabulary, the wire layer to wrap it +in message envelopes, and the registry to apply it. + +## Decision + +`lpc-model::edit` owns the canonical project overlay model: + +- `ProjectOverlay`: the full current pending edit set for a project. +- `ArtifactOverlay`: either a structured `SlotOverlay` or one + `ArtifactBodyEdit`. +- `SlotOverlay`: canonical map from `SlotPath` to `SlotEditOp`. +- `SlotEdit`: path-bearing slot edit. +- `SlotEditOp`: path-free operation, one of `EnsurePresent`, + `AssignValue(LpValue)`, or `Remove`. +- `ArtifactBodyEdit`: byte-level artifact body `ReplaceBody(Vec)` or + `Delete`. +- `OverlayMutation`: ordered edits to the overlay itself. + +The overlay represents current pending intent, not edit history. Multiple +mutations targeting the same path are coalesced into one overlay entry. +Ancestor/descendant conflicts are normalized in model code. Artifact body edits +and slot overlays are mutually exclusive for a given artifact. + +`lpc-wire` defines thin wire envelopes around model types: + +- read overlay request/response returns a full `ProjectOverlay`; +- mutate overlay request/response applies an ordered `OverlayMutationBatch`; +- commit overlay request/response returns a portable `ProjectCommitSummary`. + +`lpc-registry` stores and applies `ProjectOverlay`. It owns path validation, +slot application, effective inventory derivation, filesystem writes/deletes, and +commit. It does not define a second overlay model and does not depend on +`lpc-wire` in library code. + +The legacy `WireSlotMutation*` path was allowed during the POC, but M4 removed +it in favor of overlay project commands. + +## Consequences + +The future UI can read and mirror one canonical pending overlay instead of +reconstructing pending state from command history. + +Wire schemas stay message-shaped and avoid copying model concepts. + +Registry tests can exercise wire-shaped behavior without coupling registry +library code to the protocol crate. + +Overlay application order is deterministic and derived from the canonical +`SlotOverlay` map; user mutation order affects coalescing, not the persisted +overlay representation. + +Revisioning, idempotency, conflict semantics, and backward compatibility remain +future wire/API work. diff --git a/docs/adr/2026-06-11-asset-source-model.md b/docs/adr/2026-06-11-asset-source-model.md new file mode 100644 index 000000000..a49c74a98 --- /dev/null +++ b/docs/adr/2026-06-11-asset-source-model.md @@ -0,0 +1,65 @@ +# ADR 2026-06-11: Asset Location Model + +## Status + +Accepted + +## Context + +`ProjectRegistry` derives a project view by walking the loaded project graph. +That graph includes node definitions and assets. Early registry code treated +every asset as a non-definition file identified by `ArtifactLocation`, which was +enough for shader source files but too narrow for the model we need. + +Source files are assets, but not all assets are source files. Future image +assets need the same reference/discovery/materialization path. Source can also +be inline today, and other asset kinds may gain inline bodies later. + +## Decision + +Make assets a first-class `lpc-model::asset` concept. + +`ArtifactLocation` remains durable file identity. It answers "which file-like +artifact is this?" + +`AssetLocation` is project asset identity. It answers "where does this referenced +project asset come from?" Initial variants are: + +- artifact-backed assets, identified by `ArtifactLocation`; +- inline assets, identified by owner `NodeDefLocation` plus `SlotPath`; + +URL-backed assets should be modeled as future `ArtifactLocation` variants, not +as a separate asset location kind. + +`AssetContentType` is the specialization point for how callers should interpret or +materialize bytes/text. Initial kinds include shader source, compute shader +source, fixture SVG, image, text, and binary. + +`ProjectInventory.assets` is keyed by `AssetLocation`, and `AssetEntry` carries +the `AssetContentType`, state, and revision. Inline assets are inventory entries, but +they are not registered in `ArtifactStore`. Artifact-backed assets continue to +use `ArtifactStore` for durable location tracking, filesystem reads, overlay +body replacement, and filesystem change revisions. + +Source-file APIs may remain named `source` where they specifically deal with +authored source text. They sit under the asset model: source materialization is +a text-specific wrapper over effective asset bytes plus inline slot text. + +Normal registry operation does not scan or snapshot every file. Assets and +definitions are discovered by walking static authored references in the current +effective project graph. + +## Consequences + +The project view can represent file-backed shader sources, inline shader +sources, fixture SVGs, and future image assets with one inventory shape. + +Engine/runtime consumers can key loaded assets by `AssetLocation` instead of +assuming every runtime asset maps directly to a file. + +The model still assumes statically discoverable references. Dynamic asset or +node-definition references will need a later design. + +`AssetContentType::Image` exists as vocabulary before image loading is +implemented, so image support can reuse the same identity and inventory path +when it arrives. diff --git a/docs/adr/2026-06-11-project-registry-effective-inventory.md b/docs/adr/2026-06-11-project-registry-effective-inventory.md new file mode 100644 index 000000000..9eb99e539 --- /dev/null +++ b/docs/adr/2026-06-11-project-registry-effective-inventory.md @@ -0,0 +1,94 @@ +# ADR 2026-06-11: Project Registry Effective Inventory + +## Status + +Accepted + +## Context + +The incremental artifact reload branch started with a node-definition registry +that mixed artifact tracking, overlay application, effective reads, sync +vocabulary, commit promotion, and node-specific inventory logic. That shape was +hard to explain and was too node-only for the UI and engine cutover we need. + +The project editor needs three distinct views: + +- files: durable artifacts on disk; +- project: referenced node definitions and referenced assets, including error + entries; +- runtime: instantiated engine nodes and loaded runtime assets. + +The registry is responsible for the project view. A project artifact can produce +either node definitions or assets, and both are discovered by walking the loaded +project graph. + +## Decision + +Replace the old node-only registry concept with `lpc-registry::ProjectRegistry`. +The registry owns: + +- an `ArtifactStore` for known durable artifact locations and read freshness; +- `WithRevision` for pending edit intent; +- one effective `ProjectInventory`; +- the root `NodeDefLocation`. + +The effective inventory is the registry truth: + +```text +artifacts + overlay -> ProjectInventory { defs, assets } +``` + +`ProjectInventory` lives in `lpc-model` because clients need to inspect current +project state. It contains: + +- `NodeDefEntry` keyed by `NodeDefLocation`; +- `AssetEntry` keyed by `AssetLocation`, where file-backed sources wrap an + `ArtifactLocation` and inline sources are identified by owner `NodeDefLocation` + plus `SlotPath`. +- loaded and error states for both definitions and assets. + +The registry does not maintain a semantic `base_defs` graph. On load, overlay +apply, filesystem refresh, discard, and commit, the registry recomputes the +effective inventory and compares old inventory to new inventory. + +Runtime-facing changes are represented as `ProjectChangeSet`, not as a diff. +`ProjectChangeSet` is identifier-oriented and coarse: + +- node defs: added, removed, changed with `Body`, `KindChanged`, + `EnteredError`, or `LeftError`; +- assets: added, removed, changed with `Body`, `EnteredError`, or `LeftError`. + +Callers use the change set to decide what to refresh, then fetch current entries +from the registry. A snapshot-to-overlay helper may still exist for tests and +bootstrap workflows, but it is an operation that derives edit intent between two +file snapshots. It is not the runtime change vocabulary. + +`NodeDef::invocation_sites` and `NodeDef::referenced_assets` are the model +APIs for graph walking. The registry does not keep node-kind lists for project +topology. This pass assumes static authored references: no dynamic node-def refs +or dynamic asset refs are discovered at runtime. + +Commit persists the already-effective overlay state to durable artifacts and +clears the overlay. A successful commit should normally return no +runtime-facing `ProjectChangeSet`; runtime consumers already reacted when the +overlay became effective. + +## Consequences + +The registry API is easier to reason about: load artifacts, apply overlay +mutations, refresh durable artifact changes, commit overlay, and observe +project changes. + +Assets are first-class project inventory entries instead of being incidental +source reads behind node definitions. + +Missing referenced defs/assets remain visible as project inventory errors, which +lets the future UI render a complete project view instead of losing broken +edges. + +Full recompute is the first implementation. Incremental dirty tracking can be +added later behind the same API. + +The crate is now `lpc-registry`. Historical plans and reviews may still mention +`lpc-node-registry`, `NodeDefRegistry`, `SyncOp`, or `NodeDefView`; live source +should use the new vocabulary. diff --git a/docs/adr/2026-06-12-asset-slot-runtime-refresh.md b/docs/adr/2026-06-12-asset-slot-runtime-refresh.md new file mode 100644 index 000000000..5f7406e59 --- /dev/null +++ b/docs/adr/2026-06-12-asset-slot-runtime-refresh.md @@ -0,0 +1,67 @@ +# ADR 2026-06-12: AssetSlot Runtime Refresh + +## Status + +Accepted + +## Context + +The project registry can discover shader source files as effective assets, but +the runtime previously loaded shader source as plain strings. After a +filesystem refresh reported that `/shader.glsl` changed, the engine knew an +asset changed but the running shader node had no retained asset identity or +revision to decide whether its cached shader source should be invalidated. + +The older authored model also had source-specific slot types such as +`SourcePathSlot`, `SourceFileSlot`, and `ShaderSource`. That made source files +special even though the emerging project model treats shader source, fixture +SVGs, future images, and inline bodies as assets. + +## Decision + +Use `AssetSlot` as the authored slot shape for file-or-inline assets. + +`AssetSlotValue` is authored storage: + +- `Artifact(ArtifactSpec)` references an artifact relative to the containing + definition; +- `InlineText { extension, text }` stores UTF-8 text with an optional language + or extension hint; +- `InlineBytes { extension, bytes }` stores raw bytes with an optional extension + hint. + +`AssetLocation` remains effective project identity after registry discovery. +Artifact-backed slots resolve to `AssetLocation::Artifact`; inline asset slots +resolve to `AssetLocation::Inline { owner, path }`. + +`AssetContentType` remains a coarse bridge for current specialized consumers. It +is not the final MIME or requirements model. + +Runtime nodes do not get reattached for same-location asset body changes. +`Engine::apply_project_changes` routes changed `AssetLocation`s through +`ProjectRuntimeIndex` to affected runtime nodes. Nodes may implement +`NodeRuntime::refresh_asset` to compare the effective asset revision they last +consumed with the registry's current asset revision and invalidate only their +own cached state. + +Shader and compute shader nodes retain their source asset location and revision. +When that asset changes, they read the effective text through +`ProjectRegistry::read_asset_text_if_changed`, replace cached source text, clear +compile errors, and drop compiled shader state so the next render/produce path +compiles from the new source. + +## Consequences + +Filesystem edits to shader source files update existing runtime shader nodes +without full project reload or runtime node reattach. + +Source files are now assets in the model. Source-specific names remain +appropriate only for actual source text handling, not for asset identity or +authored asset references. + +The runtime refresh hook is generic enough for future image or binary asset +consumers without adding image loading in this milestone. + +The implementation still assumes statically discoverable asset references. +Generic slot requirement metadata, a generic reference walker, richer MIME or +format handling, and image asset loading remain future work. diff --git a/docs/adr/2026-06-12-effective-asset-materialization.md b/docs/adr/2026-06-12-effective-asset-materialization.md new file mode 100644 index 000000000..cb98fe11a --- /dev/null +++ b/docs/adr/2026-06-12-effective-asset-materialization.md @@ -0,0 +1,68 @@ +# ADR 2026-06-12: Effective Asset Materialization + +## Status + +Accepted + +## Context + +Assets are now first-class project inventory entries. Shader source files, +compute shader source files, fixture SVG mappings, future images, and inline +source text all use the `AssetLocation` and `AssetContentType` model vocabulary. + +Current engine loading still materializes shader source and fixture SVG files by +reading paths directly from the filesystem. That duplicates registry knowledge +about overlay replacement, overlay deletion, inline assets, committed artifact +revisions, and error states. + +The engine cutover should not recreate asset precedence rules for every runtime +node kind. + +## Decision + +`ProjectRegistry` owns effective asset materialization. + +The registry should provide engine-facing APIs that materialize current +effective asset bytes and text from an `AssetLocation`, such as: + +```rust +ProjectRegistry::materialize_asset(...) +ProjectRegistry::materialize_asset_text(...) +``` + +Those APIs should honor the same effective project state as the inventory: + +- artifact-backed asset plus overlay replacement returns overlay bytes at the + overlay revision; +- artifact-backed asset plus overlay delete returns a deleted error; +- artifact-backed asset without overlay reads bytes transiently through the + registry `ArtifactStore` and reports the artifact revision; +- inline source assets read from the effective owner definition and report the + owner definition revision; +- unknown or unreferenced assets return a clear error unless a later API + intentionally permits ad hoc reads. + +Source-file helpers may remain named `source` when they specifically deal with +authored source text and string diagnostics, but they sit under the broader +asset model. + +The public registry materialization boundary is generic over effective assets; +source-specific helpers are allowed only where the caller truly needs UTF-8 +source text or source-specific diagnostics. + +## Consequences + +Runtime node attachment uses one registry-owned materialization path for shader +source, compute shader source, fixture SVG mappings, and future asset kinds. + +Overlay replacement and deletion affect asset consumers consistently before +commit. + +Asset revisions come from the registry's effective state instead of a separate +engine artifact cache. + +The future UI can reason about files, project inventory, and runtime consumers +using the same asset identities. + +Authored `AssetSlot` discovery can evolve internally while preserving the +registry materialization API consumed by the engine. diff --git a/docs/adr/2026-06-12-effective-project-graph.md b/docs/adr/2026-06-12-effective-project-graph.md new file mode 100644 index 000000000..89fed0d54 --- /dev/null +++ b/docs/adr/2026-06-12-effective-project-graph.md @@ -0,0 +1,93 @@ +# ADR 2026-06-12: Effective Project Graph + +## Status + +Accepted + +## Context + +`ProjectRegistry` currently derives an effective `ProjectInventory` containing +node definitions keyed by `NodeDefLocation` and assets keyed by `AssetLocation`. +That flat inventory is enough to answer "which definitions and assets are +currently referenced?", but it is not enough to build or update the engine +runtime tree. + +The engine needs node instances and parent-child edges. A single external node +definition can be referenced more than once: + +```toml +[nodes.left] +ref = "./shader.toml" + +[nodes.right] +ref = "./shader.toml" +``` + +That project has one definition identity for `/shader.toml`, but it needs two +runtime node instances. + +Inline node definitions add another wrinkle: they are definitions located +inside an owning artifact at a slot path, but they are still instantiated as +project graph nodes. + +## Decision + +Add an effective project graph/topology model to the registry output. + +The graph models project node instances separately from node definition +identity: + +- `NodeDefLocation` remains definition identity. +- A new project node instance identity, such as `ProjectNodeKey`, identifies one + authored node instance in the effective project graph. +- Runtime `NodeId` remains engine-local runtime identity and is not used as + project graph identity. + +`ProjectInventory` may own the graph directly or expose it through an adjacent +registry-owned type, but the registry must provide this information as part of +its effective project state. + +The graph needs enough data for engine projection: + +- root project node instance; +- all reachable project node instances; +- parent-child relationships; +- child name; +- child invocation slot path; +- authored `NodeInvocation`; +- resolved `NodeDefLocation`; +- role or ownership metadata, such as root, project child, and playlist entry; +- indexes from `NodeDefLocation` to project node instances; +- indexes from `AssetLocation` to project node instances that consume the asset. + +`ProjectNodeKey` should be deterministic, serializable, stable across refreshes +when authored topology does not change, distinct from `NodeDefLocation`, and +independent of runtime `NodeId`. An ancestry-based project node path is the +preferred first implementation. + +The first implementation may continue using current model discovery APIs: + +- `NodeDef::invocation_sites`; +- `NodeDef::referenced_assets`. + +The engine must consume the registry graph and must not reimplement discovery by +matching directly on `ProjectDef`, `PlaylistDef`, shader source, or fixture +mapping internals. + +## Consequences + +The engine can build a runtime `NodeTree` from registry state without keeping +its own project walker. + +Duplicate external references produce multiple project graph nodes and later +multiple runtime nodes while sharing one `NodeDefEntry`. + +Missing or errored referenced definitions remain visible as graph nodes that +point at error `NodeDefEntry`s. Their children and assets cannot be discovered +until the definition loads successfully. + +Future generic node-reference metadata can feed the same graph API. The cutover +does not need to wait for a fully generic node slot model. + +Project graph changes can later drive incremental runtime apply. The first +runtime update strategy may still be conservative subtree rebuilds. diff --git a/docs/adr/2026-06-12-incremental-runtime-apply.md b/docs/adr/2026-06-12-incremental-runtime-apply.md new file mode 100644 index 000000000..f059335ba --- /dev/null +++ b/docs/adr/2026-06-12-incremental-runtime-apply.md @@ -0,0 +1,91 @@ +# ADR 2026-06-12: Incremental Runtime Apply + +## Status + +Accepted + +## Context + +M4 moved project edits onto `ProjectRegistry` overlays, but the server bridge +still rebuilt the whole `Engine` after accepted overlay mutations and +filesystem refreshes. That made the new registry API usable, but it kept the +old runtime lifecycle behavior: any project change dropped and recreated the +runtime projection. + +That is too coarse for the engine cutover. Runtime node identity, runtime +buffers, output sinks, resolver state, and compiled shader state are expensive +on ESP32. Plain authored value changes should not churn runtime nodes. The +registry already computes effective project changes, so the engine can consume +those summaries directly. + +Commit is a separate concern. Overlay mutation makes the effective project live; +commit only persists that already-effective state into durable artifacts. + +## Decision + +`ProjectRegistry` remains the project truth. It owns artifacts, the overlay, and +the effective `ProjectInventory`. + +`Engine` owns the runtime projection. It applies `ProjectChangeSummary` in +place through `Engine::apply_project_changes`. + +`ProjectChangeSummary` includes: + +- node-definition changes; +- asset changes; +- node-use changes through `NodeUseChangeSummary`. + +Incremental runtime apply treats node-use changes and node-definition +kind/error transitions as lifecycle/topology signals: + +- removed node uses remove runtime subtrees; +- added node uses create missing runtime spine nodes and attach runtime + payloads; +- changed node uses are conservatively reprojection candidates; +- definition kind changes and loaded/error transitions reproject affected uses. + +Same-kind `NodeDef` body changes are not lifecycle signals. Asset body changes +are not generic engine lifecycle signals. Runtime nodes are responsible for +observing every value they consume through resolver/revision-aware APIs, as +documented in +`docs/adr/2026-06-12-node-runtime-slot-value-contract.md`. + +Overlay commit does not apply runtime changes and does not rebuild the engine. +After a successful commit, the server advances the project filesystem version +past commit-origin writes so the next filesystem refresh does not reprocess the +same self-originated changes. + +Full project reload remains available as an explicit server-level recovery or +manual operation. It is not the normal edit, refresh, or commit hot path. + +## Consequences + +The server project wrapper owns both registry and engine and orchestrates: + +```text +overlay mutation -> registry change summary -> engine incremental apply +filesystem refresh -> registry change summary -> engine incremental apply +overlay commit -> artifact writes only +manual reload -> rebuild registry and engine from durable artifacts +``` + +Runtime node ids survive ordinary authored body/value edits. Structural edits +can still remove and reproject affected subtrees. + +Engine removal is now responsible for lifecycle cleanup before tree tombstones: + +- `NodeRuntime::destroy`; +- demand roots; +- output sinks; +- node-owned runtime buffers; +- project-runtime indexes; +- binding indexes. + +The first incremental apply implementation remains conservative. It may rebuild +affected structural subtrees, especially around playlist topology, but it does +not rebuild unrelated runtime nodes or use `NodeDefChangeKind::Body` as a broad +rebuild trigger. + +Future work can add finer node-specific reload hooks or structural metadata, but +those should refine this boundary rather than restore full-engine rebuilds to +the edit hot path. diff --git a/docs/adr/2026-06-12-node-runtime-slot-value-contract.md b/docs/adr/2026-06-12-node-runtime-slot-value-contract.md new file mode 100644 index 000000000..94772b77a --- /dev/null +++ b/docs/adr/2026-06-12-node-runtime-slot-value-contract.md @@ -0,0 +1,95 @@ +# ADR 2026-06-12: Node Runtime Slot Value Contract + +## Status + +Accepted + +## Context + +Project registry change summaries can report that a node definition body +changed, but that fact is too coarse to decide whether an engine runtime node +must be rebuilt. + +A `NodeDef` is authored state. A runtime node consumes effective values through +the slot resolver. Those effective values may come from authored defaults, +overlay edits, bindings, upstream produced values, or other resolver behavior. +Therefore an authored body change may not change the effective value observed +by a runtime node at all. + +For example, a slot value in a node definition can change while the runtime node +continues to read a bound value. Rebuilding the runtime node just because the +definition body changed would waste CPU, churn runtime resources, and create +unnecessary work on embedded targets. + +Some values feel structural or construction-oriented today, such as shader +source, fixture source/mapping assets, parameter definitions, or buffer layout +inputs. The current model does not yet provide a general, explicit distinction +between ordinary values and these structural inputs. + +## Decision + +Runtime nodes are expected to fully support changes to every value they +consume. + +The slot resolver is the source of truth for runtime node inputs. Runtime nodes +must read consumed values through resolver-backed APIs and use revisions or +equivalent change detection to refresh any cached internal state that depends on +those values. + +A same-kind `NodeDef` body change is not a runtime lifecycle event by default. +The engine must not destroy, recreate, or reattach runtime nodes solely because +the authored body changed. + +The engine remains responsible for lifecycle and topology changes: + +- node uses are added or removed from the effective project tree; +- a node use changes to a different definition or placement; +- a definition changes kind; +- a definition enters or leaves a load error state such that a runtime node can + no longer be constructed or can now be constructed. + +Runtime node implementations are responsible for their own consumed-value +semantics: + +- ordinary scalar, enum, record, and collection values; +- values overridden through bindings; +- source text and source-like asset contents; +- fixture mappings or other loaded asset-derived inputs; +- node-specific structural details until the model grows explicit metadata for + them. + +If a node needs to recompile, rebuild a mapping, invalidate a cache, resize a +buffer, or enter a failed state because a consumed value or asset changed, that +logic belongs in the node implementation or in node-specific helper code. It +should not be implemented as a generic engine reaction to `NodeDef` body +changes. + +Future work may add explicit model metadata, structural notifications, or +node-specific reload hooks. Those mechanisms should refine this contract rather +than replace it: runtime nodes still observe effective values, and the engine +still handles topology and lifecycle. + +## Consequences + +Incremental runtime apply can be conservative without being wasteful. It can +apply node-use additions/removals and kind/error transitions while leaving +same-kind body changes to runtime node value observation. + +Tests for project editing should distinguish lifecycle from value observation: + +- a simple authored value edit should not destroy or recreate the runtime node; +- the runtime node should still observe the changed effective value when that + value is not masked by a binding; +- a binding that masks the authored value means the runtime node should keep + seeing the bound value; +- shader source, fixture mapping, and similar asset-backed inputs should be + tested through the consuming node's refresh behavior, not by asserting a + generic engine reattach. + +When a node fails to observe a consumed value change correctly, that is a bug in +the node or resolver path. It should be fixed with a focused test for that node +instead of broadening engine-level rebuild policy. + +This contract is especially important for ESP32. Avoiding unnecessary runtime +node churn preserves CPU, memory, buffers, output sinks, and compiled shader +state on the embedded target. diff --git a/docs/adr/2026-06-12-project-registry-engine-ownership.md b/docs/adr/2026-06-12-project-registry-engine-ownership.md new file mode 100644 index 000000000..ce9a4dc71 --- /dev/null +++ b/docs/adr/2026-06-12-project-registry-engine-ownership.md @@ -0,0 +1,114 @@ +# ADR 2026-06-12: Project Registry And Engine Ownership + +## Status + +Accepted + +## Context + +The incremental artifact reload branch introduced `lpc-registry` as a separate +crate while the registry work was still a spike. The next phase cuts +`lpc-engine` over to that project model. + +There are three distinct domains: + +- files: durable project artifacts and filesystem freshness; +- project: effective node definitions, assets, overlay state, and project + change sets; +- runtime: instantiated engine nodes, buffers, bindings, services, and ticking. + +Current engine loading still owns a separate artifact cache under +`lpc-engine/src/artifact`. That cache mixes resolved artifact locations, +opaque runtime handles, loaded `NodeDef` payloads, content revision, and +refcount-like lifecycle behavior. The new registry already owns project artifact +freshness and effective node-definition state, so keeping a second engine +artifact truth would make the cutover harder to reason about. + +We also considered whether `ProjectOverlay` and `ProjectInventory` should be +owned directly by `Engine` instead of `ProjectRegistry`, and whether +`ProjectRegistry` should be a field on `Engine` or on the server-side project +container. + +## Decision + +Keep `lpc-registry` as a separate project-state crate. + +`ProjectRegistry` owns: + +- the registry `ArtifactStore` for known durable artifact locations and read + freshness; +- `WithRevision` for pending edit intent; +- the effective `ProjectInventory`; +- the root `NodeDefLocation`. + +`Engine` does not own `ProjectRegistry`. The server-side project container owns +both: + +- `ProjectRegistry`, the canonical project state; +- `Engine`, the current runtime projection of that project state. + +`Engine` owns runtime projection state: + +- `NodeTree>`; +- runtime buffers and resources; +- resolver and dataflow state; +- services, graphics, and hardware-facing runtime dependencies; +- projection indexes from project graph node instances to runtime `NodeId`s. + +`NodeTree` remains a runtime tree. It must not become the project discovery +model. + +The old engine artifact cache should be removed or fully superseded by registry +and model identities. Runtime tree entries should use project/model identities +such as `NodeDefLocation` and project node instance keys instead of an engine +local `ArtifactId`. + +Project loading returns both sides together through a lightweight +`LoadedProjectRuntime` wrapper for direct loader callers and tests. Long-lived +server ownership still lives in the server project container. + +The server project container orchestrates registry operations: + +```text +load project -> registry load -> runtime projection build +apply overlay mutation -> registry change summary -> rebuild runtime projection +commit overlay -> write artifacts -> rebuild runtime projection +refresh filesystem events -> registry change summary -> rebuild runtime projection +``` + +M4 deliberately uses a full runtime rebuild after accepted overlay edits or +filesystem refreshes. Incremental runtime updates can later consume registry +change summaries without changing ownership. + +This M4 bridge was superseded by +`docs/adr/2026-06-12-incremental-runtime-apply.md`: overlay mutation and +filesystem refresh now apply `ProjectChangeSummary` incrementally, while commit +is persistence-only and full reload is manual/recovery. + +Project reads are runtime queries against `Engine` plus `ProjectRegistry`. +Overlay reads, overlay mutations, overlay commits, and inventory reads use a +separate project command API instead of being embedded in project-read +requests. + +## Consequences + +Server and UI code can inspect and edit project state through `lpc-registry` +without depending on engine runtime internals. + +Registry APIs remain responsible for keeping overlay, artifact freshness, +effective inventory, and project change sets consistent. + +Engine work becomes a projection problem: turn current registry state and +registry changes into runtime tree, bindings, resources, and node lifecycles. + +Removing engine-local `ArtifactId` avoids two parallel identities for the same +project definition. If a temporary compatibility handle is needed during the +cutover, it should be documented as transitional and removed before the cleanup +milestone completes. + +The crate boundary adds one dependency from `lpc-engine` to `lpc-registry`, but +keeps project/editor state out of concrete runtime node code. + +Removing project mutations from project reads gives the UI an explicit +read/write split: `ProjectReadRequest` observes runtime state, while +`WireProjectCommand` carries overlay and inventory operations. diff --git a/docs/architecture.md b/docs/architecture.md index 9c9712c01..1de9d8bb3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,7 +69,7 @@ it is not currently packaged as a user-facing deployable CLI: - **Project Management** - Creates, initializes, and manages LightPlayer projects - **Debug UI** - Visual interface for inspecting node states, outputs, and project structure - **Hardware Manifests** - Interactive CRUD and validation for board profiles under - `lp-core/lpc-shared/boards` + `lp-core/lpc-hardware/boards` - **Hardware Calibration** - Host-driven GPIO square-wave calibration that maps board-visible silkscreen labels to internal HAL GPIO addresses diff --git a/docs/plans-old/2026-05-15-remove-serde-from-lpc-model/00-notes.md b/docs/plans-old/2026-05-15-remove-serde-from-lpc-model/00-notes.md index 3b3e5199e..c1df0874d 100644 --- a/docs/plans-old/2026-05-15-remove-serde-from-lpc-model/00-notes.md +++ b/docs/plans-old/2026-05-15-remove-serde-from-lpc-model/00-notes.md @@ -99,7 +99,7 @@ their current serde-facing tests are obsolete. Many small types still implement or derive serde as strings/compact structs: -- `artifact/artifact_loc.rs` +- `artifact/artifact_specifier.rs` - `binding/bus_slot_ref.rs` - `binding/node_slot_ref.rs` - `binding/binding_endpoint.rs` diff --git a/docs/plans-old/2026-05-19-versioned-source-artifacts/00-design.md b/docs/plans-old/2026-05-19-versioned-source-artifacts/00-design.md index 57de032fd..58bc542f9 100644 --- a/docs/plans-old/2026-05-19-versioned-source-artifacts/00-design.md +++ b/docs/plans-old/2026-05-19-versioned-source-artifacts/00-design.md @@ -25,7 +25,7 @@ The plan introduces: ```text lp-core/lpc-model/src/ artifact/ - artifact_loc.rs + artifact_specifier.rs artifact_read_root.rs node/ node_invocation.rs @@ -107,7 +107,7 @@ pub struct NodeInvocation { } pub enum NodeDefRef { - Path { path: ArtifactLocator }, + Path { path: ArtifactSpecifier }, Inline(Box), } ``` @@ -155,13 +155,13 @@ Model direction: #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Slotted)] #[slot(enum_encoding = "external", rename_all = "snake_case")] pub enum ShaderSource { - Path(ArtifactLocator), + Path(ArtifactSpecifier), Glsl(String), } ``` -If `ArtifactLocator` is awkward as a direct slot value, use a new -`SourcePath`/`ShaderSourcePath` wrapper that parses into `ArtifactLocator`. +If `ArtifactSpecifier` is awkward as a direct slot value, use a new +`SourcePath`/`ShaderSourcePath` wrapper that parses into `ArtifactSpecifier`. Do not expose `artifact` as the canonical authored field. Breaking migration: diff --git a/docs/plans-old/2026-05-19-versioned-source-artifacts/01-authored-model-shapes.md b/docs/plans-old/2026-05-19-versioned-source-artifacts/01-authored-model-shapes.md index e517533db..e0378adc0 100644 --- a/docs/plans-old/2026-05-19-versioned-source-artifacts/01-authored-model-shapes.md +++ b/docs/plans-old/2026-05-19-versioned-source-artifacts/01-authored-model-shapes.md @@ -76,8 +76,8 @@ pub enum ShaderSource { } ``` -Use `SourcePath` if direct `ArtifactLocator` slot support is not clean. The -engine can parse `SourcePath` into `ArtifactLocator` later. +Use `SourcePath` if direct `ArtifactSpecifier` slot support is not clean. The +engine can parse `SourcePath` into `ArtifactSpecifier` later. Update exports in: @@ -172,7 +172,7 @@ pub struct NodeInvocation { } pub enum NodeDefRef { - Path { path: ArtifactLocator }, + Path { path: ArtifactSpecifier }, Inline(Box), } ``` diff --git a/docs/plans-old/2026-05-19-versioned-source-artifacts/03-project-loader-one-file-projects.md b/docs/plans-old/2026-05-19-versioned-source-artifacts/03-project-loader-one-file-projects.md index 112fd4aa1..e7e72a7d1 100644 --- a/docs/plans-old/2026-05-19-versioned-source-artifacts/03-project-loader-one-file-projects.md +++ b/docs/plans-old/2026-05-19-versioned-source-artifacts/03-project-loader-one-file-projects.md @@ -52,7 +52,7 @@ File: Current logic: - Iterates `project_def.nodes`. -- Extracts `invocation.artifact_locator()`. +- Extracts `invocation.artifact_specifier()`. - Resolves a child artifact path. - Reads a child TOML file. - Loads `NodeDef` payload into `ArtifactStore`. diff --git a/docs/plans-old/2026-05-19-versioned-source-artifacts/future.md b/docs/plans-old/2026-05-19-versioned-source-artifacts/future.md index a5aacab67..10e463fa0 100644 --- a/docs/plans-old/2026-05-19-versioned-source-artifacts/future.md +++ b/docs/plans-old/2026-05-19-versioned-source-artifacts/future.md @@ -39,4 +39,4 @@ - **Idea:** Resolve `lib:package/item` locators for node defs and shader source. - **Why not now:** Path and inline sources must be made clean first. - **Useful context:** Authored fields should stay named `path` where they mean - relative locator, while `ArtifactLocator` remains capable of parsing `lib:`. + relative locator, while `ArtifactSpecifier` remains capable of parsing `lib:`. diff --git a/docs/plans/2026-05-05-project-artifact-initial-load/00-design.md b/docs/plans/2026-05-05-project-artifact-initial-load/00-design.md index b660d7c25..d632f5753 100644 --- a/docs/plans/2026-05-05-project-artifact-initial-load/00-design.md +++ b/docs/plans/2026-05-05-project-artifact-initial-load/00-design.md @@ -14,14 +14,14 @@ with an artifact-rooted load path: /project.toml -> ProjectDef -> ProjectNode root -> declared node artifacts ``` -The core runtime should start from a project artifact locator, load that +The core runtime should start from a project artifact specifier, load that artifact as the root node definition, then instantiate the root `ProjectNode` and its declared node artifacts. Directory discovery and special directory suffix semantics are removed from the core initial-load path. In scope: -- Stabilize the new source terminology and rustdocs around `ArtifactLocator`, +- Stabilize the new source terminology and rustdocs around `ArtifactSpecifier`, `NodeInvocation`, `NodeDef`, `NodeLoc`, and concrete `*Def` node bodies. - Add `ProjectDef` with `kind = "project"` and a named `nodes` table. - Flatten `examples/basic` early to the new canonical source layout. @@ -54,7 +54,7 @@ lp-core/ │ ├── lpc-source/src/ │ ├── artifact/ -│ │ └── artifact_loc.rs # UPDATE: ArtifactLocator docs and source-relative path semantics +│ │ └── artifact_specifier.rs # UPDATE: ArtifactSpecifier docs and source-relative path semantics │ └── node/ │ ├── node_def.rs # UPDATE: NodeDef docs/visibility/no_std cleanup │ ├── node_invocation.rs # UPDATE: artifact-only invocation semantics for this plan @@ -91,7 +91,7 @@ examples/basic/ ## Conceptual architecture ```text -ArtifactLocator("/project.toml") +ArtifactSpecifier("/project.toml") │ ▼ load ProjectDef @@ -110,7 +110,7 @@ ProjectNode becomes runtime root NodeEntry The source model has four separate address concepts: ```text -ArtifactLocator authored outside-world locator for a loadable artifact +ArtifactSpecifier authored outside-world locator for a loadable artifact ArtifactLocation engine-side resolved artifact-manager cache key NodeLoc source-side relative locator into the runtime node tree NodeId resolved runtime handle @@ -123,9 +123,9 @@ the canonical example. ## Main components -### ArtifactLocator +### ArtifactSpecifier -`ArtifactLocator` is the source-side authored locator for loading an artifact. +`ArtifactSpecifier` is the source-side authored specifier for loading an artifact. For this plan the important variant is path-based: ```toml @@ -152,7 +152,7 @@ The long-term conceptual shape may be: ```rust pub enum NodeInvocation { - Artifact(ArtifactLocator), + Artifact(ArtifactSpecifier), Inline(NodeDef), } ``` @@ -244,7 +244,7 @@ Future property references may append `#...`, for example The core loader should: 1. Load the project artifact from `/project.toml` or an explicitly supplied - `ArtifactLocator`. + `ArtifactSpecifier`. 2. Validate that it is `kind = "project"`. 3. Create/attach the root `ProjectNode`. 4. Load all project node invocations into a project-local name index. diff --git a/docs/plans/2026-05-05-project-artifact-initial-load/00-notes.md b/docs/plans/2026-05-05-project-artifact-initial-load/00-notes.md index 1e421ac0e..8b259bb28 100644 --- a/docs/plans/2026-05-05-project-artifact-initial-load/00-notes.md +++ b/docs/plans/2026-05-05-project-artifact-initial-load/00-notes.md @@ -63,7 +63,7 @@ load the project root through `ArtifactManager`. RustRover-assisted renames/moves have already started: -- `SrcArtifactSpec` has moved/renamed to `ArtifactLocator`. +- `SrcArtifactSpec` has moved/renamed to `ArtifactSpecifier`. - `SrcNodeConfig` has moved/renamed to `NodeInvocation`. - `TextureConfig` / `ShaderConfig` / `OutputConfig` / `FixtureConfig` have moved to `TextureDef` / `ShaderDef` / `OutputDef` / `FixtureDef` under @@ -86,18 +86,18 @@ encouraged. Terminology direction: -- `ArtifactLocator`: authored outside-world locator for a loadable artifact +- `ArtifactSpecifier`: authored outside-world locator for a loadable artifact (`Path`, future builtin/library variants). This is the source-side form of "where to load a node definition from". - Engine-side `ArtifactLocation`: resolved runtime/cache key used by `ArtifactManager`. This can stay separate from source-side - `ArtifactLocator`. + `ArtifactSpecifier`. - `NodeInvocation`: parent-owned instruction to instantiate a node at a `nodes` table key. Desired shape: ```rust pub enum NodeInvocation { - Artifact(ArtifactLocator), + Artifact(ArtifactSpecifier), Inline(NodeDef), } ``` @@ -324,7 +324,7 @@ Context: Many tests and server/CLI paths call `load_from_root`. The new desired runtime API is "provide the project artifact spec." Answer: demolish the old loading path as part of this plan. The core runtime -starts at `/project.toml` (or an explicitly supplied `ArtifactLocator`) and +starts at `/project.toml` (or an explicitly supplied `ArtifactSpecifier`) and loads from the project artifact. Remove directory discovery and `/project.json` from the core initial-load path rather than preserving a compatibility wrapper. Flatten `examples/basic` early, validate the idea there, then migrate remaining @@ -365,7 +365,7 @@ not the authored node spec itself. `SrcArtifactSpec` is already widespread and works, but "spec" now risks meaning both reference and definition. Answer: already started. `SrcArtifactSpec` has moved/renamed to -`ArtifactLocator`. The plan should stabilize comments, tests, and relative path +`ArtifactSpecifier`. The plan should stabilize comments, tests, and relative path semantics around that name. Keep engine-side `ArtifactLocation` as the resolved artifact-manager key. @@ -429,7 +429,7 @@ Initial audit set: | Existing type | Current role | Likely action | | --- | --- | --- | -| `lpc_source::ArtifactLocator` | authored artifact path/lib locator | keep and stabilize source-side relative path semantics | +| `lpc_model::ArtifactSpecifier` | authored artifact path/lib locator | keep and stabilize source-side relative path semantics | | `lpc_engine::ArtifactLocation` | resolved artifact-manager key | keep, maybe document as engine-side resolved locator | | `lpc_source::NodeInvocation` | currently struct `{ artifact, overrides }` | stabilize as the artifact-only invocation shape for this plan; inline variant can wait | | `lpc_model::NodeLoc` | string wrapper for another node | keep wrapper; add relative dot-syntax parser/resolution semantics and rustdocs | diff --git a/docs/plans/2026-05-05-project-artifact-initial-load/01-stabilize-source-types.md b/docs/plans/2026-05-05-project-artifact-initial-load/01-stabilize-source-types.md index 20bce270b..9d8f828a0 100644 --- a/docs/plans/2026-05-05-project-artifact-initial-load/01-stabilize-source-types.md +++ b/docs/plans/2026-05-05-project-artifact-initial-load/01-stabilize-source-types.md @@ -6,7 +6,7 @@ Stabilize the already-started rename/move state before changing loader behavior. In scope: -- Update rustdocs/comments for `ArtifactLocator`, engine-side `ArtifactLocation`, `NodeInvocation`, `NodeDef`, `NodeLoc`, and concrete `*Def` types. +- Update rustdocs/comments for `ArtifactSpecifier`, engine-side `ArtifactLocation`, `NodeInvocation`, `NodeDef`, `NodeLoc`, and concrete `*Def` types. - Add/finish `ProjectDef` in `lpc-source/src/node/project/`. - Keep concrete node bodies named `TextureDef`, `ShaderDef`, `OutputDef`, `FixtureDef`, and `ProjectDef`. - Keep `NodeLoc` as a source string wrapper for now, but add parsing/validation helpers for the relative dot syntax described in `00-design.md`. @@ -42,7 +42,7 @@ Out of scope: Relevant files: -- `lp-core/lpc-source/src/artifact/artifact_loc.rs` +- `lp-core/lpc-source/src/artifact/artifact_specifier.rs` - `lp-core/lpc-engine/src/artifact/artifact_location.rs` - `lp-core/lpc-source/src/node/node_invocation.rs` - `lp-core/lpc-source/src/node/node_def.rs` @@ -79,7 +79,7 @@ Use `BTreeMap` or the closest existing no_std-friendly Update unit tests for: -- `ArtifactLocator` path/lib round trips still pass. +- `ArtifactSpecifier` path/lib round trips still pass. - `NodeInvocation` TOML form for `[nodes.foo] artifact = "./foo.toml"`. - `NodeLoc` accepts valid relative dot examples and rejects slash paths, empty strings, and absolute-looking node paths. - `ProjectDef` deserializes a minimal `kind = "project"` TOML with a named `nodes` table. diff --git a/docs/plans/2026-05-05-project-artifact-initial-load/03-project-artifact-loader.md b/docs/plans/2026-05-05-project-artifact-initial-load/03-project-artifact-loader.md index 9f9e4149f..b98169724 100644 --- a/docs/plans/2026-05-05-project-artifact-initial-load/03-project-artifact-loader.md +++ b/docs/plans/2026-05-05-project-artifact-initial-load/03-project-artifact-loader.md @@ -6,7 +6,7 @@ Rewrite the core project initial-load path around `/project.toml` and artifact-l In scope: -- Add a new core loader entry point that starts from an `ArtifactLocator`, defaulting to `/project.toml` for project-root loads. +- Add a new core loader entry point that starts from an `ArtifactSpecifier`, defaulting to `/project.toml` for project-root loads. - Load `ProjectDef` using artifact loading infrastructure. - Instantiate or attach the root `ProjectNode` / project placeholder for `kind = "project"`. - Load child node artifacts declared in `ProjectDef.nodes`. @@ -50,7 +50,7 @@ Relevant files: The new loader should conceptually do: ```text -load_project_artifact(fs, services, ArtifactLocator::path("/project.toml")) +load_project_artifact(fs, services, ArtifactSpecifier::path("/project.toml")) -> resolve/load ProjectDef -> create CoreProjectRuntime with services.project_root() -> attach ProjectNode/root payload diff --git a/docs/plans/2026-05-05-project-artifact-initial-load/summary.md b/docs/plans/2026-05-05-project-artifact-initial-load/summary.md index d160fa1b9..ea1677649 100644 --- a/docs/plans/2026-05-05-project-artifact-initial-load/summary.md +++ b/docs/plans/2026-05-05-project-artifact-initial-load/summary.md @@ -4,7 +4,7 @@ - Added project artifacts as the root authored entry point: `project.toml` now carries `kind = "project"` and a `[nodes.*]` map of named node invocations. - Introduced source-side `Def` types for authored node bodies: `ProjectDef`, `TextureDef`, `ShaderDef`, `OutputDef`, and `FixtureDef`. -- Introduced `NodeInvocation` for "use this node here" authoring, currently backed by artifact locators and shaped for future params/bindings. +- Introduced `NodeInvocation` for "use this node here" authoring, currently backed by artifact specifiers and shaped for future params/bindings. - Added relative dot `NodeLoc` parsing in `lpc-model` and loader-side resolution for sibling/current/parent references. - Reworked `CoreProjectLoader` to start from `/project.toml`, load declared artifact files, build the root `Project` node, and attach declared child nodes without directory discovery. - Flattened the active examples to file-referenced artifacts: `project.toml`, node `.toml` files, and `shader.glsl`. @@ -27,7 +27,7 @@ #### `Def`, `Invocation`, `Locator`, `Ref` -- **Decision:** use `*Def` for authored node bodies, `NodeInvocation` for a node used at a place in a project, `ArtifactLocator` for source-side external locations, and reserve `Ref` language for runtime references. +- **Decision:** use `*Def` for authored node bodies, `NodeInvocation` for a node used at a place in a project, `ArtifactSpecifier` for source-side external locations, and reserve `Ref` language for runtime references. - **Why:** this keeps the "what is defined" and "where it is used" concepts distinct without overloading `Spec`. - **Rejected alternatives:** `NodeSpec` as both path reference and full definition, and `ArtifactRef` for source authoring paths. diff --git a/docs/plans/2026-05-12-clock-node-time-mutation/00-notes.md b/docs/plans/2026-05-12-clock-node-time-mutation/00-notes.md index 8f5a96a44..c5289d67e 100644 --- a/docs/plans/2026-05-12-clock-node-time-mutation/00-notes.md +++ b/docs/plans/2026-05-12-clock-node-time-mutation/00-notes.md @@ -54,7 +54,7 @@ Full mutation may be substantial, but the clock controls are a valuable first re - `lp-core/lpc-engine/src/engine/project_loader.rs` - `ProjectLoader` loads `/project.toml`, then iterates `project_def.nodes.entries`. - - Each child invocation is resolved through `invocation.artifact_locator()`. + - Each child invocation is resolved through `invocation.artifact_specifier()`. - Each child node def is loaded from disk by `load_node_def`. - Runtime nodes are attached in kind-specific passes. - Bindings are registered from each node def's `bindings`. diff --git a/docs/plans/2026-05-12-clock-node-time-mutation/02-inline-node-invocations.md b/docs/plans/2026-05-12-clock-node-time-mutation/02-inline-node-invocations.md index c5666abb1..885feda0f 100644 --- a/docs/plans/2026-05-12-clock-node-time-mutation/02-inline-node-invocations.md +++ b/docs/plans/2026-05-12-clock-node-time-mutation/02-inline-node-invocations.md @@ -52,9 +52,9 @@ pub enum NodeInvocation { Public helpers: -- `NodeInvocation::new(ArtifactLocator)` remains available for existing tests. +- `NodeInvocation::new(ArtifactSpecifier)` remains available for existing tests. - `NodeInvocation::inline(NodeDef)` may be added. -- `artifact_locator()` should return `Option`/`Result, _>` or be replaced with clearer helpers. +- `artifact_specifier()` should return `Option`/`Result, _>` or be replaced with clearer helpers. TOML forms: diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/00-design.md b/docs/plans/2026-05-21-artifact-pending-overlay/00-design.md new file mode 100644 index 000000000..262848158 --- /dev/null +++ b/docs/plans/2026-05-21-artifact-pending-overlay/00-design.md @@ -0,0 +1,111 @@ +# Artifact Pending Overlay — Design + +## Scope of work + +Replace materialized `SlotOverlay` (`DefDraft` / `Bytes` / `Deleted` snapshots) with +**`ArtifactOverlay`**: a Slotted, revisioned **map of current pending changes** keyed +by artifact address, **projected** over committed artifact data. + +**In:** `lpc-node-registry` overlay storage, apply, projection, commit, introspection, +test migration. + +**Out:** `lpc-wire`, `lpa-server`, `SessionLog` (M8), engine cutover. + +**Deferred (not v1):** Per-artifact cache of folded effective `NodeDef` to avoid +re-projecting on every path lookup — overlay map stays authoritative; see `future.md`. + +## File structure + +``` +lp-core/lpc-node-registry/src/ +├── edit/ +│ ├── artifact_overlay.rs # NEW: ArtifactOverlay, ArtifactPending, AssetPending +│ ├── pending_slot_key.rs # NEW: SlotPath ↔ stable map key (String) +│ ├── apply.rs # UPDATE: upsert into ArtifactOverlay +│ ├── mod.rs # UPDATE: exports; remove DefDraft/SlotOverlay +│ ├── slot_edit.rs # keep +│ ├── asset_edit.rs # keep +│ ├── artifact_edit.rs # keep (ingress vocabulary unchanged) +│ ├── def_draft.rs # DELETE +│ └── slot_overlay.rs # DELETE +├── registry/ +│ ├── projection.rs # NEW: committed + pending → effective +│ ├── node_def_registry.rs # UPDATE: field rename + pending API +│ ├── slot_apply.rs # UPDATE: upsert, no DefDraft fork +│ ├── effective_read.rs # UPDATE: delegate to projection +│ ├── commit.rs # UPDATE: fold pending → fs +│ └── node_def_entry.rs # unchanged v1 +├── source/ +│ └── materialize.rs # UPDATE: read asset pending from overlay +├── lib.rs # UPDATE: re-exports +└── tests/ # UPDATE integration tests +``` + +## Conceptual architecture + +```text + EditBatch / SyncOp::Apply + │ + ▼ + ┌────────────────────┐ + │ upsert pending │ SlotEdit → slots[path] = edit (replace) + │ │ AssetEdit → asset = Some(...) (replace; clears slots) + └─────────┬──────────┘ + │ + ▼ + ┌─────────────────────────────────────────┐ + │ ArtifactOverlay │ + │ MapSlot │ + │ ArtifactPending: │ + │ slots: MapSlot │ // key = canonical SlotPath string + │ asset: AssetPending │ // None | Delete | ReplaceBody + └─────────┬───────────────────────────────┘ + │ project (on read / commit) + ▼ + ArtifactStore (committed bytes) ──► effective bytes / NodeDef + │ + ▼ commit + filesystem write + │ + ▼ + re-sync committed defs; remove overlay keys for committed artifacts +``` + +### Reads + +| API | Returns | +|-----|---------| +| `get(loc)` | Committed `NodeDefEntry` | +| `effective_state(loc)` / `NodeDefView::get(loc)` | Project pending over committed at `loc` | +| `overlay.pending_at(location)` | `Option<&ArtifactPending>` for client sync prep | +| `overlay.is_active()` | Any pending keys exist | + +### Pending semantics (not a log) + +- **Slot path:** one `SlotEdit` per `SlotPath` key; later edit **replaces** same key. +- **Asset:** at most one `AssetPending` per artifact; setting asset pending **clears** + slot map for that artifact (mutual exclusion). +- **Delete:** `AssetPending::Delete` tombstone; projection yields missing/deleted bytes. + +## Resolved decisions (planning) + +| # | Decision | +|---|----------| +| Q1 | Overlay keyed by `ArtifactLocation` | +| Q2 | Slot pending map with replace semantics | +| Q3 | One asset pending per artifact | +| Q4 | No `SessionLog` in this plan | +| Q5 | Projection on read; no cached `NodeDefEntry.view` v1 | +| Q6 | Slotted `MapSlot` containers | +| Q7 | `lpc-node-registry` only | +| Q8 | Keep `AssetEdit::ReplaceBody` escape hatch | +| D1 | String keys for slot paths in `MapSlot` (v1) | +| D2 | Asset pending and slot pending mutually exclusive per artifact | + +## Validation (full plan) + +```bash +cargo test -p lpc-node-registry +cargo check -p lpc-node-registry --no-default-features +just check # final phase only (if lints touched) +``` diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/00-notes.md b/docs/plans/2026-05-21-artifact-pending-overlay/00-notes.md new file mode 100644 index 000000000..464a75dcf --- /dev/null +++ b/docs/plans/2026-05-21-artifact-pending-overlay/00-notes.md @@ -0,0 +1,146 @@ +# Artifact pending overlay — planning notes + +## Scope of work + +Replace the current **materialized** `SlotOverlay` (`DefDraft` / `Bytes` / `Deleted` +snapshots) with an **`ArtifactOverlay`**: a revisioned map of **current pending +changes** keyed by artifact address, projected over committed artifact data. + +**In scope (this plan):** + +- New overlay storage: `ArtifactLocation` → pending slot/asset edits (path-keyed + within artifact for slot ops) +- Apply path upserts into the map (no whole-`NodeDef` fork into `DefDraft`) +- Effective read / `NodeDefView` = committed ∪ projected pending +- Commit folds pending map → filesystem → re-sync committed registry state +- Public read API on registry for pending map (client/wire prep) +- Slotted `MapSlot` containers for revision + future wire sync reuse +- Migrate / update existing `lpc-node-registry` tests + +**Out of scope (defer):** + +- `lpc-wire` message types and client mirror (`future.md`) +- M8 `SessionLog` append-only session layer (superseded for v1 by address-keyed map; + may revisit for multi-client ordering) +- Engine cutover (`lpc-engine` consuming pending state) +- CRDT / multi-writer merge beyond per-key replace + revision CAS + +**Relationship to other docs:** + +- Supersedes the **materialized** overlay portion of + `docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-design.md` + (SessionLog → DefDraft fold). This plan uses **map of path → edit** as source of + truth instead. +- Complements `NodeDefLoc`-only registry identity (recent cutover). +- Wire/sync hardening remains in `docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening.md`. + +## Current state + +### What exists today + +``` +NodeDefRegistry + store: ArtifactStore + overlay: SlotOverlay // BTreeMap + entries: BTreeMap + +SlotOverlayEntry + Deleted | Bytes(Vec) | DefDraft(NodeDef) // materialized snapshots +``` + +**Apply (slot):** fork committed/overlay → mutate `NodeDef` in memory → store whole +`DefDraft` back. Incoming `SlotEdit` ops are **not retained**. + +**Apply (asset):** store raw `Bytes` or `Deleted` tombstone. + +**Effective read:** merge overlay snapshot with committed at read time. + +**Client pending list:** not available from server; only booleans (`slot_overlay_active`, +`slot_overlay_contains_path`). + +### What's wrong for intended UX + +- Pending changes are not enumerable or syncable to client +- `DefDraft` duplicates full parsed defs per edit +- Two representations (ops on wire, snapshots in overlay) with no round-trip + +### Target model (agreed direction) + +``` +NodeDefRegistry + store: ArtifactStore + overlay: ArtifactOverlay + defs: BTreeMap + root: Option + +ArtifactOverlay + by_artifact: MapSlot // Slotted, revisioned + +ArtifactPending + slots: MapSlot // one pending edit per slot path (replace) + asset: Option // whole-file pending (replace) + +Projection: + effective_bytes(loc) = apply_pending(store.read(loc), overlay.get(loc.artifact)) + effective_def(loc) = parse(project) sliced to loc.path for inline defs +``` + +Not a log — **address → current pending edit**. Repeated edit to same path replaces +the entry. + +## Questions — resolved (2026-05-21) + +User confirmed plan: **all Q1–Q8 yes**; D1 = string keys v1; D2 = mutual exclusion. + +| # | Answer | +|---|--------| +| Q1–Q8 | Yes (per suggested answers) | +| D1 | `MapSlot` with canonical path string key | +| D2 | Asset pending clears slot map; slot upsert clears asset pending | + +## Questions (original) + +### Confirmation batch (answer in one pass: `all yes`, or `Q1 yes, Q2 …`) + +| # | Question | Context | Suggested answer | +|---|----------|---------|------------------| +| Q1 | Overlay keyed by `ArtifactLocation` (not `String` path)? | Matches `ArtifactStore` + recent identity cutover | Yes | +| Q2 | Within a `.toml` artifact, pending slot edits keyed by `SlotPath` with **replace** semantics? | Map of path → current `SlotEdit`, not append-only | Yes | +| Q3 | At most one **asset** pending per artifact (`Option` or enum: None / Delete / ReplaceBody)? | Whole-file replace supersedes prior asset pending | Yes | +| Q4 | **No** `SessionLog` in this plan — overlay map is the pending source of truth? | M8 design had append log → materialized overlay; user rejected log model | Yes | +| Q5 | **Projection on read** for v1 — do **not** add cached `view` field on `NodeDefEntry` yet? | Avoid invalidation complexity; `NodeDefView` computes effective | Yes | +| Q6 | Slotted `MapSlot` from `lpc-model` for overlay containers (not plain `BTreeMap`)? | Reuse revision + future wire snapshot path | Yes | +| Q7 | Plan scope = **`lpc-node-registry` only** (no wire/server changes)? | Wire follows M1/M3 after registry shape is stable | Yes | +| Q8 | Keep `AssetEdit::ReplaceBody` for whole-file TOML escape hatch alongside structured `SlotEdit`? | Already in edit vocabulary; diff uses both | Yes | + +### Discussion-style (if confirmation answers differ) + +#### D1: SlotPath as MapSlot key + +`MapSlot` requires `K: MapSlotKeyLike`. `SlotPath` is not implemented today. +Options: + +- **A:** `MapSlot` with canonical path string key (`SlotPath::to_wire()` or existing display) +- **B:** Implement `MapSlotKeyLike for SlotPath` in `lpc-model` + +**Suggested:** A for v1 (minimal model churn); B as `future.md` if hot-path allocs hurt. + +#### D2: Asset pending vs slot pending on same artifact + +A `.toml` file could theoretically have both slot edits and a whole-file +`ReplaceBody`. Options: + +- **A:** Mutual exclusion — asset pending clears slot map and vice versa +- **B:** Asset pending wins at projection/commit time +- **C:** Allow both; commit applies asset body then slot ops (or reverse) + +**Suggested:** A — asset replace is escape hatch; applying it clears structured slot +pending for that artifact. + +## Notes + +- User correction: overlay is **not an op log** — it is a **mapping of current pending + changes** projected over underlying artifact data. +- Slotted system reuse: overlay containers get revision tracking and wire-serializable + shape; `SlotEdit` / `AssetEdit` remain the edit vocabulary (not meta-mutations on + overlay slots). diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/01-pending-overlay-types.md b/docs/plans/2026-05-21-artifact-pending-overlay/01-pending-overlay-types.md new file mode 100644 index 000000000..a74e3c6fb --- /dev/null +++ b/docs/plans/2026-05-21-artifact-pending-overlay/01-pending-overlay-types.md @@ -0,0 +1,132 @@ +# Phase 1: Pending Overlay Types + Slotted Storage + +## Scope of phase + +Introduce **`ArtifactOverlay`**, **`ArtifactPending`**, and **`pending_slot_key`** +helpers. Wire types into `edit/mod.rs` exports. **Do not** change registry apply, +projection, or commit yet — old `SlotOverlay` may remain until phase 2 removes it. + +**In scope:** + +- `edit/artifact_overlay.rs` — core types + unit tests +- `edit/pending_slot_key.rs` — `SlotPath` ↔ canonical `String` key +- `edit/mod.rs` — export new types alongside legacy (temporary) or replace exports if + compile allows + +**Out of scope:** + +- Registry field swap (`node_def_registry.rs`) — phase 2 +- Deleting `def_draft.rs` / `slot_overlay.rs` — phase 6 +- Wire sync + +## Code organization reminders + +- One concept per file (`artifact_overlay.rs`, `pending_slot_key.rs`). +- Public types and impl entry points at top; helpers at bottom; `#[cfg(test)] mod tests` last. +- Use `lpc_model::MapSlot` for revisioned maps. + +## Sub-agent reminders + +- Do **not** commit. +- Do **not** expand scope. +- Do **not** suppress warnings or weaken tests. +- If blocked, stop and report. +- Report: files changed, validation run, deviations. + +## Implementation details + +### `pending_slot_key.rs` + +```rust +//! Canonical string keys for slot paths in overlay maps. + +pub fn slot_path_key(path: &SlotPath) -> String { ... } +pub fn parse_slot_path_key(key: &str) -> Result { ... } +``` + +Use the same string form as existing wire/TOML path display (match how tests parse +`SlotPath::parse("controls.rate")`). Round-trip tests required. + +### `artifact_overlay.rs` + +```rust +use lpc_model::{MapSlot, Revision, SlotPath}; +use crate::{ArtifactLocation, AssetEdit, SlotEdit}; + +#[derive(Clone, Debug, Default, PartialEq)] +pub enum AssetPending { + #[default] + None, + Delete, + ReplaceBody(alloc::vec::Vec), // raw bytes, not String — matches fs write +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ArtifactPending { + pub slots: MapSlot, + pub asset: AssetPending, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ArtifactOverlay { + by_artifact: MapSlot, +} + +impl ArtifactOverlay { + pub fn new() -> Self; + pub fn is_empty(&self) -> bool; + pub fn contains(&self, location: &ArtifactLocation) -> bool; + pub fn pending_at(&self, location: &ArtifactLocation) -> Option<&ArtifactPending>; + pub fn pending_at_mut(&mut self, location: &ArtifactLocation) -> Option<&mut ArtifactPending>; + pub fn ensure_pending(&mut self, location: ArtifactLocation) -> &mut ArtifactPending; + pub fn remove(&mut self, location: &ArtifactLocation) -> bool; + pub fn clear(&mut self); + pub fn iter(&self) -> impl Iterator; +} +``` + +**Note:** `ArtifactLocation` must work as `MapSlot` key — it already implements +`Ord`/`Eq`. If `MapSlot` requires `MapSlotKeyLike`, add a thin newtype wrapper or +use string URI key (`location.to_uri()`) consistently; document choice in report. + +**Mutual exclusion helpers** (implement now, used in phase 2): + +```rust +impl ArtifactPending { + pub fn upsert_slot(&mut self, path: SlotPath, edit: SlotEdit); + pub fn set_asset(&mut self, pending: AssetPending); // clears slots + pub fn is_empty(&self) -> bool; +} +``` + +- `upsert_slot`: insert/replace `slots[slot_path_key(path)]`, set `asset = None` +- `set_asset`: assign asset, `slots = MapSlot::default()` (or clear map) + +### Tests (in `artifact_overlay.rs`) + +- Empty overlay +- Upsert two slot paths → two keys +- Upsert same path twice → second replaces first +- Set asset pending → slot map cleared +- Upsert slot after asset → asset cleared +- `remove` / `clear` + +### `edit/mod.rs` + +Export: + +```rust +pub use artifact_overlay::{ArtifactOverlay, ArtifactPending, AssetPending}; +pub use pending_slot_key::{parse_slot_path_key, slot_path_key}; +``` + +Keep legacy `SlotOverlay` exports until phase 6 unless registry already broken — prefer +keeping both temporarily. + +## Validate + +```bash +cargo test -p lpc-node-registry artifact_overlay +cargo test -p lpc-node-registry pending_slot +cargo check -p lpc-node-registry +``` diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/02-apply-upsert.md b/docs/plans/2026-05-21-artifact-pending-overlay/02-apply-upsert.md new file mode 100644 index 000000000..c082ed9dd --- /dev/null +++ b/docs/plans/2026-05-21-artifact-pending-overlay/02-apply-upsert.md @@ -0,0 +1,118 @@ +# Phase 2: Apply Upsert (Replace DefDraft Path) + +## Scope of phase + +Switch registry apply path from materialized `DefDraft` to **`ArtifactOverlay` upsert**. +Replace `NodeDefRegistry` field `overlay: SlotOverlay` with `overlay: ArtifactOverlay`. + +**In scope:** + +- `registry/node_def_registry.rs` — field type, `apply_artifact_edit`, introspection + methods (`slot_overlay_active` → `overlay_active` or keep name with deprecated alias) +- `registry/slot_apply.rs` — upsert `SlotEdit` into overlay; remove `fork_slot_draft` / + `apply_def_draft` +- `edit/apply.rs` — asset ops upsert into `ArtifactOverlay` (not `SlotOverlay`) +- Rename internal uses: `slot_overlay` → `overlay` where touched + +**Out of scope:** + +- Effective read / projection (phase 3) — tests that read effective state may fail until + phase 3; keep compile green by leaving effective_read temporarily on old paths OR + stub projection as committed-only with TODO — **prefer minimal stub in effective_read + that returns committed until phase 3** only if tests block; coordinate in report. +- Commit (phase 4) +- Delete old files (phase 6) + +## Code organization reminders + +- Apply logic stays in `slot_apply.rs` + `edit/apply.rs`. +- Registry orchestrates; overlay module holds storage only. + +## Sub-agent reminders + +- Do **not** commit. +- Do **not** expand scope. +- Do **not** suppress warnings or weaken tests. +- If effective tests fail, document which fail and why (expected until phase 3); do not + delete tests. + +## Implementation details + +### Slot apply (`slot_apply.rs`) + +Replace body of `apply_slot_op`: + +```rust +pub(crate) fn apply_slot_op( + &mut self, + path: LpPathBuf, + op: &SlotEdit, + ... +) -> Result<(), EditError> { + ensure_toml_path(&path)?; + let location = self.resolve_location_for_path(&path)?; // or existing helper + let pending = self.overlay.ensure_pending(location); + pending.upsert_slot(op.path.clone(), op.clone()); // or upsert with op fields + Ok(()) +} +``` + +Remove: `fork_slot_draft`, `fork_committed_def` from apply path (keep parse helpers if +projection needs them in phase 3). + +**Apply full op:** upsert stores the **`SlotEdit` as sent** (including path inside op). +If incoming batch has multiple ops same path, last wins via sequential upsert. + +### Asset apply (`edit/apply.rs`) + +Change signature to accept `&mut ArtifactOverlay`: + +```rust +pub(crate) fn apply_asset_op( + overlay: &mut ArtifactOverlay, + location: ArtifactLocation, + op: &AssetEdit, +) -> Result<(), EditError> +``` + +Map: + +- `AssetEdit::Delete` → `pending.set_asset(AssetPending::Delete)` +- `AssetEdit::ReplaceBody(text)` → `AssetPending::ReplaceBody(text.into_bytes())` + +Registry resolves `EditTarget` → `ArtifactLocation` before calling. + +### Registry (`node_def_registry.rs`) + +- `overlay: ArtifactOverlay` +- `apply_artifact_edit`: pass location + ops to overlay upsert +- Update: `slot_overlay_active` → delegate to `overlay.is_empty()` negated +- Update: `slot_overlay_contains_path` → resolve path to location, `overlay.contains` +- Update: `slot_overlay_bytes` → return bytes only when `AssetPending::ReplaceBody` + (not for slot-only pending) +- `remove_pending_edit`: `overlay.remove(&location)` +- `discard_slot_overlay`: `overlay.clear()` + +Add private helper if needed: + +```rust +fn artifact_location_for_edit(&self, path: &LpPathBuf) -> Result +``` + +### Tests + +Update or add unit tests in `slot_apply` / registry for: + +- Apply slot op → overlay has one key, no DefDraft +- Apply asset op → overlay has asset pending, slots empty +- Second slot op same path → one key updated + +## Validate + +```bash +cargo check -p lpc-node-registry +cargo test -p lpc-node-registry overlay_lifecycle +cargo test -p lpc-node-registry pending_sync +``` + +Note: effective/commit tests may fail — list in report if so. diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/03-projection-layer.md b/docs/plans/2026-05-21-artifact-pending-overlay/03-projection-layer.md new file mode 100644 index 000000000..d7e3d9378 --- /dev/null +++ b/docs/plans/2026-05-21-artifact-pending-overlay/03-projection-layer.md @@ -0,0 +1,134 @@ +# Phase 3: Projection Layer + Effective Read + +## Scope of phase + +Implement **projection**: committed artifact bytes/def + `ArtifactPending` → effective +bytes / `NodeDefState`. Rewire `effective_read.rs`, `NodeDefView`, and +`materialize.rs` asset overlay reads. + +**In scope:** + +- `registry/projection.rs` — new module +- `registry/effective_read.rs` — delegate to projection +- `registry/mod.rs` — declare `mod projection` +- `source/materialize.rs` — read `AssetPending` from overlay +- `view/node_def_view.rs` — unchanged API, works via effective_read + +**Out of scope:** + +- Commit (phase 4) +- Public `pending_at` API (phase 5) +- Delete old overlay files (phase 6) +- **Cached effective projection** — v1 folds on each read; see `future.md`. Design + `projection.rs` so a per-artifact cache can wrap it later without API churn. + +## Code organization reminders + +- `projection.rs` owns fold logic; effective_read stays thin wrappers. +- Reuse `slot_apply.rs` helpers: `apply_op_to_def`, `parse_def_bytes`, `serialize_slot_draft`. + +## Sub-agent reminders + +- Do **not** commit. +- Do **not** expand scope. +- Fix failing tests from phase 2; all effective tests must pass this phase. + +## Implementation details + +### `projection.rs` + +Core functions: + +```rust +/// Effective raw bytes for an artifact path (for fs-like read). +pub fn project_artifact_bytes( + committed: Option<&[u8]>, + pending: Option<&ArtifactPending>, + ctx: &ParseCtx<'_>, +) -> Result>, RegistryError>; + +/// Effective NodeDefState at artifact root. +pub fn project_artifact_def( + committed_state: &NodeDefState, + pending: Option<&ArtifactPending>, + ctx: &ParseCtx<'_>, +) -> NodeDefState; + +/// Effective def at inline `NodeDefLoc` (slice projected root def). +pub fn project_def_at_loc( + loc: &NodeDefLoc, + committed_entry: &NodeDefEntry, + pending: Option<&ArtifactPending>, + ctx: &ParseCtx<'_>, +) -> NodeDefState; +``` + +**Asset pending:** + +- `None` → use committed bytes +- `Delete` → `None` / parse error (match current deleted overlay behavior) +- `ReplaceBody(bytes)` → use bytes directly + +**Slot pending:** + +1. Parse committed bytes → `NodeDef` (or use committed loaded state) +2. For each `(path_key, edit)` in pending.slots (stable iteration order — sort keys for + determinism): `apply_op_to_def(&mut def, edit, ctx, frame)` +3. Serialize back to bytes if needed, or keep def in memory for state + +Use `Revision` from caller frame parameter for apply ops. + +**Inline defs:** after projecting artifact root def, use existing +`def_state_at_source` / `collect_invocations` walk (from effective_read) to slice +`loc.path`. + +### `effective_read.rs` + +Replace `DefDraft` / `SlotOverlayEntry` match arms: + +```rust +let pending = self.overlay.pending_at(location); +// read committed bytes from store +let bytes = project_artifact_bytes(...)?; +``` + +`effective_state(loc)`: + +- Get committed entry from `defs` +- Get pending via `loc.artifact` +- If no overlay entry for artifact path → return committed state +- Else `project_def_at_loc` + +Remove references to `SlotOverlayEntry::DefDraft`. + +### `materialize.rs` + +Replace `SlotOverlayEntry` matching with `ArtifactPending`: + +- Overlay bytes from `AssetPending::ReplaceBody` +- Delete → `ArtifactReadFailure::Deleted` +- Slot-only pending on shader's **parent toml** does not affect glsl file read (unchanged + behavior) + +### Tests + +All must pass: + +```bash +cargo test -p lpc-node-registry effective_projection +cargo test -p lpc-node-registry slot_overlay +cargo test -p lpc-node-registry asset_overlay +``` + +Add projection unit tests in `projection.rs`: + +- Committed clock + pending AssignValue → effective rate changed +- Asset replace body → effective bytes +- Asset delete → error/None +- Inline child path projection + +## Validate + +```bash +cargo test -p lpc-node-registry +``` diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/04-commit-fold.md b/docs/plans/2026-05-21-artifact-pending-overlay/04-commit-fold.md new file mode 100644 index 000000000..2b1c75f05 --- /dev/null +++ b/docs/plans/2026-05-21-artifact-pending-overlay/04-commit-fold.md @@ -0,0 +1,93 @@ +# Phase 4: Commit — Fold Pending → Filesystem + +## Scope of phase + +Rewrite commit promotion to **fold pending map** into filesystem writes instead of +serializing `DefDraft`. + +**In scope:** + +- `registry/commit.rs` +- Integration with existing `sync_def_artifact` after fs write + +**Out of scope:** + +- Public pending introspection API (phase 5) +- Wire + +## Code organization reminders + +- Commit plan struct builds writes from `ArtifactOverlay::iter()`, not `SlotOverlayEntry`. +- Reuse `serialize_slot_draft` / projection for slot-only pending. + +## Sub-agent reminders + +- Do **not** commit. +- Do **not** expand scope. +- All commit_promotion tests must pass. + +## Implementation details + +### Commit plan from overlay + +Replace `SlotOverlayCommitPlan::from_slot_overlay`: + +```rust +struct OverlayCommitPlan { + writes: Vec<(LpPathBuf, Vec)>, + deletes: Vec, +} + +impl OverlayCommitPlan { + fn from_overlay( + overlay: &ArtifactOverlay, + store: &ArtifactStore, + fs: &dyn LpFs, + ctx: &ParseCtx<'_>, + ) -> Result; +} +``` + +For each `(location, pending)` in `overlay.iter()`: + +1. Resolve `LpPathBuf` from `location.file_path()` +2. **Asset pending:** + - `Delete` → push to `deletes` + - `ReplaceBody(bytes)` → push to `writes` +3. **Slot pending only** (asset None): + - Read committed bytes from store/fs + - Project with `project_artifact_bytes` / def fold + - Serialize TOML → push to `writes` +4. Skip empty pending buckets + +Do **not** write if projected bytes equal committed (optional optimization — OK to skip +for v1). + +### `commit_slot_overlay` flow + +Keep existing order: + +1. Build plan from overlay +2. Write/delete fs +3. `store.apply_fs_changes` +4. Register new paths +5. `sync_def_artifact` for affected `.toml` locations +6. `reconcile_artifacts` +7. `overlay.clear()` + +Remove all `SlotOverlayEntry` / `DefDraft` / `serialize_slot_draft(draft.def)` paths +from commit. + +### Edge cases + +- Implicit create: path in overlay but not in store — register on commit (existing behavior) +- Slot + asset mutual exclusion already enforced at apply — commit sees one or the other +- Empty overlay → early return (existing) + +## Validate + +```bash +cargo test -p lpc-node-registry commit_promotion +cargo test -p lpc-node-registry pending_sync +cargo test -p lpc-node-registry project_diff +``` diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/05-pending-introspection-api.md b/docs/plans/2026-05-21-artifact-pending-overlay/05-pending-introspection-api.md new file mode 100644 index 000000000..9b50dd8fe --- /dev/null +++ b/docs/plans/2026-05-21-artifact-pending-overlay/05-pending-introspection-api.md @@ -0,0 +1,78 @@ +# Phase 5: Registry Pending Introspection API + +## Scope of phase + +Expose read-only API for **pending map** contents — prep for wire/client sync without +implementing wire yet. + +**In scope:** + +- `ArtifactOverlay` public iteration (if not already public) +- `NodeDefRegistry` methods +- `lib.rs` re-exports +- Unit tests for introspection + +**Out of scope:** + +- `lpc-wire` types +- Session/version CAS enforcement (document revision fields available on MapSlot) + +## Code organization reminders + +- Keep introspection on registry as thin delegates to overlay. +- No mutation APIs beyond existing apply/discard/remove. + +## Sub-agent reminders + +- Do **not** commit. +- Do **not** expand scope. + +## Implementation details + +### Registry API + +```rust +impl NodeDefRegistry { + /// Whether any artifact has pending edits. + pub fn overlay_active(&self) -> bool; + + /// Pending edits for one artifact, if any. + pub fn pending_at(&self, location: &ArtifactLocation) -> Option<&ArtifactPending>; + + /// Iterate artifacts with pending edits (stable order). + pub fn iter_pending(&self) -> impl Iterator; + + /// Whether a specific slot path has a pending edit within an artifact. + pub fn has_pending_slot(&self, location: &ArtifactLocation, path: &SlotPath) -> bool; +} +``` + +Deprecate or alias old names: + +- `slot_overlay_active` → `overlay_active` (keep deprecated alias one release if exported) +- `slot_overlay_contains_path(path)` → resolve location, check `overlay.contains` + +Document in module docs on `NodeDefRegistry` that pending is **address-keyed current +edits**, syncable via future wire. + +### `ArtifactPending` accessors + +```rust +impl ArtifactPending { + pub fn slot_edits(&self) -> impl Iterator; + pub fn asset_pending(&self) -> &AssetPending; + pub fn slots_revision(&self) -> Revision; // delegate to MapSlot if available +} +``` + +### Tests + +- Load project, apply edit, assert `iter_pending` yields one artifact with one slot key +- Apply asset edit, assert slot map empty in introspection +- Commit clears `iter_pending` + +## Validate + +```bash +cargo test -p lpc-node-registry +``` diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/06-test-migration-dead-code.md b/docs/plans/2026-05-21-artifact-pending-overlay/06-test-migration-dead-code.md new file mode 100644 index 000000000..731ca86b6 --- /dev/null +++ b/docs/plans/2026-05-21-artifact-pending-overlay/06-test-migration-dead-code.md @@ -0,0 +1,77 @@ +# Phase 6: Test Migration + Delete Dead Overlay Code + +## Scope of phase + +Remove **`DefDraft`**, **`SlotOverlay`**, **`SlotOverlayEntry`**. Clean deprecated aliases +if safe. Ensure all integration tests assert pending-map semantics where relevant. + +**In scope:** + +- Delete `edit/def_draft.rs`, `edit/slot_overlay.rs` +- Update `edit/mod.rs`, `lib.rs` — remove dead exports and deprecated aliases for + `SlotOverlay` / `DefDraft` / `OverlayEntry` / `SlotDraft` if nothing in workspace uses + them (grep first; keep deprecated types only if external callers exist in same crate tests) +- Update tests that referenced `slot_overlay_*` naming to `overlay_*` where user-facing +- Grep for `DefDraft`, `SlotOverlayEntry`, `SlotOverlay`, `apply_def_draft` + +**Out of scope:** + +- Roadmap doc updates (optional one-line note in summary only) +- Wire crate + +## Code organization reminders + +- After deletes, `cargo check -p lpc-node-registry --no-default-features` must pass. +- Tests at bottom of files. + +## Sub-agent reminders + +- Do **not** commit. +- Do **not** weaken tests to green. + +## Implementation details + +### Deletions + +- `lp-core/lpc-node-registry/src/edit/def_draft.rs` +- `lp-core/lpc-node-registry/src/edit/slot_overlay.rs` + +### Export cleanup (`edit/mod.rs`, `lib.rs`) + +Remove: + +```rust +pub use def_draft::DefDraft; +pub use slot_overlay::{SlotOverlay, SlotOverlayEntry}; +``` + +Remove deprecated type aliases if grep shows no uses outside this crate's deprecated +re-exports in `lib.rs` legacy block. + +### Test updates + +Review and adjust assertions in: + +- `tests/slot_overlay.rs` — rename file optional (not required); update comments +- `tests/overlay_lifecycle.rs` +- `tests/commit_promotion.rs` +- `tests/pending_sync.rs` +- `registry/commit.rs` tests — remove `DefDraft` fixture + +Add one test: after two AssignValue same path, introspection shows **one** slot edit (last +value). + +### Grep checklist + +```bash +rg 'DefDraft|SlotOverlay|SlotOverlayEntry|slot_overlay' lp-core/lpc-node-registry +``` + +Zero hits in src/ except possibly CHANGELOG — fix all. + +## Validate + +```bash +cargo test -p lpc-node-registry +cargo check -p lpc-node-registry --no-default-features +``` diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/07-cleanup-validation.md b/docs/plans/2026-05-21-artifact-pending-overlay/07-cleanup-validation.md new file mode 100644 index 000000000..2eabcb30a --- /dev/null +++ b/docs/plans/2026-05-21-artifact-pending-overlay/07-cleanup-validation.md @@ -0,0 +1,67 @@ +# Phase 7: Cleanup, Validation, Summary + +## Scope of phase + +Final grep for TODOs/temp code, formatting, full validation, write `summary.md`. + +**In scope:** + +- `cargo +nightly fmt` on touched crate +- Fix clippy in `lpc-node-registry` if any new warnings +- `summary.md` per plan template +- Optional one-line cross-link in + `docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-design.md` + **only if** user wants — prefer note in summary only: "M8 overlay layer superseded by + artifact-pending-overlay plan" + +**Out of scope:** + +- `just check` full workspace unless quick; minimum `cargo test -p lpc-node-registry` +- Git commit (user triggers separately unless asked) + +## Code organization reminders + +- Remove debug prints, stray TODOs from phases 1–6. +- Ensure module docs on `ArtifactOverlay` describe map-not-log semantics. + +## Sub-agent reminders + +- Do **not** commit unless user explicitly requests in phase prompt. +- Report full validation output. + +## Implementation details + +### Grep cleanup + +```bash +rg 'TODO|FIXME|println!' lp-core/lpc-node-registry/src/edit lp-core/lpc-node-registry/src/registry +rg 'DefDraft|SlotOverlay' lp-core/lpc-node-registry +``` + +### Format + +```bash +cargo +nightly fmt -p lpc-node-registry +``` + +### Validation + +```bash +cargo test -p lpc-node-registry +cargo check -p lpc-node-registry --no-default-features +cargo clippy -p lpc-node-registry --all-targets -- -D warnings +``` + +If clippy fails on pre-existing issues outside overlay touch, report without drive-by fixes. + +### `summary.md` + +Write per plan template: + +- **What was built** — bullet list +- **Decisions for future reference** — map-not-log, mutual exclusion, string slot keys, + no SessionLog v1, projection-on-read, Slotted MapSlot, supersede M8 materialized overlay + +## Validate + +All commands above green. diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/future.md b/docs/plans/2026-05-21-artifact-pending-overlay/future.md new file mode 100644 index 000000000..e67e662f6 --- /dev/null +++ b/docs/plans/2026-05-21-artifact-pending-overlay/future.md @@ -0,0 +1,33 @@ +## Wire sync for ArtifactOverlay + +- **Idea:** Expose overlay roots via `slot_sync_codec` / `WireSlotPatch` per artifact or one registry root. +- **Why not now:** Registry shape must land first; M1 wire design still open. +- **Useful context:** `lpc-wire/src/slot/access_sync.rs`, M1 `ui-parity.md` pending row. + +## MapSlotKeyLike for SlotPath + +- **Idea:** Use `MapSlot` directly instead of string keys. +- **Why not now:** Requires `lpc-model` change; string keys sufficient for v1. +- **Useful context:** `lpc-model/src/slot/value_slot.rs` `MapSlotKeyLike`. + +## SessionLog (M8) for ordering / audit + +- **Idea:** Optional append-only log above the pending map for multi-client ordering. +- **Why not now:** Address-keyed map + revision CAS is enough for v1 single editor. +- **Useful context:** `m8-edit-session-sync/00-design.md` — explicitly superseded for overlay storage. + +## Cached effective projection (per artifact) + +- **Idea:** After folding `committed + pending → NodeDef`, cache the result keyed by + `ArtifactLocation` (or on each affected `NodeDefEntry`). Path lookups (`effective_state`, + `NodeDefView`, slot accessors) hit the cache instead of re-cloning and re-applying the + pending map on every read. +- **Invalidation:** Clear or rebuild cache when that artifact's `ArtifactPending` bucket + changes (any upsert/remove on apply, or `remove_pending_edit` / `discard` / successful + commit for that location). Committed fs sync that changes the base def also invalidates. +- **Why not v1:** Ephemeral fold in `projection.rs` is simpler and has no invalidation bugs; + pending maps are usually small so per-read cost may be fine initially. +- **API shape:** Phase 3 should keep projection behind `project_*` helpers so a cache layer + can wrap them without changing `NodeDefView` callers. +- **Useful context:** User agreed v1 = no stored effective; caching is an optimization for + hot path reads, not a second source of truth (overlay map remains authoritative). diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/summary.md b/docs/plans/2026-05-21-artifact-pending-overlay/summary.md new file mode 100644 index 000000000..bfd751bcb --- /dev/null +++ b/docs/plans/2026-05-21-artifact-pending-overlay/summary.md @@ -0,0 +1,57 @@ +# Artifact Pending Overlay — Implementation Summary + +## What changed + +Replaced path-keyed `SlotOverlay` / `DefDraft` materialization with address-keyed +`ArtifactOverlay` storing **current pending edits** per `ArtifactLoc`: + +- **Slot pending:** `MapSlot` with upsert-by-edit-key and apply-order + tracking (supports multiple `MapInsert` ops on the same map path). +- **Asset pending:** `AssetPending` (`None` | `Delete` | `ReplaceBody`) mutually exclusive + with slot map. +- **Projection:** `registry/projection.rs` folds committed + pending on read (in-memory + for loaded defs; bytes path for commit / effective bytes). +- **Commit:** folds pending map → filesystem writes via `OverlayCommitPlan`. +- **Introspection:** `overlay_active`, `pending_at`, `iter_pending`, `has_pending_slot`. + +## Removed + +- `edit/def_draft.rs` +- `edit/slot_overlay.rs` +- Public exports: `DefDraft`, `SlotOverlay`, `SlotOverlayEntry` + +## Key files + +| File | Role | +|------|------| +| `edit/artifact_overlay.rs` | Overlay storage + mutual exclusion | +| `edit/pending_slot_key.rs` | `slot_path_key`, `slot_edit_key`, parse | +| `registry/projection.rs` | Fold committed + pending | +| `registry/effective_read.rs` | Effective reads delegate to projection | +| `registry/commit.rs` | Promote overlay → fs | +| `registry/slot_apply.rs` | Upsert slot ops (no fork draft) | + +## Deviations from plan + +1. **Outer overlay map** uses `MapSlot` keyed by + `ArtifactLoc::to_uri()` (not `MapSlot`) because `ArtifactLoc` does not + implement `MapSlotKeyLike`. +2. **Slot edit keys** use `slot_edit_key()` (path + op kind + map key for map ops), not + path-only — required for multiple map inserts on one path. +3. **Apply order** preserved via private `slot_order: Vec`, not sorted keys. +4. **Inline def projection** always folds from **artifact root** entry, then slices + `NodeDefLoc.path` (fixes child effective view). + +## Validation + +```bash +cargo test -p lpc-node-registry # 86 tests pass +cargo check -p lpc-node-registry --no-default-features +cargo +nightly fmt -p lpc-node-registry +``` + +## Follow-ups (see `future.md`) + +- Wire sync / client read-back of pending map +- Per-artifact cached effective projection +- `SessionLog` integration (M8) diff --git a/docs/plans/2026-05-21-artifact-routed-file-reload/00-notes.md b/docs/plans/2026-05-21-artifact-routed-file-reload/00-notes.md new file mode 100644 index 000000000..9e4ecd1cc --- /dev/null +++ b/docs/plans/2026-05-21-artifact-routed-file-reload/00-notes.md @@ -0,0 +1,166 @@ +# Artifact-Routed File Reload Notes + +## Scope + +Implement file-change reload so a running LightPlayer project updates only the artifacts and nodes affected by changed files. + +The target architecture is: + +- File changes are routed into the running engine. +- The artifact manager owns file identity, content freshness, and load error state. +- Nodes react to changed artifacts in place. +- File changes must not call `Project::reload()` or reconstruct the whole `Engine`. +- Hot reload must avoid peak-memory patterns that keep an old whole-engine runtime alive while constructing a new one. +- Source-file artifacts must not retain full file contents in memory. They should track only identity and freshness for now: file path/name plus content version. Nodes lazy-load actual bytes/text only while preparing or compiling. + +This plan covers: + +- Replacing the server file-change path that currently calls `Project::reload()`. +- Clarifying the artifact model before building reload on top of it. +- Extending `ArtifactStore` so source files such as GLSL and SVG are normal file-backed artifacts, not special server reload cases, while keeping their contents out of long-lived heap storage. +- Separating parsed node definitions from artifacts if needed, so artifact storage does not become a confusing node definition registry. +- Registering node-to-artifact dependencies during project load. +- Letting shader and fixture nodes respond to changed dependent artifacts. +- Handling node TOML changes by reloading/repreparing affected nodes in place. +- Capturing bad source/TOML as artifact and node error state while keeping last-good runtime state where possible. +- Preserving scarce ESP32 heap by avoiding cached source text, duplicate engines, and unnecessary duplicate node runtimes. + +This plan does not cover: + +- A full optimal graph diff for arbitrary `project.toml` edits in the first implementation. +- Library artifact specifiers. +- Host precompilation or any change that weakens the on-device GLSL JIT path. + +## Current State + +Relevant files: + +- `lp-app/lpa-server/src/server.rs` + - `LpServer::tick` collects project-relative `FsChange`s. + - Current behavior calls `project.reload()` for project changes. + - This is the wrong architectural boundary for file reload. + +- `lp-app/lpa-server/src/project.rs` + - `Project::reload()` drops the old runtime and calls `ProjectLoader::load_from_root`. + - This remains useful for explicit user-initiated full reload/load flows, but should not run on filesystem changes. + +- `lp-core/lpc-engine/src/engine/engine.rs` + - `Engine::handle_fs_changes(&mut self, _changes: &[FsChange])` exists but is a no-op. + - `Engine` owns `ArtifactStore`, `artifact_nodes`, `demand_roots`, and runtime node tree. + - `TickContext` currently receives the owning node definition artifact id and `content_frame`. + +- `lp-core/lpc-engine/src/artifact/*` + - `ArtifactStore` maps `ArtifactLocation` to `ArtifactId`. +- `ArtifactLocation::File(LpPathBuf)` already exists. +- `ArtifactLocation::InlineNode { owner, name }` exists, but this is likely a model smell. Inline node definitions are not artifacts; they are node definitions derived from an owning artifact such as `project.toml` or a playlist node definition. +- `ArtifactState` currently stores only `NodeDef` payloads. +- `ArtifactStore::load_with` overwrites state with an error on load failure. +- There is no generic text/source-file payload. +- There is no last-good payload plus latest-error model. +- There is no content fingerprint/hash/diff API. Do not add one in the first pass. +- The filesystem abstraction currently exposes `read_file`, which returns a `Vec`. Avoid using it in artifact freshness handling unless a node is actively preparing and will drop the buffer immediately. + +- `lp-core/lpc-engine/src/engine/project_loader.rs` +- Node TOML files are loaded into `ArtifactStore` as `NodeDef`. + - `ShaderSource::Path` is read by `read_shader_source`, producing a `String` passed to `ShaderNode::new`. + - `MappingConfig::SvgPath` is read by `resolve_fixture_mapping`, producing a resolved `MappingConfig` passed to `FixtureNode::new`. + - GLSL and SVG files are therefore not tracked as artifacts after project load. + +- `lp-core/lpc-engine/src/nodes/shader/shader_node.rs` + - Shader node already syncs authored shader config from node def slots during `produce`. +- Shader source text is currently stored directly as `glsl_source`. +- Compile failures are stored in `compilation_error`. +- Current compile failure drops `self.shader`; for hot reload we should stage new compiles so last-good compiled shader can remain active when a changed source fails. +- For path-based shader sources, long-lived `glsl_source: String` is wasteful. The node should store a source artifact reference and last-seen source frame, read source text only during compilation, and drop it after compile succeeds or fails. + +- `lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs` + - Fixture node already syncs mutable authored mapping fields for `PathPoints`. + - `SvgPath` is currently ignored by runtime sync because it is resolved at load time. +- Runtime holds precomputed/direct mapping state derived from the resolved mapping. +- For SVG mappings, the SVG text should be read only while resolving a candidate mapping. The long-lived fixture state should keep the resolved/precomputed mapping, not the SVG text. + +- `lp-core/lpc-engine/src/node/contexts.rs` + - `TickContext::artifact_ref`, `artifact_content_frame`, and `artifact_changed_since` exist. + - These only cover the owning node definition artifact, not dependent source artifacts. + +- `lp-core/lpc-engine/src/engine/slot_mutation.rs` +- Slot mutation updates a node definition artifact in place and bumps `content_frame`. +- This is the closest existing example of the intended model: update artifact payload, let node runtime observe content frame and sync from authored slots. + +## Model Correction + +The current names and ownership boundaries are muddy: + +- An artifact should be the thing loaded from outside the runtime graph: usually a file or future library resource. +- A `NodeDef` is derived from an artifact. It is not itself the artifact. +- Inline node definitions are also derived from an owning artifact. They should not require fake artifact locations. +- A running node entry should point to a node definition handle, and that handle should be backed by a node definition registry or table. +- The artifact store should answer "what source changed, and what version is it?" +- The node definition registry should answer "what parsed node definition does this node instance use, and when did that definition last change?" + +This suggests a split: + +- `ArtifactStore`: source identity and freshness. Minimal file artifacts store path plus content version. It should not assume payloads are only `NodeDef`. +- `NodeDefRegistry` or equivalent: parsed `NodeDef` storage, including definitions parsed from node TOML files and inline definitions parsed out of another artifact. +- `NodeDefHandle`: handle into the node definition registry, not an artifact id plus path pretending every node def is an artifact root. +- Dependency index: maps artifact ids to derived node def handles and live node ids. + +The reload plan should start by making this model explicit enough that source-file reload does not add more special cases to `ArtifactStore`. + +## User Notes + +- File reloads should only reload what they need. +- Reload must be routed through the artifact manager. +- Server/file-watcher code should not know special cases for GLSL or SVG. +- GLSL and SVG should just be file artifacts. +- Artifacts are not meant to be only `NodeDef`s. The artifact is the source from which a `NodeDef` or source text comes. +- `ArtifactLocation::InlineNode` is probably wrong; inline node definitions belong in a node definition registry derived from the owning artifact. +- A separate `NodeDefRegistry` may be needed to clarify responsibilities. +- The running project should handle changes. +- Changes to node files should reload the affected node, not the whole project. +- It is acceptable initially to reload child nodes when a node file changes, but avoid unnecessary whole-project reload. +- ESP32 memory is tight; do not implement transactional whole-engine reload that holds two engines at once. +- 300 KB of heap is a real production constraint. The first real art project hit memory limits, so this design should prefer metadata, handles, and staged local work over retaining full files or duplicating runtime state. +- Keep the first artifact metadata model minimalist. Name/path and version are probably enough. Add digest, length, or richer read/stat metadata only when a real bug or performance problem demonstrates the need. +- Preserve the on-device GLSL JIT. Do not gate or stub the compiler path. + +## Open Questions + +### How should `project.toml` file changes behave in the first implementation? + +- **Context:** Node TOML changes can be mapped to a specific loaded node through `artifact_nodes`. `project.toml` changes can add, remove, or rewire top-level nodes and may require graph reconciliation. +- **Suggested answer:** Do not call `Project::reload()`. Treat `project.toml` as the root graph artifact. Phase 1 should route it through artifact invalidation and record a root/project error state if live reconciliation is unsupported. A later phase can implement limited reconciliation for adding/removing/repointing top-level nodes. + +### Should source file artifacts store bytes or UTF-8 text? + +- **Context:** GLSL and SVG need UTF-8 text. Future resources may need bytes. `ArtifactState` currently stores only `NodeDef`. +- **Answer:** No for long-lived source files. Source-file artifacts should store metadata only: location/path and content version. Nodes lazy-load actual bytes/text from `ArtifactReadRoot` only while preparing/compiling. Node definitions remain stored as parsed `NodeDef` because the live graph reads their slots every frame. + +### Should parsed node definitions remain in `ArtifactStore`? + +- **Context:** `ArtifactStore` currently stores `NodeDef` directly and even has `ArtifactLocation::InlineNode`, which makes it act like a node definition registry. The user clarified that an artifact is the thing from which a node def comes, not the node def itself. +- **Suggested answer:** Introduce a separate `NodeDefRegistry` or equivalent table. Keep parsed `NodeDef`s there. Artifacts should track source freshness and maybe load/read errors. The registry should track derived definitions and their source artifact/version. + +### What replaces `ArtifactLocation::InlineNode`? + +- **Context:** Inline nodes are authored inside another artifact, such as `project.toml` or a playlist/node definition file. They need stable definition handles but are not separately loadable files. +- **Suggested answer:** Represent inline definitions as `NodeDefHandle`s in `NodeDefRegistry`, with metadata pointing to their owning source artifact plus an inline path/name. Do not represent them as artifact locations. + +### Should failed hot reload keep last-good payload? + +- **Context:** `ArtifactStore::load_with` currently replaces state with error on failure. For hot reload, replacing the only good payload would make dependent nodes lose working state. +- **Suggested answer:** Yes. Store last-good payload and latest error separately. Load failure updates error metadata and error frame, not the last-good payload/content frame. + +### How should artifact diffing work without retaining full file contents? + +- **Context:** We might eventually want unchanged writes to avoid bumping `content_frame`, but retaining or reading full source text for generic diffing is too expensive for this first pass. +- **Answer:** Do not solve byte-level diffing in the first pass. Treat filesystem change events as version bumps for known file artifacts. If duplicate writes become noisy later, add a cheap digest/length or filesystem-provided version/stat layer as a follow-up. + +### Should changed GLSL/SVG immediately reprepare nodes, or should nodes notice on next tick/render? + +- **Context:** Demand roots tick every frame, but shader compile happens during render, not produce. Fixture control render happens in control rendering. Immediate reprepare gives faster error readback but can do expensive work in the file-change handler. +- **Suggested answer:** Artifact invalidation/checking happens immediately and updates metadata only. Nodes check dependent artifact frames during their normal produce/render/control paths and stage work there. The engine may mark affected nodes dirty/error-readable immediately when artifact stat/read/hash fails. + +## Suggested Plan Name + +`2026-05-21-artifact-routed-file-reload` diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/01-FIXED-root-ensure-resets-existing-kind.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/01-FIXED-root-ensure-resets-existing-kind.md new file mode 100644 index 000000000..80c9e7461 --- /dev/null +++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/01-FIXED-root-ensure-resets-existing-kind.md @@ -0,0 +1,43 @@ +# Root Ensure Resets Existing Kind + +- **Severity:** P1 +- **Status:** fixed +- **First seen:** 2026-05-27-review.md +- **Last reviewed:** 2026-05-27-review.md +- **Owner:** unassigned + +## Finding + +`EnsurePresent` on a root variant path always calls `set_slot_variant_default` through `NodeArtifact` before checking whether the artifact is already on that variant. That can discard an existing node def payload when a client sends a variant-qualified path such as `Shader.render_order` against an already-`Shader` artifact. + +## Evidence + +- `lp-core/lpc-node-registry/src/registry/slot_apply.rs:109` - any first field segment is treated as a possible root variant. +- `lp-core/lpc-node-registry/src/registry/slot_apply.rs:111` - the code calls `set_slot_variant_default` unconditionally once the variant name is valid. +- `lp-core/lpc-node-registry/src/registry/slot_apply.rs:125` - the default-constructed artifact payload is written back to `def`. +- `lp-core/lpc-model/src/slot/slot_mutation.rs:198` - the generic `ensure_slot_present` path correctly guards nested enum changes with `if en.variant() != name.as_str()`, but the root bridge bypasses that guard. + +## Impact + +A valid edit can accidentally reset unrelated fields in the same artifact. For example, assigning one value through a variant-qualified root path can first replace the whole active root node with its default body, then assign only the requested leaf. Any fields not also present in the edit batch are lost. + +## Suggested Fix + +Before calling `set_slot_variant_default` in the root bridge, check whether `def.variant_name() == variant.as_str()`. If it already matches, either call generic `ensure_slot_present` on the current `NodeDef` for the tail path or wrap in `NodeArtifact` without resetting the variant. + +## Resolution + +`apply_ensure_present` now recognizes authored root node variants explicitly, only resets the root def when the requested root variant differs from the current artifact kind, and returns the payload-relative tail path for the following value assignment. Same-kind root-qualified edits therefore operate on the active payload instead of rebuilding the whole artifact. + +## Validation + +- Added `c1_root_variant_path_preserves_existing_same_kind_payload`, which starts with a non-default `Clock` def, applies `SlotEdit::AssignValue` through a root variant-qualified path, and asserts unrelated payload fields are preserved. +- `cargo fmt --check`: passed +- `cargo check -p lpc-node-registry`: passed +- `cargo test -p lpc-model slot_mutation`: passed +- `cargo test -p lpc-node-registry`: passed + +## History + +- 2026-05-27: opened by Codex review. +- 2026-05-27: fixed by preserving same-kind root payloads during root variant-qualified ensure. diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/02-FIXED-structural-edits-leave-stale-descendants.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/02-FIXED-structural-edits-leave-stale-descendants.md new file mode 100644 index 000000000..4d4f81f6d --- /dev/null +++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/02-FIXED-structural-edits-leave-stale-descendants.md @@ -0,0 +1,42 @@ +# Structural Edits Leave Stale Descendants + +- **Severity:** P2 +- **Status:** fixed +- **First seen:** 2026-05-27-review.md +- **Last reviewed:** 2026-05-27-review.md +- **Owner:** unassigned + +## Finding + +Overlay upsert identity is now exact `SlotPath` equality only. That is clean for leaf writes, but structural edits such as `Remove { path: entries[0] }` or `EnsurePresent { path: entries[0].node.Shader }` can leave older descendant edits queued underneath the same parent. + +## Evidence + +- `lp-core/lpc-node-registry/src/edit/artifact_overlay.rs:39` - `upsert_slot` keys the pending edit by the new edit's exact path. +- `lp-core/lpc-node-registry/src/edit/artifact_overlay.rs:43` - replacement only removes an existing edit when `existing.path() == &target`. +- `lp-core/lpc-node-registry/src/registry/slot_apply.rs:57` - pending ops are applied later in stored order, so stale descendants still execute after a parent presence/kind change unless they happen to share the same exact path. + +## Impact + +A normal editing sequence can produce either surprising final state or a projection error. For example, a pending descendant assignment for a Clock child can survive a later parent edit that changes that child to Shader. If the stale descendant runs after the kind change, the effective projection can become a parse/mutation error; if it runs before, the later structural edit silently discards it. Both are hard for clients to reason about. + +## Suggested Fix + +Teach `ArtifactEdits::upsert_slot` about ancestor/descendant conflict policy. A parent `Remove` or structural `EnsurePresent` should remove pending descendants under that path. A later descendant `AssignValue` should supersede an ancestor `Remove` when auto-vivification is intended to recreate the path. + +## Resolution + +`ArtifactEdits::upsert_slot` now keeps exact path identity for ordinary replacement while adding ancestor/descendant conflict cleanup for structural edits. Parent removes and structural ensures clear stale descendants, positive edits clear ancestor removes so auto-vivification can recreate paths, and redundant child removes are skipped when an ancestor remove already covers the same subtree. + +## Validation + +- Added overlay tests for parent remove after descendant assign, descendant assign after parent remove, enum-kind ensure after stale descendant assign, and a root field ensure regression that keeps root variant ensures intact. +- `cargo fmt --check`: passed +- `cargo check -p lpc-node-registry`: passed +- `cargo test -p lpc-model slot_mutation`: passed +- `cargo test -p lpc-node-registry`: passed + +## History + +- 2026-05-27: opened by Codex review. +- 2026-05-27: fixed by adding structural ancestor/descendant conflict cleanup to overlay slot upsert. diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/03-wire-edit-contract-missing.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/03-wire-edit-contract-missing.md new file mode 100644 index 000000000..8e7ed29de --- /dev/null +++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/03-wire-edit-contract-missing.md @@ -0,0 +1,53 @@ +# Wire Edit Contract Missing + +- **Severity:** P1 +- **Status:** open +- **First seen:** 2026-06-10-api-design-review.md +- **Last reviewed:** 2026-06-10-api-design-review.md +- **Owner:** unassigned + +## Finding + +The new registry edit model is not represented by a durable wire contract. `SlotEdit` is serde-ready locally, but the registry ingress enum mixes client intent with server-local filesystem notifications, and the existing `lpc-wire` mutation API is still the old value-leaf-only engine mutation path. That means we cannot currently write the requested "using on-wire API" registry test for add/delete node, structural def edits, asset CRUD, pending overlay, and commit. + +## Evidence + +- `lp-core/lpc-node-registry/src/registry/sync_op.rs:7` - `SyncOp` is documented as "filesystem or pending-edit CRUD", combining server-local `Fs(FsEvent)` with client-ish edit/commit operations. +- `lp-core/lpc-node-registry/src/registry/sync_op.rs:8` - `SyncOp` derives `Clone`, `Debug`, and `PartialEq`, but not serde/schema traits for wire use. +- `lp-core/lpc-node-registry/src/registry/sync_op.rs:10` - `SyncOp::Fs` carries `lpfs::FsEvent`, which should not be client wire vocabulary. +- `lp-core/lpc-node-registry/src/edit_model/slot_edit.rs:6` - `SlotEdit` itself derives serde, but it is only the slot-level operation, not an edit request envelope with target, batch id, expected revision, commit/discard intent, or response semantics. +- `lp-core/lpc-node-registry/src/edit_model/artifact_overlay.rs:28` - `AssetEdit` is not currently a wire/schema type. +- `lp-core/lpc-wire/src/slot/mutation.rs:24` - `WireSlotMutationRequest` still addresses a string `root` plus `SlotPath` with shape/data CAS revisions. +- `lp-core/lpc-wire/src/slot/mutation.rs:37` - `WireSlotMutationOp` only supports `SetValue`. +- `lp-core/lpc-wire/src/messages/project_read/project_read_request.rs:25` - project reads still carry `Vec`, not registry edit batches. +- `lp-core/lpc-engine/src/engine/slot_mutation.rs:19` - the current server-facing mutation path still mutates `Engine` state directly from `WireSlotMutationRequest`. + +## Impact + +This blocks using the registry as the authoritative edit layer over the wire. A client cannot express asset creation/deletion, node add/remove, structural `EnsurePresent`, overlay discard/commit, or registry commit results through the current protocol. If `SyncOp` is promoted directly, it will leak filesystem events and registry-local details onto the wire before revision, idempotency, and response semantics are decided. + +## Suggested Fix + +Define a wire-facing edit contract separately from registry internals, then add a small adapter into `NodeDefRegistry`. + +Suggested shape: + +- `ClientEditRequest { id, ops, base_revision?, commit_policy? }` +- `ClientEditOp::UpsertSlot { artifact_path, edit: SlotEdit }` +- `ClientEditOp::SetAsset { artifact_path, edit: AssetEdit }` +- `ClientEditOp::RemovePending { artifact_path }` +- `ClientEditOp::ClearPending` +- `ClientEditOp::Commit` +- `ClientEditResponse { id, accepted/rejected, pending_changed, committed: SyncResult?, current_revision? }` + +Keep `FsEvent` and filesystem sync as server-local registry API. Map wire requests to registry operations inside server code. + +## Validation + +- Add `lpc-wire` serde/schema roundtrip tests for the new edit request/response. +- Add a registry adapter test that applies the wire request sequence and asserts the same `ArtifactOverlay`/`SyncOutcome` as direct registry calls. +- Add a negative test proving clients cannot send filesystem events. + +## History + +- 2026-06-10: opened by Codex API design review. diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/04-engine-cutover-story-test-missing.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/04-engine-cutover-story-test-missing.md new file mode 100644 index 000000000..a197ab1bb --- /dev/null +++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/04-engine-cutover-story-test-missing.md @@ -0,0 +1,53 @@ +# Engine Cutover Story Test Missing + +- **Severity:** P2 +- **Status:** open +- **First seen:** 2026-06-10-api-design-review.md +- **Last reviewed:** 2026-06-10-api-design-review.md +- **Owner:** unassigned + +## Finding + +The registry has good slice tests, but no single test demonstrates the full behavior needed before engine cutover: load a root artifact, discover both inline and file-backed child nodes, apply a wire-shaped overlay with every supported mutation family, commit, reload, and prove the committed/effective graph is what the engine would consume. + +## Evidence + +- `lp-core/lpc-node-registry/tests/project_diff.rs:30` - project diff tests prove snapshot-to-overlay-to-commit equivalence, but they operate through `diff()`/`ArtifactOverlay`, not a wire-shaped edit sequence. +- `lp-core/lpc-node-registry/tests/pending_sync.rs:21` - pending sync tests cover small `SyncOp` batches, including one slot edit plus commit, but not a complete project load/edit/reload workflow. +- `lp-core/lpc-node-registry/tests/asset_overlay.rs:32` - asset overlay tests cover materializing pending asset create/replace/delete before commit. +- `lp-core/lpc-node-registry/tests/slot_overlay.rs:41` - slot overlay tests cover value edits and inline child projection. +- `lp-core/lpc-node-registry/tests/commit_promotion.rs:39` - commit tests cover slot/asset flush and inline child change details in separate scenarios. +- `lp-core/lpc-engine/src/engine/project_loader.rs:755` - the engine still loads project TOML directly through its existing loader path. +- `lp-core/lpc-engine/src/engine/slot_mutation.rs:19` - the engine still owns the current direct slot mutation path. + +## Impact + +Without a story test, it is hard to tell whether the registry API is actually ready for engine consumption or merely internally plausible. This is exactly the sort of broad workflow that can fail at the boundaries: node address mapping, inline child paths, child file references, asset freshness, pending overlay projection, commit results, and post-commit reload can all be individually green while still not composing into the engine cutover path. + +## Suggested Fix + +Add a focused `lpc-node-registry` integration test, for example `tests/engine_cutover_story.rs`, that uses a memory filesystem and a wire-shaped adapter once issue 03 exists. + +The scenario should include: + +- Root `project.toml` or `playlist.toml` loaded from an artifact. +- At least one inline child def and one `ref` child def in a separate file. +- At least one shader/fixture asset reference registered through `referenced_asset_paths`. +- Overlay setup through the future wire-shaped API, not direct `ArtifactOverlay` construction. +- Slot value edit via `AssignValue`. +- Structural creation via `EnsurePresent` that adds a node/map entry. +- Structural deletion via `Remove` that deletes a node/map entry. +- Kind/variant change via `EnsurePresent` on a variant path. +- Asset create, replace, and delete. +- Commit. +- Fresh registry reload from filesystem proving the committed artifacts are sufficient for engine loading. +- Assertions on `SyncOutcome`, `NodeDefUpdates`, `NodeDefChangeDetail`, effective reads, materialized source, and final filesystem bytes. + +## Validation + +- `cargo test -p lpc-node-registry --test engine_cutover_story` +- `cargo test -p lpc-node-registry` + +## History + +- 2026-06-10: opened by Codex API design review. diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/05-sync-errors-are-lossy.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/05-sync-errors-are-lossy.md new file mode 100644 index 000000000..867abc916 --- /dev/null +++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/05-sync-errors-are-lossy.md @@ -0,0 +1,49 @@ +# Sync Errors Are Lossy + +- **Severity:** P1 +- **Status:** open +- **First seen:** 2026-06-10-api-design-review.md +- **Last reviewed:** 2026-06-10-api-design-review.md +- **Owner:** unassigned + +## Finding + +Several filesystem sync and committed-artifact refresh paths collapse errors into no-op/default behavior. That is risky for engine cutover because the engine needs to know when reload failed, which artifact failed, and whether it should keep old runtime state, mark a node error, or rebuild part of the graph. + +## Evidence + +- `lp-core/lpc-node-registry/src/registry/sync.rs:70` - `sync_fs` wraps filesystem events into `SyncOp::Fs`. +- `lp-core/lpc-node-registry/src/registry/sync.rs:71` - `sync_fs` maps `Ok(outcome)` to committed changes. +- `lp-core/lpc-node-registry/src/registry/sync.rs:73` - any `SyncError` becomes `SyncResult::default()`. +- `lp-core/lpc-node-registry/src/registry/sync.rs:104` - `apply_fs_sync` ignores the result of `reconcile_artifacts()`. +- `lp-core/lpc-node-registry/src/registry/inventory.rs:32` - `sync_def_artifact` derives the new inventory. +- `lp-core/lpc-node-registry/src/registry/inventory.rs:35` - inventory errors are swallowed with `return`. +- `lp-core/lpc-node-registry/src/registry/commit.rs:115` - commit refresh calls `registry.sync_def_artifact(...)`. +- `lp-core/lpc-node-registry/src/registry/commit.rs:118` - that helper returns `Ok(())` regardless of inventory/reconcile failures inside `sync_def_artifact`. +- `lp-core/lpc-node-registry/src/registry/effective_read.rs:113` - committed byte reads collapse any store read error to `Ok(None)`. + +## Impact + +The registry can silently report "no changes" while failing to reconcile the artifact inventory. For an engine reload path, that can leave stale runtime nodes alive, miss newly invalid definitions, skip child removal/addition, or hide broken references. Parse errors are represented as `NodeDefState::ParseError`, which is good, but structural registry failures such as duplicate definition locations, specifier resolution failures, artifact unregister failures, or missing committed bytes need explicit reporting. + +## Suggested Fix + +Make the engine-facing sync API explicit about failures. + +Recommended direction: + +- Change the engine/server path to use `Result` or `Result` instead of `sync_fs`'s lossy convenience. +- Keep a lossy helper only if it is renamed to make the behavior obvious, such as `sync_fs_best_effort`. +- Have `sync_def_artifact` return `Result<(), RegistryError>` and propagate inventory failures to both filesystem sync and commit. +- Include per-artifact error details in `SyncResult` if the desired policy is "continue but report failures". +- Treat missing committed bytes distinctly from "unknown/unregistered path" in effective reads. + +## Validation + +- Add a filesystem sync test where a child `ref` changes to an invalid specifier and assert the error is visible. +- Add a commit test where a pending TOML edit creates an invalid referenced child and assert commit reports the failure or records a parse/error state. +- Add a test proving `sync_fs` no longer hides `reconcile_artifacts` failure, or explicitly rename and document the lossy helper. + +## History + +- 2026-06-10: opened by Codex API design review. diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/2026-05-27-review.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/2026-05-27-review.md new file mode 100644 index 000000000..b93952588 --- /dev/null +++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/2026-05-27-review.md @@ -0,0 +1,32 @@ +# Review - codex/incremental-artifact-reload + +## Target + +- **Branch/PR:** codex/incremental-artifact-reload +- **Base:** a5064171dd09a48c22b1ac2e93a3cf6d9b106aad +- **Head:** 8b6fac237d81a74d7f9d86cf62534d6794af4a26 +- **Review date:** 2026-05-27 +- **Reviewer:** Codex + +## Summary + +Reviewed the committed cleanup that simplifies `SlotEdit` to path-based `EnsurePresent`, `AssignValue`, and `Remove`, adds model-level `ensure_slot_present`, folds pending asset naming into `AssetEdit`, and updates registry projection/diff/application paths. The direction is good and noticeably simpler. The two correctness risks found during review have been fixed in the follow-up patch. + +## Findings + +| Issue | Severity | Status | Summary | +| --- | --- | --- | --- | +| [01-FIXED-root-ensure-resets-existing-kind.md](01-FIXED-root-ensure-resets-existing-kind.md) | P1 | fixed | Root variant-qualified ensure can default-reset an artifact that is already on the requested kind. | +| [02-FIXED-structural-edits-leave-stale-descendants.md](02-FIXED-structural-edits-leave-stale-descendants.md) | P2 | fixed | Exact-path overlay upsert leaves stale descendant edits after parent structural edits. | + +## Validation + +- `cargo fmt --check`: passed +- `cargo check -p lpc-node-registry`: passed +- `cargo test -p lpc-model slot_mutation`: passed +- `cargo test -p lpc-node-registry`: passed with existing dead-code warnings in shared test helpers + +## Notes + +- Over-the-wire edit schema, revisioning, and backward compatibility were not reviewed in depth because that work has not been done yet. +- The review target was the single committed cleanup at `8b6fac237d81a74d7f9d86cf62534d6794af4a26`. diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/2026-06-10-api-design-review.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/2026-06-10-api-design-review.md new file mode 100644 index 000000000..7b1c47aba --- /dev/null +++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/2026-06-10-api-design-review.md @@ -0,0 +1,106 @@ +# Review - codex/incremental-artifact-reload API Design + +## Target + +- **Branch/PR:** codex/incremental-artifact-reload +- **Base:** origin/codex/incremental-artifact-reload (`a9c644b341554cf14e3f0767262d56e60baa8dcc`) +- **Head:** `13f928ef76ac52dc2e4ce8a92baa62cb92f34c90` +- **Review date:** 2026-06-10 +- **Reviewer:** Codex + +## Summary + +`lpc-node-registry` now has a solid local prototype for artifact inventory, parsed node definitions, pending overlay edits, effective reads, commit promotion, and diff-driven project changes. The current tests prove these pieces in useful slices. They do **not** yet prove the whole engine cutover workflow, mostly because the new registry edit model has no real wire contract and the current `lpc-wire` mutation API is still the old value-leaf-only engine mutation path. + +The short answer to "can we show the full functionality in a test today?" is **not yet**. We can show most underlying mechanics with direct registry APIs, but not the requested "using on-wire API" story. + +## Findings + +| Issue | Severity | Status | Summary | +| --- | --- | --- | --- | +| [03-wire-edit-contract-missing.md](03-wire-edit-contract-missing.md) | P1 | open | New registry edits have no durable wire request/response contract; current wire mutation only supports value leaves. | +| [04-engine-cutover-story-test-missing.md](04-engine-cutover-story-test-missing.md) | P2 | open | Existing tests cover slices, but no single test proves the full root-load, child-discovery, edit, asset CRUD, commit, reload path needed for engine cutover. | +| [05-sync-errors-are-lossy.md](05-sync-errors-are-lossy.md) | P1 | open | Filesystem sync and committed refresh paths can swallow registry errors and report no-op/default results. | + +## What Works Now + +| Capability | Current state | Evidence | +| --- | --- | --- | +| Root artifact load | Implemented through `NodeDefRegistry::load_root`; root path must be absolute, root artifact is registered, reachable children/assets are registered. | `lp-core/lpc-node-registry/src/registry/load.rs:14`, `lp-core/lpc-node-registry/src/registry/load.rs:30`, `lp-core/lpc-node-registry/src/registry/load.rs:33` | +| Inline child discovery | Implemented via model-level `NodeDef::invocation_sites` and registry recursive registration. | `lp-core/lpc-model/src/nodes/node_def.rs:217`, `lp-core/lpc-node-registry/src/registry/load.rs:65`, `lp-core/lpc-node-registry/src/registry/load.rs:90` | +| File-backed child discovery | Implemented for `NodeInvocation::Ref`, resolving relative artifact specifiers and registering child artifact roots. | `lp-core/lpc-node-registry/src/registry/load.rs:68`, `lp-core/lpc-node-registry/src/registry/load.rs:72`, `lp-core/lpc-node-registry/src/registry/load.rs:78` | +| Asset discovery | Implemented for shader, compute shader, and fixture refs through `NodeDef::referenced_asset_paths`. | `lp-core/lpc-model/src/nodes/node_def.rs:246`, `lp-core/lpc-node-registry/src/registry/inventory.rs:280` | +| Pending overlay | Implemented as `ArtifactOverlay`, keyed by `ArtifactLoc`, with current pending slot edits or one pending asset edit per artifact. | `lp-core/lpc-node-registry/src/edit_model/artifact_overlay.rs:12`, `lp-core/lpc-node-registry/src/edit_model/artifact_overlay.rs:18`, `lp-core/lpc-node-registry/src/edit_model/artifact_overlay.rs:74` | +| Slot edit language | Simplified to `EnsurePresent`, `AssignValue`, and `Remove`. | `lp-core/lpc-node-registry/src/edit_model/slot_edit.rs:8` | +| Auto-create/defaulting | `AssignValue` first calls `apply_ensure_present`; `EnsurePresent` delegates to model slot mutation helpers. | `lp-core/lpc-node-registry/src/edit_apply/slot_edit_apply.rs:53`, `lp-core/lpc-node-registry/src/edit_apply/slot_edit_apply.rs:56`, `lp-core/lpc-node-registry/src/edit_apply/slot_edit_apply.rs:97` | +| Effective reads | Implemented through `NodeDefView`, `effective_state`, `read_effective_bytes`, and `materialize_source`. | `lp-core/lpc-node-registry/src/registry/effective_read.rs:19`, `lp-core/lpc-node-registry/src/registry/effective_read.rs:61`, `lp-core/lpc-node-registry/src/registry/effective_read.rs:80`, `lp-core/lpc-node-registry/src/registry/effective_read.rs:85` | +| Commit | Implemented: writes pending bytes/deletes to fs, refreshes affected defs, reconciles artifacts, clears overlay. | `lp-core/lpc-node-registry/src/registry/commit.rs:17`, `lp-core/lpc-node-registry/src/registry/commit.rs:38`, `lp-core/lpc-node-registry/src/registry/commit.rs:44`, `lp-core/lpc-node-registry/src/registry/commit.rs:82` | +| Diff to overlay | Implemented under the `diff` feature for snapshot equivalence tests. | `lp-core/lpc-node-registry/src/diff/project_diff.rs:17` | + +## Test Coverage Map + +| Needed behavior | Covered today? | Notes | +| --- | --- | --- | +| Load root node from artifact | Yes | `project_diff.rs:a1_roundtrip_load_root_after_commit`; registry unit tests also cover root load. | +| Load inline child node | Yes | `slot_overlay.rs:c2_inline_child_slot_patch_visible_in_view`; `commit_promotion.rs:c2_inline_child_changed_after_commit`. | +| Load referenced child node from separate file | Partial/yes | `fs_change_semantics.rs:s5b_path_child_parse_error_reports_entered_error` creates a ref child and syncs errors. No combined story test. | +| Register referenced shader/fixture assets | Yes | `fs_change_semantics.rs:s2_glsl_edit_only_bumps_artifact_store_revision` and `s3_svg_edit_only_bumps_artifact_store_revision`. | +| Set up overlay | Yes, local API | `overlay_lifecycle.rs` and `pending_sync.rs`; not wire API. | +| Value leaf edit | Yes | `slot_overlay.rs:c1_setslot_patches_clock_rate_in_view`, `commit_promotion.rs:d2_commit_updates_committed_and_clears_overlay`. | +| Structural create / add node | Partial | `EnsurePresent` exists and diff can create artifacts, but no explicit node-add story test using a wire-like API. | +| Structural delete / delete node | Partial | `Remove` exists and overlay conflict cleanup is tested, but no explicit node-delete story test using a wire-like API. | +| Kind/variant change | Partial | Filesystem kind change is tested; slot-driven kind/variant change is not covered in a full workflow. | +| Asset create | Yes pre-commit | `asset_overlay.rs:c4a_add_asset_via_overlay_implicit_create`; commit path is less explicit outside diff tests. | +| Asset replace | Yes | `asset_overlay.rs:c4c_replace_glsl_via_overlay_def_unchanged`, `commit_promotion.rs:d2_commit_setbytes_updates_committed`. | +| Asset delete | Yes pre-commit | `asset_overlay.rs:c4b_delete_asset_via_overlay`; commit-delete story should be explicit. | +| Commit changes | Yes | `commit_promotion.rs` and `pending_sync.rs`. | +| Reload committed state as engine would | Partial | `project_diff.rs:a1_roundtrip_load_root_after_commit` reloads after diff commit, but not after all mutation families. | +| On-wire API | No | Current `lpc-wire` mutation is still `WireSlotMutationRequest` + `SetValue`; registry `SyncOp` is local and not serde/schema-ready. | + +## Current Terms + +- **Artifact**: a file-like authored thing tracked by the registry, usually `.toml`, `.glsl`, `.svg`, or similar. +- **Asset**: non-def artifact body edited as bytes/text, such as shader source or fixture mapping. Registry code now generally uses `AssetEdit` for this. +- **Source**: still valid when it means authored shader/source text (`SourceFileSlot`, `materialize_source`). Avoid using it for generic artifact bookkeeping. +- **ArtifactLoc**: registry identity for an artifact, currently path-backed for these workflows. +- **ArtifactStore**: registry-owned catalog of artifact locations, revisions, and transient read state. +- **NodeDefLocation**: identity of a parsed node definition: artifact location plus `SlotPath` inside that artifact. Root defs use the root path; inline defs use child invocation paths. +- **NodeDefEntry**: current parsed registry entry: `NodeDefLocation`, `NodeDefState`, and revision. +- **NodeDefState**: either a loaded `NodeDef` or a parse/error placeholder. +- **NodeDefRegistry**: owner of committed parsed defs plus pending overlay; the main local API for load, sync, effective read, and commit. +- **ArtifactOverlay**: pending current-state map keyed by `ArtifactLoc`. It is not an append-only edit log. +- **ArtifactEdits**: one artifact's pending state: ordered slot edits or one asset edit. +- **SlotEdit**: structural/value slot edit within a `.toml` artifact: `EnsurePresent`, `AssignValue`, or `Remove`. +- **AssetEdit**: asset body operation: `None`, `ReplaceBody(Vec)`, or `Delete`. +- **SyncOp**: local registry ingress enum mixing filesystem events, pending edits, remove/clear, and commit. This is not a settled wire type. +- **SyncOutcome**: result of processing `SyncOp`s: committed changes plus a pending-changed bit. +- **SyncResult**: factual committed registry changes: `NodeDefUpdates` plus `NodeDefChangeDetail`s. +- **NodeDefUpdates**: added/changed/removed def locations. +- **NodeDefChangeDetail**: coarse change classification: content, kind changed, entered error, left error. +- **NodeDefView**: read-only effective projection over registry committed state plus overlay. +- **ProjectSnapshot / diff**: host/test harness tools that compute an `ArtifactOverlay` between filesystem snapshots. + +## Half-Baked Or Still Moving + +- **Wire vocabulary**: old `WireSlotMutation` still exists; new registry edits are not exposed as `lpc-wire` request/response types. +- **Revision/concurrency**: registry pending edits do not yet carry client ids, base revisions, conflict policy, or idempotency semantics. +- **`SyncOp` naming/boundary**: useful local test API, but too broad for wire because it includes `FsEvent`. +- **Error policy**: parse errors are modeled, but some sync/inventory/reconcile errors are swallowed. +- **Engine identity mapping**: the registry uses artifact path plus `SlotPath`; the engine/UI still often think in `node..def` roots. +- **Full generic topology**: `NodeDef::invocation_sites` is centralized in `lpc-model`, but it is still explicit for `Project` and `Playlist`, not a fully generic slot-shape walker. +- **Compatibility facade**: `edit` re-exports `edit_model`/`edit_apply` for stability; this is fine temporarily but should not become the conceptual home. +- **Commit helper naming**: internal names like `commit_slot_overlay` still carry older slot-overlay vocabulary. +- **Source/asset wording**: authored shader source APIs should stay named source; registry bookkeeping should continue moving toward artifact/asset/definition location. + +## Suggested Next Step + +Do not cut the engine over yet. First, define the wire edit contract and add the engine-cutover story test against a registry adapter. Once that test is green locally, the engine cutover has a concrete target instead of a moving API. + +## Validation + +- `cargo test -p lpc-node-registry`: passed with existing dead-code warnings in shared integration-test helpers. + +## Notes + +- The worktree had pre-existing local changes before this review: `artifact_location.rs` is renamed to `artifact_loc.rs`, and `artifact/mod.rs` points at the new module name. This review did not modify those code changes. +- Existing issue files `01` and `02` remain fixed and were not reopened. diff --git a/docs/roadmaps-old/2026-05-16-slot-codec-serde-removal/m4-remove-serde-abandoned/00-notes.md b/docs/roadmaps-old/2026-05-16-slot-codec-serde-removal/m4-remove-serde-abandoned/00-notes.md index 58c457750..ba189b4b0 100644 --- a/docs/roadmaps-old/2026-05-16-slot-codec-serde-removal/m4-remove-serde-abandoned/00-notes.md +++ b/docs/roadmaps-old/2026-05-16-slot-codec-serde-removal/m4-remove-serde-abandoned/00-notes.md @@ -159,7 +159,7 @@ Examples: - `value/value_path.rs` - `project/config.rs` - `server/server_config.rs` -- `artifact/artifact_loc.rs` +- `artifact/artifact_specifier.rs` Some of these are not slot-authored node definitions but still live in `lpc-model`. Each needs a decision: convert to slot/value codecs, move serde diff --git a/docs/roadmaps/2026-05-06-slot-domain-cutover/m1.2-authored-slot-serde-mockup-pressure/00-notes.md b/docs/roadmaps/2026-05-06-slot-domain-cutover/m1.2-authored-slot-serde-mockup-pressure/00-notes.md index 698910179..50125faa4 100644 --- a/docs/roadmaps/2026-05-06-slot-domain-cutover/m1.2-authored-slot-serde-mockup-pressure/00-notes.md +++ b/docs/roadmaps/2026-05-06-slot-domain-cutover/m1.2-authored-slot-serde-mockup-pressure/00-notes.md @@ -69,7 +69,7 @@ Out of scope: - Semantic slots wrap `Versioned` and expose metadata/access, but do not yet deserialize from authored strings/scalars/records. - `SourcePathSlot` and `ArtifactPathSlot` currently store `String`; real source - uses `LpPathBuf` and `ArtifactLocator`, so the mockup can either stay stringy + uses `LpPathBuf` and `ArtifactSpecifier`, so the mockup can either stay stringy for M1.2 or pressure more precise semantic wrappers. ### Mockup Source Model @@ -92,7 +92,7 @@ Out of scope: Real `lpc-source` remains plain serde structs: - `ProjectDef.nodes: BTreeMap` -- `NodeInvocation.artifact: ArtifactLocator` +- `NodeInvocation.artifact: ArtifactSpecifier` - `TextureDef.width/height` - `ShaderDef.glsl_path: LpPathBuf` - `ShaderDef.texture_loc: RelativeNodeRef` @@ -142,7 +142,7 @@ the mockup or add more precise wrappers before M2. Suggested direction: keep mockup path slots string-backed for the first serde slice, but add tests that make the authored format explicit. Add a future note or M2 task to decide whether real source needs `LpPathBufSlot` and -`ArtifactLocatorSlot`. +`ArtifactSpecifierSlot`. ### Arrays Versus Stable-Key Maps diff --git a/docs/roadmaps/2026-05-06-slot-domain-cutover/m2-source-def-slot-roots/00-notes.md b/docs/roadmaps/2026-05-06-slot-domain-cutover/m2-source-def-slot-roots/00-notes.md index 3125b57e0..df3cb4038 100644 --- a/docs/roadmaps/2026-05-06-slot-domain-cutover/m2-source-def-slot-roots/00-notes.md +++ b/docs/roadmaps/2026-05-06-slot-domain-cutover/m2-source-def-slot-roots/00-notes.md @@ -132,7 +132,7 @@ Out of scope: - `examples/basic/project.toml` also contains `uid = "basic"`, which is currently ignored because `ProjectDef` has no `uid` field. - `NodeInvocation`: - - fields: `artifact: ArtifactLocator`, `overrides: Vec<(ValuePath, SrcBinding)>` + - fields: `artifact: ArtifactSpecifier`, `overrides: Vec<(ValuePath, SrcBinding)>` - overrides are transitional and still use `ValuePath` because resolver/binding code has not moved fully to slots. - `TextureDef`: - fields: `width: u32`, `height: u32` @@ -271,9 +271,9 @@ Decision: do not add arrays and do not add custom serde that hides arrays behind Implication: `examples/basic` can change from TOML arrays to keyed path tables if M2 chooses the structured mapping path. -### Artifact Locator And Path Leaves +### Artifact Specifier And Path Leaves -`ArtifactLocator`, `LpPathBuf`, and `RelativeNodeRef` are authored string-like values with different semantics. +`ArtifactSpecifier`, `LpPathBuf`, and `RelativeNodeRef` are authored string-like values with different semantics. Suggested direction: add or reuse semantic leaf shapes/conversions: @@ -281,7 +281,7 @@ Suggested direction: add or reuse semantic leaf shapes/conversions: - `LpPathBuf` / GLSL path -> `source_path_shape()` or a more precise `LpPathBuf`-backed slot if we decide source defs should retain that concrete type. -- `ArtifactLocator` -> `artifact_path_shape()` or a more specific artifact +- `ArtifactSpecifier` -> `artifact_path_shape()` or a more specific artifact locator field/access implementation in `lpc-source`. ## Open Questions diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/decisions.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/decisions.md new file mode 100644 index 000000000..1e77d6234 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/decisions.md @@ -0,0 +1,163 @@ +# Artifact-Routed File Reload — Decisions + +#### Parallel build in lpc-node-registry until M6 + +- **Decision:** M1–**M4** build the new system in `lpc-node-registry` alongside + the existing `lpc-engine` path. **ChangeSet** is a + [promoted roadmap](../2026-05-21-changeset-change-management/overview.md). + **No `lpc-engine` edits until M6.** +- **Why:** Prove fs-change and projection semantics in isolation; keep the app + working on the old stack during development. +- **Rejected alternatives:** In-place refactor of `lpc-engine` from M1; dual + models in one loader before harness gate. +- **Revisit when:** M6 cutover (mandatory after M4 + ChangeSet M6 gate). + +#### ChangeSet before engine cutover + +- **Decision:** **ChangeSet** change management is proven in the promoted roadmap + [`2026-05-21-changeset-change-management`](../2026-05-21-changeset-change-management/overview.md) + before **M6** engine cutover here. Client edits are ordered, id'd, in-memory + until **commit**; express node def slot patches and asset add/replace/delete. +- **Why:** Client-driven edits are as critical as fs-reload; cutover without + this shape repeats overlay work. +- **Rejected alternatives:** Minimal projection only (old M4.1); defer ChangeSet + until after M6; keep as nested M5 plan only. +- **Revisit when:** Wire protocol and CRDT merge (ChangeSet roadmap `future.md`). + +#### ChangeSet as test and diff vocabulary + +- **Decision:** ChangeSet ops are the **canonical edit vocabulary** for client + UI, future **project diff**, and **incremental stress replay** — not a + separate ad-hoc mutation path for tests. +- **Why:** One representation supports compose/morph user stories, wire + messages, and high-level tests decomposed into long op streams for host/emu/device. +- **Rejected alternatives:** Filesystem-only test setup; whole-`reload()` per + scenario on embedded targets. +- **Revisit when:** Project diff tooling (`future.md`); post-M6 replay harness. + +#### Asset vs artifact naming + +- **Decision:** **Asset** = non-node dependency file (GLSL, SVG, …). + **Artifact** = store identity / freshness entry (any file path). ChangeSet + carries **asset** ops; commit bumps **artifact** store + registry. +- **Why:** Clear vocabulary for node TOMLs vs dependency files. +- **Resolved:** Single ChangeSet stream with `NodeChange` / `AssetChange` + variants; see [ChangeSet roadmap](../2026-05-21-changeset-change-management/decisions.md). + +#### lpc-node-registry crate; retire lpc-slot-mockup + +- **Decision:** New **`lpc-node-registry`** crate (`no_std` + alloc); delete + **`lpc-slot-mockup`** at M1 start. +- **Why:** Clean namespace; fast isolated tests; avoid lpvm-heavy `lpc-engine` + churn before semantics are proven. +- **Rejected alternatives:** Build directly in `lpc-engine`; keep mockup. +- **Revisit when:** Unlikely — crate boundary matches domain. + +#### ArtifactStore vs NodeDefRegistry split + +- **Decision:** `ArtifactStore` tracks source freshness only; `NodeDefRegistry` + owns parsed `NodeDef` entries keyed by `NodeDefId`. +- **Why:** Artifacts are sources; defs are derived. Conflating them blocked + file-only reload (GLSL/SVG) and overlay projection. +- **Rejected alternatives:** Generic artifact payload enum; incremental facade + over current store. +- **Revisit when:** Never for this boundary — extend with `BinaryFileSlot` etc. + +#### NodeDefUpdates drives reload + +- **Decision:** Registry **`sync`** returns `NodeDefUpdates` + `{ added, changed, removed }`; driver applies **`apply_fs_changes`** to + `ArtifactStore` first; engine applies lifecycle from report. +- **Why:** Bounded, testable unit between fs events and node tree mutation. + Driver owns fs + store; registry owns parse + diff. +- **Rejected alternatives:** Engine re-parses directly; whole `Project::reload()`; + registry calling `apply_fs_changes` internally. + +#### Registry bootstrap via load_root + +- **Decision:** **`load_root(absolute_path)`** is the single public bootstrap + entry. Root may be any node-definition TOML kind; `project.toml` is convention. + Path-backed child registration is private (walk recursion). +- **Why:** Matches engine/test driver model: init once, then fs loop → + `NodeDefUpdates`. M5 ChangeSet commit uses same `sync` output shape. +- **Rejected alternatives:** Public `register_file` per artifact; requiring + `NodeDef::Project` at root. + +#### Prove semantics before cutover (M4 + ChangeSet gate) + +- **Decision:** No production cutover until **M4** (fs-change) here and + **ChangeSet roadmap M6** (diff + equivalence gate) pass. +- **Why:** Both reload and client edit paths must be proven in parallel stack. +- **Rejected alternatives:** Cutover after M4 only. + +#### SourceFileSlot + SourceFileRef (not node SourceRef) + +- **Decision:** Authored `SourceFileSlot` with `$path` / extension-key inline + TOML; resolved `SourceFileRef` in slot data; materialize via context. +- **Why:** File vs inline is a slot concern; nodes read resolved values like + other slots; no big data in slot values. +- **Rejected alternatives:** `ShaderSource` enum; runtime `SourceRef` on nodes. +- **Revisit when:** `BinaryFileSlot` for byte payloads. + +#### Error propagation, no last-good (v1) + +- **Decision:** Parse/load/validation errors put defs/artifacts in error state; + destroy dependent nodes; cascade parent errors. +- **Why:** Simpler semantics and tests; avoids stale runtime after bad edits. +- **Rejected alternatives:** Last-good def + last-good compiled shader on failure. +- **Revisit when:** Live editing UX demands retaining last-good visuals. + +#### DefView is the sole read path + +- **Decision:** Nodes read through **`NodeDefView`** (base registry + active + **ChangeSet** projection). Proven in [ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md) before M6. +- **Why:** All client edits flow through ChangeSet; commit promotes to base. +- **Rejected alternatives:** Direct registry mutation from wire; overlay only in + UI mockup branch. + +#### Inline def change does not imply parent changed + +- **Decision:** `NodeDefUpdates` reports def-level deltas; inline child edit + marks child `changed`, not parent, unless parent payload changed. +- **Why:** Avoid unnecessary parent/node refresh on nested edits. +- **Rejected alternatives:** Mark entire artifact subtree changed on any edit. + +#### Kind change requires node delete/recreate + +- **Decision:** When a bound def's **`NodeKind`** changes, the engine **deletes + and recreates** the runtime node — no in-place slot refresh. Registry still + reports the `NodeDefId` in **`changed`**; shell stubs at invocation sites + include kind so parent containers also **`changed`** when an inline child's + kind flips. +- **Why:** Node kind determines runtime type, wiring, and lifecycle; treating + kind change as a content patch would leave stale node state. +- **Rejected alternatives:** In-place refresh on kind change; separate + `removed`+`added` ids for kind flips in M2. +- **Revisit when:** Stable `NodeDefId` preservation across kind morph (future). + +#### project.toml graph reconciliation deferred (M8) + +- **Decision:** Leaf file + source file reload first; topology/wiring changes + are a separate milestone after server wire-up. +- **Why:** Graph mutation is distinct from def payload updates; large scope. +- **Rejected alternatives:** Full graph diff in first reload slice. + +#### Explicit Project::reload retained + +- **Decision:** User-initiated full reload keeps drop-and-rebuild; fs watcher + uses incremental path only. +- **Why:** Escape hatch for unsupported edits and debugging. +- **Revisit when:** Unlikely. + +#### Slot provenance via ExplainSlot probe (M10) + +- **Decision:** Provenance is **on-demand**, not on tick/registry read paths. + Client attaches `ExplainSlot` probe to `project_read` (wire types in + `lpc-wire`; engine stub today) or re-derives locally on host when it holds + bindings + ChangeSet. +- **Why:** ESP32 memory; values-only hot path; M3.5 resolver trace seeds + explain output. +- **Rejected alternatives:** Provenance on every `Production`; registry + `explain_slot()` in v1; dedicated `lpa-server` provenance logic. +- **Revisit when:** Thin remote clients without local edit context. +- **Tracked:** [M10](m10-slot-provenance-client.md). diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/future.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/future.md new file mode 100644 index 000000000..eadee74f1 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/future.md @@ -0,0 +1,75 @@ +# Future Work — Artifact-Routed Reload + +## Slot resolution probes (ExplainSlot) + +- **Tracked as [M10](m10-slot-provenance-client.md)** — post-M6 milestone. +- **Idea:** Provenance is **on-demand**, not on every read. Client attaches an + `ExplainSlot` probe to `project_read` (wire types exist; engine stub today) or + re-derives locally on host when it holds bindings + ChangeSet. +- **Cascade:** binding / merge → effective registry def (overlay ∪ base) → + produced-slot fallback; optional `include_trace` for resolver steps. +- **Why not on tick read path:** ESP32 memory; values-only on hot path. +- **Why not lpa-server logic v1:** Server forwards `project_read` probes; engine + executes explain. +- **Revisit when:** Thin remote clients without local edit context; registry + `explain_slot` helper. + +## Project diff → ChangeSet stream + +- **Implemented in [ChangeSet M6](../2026-05-21-changeset-change-management/m6-diff-equivalence-gate.md)** — gates parent engine cutover. +- Post-M6: extend diff to arbitrary `examples/*` pairs for stress replay. + +## ChangeSet replay stress harness (host / emu / device) + +- **Idea:** Record or generate a ChangeSet log; replay through full engine + (post-M6) with configurable granularity (batch commit vs per-op apply). One + high-level test name (`empty → fyeah-sign`) drives hundreds/thousands of + incremental mutations. +- **Why not now:** Requires M6 engine on ChangeSet path; ChangeSet roadmap + proves registry harness first. +- **What it catches:** Panics on partial graph states, OOM spikes on ESP32, + allocator fragmentation from repeated compile/prepare cycles, refcount leaks + on artifact bump — failures whole-reload tests rarely trigger. +- **Useful context:** Same log runs on `cargo test`, `fw-emu`, and on-device CI + when available; compare heap high-water marks across targets. + +## Binary file sources (`BinaryFileSlot`) + +- **Idea:** Sibling to `SourceFileSlot` for byte payloads (textures, binary blobs) with the same file-or-inline authored shape and engine-side artifact registration + resolution. +- **Why not now:** This roadmap covers text sources (GLSL, SVG) and node TOML reload; no current node def field needs binary file-or-inline yet. +- **Useful context:** Same TOML encoding as `SourceFileSlot` (`$path` or extension key); inline values are base64. See roadmap `notes.md` § SourceFileSlot TOML encoding. + +## Last-good state on reload failure + +- **Idea:** Retain last-good parsed defs / compiled shaders when a hot reload fails (e.g. bad GLSL edit). +- **Why not now:** v1 propagates errors — def error state destroys dependent nodes; parents cascade to error. Simpler semantics, easier to test. +- **Useful context:** Roadmap `notes.md` § Error semantics. + +## `project.toml` / graph reconciliation + +- **Idea:** Engine applies tree add/remove/repoint when parent invocation maps + change; registry reports def-level `NodeDefUpdates`. Incremental add/remove + of top-level nodes when root project artifact changes. +- **Why not now:** Leaf node TOML + source file reload covers most edit loops; + requires def-vs-child-def vs wiring distinction solid first (M8). +- **Useful context:** `artifact_nodes` inverse index; `Engine` tree mutation + APIs; roadmap `notes.md` Q1. + +## ChangeSet wire protocol + CRDT merge + +- **Idea:** Full `lpc-wire` ChangeSet messages; concurrent edit merge. +- **Why not now:** ChangeSet roadmap proves in-memory ordered ChangeSet + + commit/discard in harness. +- **Useful context:** [ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md); + `lightplayer-app-ui` overlay mockup for SlotOp reference. + +## Artifact digest / unchanged-write filtering + +- **Idea:** Cheap stat/digest to avoid bumping `content_frame` on no-op writes. +- **Why not now:** Filesystem change events as version bumps are sufficient for first pass; ESP32 cost of retaining or hashing full sources is undesirable until proven necessary. +- **Useful context:** Plan notes in original `docs/plans/2026-05-21-artifact-routed-file-reload/00-notes.md`. + +## Library artifact specifiers + +- **Idea:** `ArtifactSpecifier::Lib(...)` resolved through a library namespace. +- **Why not now:** File-backed reload first; `ArtifactLocation::try_from_src_spec` already rejects lib locators. diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store.md new file mode 100644 index 000000000..edc2b134e --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store.md @@ -0,0 +1,132 @@ +# Milestone 1: `lpc-node-registry` + ArtifactStore + +## Title And Goal + +Bootstrap **`lpc-node-registry`** and implement a **freshness-only +`ArtifactStore`**: file identity, monotonic version, and load error state — **no +parsed payloads, no cached file bytes**. + +This milestone establishes the new crate and the bottom layer of the target +stack. Everything above (parsed defs, ChangeSets, engine) builds on it. + +## Parallel Build + +**M1 does not modify `lpc-engine`.** Production keeps the old +`lpc-engine/src/artifact/` path (NodeDef payloads, `InlineNode`, etc.) until +**M6**. New types live in `lpc-node-registry` with clear module boundaries. + +## Relationship To M2 (split vs combined) + +**Recommendation: keep M1 and M2 as separate milestones**, implement back-to-back +(often one plan folder with phase 1 / phase 2). + +| | M1 (this milestone) | M2 | +|---|---------------------|-----| +| **Question answered** | "Which files exist, what version are they, did read fail?" | "What `NodeDef`s derive from those files?" | +| **Owns** | Crate bootstrap, `ArtifactStore`, `FsChange` → bump | `NodeDefRegistry`, `NodeDefUpdates`, parse hook | +| **Testable alone?** | Yes — acquire, bump, error state, no parser | Needs M1 store feeding artifact changes | +| **Gate** | `cargo test -p lpc-node-registry` artifact tests green | Registry update tests green | + +Combining into one milestone is workable if you prefer a single PR, but the **M1 +done gate** should still be: store complete and tested **before** registry work +starts — otherwise artifact semantics get buried in parse/diff noise. + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/` + +## Scope + +### Crate bootstrap (first deliverable) + +- **Delete `lp-core/lpc-slot-mockup`** — remove crate and all workspace / + `default-members` / clippy exclusion references (not in CI `justfile`; defunct + slot-pressure harness). +- **Create `lp-core/lpc-node-registry`**: + - `#![no_std]` + `alloc` + - Deps (initial): `lpc-model`, `lpfs`, `lpc-shared` (paths/revisions as needed) + - Layout: `src/lib.rs`, `src/artifact/` (M1); stub modules or `mod` declarations + for `registry/`, `source/`, `change/`, `view/` (filled in later milestones) + - `cargo test -p lpc-node-registry` runs in host CI (`just check` path) + +### ArtifactStore (freshness-only) + +Track **file artifacts callers acquire** — node TOMLs, GLSL, SVG, etc. — as +**`ArtifactLocation::File(path)`** entries. v1 has **no `InlineNode`** +location type (inline defs are registry paths in M2, not separate artifact +locations). + +Per entry: + +- **`ArtifactId`** — opaque handle (same pattern as today; may reuse type from + `lpc-model` or define in this crate). +- **Identity** — path + stable id even when load/read fails. +- **`revision`** — monotonic bump on invalidation (fs change, + simulated bump in tests). No content hash/digest in v1. (Not `content_frame`.) +- **Requester ownership** — entries exist because a caller **acquired** them; + **`release`** at refcount zero removes the entry. Fs changes do not register + artifacts. +- **State** — `ArtifactReadState` (`Unread` / `ReadOk` / `Failed(ArtifactReadFailure)`); + failures: `Deleted`, `NotFound`, `Io`, `InvalidPath`. + +API surface (conceptual): + +- `acquire_specifier` / `acquire_location` → `ArtifactId` (always entry unless resolution fails) +- `release(id)` +- `revision(id) → Revision` +- `apply_fs_changes(&[FsChange], frame)` — bumps **held** entries only +- `read_error` via `read_state` on entry +- Optional **`read_bytes(id, fs) → Result<..., ArtifactError>`** for **transient** + reads during tests / later registry parse — bytes not stored on the entry + +### Tests (M1 gate) + +Prove the store **without** `NodeDefRegistry` or TOML parsing: + +- Acquire same path twice → same `ArtifactId`. +- Simulated `FsChange` on path → `revision` bumps. +- Unrelated path change → other entries unchanged. +- Read failure → entry stays registered, error state set, version semantics defined. +- No long-lived payload after read helper returns (if read helper exists in M1). + +Out of scope: + +- **`NodeDefRegistry`**, **`NodeDefUpdates`**, inline def paths (**M2**). +- TOML / `NodeDef` parse integration (**M2**). +- `SourceFileSlot` / `SourceFileRef` (**M3**). +- Reload harness, expected engine actions (**M4**). +- ChangeSet (**M5**). +- Any `lpc-engine` / `ProjectLoader` edits (**M6**). + +## Key Decisions + +- **M1 = new home + artifact freshness layer** — not "a refactor of engine + ArtifactStore in place." +- **Metadata only** — path + version + error; lazy transient read at parse/prepare time. +- **Fs event = version bump** — no byte-level diff in v1. +- **All file types equal** — GLSL and SVG are artifacts from M1, not special cases + added later. +- **Parallel crate only** — zero `lpc-engine` churn this milestone. + +## Deliverables + +- `lp-core/lpc-node-registry/` crate in workspace, CI-clean. +- `lpc-slot-mockup` removed. +- `src/artifact/` — store, id, location, state, fs-change bump. +- Unit tests satisfying M1 gate above. + +## Dependencies + +- None (first milestone). + +## Execution Strategy + +Full plan written — see `m1-artifact-store/` (`00-design.md`, phases `01`–`05`). + +Dispatch: **`composer-2.5-fast`** default; phase 01 uses **`composer-2-fast`** +(bootstrap only). Implement via `/implement`; single commit at end. + +Suggested chat opener: + +> M1 plan is ready in `m1-artifact-store/`. Run `/implement` to dispatch phases +> 01–05. Agree? diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/00-design.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/00-design.md new file mode 100644 index 000000000..9dac0c283 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/00-design.md @@ -0,0 +1,186 @@ +# M1 Design — `lpc-node-registry` + ArtifactStore + +## Scope + +Bootstrap `lpc-node-registry` and implement a **freshness-only**, **requester-owned** +`ArtifactStore`. Entries exist because a caller acquired them; the filesystem +only **invalidates** (bumps `revision`) entries that are already held. + +## Ownership model + +``` +Engine (M6) ──acquire──► NodeDefRegistry (M2) ──acquire──► ArtifactStore + │ │ + │ release when done │ + └──────────────────────────────┘ +``` + +- **Acquire** resolves an authored specifier to `ArtifactLocation::File(path)`, + creates or reuses the entry, increments **refcount**, returns **`ArtifactId`**. +- **Release** decrements refcount; at **zero**, entry is **removed** from the store. +- **Fs changes** do not create entries. They only affect paths that already have + an acquired entry in the store. +- **Bad locator** (e.g. `lib:…` unsupported) → **`ArtifactError::Resolution`** + at acquire time — no entry created. + +This matches production engine refcount semantics without retaining `NodeDef` +payloads. + +## File structure + +``` +lp-core/lpc-node-registry/ +├── Cargo.toml +└── src/ + ├── lib.rs # crate root, re-exports + ├── artifact/ + │ ├── mod.rs + │ ├── artifact_id.rs # opaque ArtifactId + │ ├── artifact_location.rs # File(LpPathBuf) only; try_from_specifier + │ ├── artifact_error.rs + │ ├── artifact_read_state.rs # Unread | ReadOk | Failed(ArtifactReadFailure) + │ ├── artifact_entry.rs + │ └── artifact_store.rs # acquire, release, apply_fs_changes, read_bytes + ├── registry/mod.rs # stub — M2 + ├── source/mod.rs # stub — M3 + ├── change/mod.rs # stub — M5 + └── view/mod.rs # stub — M5 +``` + +Delete: `lp-core/lpc-slot-mockup/` and workspace member entry. + +## Conceptual architecture + +``` + acquire(locator) / release(id) + │ +┌──────────────┐ ┌─────────▼─────────┐ apply_fs_changes([FsChange]) +│ Caller │───►│ ArtifactStore │◄────────────────────────────── +│ (tests/M2) │ │ │ +└──────────────┘ │ by_handle │ + │ location→handle │ + └─────────┬─────────┘ + │ read_bytes(id, &LpFs) [transient] + ▼ + Vec dropped; entry keeps + ReadOk / ReadError only +``` + +### ArtifactEntry (freshness-only) + +| Field | Type | Role | +|-------|------|------| +| `id` | `ArtifactId` | Opaque handle | +| `location` | `ArtifactLocation` | Resolved `File(path)` | +| `refcount` | `u32` | Requester ownership | +| `revision` | `Revision` | Monotonic content generation | +| `read_state` | `ArtifactReadState` | Last read / fs-notify outcome (see below) | + +No `NodeDef`, no `Vec` on the entry. + +### Read state (structured failures) + +```rust +enum ArtifactReadState { + Unread, + ReadOk, + Failed(ArtifactReadFailure), +} + +enum ArtifactReadFailure { + /// FsChange::Delete while entry held — watcher-sourced; no read required. + Deleted, + /// read_bytes: file not on disk (never existed, or gone before notify). + NotFound, + /// read_bytes: FsError::Filesystem or host I/O. + Io { message: String }, + /// read_bytes: FsError::InvalidPath. + InvalidPath { message: String }, +} +``` + +Acquire-time locator errors stay in `ArtifactError::Resolution` — not mixed into +`read_state`. + +### Revision bumps + +| Event | Effect on matching acquired entry | +|-------|-------------------------------------| +| `FsChange::Modify` | `revision = frame` (or `revision.next()`), `read_state = Unread` | +| `FsChange::Create` | Same as modify (path already held — content replaced) | +| `FsChange::Delete` | Bump `revision`; `read_state = Failed(Deleted)` | +| `read_bytes` Ok | `read_state = ReadOk` (bytes not stored) | +| `read_bytes` Err | Map `FsError` → `Failed(NotFound \| Io \| InvalidPath)` | + +`apply_fs_changes` takes a **`Revision` frame** argument (caller-supplied sync +tick), same pattern as engine `acquire_location(..., frame)`. + +### Public API (M1) + +```rust +impl ArtifactStore { + pub fn new() -> Self; + + pub fn acquire_location( + &mut self, + location: ArtifactLocation, + frame: Revision, + ) -> ArtifactId; + + pub fn acquire_specifier( + &mut self, + locator: &ArtifactSpecifier, + frame: Revision, + ) -> Result; + + pub fn release(&mut self, id: &ArtifactId, frame: Revision) -> Result<(), ArtifactError>; + + pub fn apply_fs_changes(&mut self, changes: &[FsChange], frame: Revision); + + pub fn read_bytes( + &mut self, + id: &ArtifactId, + fs: &dyn LpFs, + ) -> Result, ArtifactError>; + + pub fn revision(&self, id: &ArtifactId) -> Option; + pub fn entry(&self, id: &ArtifactId) -> Option<&ArtifactEntry>; +} +``` + +## Main components + +- **`ArtifactLocation`** — resolved cache key; M1: `File(LpPathBuf)` only. +- **`ArtifactStore`** — refcounted freshness cache; fs changes are invalidations. +- **`ArtifactReadState`** — separates “content generation” (`revision`) from + “last read attempt outcome”. +- **Stubs** — `registry`, `source`, `change`, `view` modules declared for roadmap + layout; empty in M1. + +## Validation + +```bash +cargo +nightly fmt --all +cargo test -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets -- -D warnings +``` + +## Out of scope + +- `NodeDefRegistry`, TOML parse, `SourceFileSlot`, ChangeSet, engine cutover. + +## Plan phases (dispatch) + +Default model policy: **`composer-2.5-fast`**; **`composer-2-fast`** only for +very simple mechanical work. See `/plan` and `/implement` commands. + +| # | Phase | Dispatch | +|---|-------|----------| +| 01 | Bootstrap crate + delete mockup | [sub-agent: yes, model: **composer-2-fast**] | +| 02 | Artifact types | [sub-agent: yes, model: **composer-2.5-fast**] | +| 03 | Store acquire/release + fs changes | [sub-agent: yes, model: **composer-2.5-fast**] | +| 04 | Transient `read_bytes` | [sub-agent: yes, model: **composer-2.5-fast**] | +| 05 | Cleanup + validation + `summary.md` | [sub-agent: **supervised**, model: **composer-2.5-fast**] | + +Phases run sequentially (each depends on the previous). Single commit at end +of plan per `/implement`. diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/00-notes.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/00-notes.md new file mode 100644 index 000000000..c7f43b344 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/00-notes.md @@ -0,0 +1,102 @@ +# M1 Plan Notes — `lpc-node-registry` + ArtifactStore + +## Scope of work + +Bootstrap **`lpc-node-registry`** and implement a **freshness-only +`ArtifactStore`**: + +1. **Crate bootstrap** — new `no_std` + `alloc` crate; delete `lpc-slot-mockup`. +2. **Requester-owned artifacts** — entries exist because a caller **acquired** + them; **`release`** when done. No filesystem-driven auto-registration. +3. **Freshness metadata** — per entry: path, **`revision`** (`lpc_model::Revision`), + read outcome state, **no cached bytes**, **no `NodeDef` payload**. +4. **Fs integration** — `apply_fs_changes` bumps **`revision`** on **existing** + acquired entries whose path matches; handles missing/deleted files without + removing entries while refs are held. +5. **Transient read** — `read_bytes(id, fs)` for M2 parse path; does not retain + payload. +6. **Unit tests** — M1 gate; `cargo test -p lpc-node-registry`. + +**Out of scope:** `NodeDefRegistry` (M2), parsing, `SourceFileSlot`, ChangeSet, +`lpc-engine` edits. + +## Current state + +See prior analysis: production artifact logic in `lpc-engine/src/artifact/` +(NodeDef payloads, refcount). No `lpc-node-registry` yet. `lpc-slot-mockup` has +no Cargo dependents. + +## Questions — resolved + +### Confirmation batch (Q1–Q2, Q4, Q6–Q8) + +| # | Answer | +|---|--------| +| Q1 | **Yes** — delete `lpc-slot-mockup` entirely | +| Q2 | **Yes** — new types in `lpc-node-registry` (parallel to engine until M6) | +| Q4 | **Yes** — `ArtifactLocation::File` only in M1 | +| Q6 | **Yes** — transient `read_bytes` in M1 | +| Q7 | **Yes** — stub `registry`, `source`, `change`, `view` modules | +| Q8 | **Yes** — `#![no_std]` + `default = ["std"]` | + +### Q3, Q5, Q9 — revised by ownership model (user) + +| # | Original suggestion | **Decision** | +|---|---------------------|--------------| +| Q3 | No refcount | **Refcount acquire/release** — artifacts owned by requester | +| Q5 | `content_frame` | **`revision`** field name (`lpc_model::Revision`) | +| Q9 | Delete removes entry | **Fs Delete bumps revision on held entries**; entry removed only on **release to refcount 0** | + +### Q10 — artifact state (user) + +**Decision:** Requester-owned entries + read outcome state. + +- **`acquire`** (resolve locator → location) **always yields an entry** unless the + locator is badly formed (e.g. unsupported `lib:`). +- Entry lifetime tied to **refcount**, not filesystem. +- Bootstrap chain (future): **engine → registry acquires `project.toml` → …** +- Missing/deleted files: entry persists while acquired; **`revision` bumps** on fs + delete/modify; read state reflects failure without dropping identity. + +```rust +enum ArtifactReadState { + Unread, + ReadOk, + Failed(ArtifactReadFailure), +} + +enum ArtifactReadFailure { + Deleted, + NotFound, + Io { message: String }, + InvalidPath { message: String }, +} +``` + +- **`revision` bump** on fs modify/create → `Unread` (clears prior failure). +- **`FsChange::Delete`** → bump + `Failed(Deleted)` immediately. +- **`release` at refcount 0** → remove entry from store. + +## Resolved decisions (roadmap + plan) + +- M1 does **not** touch `lpc-engine`. +- Metadata only — no byte retention on entries. +- Fs events affect **already-acquired** entries only. +- Field name **`revision`**, not `content_frame`. + +## Notes + +- User: artifacts are owned by the **requester**, not the filesystem. The node + system asks the store; store returns an entry (unless locator resolution fails). +- M2 `NodeDefRegistry` will acquire artifacts when defs need file sources; M1 + tests simulate acquire/release directly. + +## Implementation dispatch + +Per `/plan` and `/implement` (updated model policy): + +| Phase | Model | +|-------|-------| +| 01 Bootstrap | `composer-2-fast` | +| 02–04 | `composer-2.5-fast` | +| 05 Cleanup | `composer-2.5-fast` (supervised) | diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/01-bootstrap-crate.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/01-bootstrap-crate.md new file mode 100644 index 000000000..4c53b31a0 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/01-bootstrap-crate.md @@ -0,0 +1,105 @@ +# Phase 01 — Delete mockup and bootstrap crate + +**Dispatch:** [sub-agent: yes, model: composer-2-fast, parallel: -] + +## Scope of phase + +- Delete `lp-core/lpc-slot-mockup/` entirely. +- Remove `"lp-core/lpc-slot-mockup"` from workspace `Cargo.toml` `members`. +- Create `lp-core/lpc-node-registry/` with `Cargo.toml`, `src/lib.rs`, and **empty + stub modules** for future milestones. +- Crate must compile (`cargo check -p lpc-node-registry`). + +**Out of scope:** `artifact/` implementation (phase 02+). Do not touch `lpc-engine`. + +## Code Organization Reminders + +- One concept per file; entry points at top of each file, helpers at bottom. +- Tests at bottom of files in `#[cfg(test)] mod tests`. +- Stub modules get a one-line `//! M2` (etc.) doc comment only. + +## Sub-agent Reminders + +- Do **not** commit. +- Do **not** expand scope. +- Do **not** suppress warnings with `#[allow]` — fix them. +- Do **not** disable or weaken existing tests. +- If blocked, stop and report back. + +## Implementation Details + +### Delete mockup + +Remove directory `lp-core/lpc-slot-mockup/`. Grep workspace for +`lpc-slot-mockup` and remove references (expect only root `Cargo.toml` members). + +### `lp-core/lpc-node-registry/Cargo.toml` + +```toml +[package] +name = "lpc-node-registry" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[features] +default = ["std"] +std = ["lpc-model/std", "lpfs/std"] + +[dependencies] +lpc-model = { path = "../lpc-model", default-features = false } +lpfs = { path = "../../lp-base/lpfs", default-features = false } + +[lints] +workspace = true +``` + +Add `"lp-core/lpc-node-registry"` to workspace `members` in root `Cargo.toml` +(near other `lp-core/*` entries). Add to `default-members` as well (host CI). + +### `src/lib.rs` + +```rust +#![no_std] + +extern crate alloc; + +#[cfg(feature = "std")] +extern crate std; + +pub mod artifact; + +mod registry; +mod source; +mod change; +mod view; +``` + +Stub files (`src/registry/mod.rs`, etc.): + +```rust +//! NodeDefRegistry — milestone M2. +``` + +`src/artifact/mod.rs` — empty module declaration only for now (phase 02 fills it): + +```rust +// Types added in phase 02. +``` + +Or minimal placeholder so `pub mod artifact` compiles. + +### Workspace + +Ensure `cargo check -p lpc-node-registry` succeeds with no warnings. + +## Validate + +```bash +cargo check -p lpc-node-registry +rg "lpc-slot-mockup" Cargo.toml lp-core/ --glob '!docs/**' || true +``` + +Expected: no mockup dir; registry crate compiles. diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/02-artifact-types.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/02-artifact-types.md new file mode 100644 index 000000000..b55e2c400 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/02-artifact-types.md @@ -0,0 +1,127 @@ +# Phase 02 — Artifact types + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +Implement artifact **types** under `lpc-node-registry/src/artifact/` (no store logic +yet beyond what tests need for type checking): + +- `ArtifactId` +- `ArtifactLocation` (`File` only) + `try_from_specifier` +- `ArtifactError` +- `ArtifactReadState` +- `ArtifactReadFailure` +- `ArtifactEntry` + +Export all public types from `artifact/mod.rs`. + +**Out of scope:** `ArtifactStore` methods (phase 03). Do not touch `lpc-engine`. + +## Code Organization Reminders + +- Granular files per type; `mod.rs` re-exports. +- Unit tests for `try_from_specifier` at bottom of `artifact_location.rs`. +- Helpers at bottom of files. + +## Sub-agent Reminders + +- Do **not** commit. +- Stay in scope — types only. +- Fix warnings; no `#[allow]` without reason. +- Report back: files added, tests run. + +## Implementation Details + +### `artifact_id.rs` + +Mirror engine pattern: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ArtifactId { handle: u32 } +``` + +`from_raw` / `handle()` — `from_raw` pub(crate) until store needs it, or pub(crate) in store module only. + +### `artifact_location.rs` + +```rust +#[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord)] +pub enum ArtifactLocation { + File(LpPathBuf), +} +``` + +- `file(path) -> Self` +- `try_from_specifier(loc: &ArtifactSpecifier) -> Result` + - `ArtifactSpecifier::Path(p)` → `File(p.clone())` + - `ArtifactSpecifier::Lib(_)` → `ArtifactError::Resolution("library artifact references are not supported yet")` + +Use `lpc_model::{ArtifactSpecifier, LpPathBuf}`. + +Port ordering tests from `lpc-engine/src/artifact/artifact_location.rs` (file-only subset). + +### `artifact_error.rs` + +```rust +pub enum ArtifactError { + UnknownHandle { handle: u32 }, + InvalidRelease { handle: u32 }, + Resolution(String), + Read(String), +} +``` + +No `Load`/`Prepare` — freshness store uses `Read` for transient read failures. + +### `artifact_read_state.rs` + +```rust +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ArtifactReadState { + Unread, + ReadOk, + Failed(ArtifactReadFailure), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ArtifactReadFailure { + Deleted, + NotFound, + Io { message: String }, + InvalidPath { message: String }, +} +``` + +Add `ArtifactReadFailure::from_fs_error(FsError) -> Self` helper mapping +`FsError::NotFound` → `NotFound`, `Filesystem` → `Io`, `InvalidPath` → +`InvalidPath`. + +### `artifact_entry.rs` + +```rust +pub struct ArtifactEntry { + pub id: ArtifactId, + pub location: ArtifactLocation, + pub refcount: u32, + pub revision: Revision, + pub read_state: ArtifactReadState, +} +``` + +Use `lpc_model::Revision`. + +### Tests + +In `artifact_location.rs`: + +- Path specifier resolves to `File`. +- Lib locator returns `Resolution` error. + +## Validate + +```bash +cargo test -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets -- -D warnings +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/03-artifact-store-core.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/03-artifact-store-core.md new file mode 100644 index 000000000..b39e52dd2 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/03-artifact-store-core.md @@ -0,0 +1,82 @@ +# Phase 03 — ArtifactStore acquire, release, fs changes + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +Implement `ArtifactStore` with: + +- `new`, `acquire_location`, `acquire_specifier`, `release` +- `apply_fs_changes` +- `revision`, `entry`, `refcount` accessors + +**Out of scope:** `read_bytes` (phase 04). No `lpc-engine` edits. + +## Code Organization Reminders + +- Store impl in `artifact_store.rs`; entry points at top, private helpers at bottom. +- Tests at bottom of `artifact_store.rs`. +- Field name **`revision`**, not `content_frame`. + +## Sub-agent Reminders + +- Do **not** commit. +- Requester-owned model: entries only from acquire; fs never creates entries. +- Report deviations. + +## Implementation Details + +### `ArtifactStore` fields + +```rust +pub struct ArtifactStore { + by_handle: BTreeMap, + location_to_handle: BTreeMap, + next_handle: u32, +} +``` + +### `acquire_location(location, frame: Revision) -> ArtifactId` + +- If location exists: increment `refcount`, return same id. +- Else: allocate handle, insert entry with: + - `refcount = 1` + - `revision = frame` + - `read_state = Unread` + +### `acquire_specifier(locator, frame) -> Result` + +Resolve via `ArtifactLocation::try_from_specifier`, then `acquire_location`. + +### `release(id, _frame) -> Result<(), ArtifactError>` + +- Decrement `refcount`; error if unknown handle or already zero. +- At **refcount 0**: remove entry from `by_handle` and `location_to_handle`. + +### `apply_fs_changes(changes: &[FsChange], frame: Revision)` + +For each change, find entry by **path match** on `ArtifactLocation::File(path)`: + +- **No matching acquired entry** → skip (fs does not register artifacts). +- **`Modify` / `Create`**: set `revision = frame`, `read_state = Unread`. +- **`Delete`**: set `revision = frame`, `read_state = Failed(Deleted)`. + +Path match: compare `FsChange.path` to entry's file path (`LpPathBuf` equality). + +### Tests (required) + +1. `acquire_same_location_reuses_handle_and_increments_refcount` +2. `release_at_zero_removes_entry` +3. `fs_modify_bumps_revision_and_sets_unread` — acquire, apply Modify, assert revision + Unread +4. `fs_change_on_unacquired_path_is_noop` — apply change before acquire, then acquire gets fresh revision from acquire frame only +5. `fs_delete_sets_deleted_failure_while_entry_held` +6. `acquire_specifier_rejects_lib` + +Use `lpfs::FsChange`, `ChangeType`, `LpPathBuf`. + +## Validate + +```bash +cargo test -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets -- -D warnings +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/04-transient-read.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/04-transient-read.md new file mode 100644 index 000000000..e30cbd888 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/04-transient-read.md @@ -0,0 +1,60 @@ +# Phase 04 — Transient read_bytes + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +Add `ArtifactStore::read_bytes` — read file content through `LpFs` without +retaining bytes on the entry. + +**Out of scope:** TOML parsing, registry integration. + +## Code Organization Reminders + +- Add method to existing `artifact_store.rs`. +- Tests at bottom of file using `lpfs::LpFsMemory`. + +## Sub-agent Reminders + +- Do **not** commit. +- Returned `Vec` must not be stored on `ArtifactEntry`. +- Fix warnings properly. + +## Implementation Details + +### `read_bytes(&mut self, id: &ArtifactId, fs: &dyn LpFs) -> Result, ArtifactError>` + +1. Look up entry; `UnknownHandle` if missing. +2. Extract `File(path)` from location; internal error if not file. +3. Call `fs.read_file(path)` (or equivalent `LpFs` API). +4. On **Ok(bytes)**: set `read_state = ReadOk`; return bytes. +5. On **Err(e)**: set `read_state = Failed(ArtifactReadFailure::from_fs_error(e))`; + return matching `ArtifactError::Read(failure)`. + +Reading does **not** bump `revision`. + +If entry already has `ReadError` from fs delete, read attempt may still run (file +missing) — either outcome is acceptable; prefer updating read_state from actual +read result. + +### Tests + +Use `LpFsMemory`: + +1. **`read_bytes_success_sets_read_ok`** — write file to mem fs, acquire, read, + assert `ReadOk`, drop returned vec, assert entry still has no payload field. +2. **`read_bytes_missing_file_sets_not_found`** — acquire path not in fs, read fails, + `Failed(NotFound)` set, entry still exists with refcount. +3. **`read_after_fs_modify_requires_unread_or_reread`** — acquire, read Ok, apply + Modify fs change (Unread), read again gets new content. + +Enable `std` feature on crate for test if mem fs needs it (already default for tests). + +Check `LpFs` trait in `lp-base/lpfs/src/lp_fs.rs` for correct read method name. + +## Validate + +```bash +cargo test -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets -- -D warnings +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/05-cleanup-validation.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/05-cleanup-validation.md new file mode 100644 index 000000000..815b2ff63 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/05-cleanup-validation.md @@ -0,0 +1,64 @@ +# Phase 05 — Cleanup and validation + +**Dispatch:** [sub-agent: supervised, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +- Re-export primary artifact types from `lib.rs` for downstream crates. +- Grep diff for debug prints, stray TODOs, `content_frame` naming. +- Run full validation commands. +- Write `summary.md` per plan process. + +**Out of scope:** New features, M2 work. + +## Code Organization Reminders + +- `lib.rs` re-exports: `ArtifactStore`, `ArtifactId`, `ArtifactLocation`, + `ArtifactError`, `ArtifactReadState`, `ArtifactEntry`. + +## Sub-agent Reminders + +- Do **not** commit (plan commits once at end). +- Fix all clippy warnings in `lpc-node-registry`. + +## Implementation Details + +### `lib.rs` re-exports + +```rust +pub use artifact::{ + ArtifactEntry, ArtifactError, ArtifactId, ArtifactLocation, ArtifactReadState, + ArtifactStore, +}; +``` + +### Update milestone cross-reference (optional doc touch) + +If `docs/roadmaps/.../m1-artifact-store.md` still says `content_frame`, update to +`revision` and requester-owned acquire/release (only if editing docs in this phase). + +### `summary.md` + +Create plan summary with **What was built** and **Decisions for future reference** +(ownership model, revision naming, fs does not register, release-at-zero removes entry). + +### Grep checks + +```bash +rg "content_frame" lp-core/lpc-node-registry/ +rg "TODO|dbg!|println!" lp-core/lpc-node-registry/ +rg "lpc-slot-mockup" Cargo.toml lp-core/ +``` + +Fix any findings. + +## Validate + +```bash +cargo +nightly fmt --all +cargo test -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets -- -D warnings +cargo check -p lpc-node-registry --no-default-features +``` + +All must pass. diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/summary.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/summary.md new file mode 100644 index 000000000..364ab098a --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/summary.md @@ -0,0 +1,34 @@ +# M1 Summary — `lpc-node-registry` + ArtifactStore + +### What was built + +- Added `lp-core/lpc-node-registry` crate (`no_std` + alloc) with stub modules for M2–M5. +- Removed defunct `lp-core/lpc-slot-mockup` from workspace. +- Implemented requester-owned `ArtifactStore`: acquire/release refcount, `revision`, `apply_fs_changes`. +- Added structured read outcomes: `ArtifactReadState` + `ArtifactReadFailure` (`Deleted`, `NotFound`, `Io`, `InvalidPath`). +- Transient `read_bytes` via `LpFs` (no cached payload on entries). +- 11 unit tests covering acquire, release, fs invalidation, and read paths. + +### Decisions for future reference + +#### Requester-owned artifacts (not filesystem-registered) + +- **Decision:** Entries exist only after `acquire`; fs changes invalidate held paths; `release` at refcount 0 removes entry. +- **Why:** Client/registry drives identity; fs is an invalidation source, not registration. +- **Rejected alternatives:** Auto-register all project files on fs events; no refcount. +- **Revisit when:** Unlikely for this boundary. + +#### Structured `ArtifactReadFailure` vs string errors + +- **Decision:** `Failed(Deleted | NotFound | Io | InvalidPath)` distinct from `ArtifactError::Resolution` at acquire. +- **Why:** Registry/engine need typed outcomes; `FsError` mapping preserved. +- **Rejected alternatives:** Single `ReadError { message }` string. +- **Revisit when:** M5 overlay may add non-fs read paths (AssetView). + +#### Field name `revision` not `content_frame` + +- **Decision:** `ArtifactEntry.revision` uses `lpc_model::Revision`. +- **Why:** Content-generation marker; aligns with sync vocabulary, distinct from engine's legacy name. +- **Revisit when:** M6 cutover may map engine tick context naming. + +Plan: docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/ diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m10-slot-provenance-client.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m10-slot-provenance-client.md new file mode 100644 index 000000000..18fec49fe --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m10-slot-provenance-client.md @@ -0,0 +1,88 @@ +# Milestone 10: Slot Resolution Probes + +## Title And Goal + +Implement **on-demand provenance** via **project-read probes** — not on the +normal value read path. When the client wants to know how a consumed slot +resolved, it attaches an `ExplainSlot` probe to a `project_read` request (or +re-derives locally on the host when it already holds bindings + ChangeSet). + +Runtime ticks and ordinary slot reads stay lean: **value only**, no provenance +structs on `Production` or registry hot paths. + +## Prerequisites + +- **M6** engine cutover — consumed-slot resolution reads effective defs from + `NodeDefRegistry` (bindings → registry def). +- **ChangeSet roadmap** green — overlay + commit in mainline registry. +- Resolver trace events shaped for explain output (see M3.5 resolver notes). + +## Existing hooks + +Wire types already exist; engine returns `Unsupported` today: + +- `lpc-wire`: `ExplainSlotProbeRequest { node, slot, include_trace }`, + `ExplainSlotProbeResult`, `SlotExplanation { value, trace }` +- `ProjectProbeRequest::ExplainSlot` — piggybacks on `project_read` beside + normal queries (same pattern as `RenderProduct` probe) +- `Engine::read_project_explain_slot_probe` — stub in `project_read_probes.rs` + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m10-slot-provenance-client/` + +## Scope + +In scope: + +- **Implement `ExplainSlot` probe** in engine: run resolver for `(node, slot)`, + collect effective value + optional trace (`include_trace`). +- Trace steps cover the resolution cascade: + **binding(s) / merge** → **effective registry def** (overlay ∪ base) → + produced-slot fallback when applicable. +- Host **`lpa-client` helper** to attach explain probes to project reads and + parse `SlotExplanation` for UI badges. +- **Optional local re-derive** in host client when it holds bindings + + ChangeSet + overlay membership — same cascade, no round-trip (inspector / + offline editing). + +Out of scope: + +- Provenance on every consumed-slot resolve or tick read (ESP32 memory). +- Dedicated server-side provenance logic in `lpa-server` beyond forwarding + `project_read` probes to engine. +- Registry `explain_slot()` API — defer unless probe + local re-derive prove + insufficient. +- Full inspector UI — hooks only. + +## Key Decisions + +- **Probe, not pollute reads:** Normal reads deliver effective values; explain + is a separate request-scoped probe. +- **Client chooses when:** UI adds `ExplainSlot` probe only for inspected slots + (or re-derives locally on host). +- **No lpa-server v1 logic:** Server forwards probes; engine executes explain. +- **May revisit:** Thin/remote clients without local ChangeSet may need richer + wire explain or registry helpers later. + +## Deliverables + +- `read_project_explain_slot_probe` implemented (resolver + trace). +- `lpa-client` probe attachment + optional local `SlotProvenance` helper. +- Tests: binding wins, overlay wins over committed def, merge contributors in + trace when `include_trace`. + +## Dependencies + +- M6 engine cutover. +- ChangeSet roadmap complete (overlay + effective reads in registry). + +## Execution Strategy + +Small plan after M6/M7 stabilize. Primary work is engine probe implementation + +client wiring; no ChangeSet roadmap changes. + +Suggested chat opener: + +> M10: implement ExplainSlot probe (wire exists, engine stub) — bindings then +> effective def then trace. Agree? diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry.md new file mode 100644 index 000000000..698da2600 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry.md @@ -0,0 +1,72 @@ +# Milestone 2: NodeDefRegistry + NodeDefUpdates + +## Title And Goal + +Introduce **`NodeDefRegistry`** in the existing **`lpc-node-registry`** crate +(M1 bootstrap) as the owner of parsed node definitions, with **`NodeDefUpdates`** +reporting when artifacts change. + +**Prerequisite:** M1 crate + `ArtifactStore` complete and tested in isolation. + +## Parallel Build + +**M2 does not modify `lpc-engine`.** Registry and update logic are new code in +`lpc-node-registry`, tested against the M1 artifact store and real `NodeDef` +parse paths — not wired into `ProjectLoader`. + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/` + +## Scope + +In scope: + +- `NodeDefId` opaque handle (same pattern as `ArtifactId`). +- Bootstrap: **`load_root(root_path)`** — any node-definition TOML (project is + convention); registry walks and registers all defs. +- Steady state: driver **`apply_fs_changes`** on store, then **`sync`** → + `NodeDefUpdates`. +- Registry entry source `{ artifact_id, path_in_artifact: SlotPath }` (root = + `SlotPath::root()`). +- Identity separate from content: `Loaded` / `ParseError` / `ValidationError`. +- **`sync`** → `NodeDefUpdates` (driver applies fs to store first). +- Inline defs derived from parent artifact at non-root paths. +- Def content change does not mark parent def changed when only a child inline + def changed. +- Stub **`NodeDefView`** read path (base registry only; **ChangeSet overlay in M5**). +- Unit tests: artifact re-parse scenarios, inline child isolation, add/remove. + +Out of scope: + +- Production `Engine` / `ProjectLoader` cutover (**M6**). +- `SourceFileSlot` (M3). +- Engine tree mutation from updates (M4 harness). + +## Key Decisions + +- **Parallel crate only** — no `lpc-engine` / `ProjectLoader` edits. +- Registry is testable in isolation: artifact change in, `NodeDefUpdates` out. +- v1 may recreate def entries wholesale (no stable id preservation yet). +- Graph wiring changes surface as parent def `changed` but tree mutation is + engine responsibility (**M8**). + +## Deliverables + +- `lp-core/lpc-node-registry/src/registry/` (registry, updates, view stub). +- Parser integration hook (read artifact bytes → `NodeDef` or error). +- Tests covering leaf TOML change, inline child edit, child add/remove. + +## Dependencies + +- M1 ArtifactStore freshness model. + +## Execution Strategy + +Full plan. Registry entry lifecycle, inline path scheme, and update diff logic +need design doc before implementation. + +Suggested chat opener: + +> This milestone needs a full plan — NodeDefRegistry, NodeDefUpdates diff rules, +> and inline def paths. I'll run the plan process then implement. Agree? diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/00-design.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/00-design.md new file mode 100644 index 000000000..6c68a6027 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/00-design.md @@ -0,0 +1,306 @@ +# M2 Design — NodeDefRegistry + NodeDefUpdates + +## Scope + +Implement **`NodeDefRegistry`** in `lpc-node-registry`: parsed `NodeDef` +storage keyed by `NodeDefId`, driven by M1 `ArtifactStore` freshness, reporting +**`NodeDefUpdates`** on artifact changes. Stub **`NodeDefView`** for future +ChangeSet overlay (M5). + +No `lpc-engine` edits. + +## Ownership model + +``` +┌─────────────┐ +│ Driver │ tests / engine (M6) / M4 harness +│ owns fs + │ +│ ArtifactStore│ +└──────┬──────┘ + │ + │ bootstrap (once): load_root("/project.toml") // any root .toml kind + │ loop: store.apply_fs_changes(...); registry.sync(...) → NodeDefUpdates + ▼ +┌──────────────────┐ acquire/release ┌────────────────┐ +│ NodeDefRegistry │ ◄──────────────────────►│ ArtifactStore │ +└──────────────────┘ └────────────────┘ + │ + │ M5: ChangeSet commit → same NodeDefUpdates shape + ▼ + NodeDefUpdates { added, changed, removed } +``` + +- **Driver** owns `LpFs` and **`ArtifactStore`**. Registry does **not** call + `apply_fs_changes` — driver applies fs (or future ChangeSet commit) to the + store first, then calls **`sync`**. +- Registry is the **requester** for file artifacts backing defs it tracks. +- **Inline defs** live at non-root `SlotPath` within a **parent file artifact** + — no `ArtifactLocation::InlineNode`. +- When the last registry entry referencing a file artifact is removed, registry + **`release`s** that artifact. + +## Driver API (public contract) + +Two entry points for M2. Everything else is private. + +### Bootstrap — `load_root` + +```rust +pub fn load_root( + &mut self, + store: &mut ArtifactStore, + fs: &dyn LpFs, + root_path: &LpPath, // absolute, e.g. "/project.toml" + frame: Revision, + ctx: &ParseCtx<'_>, +) -> Result +``` + +- Called **once** per load (or full reload). +- **`root_path`** points at any node-definition TOML. Convention is + `project.toml`, but kind is **not** enforced — `load_root("/playlist.toml")` + is valid. +- Acquires the file artifact, parses root def, walks invocations recursively, + registers all discovered defs. +- Returns the **`NodeDefId`** for the root entry (`SlotPath::root()` on that + artifact). + +### Steady state — `sync` + +```rust +pub fn sync( + &mut self, + store: &mut ArtifactStore, + fs: &dyn LpFs, + frame: Revision, + ctx: &ParseCtx<'_>, +) -> NodeDefUpdates +``` + +- Called after driver has applied **`store.apply_fs_changes(changes, frame)`** + (M2) or equivalent bumps from ChangeSet commit (M5). +- Re-derives defs for artifacts whose **`revision`** advanced; returns + **`NodeDefUpdates`**. +- Safe to call with no pending changes — returns empty sets. + +### Private helpers (not public) + +- `register_file_at_path` — recursive registration for path-backed + `NodeInvocation` refs (internal to walk). +- `derive_artifact_inventory`, `register_invocations`, etc. + + +## File structure + +``` +lp-core/lpc-node-registry/src/ +├── lib.rs # re-export registry + view types +├── registry/ +│ ├── mod.rs +│ ├── node_def_id.rs # opaque NodeDefId +│ ├── def_source.rs # { artifact_id, path: SlotPath } +│ ├── node_def_state.rs # Loaded | ParseError | ValidationError +│ ├── node_def_entry.rs # source + state + last_seen_revision +│ ├── node_def_updates.rs # { added, changed, removed } +│ ├── registry_error.rs +│ ├── parse_ctx.rs # ParseCtx { shapes: &SlotShapeRegistry } +│ ├── def_shell.rs # shell equality (inline → kind stub) +│ ├── def_walker.rs # walk Project + Playlist invocations +│ └── node_def_registry.rs # load_root, sync (+ private walk/register) +└── view/ + ├── mod.rs + └── node_def_view.rs # stub: base registry lookup +``` + +## Core types + +### DefSource + +```rust +pub struct DefSource { + pub artifact_id: ArtifactId, + pub path: SlotPath, // SlotPath::root() = artifact root file def +} +``` + +Maps 1:1 to future engine `NodeDefHandle` (M6 rename). + +### NodeDefState + +```rust +pub enum NodeDefState { + Loaded(NodeDef), + ParseError(NodeDefParseError), + ValidationError(/* reserved — unused in M2 */), +} +``` + +- Entry **always exists** when registry knows a def should exist at a source + (identity separate from content — roadmap error semantics). +- Artifact read failure during derive → entry enters `ParseError` (or dedicated + read error variant wrapped for display); no last-good retention. + +### NodeDefUpdates + +```rust +pub struct NodeDefUpdates { + pub added: BTreeSet, + pub changed: BTreeSet, + pub removed: BTreeSet, +} +``` + +Def-level deltas only. Engine tree mutation is out of scope (M4/M8). + +### ParseCtx + +```rust +pub struct ParseCtx<'a> { + pub shapes: &'a SlotShapeRegistry, +} +``` + +Tests use `SlotShapeRegistry::default()` (same as engine). + +## Derive pipeline + +### load_root (public) + +1. Require **absolute** `root_path` (driver responsibility). +2. `store.acquire_location(ArtifactLocation::file(root_path), frame)`. +3. `read_bytes` → `NodeDef::read_toml(ctx.shapes, text)`. +4. Register root entry at `{ artifact_id, SlotPath::root() }`. +5. **`def_walker`**: discover nested defs (see below). +6. Record `last_seen_revision = store.revision(artifact_id)`. + +Returns root `NodeDefId`. Store **`root_path`** on registry (optional field) for +driver introspection; walker uses per-artifact paths from `artifact_root_path` +map. + +### def_walker (private, invoked from load_root / sync derive) + +Invocation sites in v1 model: + +| Parent `NodeDef` | Field | Child path suffix | +|------------------|-------|-------------------| +| `Project` | `nodes.{name}` | `nodes.{name}` (value is `NodeInvocation`) | +| `Playlist` | `entries.{key}.node` | `entries.{key}.node` | + +For each `NodeInvocation`: + +| `NodeDefRef` | Action | +|--------------|--------| +| `Path(locator)` | Resolve relative to **containing artifact file path** → recursive **`register_file_at_path`** → child at **child artifact root** | +| `Inline(def)` | Register entry at `{ parent_artifact_id, path }` with full body; recurse walker into inline `NodeDef` body for nested invocations | + +### sync (public) + +For each **tracked file artifact** where `store.revision(id)` differs from +last-derived revision for that artifact: + +1. Re-read bytes and re-derive full def inventory for that artifact subtree. +2. Diff old vs new `DefSource → entry` maps: + - In old, not new → **`removed`** + - In new, not old → **`added`** + - In both → compare for **`changed`** (rules below) +3. Update `last_seen_revision` on success. +4. Release artifacts no longer referenced. + +**Driver sequence (M2 tests and M6 engine):** + +```rust +store.apply_fs_changes(&changes, frame); +let updates = registry.sync(&mut store, fs, frame, ctx); +// apply updates to node tree (engine M6 / harness M4) +``` + +## Changed detection: shell vs body + +### Body equality (leaf + inline child entries) + +Full `NodeDef` equality at the entry's source path. Used for defs that **are** +the invoked definition (artifact root file defs, inline child defs). + +### Shell equality (parent entries that contain invocations) + +For parent defs (`Project`, `Playlist`, or any future container), compute a +**shell** view: + +- Walk the `NodeDef`; at each `NodeInvocation` with `NodeDefRef::Inline(_)`, + replace the inline body with a **kind-only stub** (`NodeDef` of same + `NodeKind`, default/minimal payload). +- Path-backed invocations compare by **resolved locator string** (and presence). + +Parent is **`changed`** when shell differs. Inline **child body** edit without +shell change → child **`changed`**, parent **not** **`changed`**. + +### Kind change (engine contract) + +**Any `NodeKind` change on a bound def requires runtime delete/recreate** (M6). +Registry reports the existing `NodeDefId` in **`changed`**; engine must not +treat kind change as in-place slot refresh. + +Kind change effects on updates: + +| Scenario | Registry report | +|----------|-------------------| +| Inline child kind flip (e.g. `Shader` → `Clock`) | Child **`changed`**; parent shell includes invocation kind → parent **`changed`** | +| Root file def kind flip | Root **`changed`** | +| Path-backed child file kind flip | Child root **`changed`**; parent shell unchanged if locator string unchanged | + +M2 tests must cover at least one kind-change scenario. + +## NodeDefView (stub) + +```rust +pub struct NodeDefView<'a> { + registry: &'a NodeDefRegistry, +} + +impl<'a> NodeDefView<'a> { + pub fn get(&self, id: &NodeDefId) -> Option<&NodeDefEntry>; +} +``` + +No ChangeSet overlay (M5). No mutation API. + +## Test scenarios (gate) + +| # | Scenario | Setup | Expected updates | +|---|----------|-------|------------------| +| T1 | Leaf file edit | `load_root("/clock.toml")`; modify file; `apply_fs_changes` + `sync` | Root in `changed` | +| T2 | Inline child edit | `load_root("/playlist.toml")` with inline entry; edit inline shader slot | Child `changed`; playlist **not** `changed` | +| T3 | Entry add/remove | `load_root("/playlist.toml")`; add/remove `[entries.N]` | New/removed child ids; playlist in `changed` | +| T4 | Path-backed child file edit | `load_root("/playlist.toml")` with path entry; modify child file | Child `changed`; playlist **not** `changed` | +| T5 | Inline child kind change | `load_root("/playlist.toml")`; flip inline kind | Child + playlist in `changed` | + +Use `LpFsMemory` + inline TOML fixtures (adapt patterns from +`playlist_entry.rs` tests). Optionally load paths under `examples/basic/`. + +## Validation + +```bash +cargo +nightly fmt --all +cargo test -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets -- -D warnings +``` + +## Out of scope + +- `lpc-engine` cutover (M6). +- `SourceFileSlot` / asset artifacts (M3). +- ChangeSet overlay (M5). +- Stable `NodeDefId` across ambiguous edits. +- Semantic `ValidationError` population. + +## Plan phases (dispatch) + +| # | Phase | Dispatch | +|---|-------|----------| +| 01 | Registry types + ParseCtx | [sub-agent: yes, model: **composer-2.5-fast**] | +| 02 | Def walker + shell helpers | [sub-agent: yes, model: **composer-2.5-fast**] | +| 03 | NodeDefRegistry `load_root` + artifact tracking | [sub-agent: yes, model: **composer-2.5-fast**] | +| 04 | `sync` + diff rules | [sub-agent: yes, model: **composer-2.5-fast**] | +| 05 | NodeDefView stub + gate tests + cleanup | [sub-agent: **supervised**, model: **composer-2.5-fast**] | + +Phases run sequentially. Single commit at end of plan per `/implement`. diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/00-notes.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/00-notes.md new file mode 100644 index 000000000..dfea16db2 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/00-notes.md @@ -0,0 +1,165 @@ +# M2 Plan Notes — NodeDefRegistry + NodeDefUpdates + +## Scope of work + +Implement **`NodeDefRegistry`** in `lpc-node-registry` as the owner of parsed node +definitions, with **`NodeDefUpdates`** reporting def-level deltas when held +artifacts change. + +In scope: + +- `NodeDefId` opaque handle (same pattern as `ArtifactId`). +- Registry entry source `{ artifact_id, path_in_artifact: SlotPath }` (root = + `SlotPath::root()`). +- Entry state: `Loaded(NodeDef)` / `ParseError` / `ValidationError` (variant + reserved; M2 only populates `Loaded` and `ParseError`). +- Registry **acquires file artifacts** via M1 `ArtifactStore` for every + file-backed def it tracks; **releases** when no defs reference that artifact. +- Inline defs live at non-root paths within a **parent file artifact** — no + `ArtifactLocation::InlineNode` (rejects old engine pattern). +- **`load_root(root_path)`** bootstrap + **`sync(...)`** steady state → + `NodeDefUpdates { added, changed, removed }`. Driver owns fs + store; + applies `apply_fs_changes` before `sync`. +- Parent def **not** marked `changed` when only an inline **child body** edits; + parent **is** marked `changed` when its shell changes (entry add/remove, + path↔inline ref flip, non-child slot edits). +- Stub **`NodeDefView`** — reads base registry only (ChangeSet overlay in M5). +- Unit tests: leaf TOML edit, inline child isolation, playlist entry add/remove. + +Out of scope: + +- `lpc-engine` / `ProjectLoader` edits (**M6**). +- `SourceFileSlot` / GLSL file artifacts (**M3**). +- Engine tree mutation from updates (**M4** harness, **M8** graph). +- Stable `NodeDefId` preservation across ambiguous edits (future). +- Semantic validation beyond TOML parse (**ValidationError** stub only). + +## Current state + +### M1 (done) + +`lpc-node-registry` crate exists with requester-owned `ArtifactStore`: + +- `acquire_location` / `acquire_specifier` / `release` +- `apply_fs_changes` bumps `revision` on held entries +- Transient `read_bytes(id, fs)` — no cached bytes on entries + +`registry/`, `view/`, `source/`, `change/` modules are stubs. + +### Engine reference (do not modify in M2) + +`lpc-engine` today conflates artifacts + defs: + +- `NodeDefHandle { artifact, path: SlotPath }` — non-root paths reserved but + unused; all defs at artifact root. +- Inline defs use `ArtifactLocation::InlineNode` + synthetic path + `{project}#nodes.{name}` — **M2 rejects this**; inline defs are registry + paths only. + +### Model parsing + +- `NodeDef::read_toml(registry, text) -> Result` +- `SlotShapeRegistry::default()` sufficient for tests (same as engine). +- `NodeInvocation` appears in: + - `ProjectDef.nodes` — map key is node name; path = `nodes.{name}` + - `PlaylistEntry.node` — path = `entries.{key}.node` + +### Test fixtures + +- `examples/basic/` — leaf file edits (e.g. `clock.toml`). +- `examples/button-playlist/` — path-backed playlist entries today; tests will + use inline variants (see `playlist_entry.rs` inline test TOML). + +Paths must be project-root relative with leading `/` for `LpFsMemory`. + +## Architecture sketch (driver model) + +``` +Driver (tests / engine) + │ + ├─ load_root("/project.toml") // any root node .toml; project is convention + │ └─► registry walks tree, acquires artifacts, registers all defs + │ + └─ loop: + store.apply_fs_changes(changes, frame) // driver-owned + updates = registry.sync(store, fs, frame, ctx) + // M5: ChangeSet commit bumps store similarly → same sync + apply NodeDefUpdates to node tree (M4/M6) +``` + +Internal walk uses private `register_file_at_path` for path-backed +`NodeInvocation` refs. + +### Parent vs child `changed` (shell rule) + +Compare two views per def path: + +| View | Used for | Inline child body | +|------|----------|---------------------| +| **Shell** | Parent `changed` | Replaced with `Inline { kind }` stub (no nested payload) | +| **Body** | Leaf / inline-child `changed` | Full `NodeDef` equality | + +Example: edit inline shader slots under `entries.2.node` → child `changed`, +playlist shell unchanged → parent **not** `changed`. + +Example: add `entries.3` → shell changes → parent **changed** + new child +`added`. + +## Questions — resolved + +### Confirmation batch (Q1–Q7) + +| # | Decision | +|---|----------| +| Q1 | Registry **acquires/releases** file artifacts it tracks; inline defs share parent artifact | +| Q2 | Def source = `{ artifact_id, SlotPath }` (`nodes.{name}`, `entries.{key}.node`, root = file def) | +| Q3 | Path-backed children → separate file artifact + root def; child file change → child `changed` only | +| Q4 | `NodeDefUpdates { added, changed, removed: BTreeSet }` — no extra inventory field in M2 | +| Q5 | Shell/body split for parent vs inline-child `changed` detection | +| Q6 | `ValidationError` variant exists; only `ParseError` populated in M2 | +| Q7 | Tests use `load_root` on leaf/playlist fixtures; project root supported | + +### Q10 — Driver-owned registration (user) + +**Decision:** + +- **Public API:** `load_root(absolute_path)` once; `sync(...)` after driver + applies fs changes to `ArtifactStore`. +- Root kind **not** enforced — `project.toml` is convention only. +- Registry does **not** call `apply_fs_changes`; driver owns store + fs. +- M5 ChangeSet commit follows same pattern → `NodeDefUpdates`. +- `register_file_at_path` is **private** (internal path-ref recursion during walk). + +### Q8 — ProjectDef registration + +**Decision:** Walker supports `Project` + `Playlist`; gate tests focus on leaf +file + playlist inline/path scenarios. + +### Q9 — Kind change semantics (user) + +**Context:** `NodeKind` is part of node identity for the engine runtime. A def +whose kind changes (e.g. `Shader` → `Clock`) cannot be refreshed in place. + +**Decision:** + +- Registry still reports the same `NodeDefId` in **`changed`** when kind flips + (v1 id preservation is best-effort; M6 engine decides recreate either way). +- **Shell stubs** for inline invocations include **`NodeKind`** — a kind flip at + an invocation site changes the parent shell → parent **`changed`**. +- **Engine contract (M6, document now):** kind change on a bound def → **delete + and recreate** the runtime node; do not attempt in-place slot refresh. +- M2 tests: include at least one case where inline/path child **kind** changes + and assert child (and parent shell when applicable) land in `changed`. + +## Resolved decisions (roadmap — no re-litigation) + +- Parallel build in `lpc-node-registry`; no `lpc-engine` edits until M6. +- ArtifactStore = freshness only; registry = parsed defs. +- No last-good on parse failure — error state on entry (engine cascade in M6). +- Inline child edit must not imply parent `changed` (decisions.md). +- v1 may recreate entries wholesale — stable id preservation deferred. + +## Notes from prior discussion + +- User agreed to full M2 plan before implementation. +- M1 commit: `bfd0945d` — artifact store complete, 11 tests passing. diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/01-registry-types.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/01-registry-types.md new file mode 100644 index 000000000..0754563d1 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/01-registry-types.md @@ -0,0 +1,140 @@ +# Phase 01 — Registry types + ParseCtx + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +Add core registry types and wire module exports. No walker, no store integration +yet. + +**In scope:** + +- `NodeDefId` (opaque handle, mirror `ArtifactId` pattern) +- `DefSource { artifact_id, path: SlotPath }` +- `NodeDefState` (`Loaded` / `ParseError` / `ValidationError` stub) +- `NodeDefEntry` (source, state, `last_seen_revision: Revision`) +- `NodeDefUpdates { added, changed, removed: BTreeSet }` +- `RegistryError` +- `ParseCtx<'a> { shapes: &'a SlotShapeRegistry }` +- `registry/mod.rs` re-exports; update `lib.rs` to pub-export registry types + +**Out of scope:** Walker, registry impl, view, tests beyond type smoke if needed. + +## Code Organization Reminders + +- One concept per file under `registry/` (match M1 `artifact/` layout). +- Public types / re-exports at top of `mod.rs`; impls in dedicated files. +- Tests at bottom of files only if trivial; gate tests live in phase 05. + +## Sub-agent Reminders + +- Do **not** commit. +- Do **not** edit `lpc-engine`. +- Report deviations. + +## Implementation Details + +### `node_def_id.rs` + +Mirror `artifact/artifact_id.rs`: + +```rust +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct NodeDefId(u32); + +impl NodeDefId { + pub fn from_raw(raw: u32) -> Self; + pub fn raw(self) -> u32; +} +``` + +### `def_source.rs` + +```rust +pub struct DefSource { + pub artifact_id: ArtifactId, + pub path: SlotPath, +} +``` + +Implement `Eq`, `Hash`, `Ord` (derive or manual — must be stable map key). + +### `node_def_state.rs` + +```rust +pub enum NodeDefState { + Loaded(NodeDef), + ParseError(NodeDefParseError), + ValidationError(ValidationErrorPlaceholder), // or unit/newtype stub +} +``` + +Add accessor helpers: `is_loaded()`, `kind() -> Option` (from loaded +def only). + +`ValidationError` is a placeholder enum/struct — document with `// M2: unused` +comment; no semantic validator yet. + +### `node_def_entry.rs` + +```rust +pub struct NodeDefEntry { + pub id: NodeDefId, + pub source: DefSource, + pub state: NodeDefState, + pub last_seen_revision: Revision, +} +``` + +### `node_def_updates.rs` + +```rust +#[derive(Default)] +pub struct NodeDefUpdates { + pub added: BTreeSet, + pub changed: BTreeSet, + pub removed: BTreeSet, +} + +impl NodeDefUpdates { + pub fn is_empty(&self) -> bool; + pub fn merge(&mut self, other: Self); // optional helper for tests +} +``` + +### `registry_error.rs` + +Cover at minimum: + +- Locator/path resolution failure +- Duplicate `DefSource` registration +- Unknown `NodeDefId` lookup +- Wrap `ArtifactError` from store ops + +### `parse_ctx.rs` + +```rust +pub struct ParseCtx<'a> { + pub shapes: &'a SlotShapeRegistry, +} +``` + +### `lib.rs` + +```rust +pub mod registry; +pub use registry::{ + DefSource, NodeDefEntry, NodeDefId, NodeDefRegistry, NodeDefState, + NodeDefUpdates, ParseCtx, RegistryError, +}; +``` + +`NodeDefRegistry` can be an empty struct stub in `node_def_registry.rs` for now +(phase 03 fills impl). + +## Validate + +```bash +cargo check -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets -- -D warnings +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/02-def-walker-shell.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/02-def-walker-shell.md new file mode 100644 index 000000000..e1434a5d7 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/02-def-walker-shell.md @@ -0,0 +1,124 @@ +# Phase 02 — Def walker + shell helpers + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +Implement static discovery of nested node defs and shell comparison for parent +`changed` detection. + +**In scope:** + +- `def_walker.rs` — enumerate `NodeInvocation` sites from parsed `NodeDef` +- `def_shell.rs` — shell view with inline bodies replaced by kind-only stubs +- Unit tests for walker paths and shell/body distinction + +**Out of scope:** `NodeDefRegistry` store integration, `sync`. + +## Code Organization Reminders + +- Walker and shell are pure functions over `NodeDef` / `lpc-model` types. +- Tests at bottom of each file. +- Helper functions below tests in test module. + +## Sub-agent Reminders + +- Do **not** commit. +- Do **not** edit `lpc-engine` or `lpc-model`. +- Report deviations. + +## Implementation Details + +### Walker output type + +```rust +pub struct InvocationSite { + pub path: SlotPath, // path to the NodeInvocation field + pub invocation: NodeInvocation, // clone or ref in API — clone is fine for M2 +} +``` + +```rust +pub fn collect_invocations(def: &NodeDef, base: &SlotPath) -> Vec; +``` + +**Traversal rules:** + +| `NodeDef` variant | Walk | +|-------------------|------| +| `Project` | For each `(name, inv)` in `nodes.entries`: path = `base.join_field(name)` | +| `Playlist` | For each `(key, entry)` in `entries.entries`: path = `base.join_field("entries").join_key(key).join_field("node")` | +| Other | No nested invocations (empty) | + +Use existing `SlotPath` APIs (`join_field`, `join_key`, or equivalent helpers +from `lpc-model`). If no join helpers exist, extend path by pushing +`SlotPathSegment` directly in this crate only — do not modify `lpc-model`. + +For **`Inline` defs** registered at a path: after registering inline body, +recurse `collect_invocations(inline_def, &path)` to find nested invocations +(e.g. inline playlist with inline entries). + +### Path resolution helper + +```rust +pub fn resolve_node_specifier( + containing_file: &LpPath, + locator: &ArtifactSpecifier, +) -> Result; +``` + +Mirror engine `resolve_path_specifier_from_dir` logic: + +- `ArtifactSpecifier::Path`: absolute as-is; relative joined to containing file's + parent directory. +- `ArtifactSpecifier::Lib`: `RegistryError` (unsupported). + +Use `lpfs::LpPath` / `LpPathBuf`. Reference: +`lpc-engine/src/engine/project_loader.rs` `resolve_path_specifier_from_dir` (read +only — do not edit engine). + +### Shell helpers (`def_shell.rs`) + +```rust +/// Parent-facing view: inline invocation bodies replaced with kind-only stubs. +pub fn def_shell(def: &NodeDef) -> NodeDef; + +/// True when full bodies differ (for leaf / inline-child entries). +pub fn body_changed(before: &NodeDef, after: &NodeDef) -> bool; + +/// True when parent shells differ. +pub fn shell_changed(before: &NodeDef, after: &NodeDef) -> bool; +``` + +**Shell stub rule for inline invocations:** + +- Replace `NodeDefRef::Inline(full)` with `NodeDefRef::Inline(Box::new(kind_stub))` + where `kind_stub` is a minimal `NodeDef` of the same `NodeKind` (default + variant / empty struct — match pattern used in model tests). +- Path invocations: compare locator string (`ArtifactSpecifier` display / path + field). + +**Kind in shell:** stub carries `NodeKind` — kind flip at invocation site +must make `shell_changed` return true (feeds parent `changed` in phase 04). + +### Tests (this phase) + +1. **Project invocation paths** — parse minimal project TOML via + `NodeDef::from_toml_str`, assert sites at `nodes.clock`, `nodes.shader`. +2. **Playlist inline path** — use inline child TOML from + `playlist_entry.rs` test; assert site at `entries.2.node`. +3. **Shell vs body** — two playlist defs differing only in inline shader slot + → `body_changed` true on inline child, `shell_changed` false on playlist. +4. **Kind flip** — inline child kind `Shader` → `Clock` → `shell_changed` true + on playlist. + +Keep tests under ~20 lines each; extract TOML fixtures to helper fns at bottom +of test module. + +## Validate + +```bash +cargo test -p lpc-node-registry def_walker +cargo test -p lpc-node-registry def_shell +cargo clippy -p lpc-node-registry --all-targets -- -D warnings +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/03-registry-register.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/03-registry-register.md new file mode 100644 index 000000000..6bac9144b --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/03-registry-register.md @@ -0,0 +1,160 @@ +# Phase 03 — NodeDefRegistry `load_root` + artifact tracking + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +Implement `NodeDefRegistry` bootstrap via **`load_root`**, internal entry storage, +artifact acquire/release tracking, and recursive registration via walker. + +**In scope:** + +- `NodeDefRegistry` fields and **`load_root`** (public) +- Private **`register_file_at_path`** for path-backed invocation recursion +- Parse artifact bytes → entries at root + walked children +- Track which `ArtifactId`s registry holds (for `sync` in phase 04) + +**Out of scope:** `sync`, diff/`NodeDefUpdates` emission, `NodeDefView`. + +## Code Organization Reminders + +- `node_def_registry.rs`: public API top (`load_root`), private helpers bottom, + tests at end (registration smoke tests only — full gate in phase 05). +- Use phase 01 types and phase 02 walker/shell/resolve helpers. + +## Sub-agent Reminders + +- Do **not** commit. +- Do **not** edit `lpc-engine`. +- Registry acquires/releases via M1 `ArtifactStore` only. +- **`load_root`** is the only public bootstrap entry point. +- Report deviations. + +## Implementation Details + +### `NodeDefRegistry` fields + +```rust +pub struct NodeDefRegistry { + entries: BTreeMap, + source_index: BTreeMap, + artifact_refs: BTreeMap, + artifact_root_path: BTreeMap, + root_id: Option, // id from load_root, if any + next_id: u32, +} +``` + +### `load_root` (public) + +```rust +pub fn load_root( + &mut self, + store: &mut ArtifactStore, + fs: &dyn LpFs, + root_path: &LpPath, + frame: Revision, + ctx: &ParseCtx<'_>, +) -> Result +``` + +**Steps:** + +1. Require absolute `root_path` — return `RegistryError::InvalidPath` if not + absolute (or normalize if `LpPath` API supports it clearly). +2. `path_buf = root_path.to_path_buf()` (or equivalent). +3. `artifact_id = store.acquire_location(ArtifactLocation::file(path_buf.clone()), frame)`. +4. `registry_acquire_artifact(artifact_id, &path_buf)` — increment + `artifact_refs`, record `artifact_root_path`. +5. `load_and_register_root(store, fs, artifact_id, &path_buf, frame, ctx)?`. +6. Store `root_id = Some(id)`; return root `NodeDefId`. + +Registry must be **empty** on first `load_root` in M2 (return error if not +empty — or document `clear`/reload policy; prefer error on non-empty for M2). + +### `register_file_at_path` (private) + +Used when walker hits `NodeDefRef::Path(locator)`: + +```rust +fn register_file_at_path( + &mut self, + store: &mut ArtifactStore, + fs: &dyn LpFs, + locator: &ArtifactSpecifier, + containing_file: &LpPath, + frame: Revision, + ctx: &ParseCtx<'_>, +) -> Result +``` + +1. `resolve_node_specifier(containing_file, locator)?` → absolute path. +2. Acquire artifact + register root (same as steps in `load_root` for that file). +3. Return child root `NodeDefId`. + +Do **not** expose locator/containing_dir on public API. + +### `load_and_register_root` (private) + +1. `store.read_bytes(&artifact_id, fs)?` → UTF-8 string. +2. `NodeDef::read_toml(ctx.shapes, &text)` → `NodeDefState`. +3. Insert entry at `DefSource { artifact_id, path: SlotPath::root() }`. +4. If `Loaded`, call `register_invocations(...)` with walker output. + +### `register_invocations` (private, recursive) + +For each `InvocationSite` from `collect_invocations`: + +| `NodeDefRef` | Action | +|--------------|--------| +| `Path(loc)` | `register_file_at_path(store, fs, loc, parent_file, frame, ctx)` | +| `Inline(body)` | Insert entry at `{ parent_artifact_id, site.path }`; recurse into inline body | + +**Duplicate `DefSource`:** `RegistryError::DuplicateSource`. + +### Entry insertion + +```rust +fn insert_entry( + &mut self, + source: DefSource, + state: NodeDefState, + revision: Revision, +) -> Result; +``` + +### Read failure handling + +If `read_bytes` fails or parse fails: + +- Still create entry at intended source with error state. +- Do not skip identity creation (roadmap: identity separate from content). + +### `release_artifact_if_unused` (private, phase 04) + +Decrement `artifact_refs`; at zero call `store.release` and remove path maps. + +### Public accessors (for view + tests) + +```rust +pub fn root_id(&self) -> Option; +pub fn get(&self, id: &NodeDefId) -> Option<&NodeDefEntry>; +pub fn get_by_source(&self, source: &DefSource) -> Option<&NodeDefEntry>; +``` + +### Smoke tests (this phase) + +Use **`load_root`** only (not internal helpers): + +1. `load_root("/playlist.toml")` with inline child fixture — root + inline child + in `source_index`. +2. `load_root("/project.toml")` with path-backed nodes — multiple artifacts in + `artifact_refs`. +3. Second `load_root` on non-empty registry → error. + +## Validate + +```bash +cargo test -p lpc-node-registry node_def_registry +cargo clippy -p lpc-node-registry --all-targets -- -D warnings +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/04-update-diff.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/04-update-diff.md new file mode 100644 index 000000000..b4ed377a1 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/04-update-diff.md @@ -0,0 +1,124 @@ +# Phase 04 — `sync` + diff rules + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +Implement **`sync`**: detect artifact revision bumps (after driver applies fs +changes to store), re-derive def inventory, emit `NodeDefUpdates` with +shell/body rules and kind-change behavior. + +**In scope:** + +- **`sync`** on `NodeDefRegistry` (public) +- Per-artifact re-derive and diff against previous inventory +- Shell vs body `changed` classification +- Artifact release for removed defs + +**Out of scope:** `NodeDefView`, full gate test suite (phase 05). + +## Code Organization Reminders + +- Diff logic in `node_def_registry.rs` or `registry/def_diff.rs` if file grows + large. +- Tests use driver pattern: `apply_fs_changes` then `sync`. +- Tests at bottom of registry module for T1–T4 (T5 in phase 05). + +## Sub-agent Reminders + +- Do **not** commit. +- Do **not** edit `lpc-engine`. +- Registry must **not** call `store.apply_fs_changes` — tests/driver do that first. +- **Kind change:** report in `changed`; comment documents M6 delete/recreate. +- Report deviations. + +## Implementation Details + +### `sync` (public) + +```rust +pub fn sync( + &mut self, + store: &mut ArtifactStore, + fs: &dyn LpFs, + frame: Revision, + ctx: &ParseCtx<'_>, +) -> NodeDefUpdates +``` + +### Driver sequence (document in module doc) + +```rust +store.apply_fs_changes(&changes, frame); +let updates = registry.sync(&mut store, fs, frame, ctx); +``` + +Registry reads `store.revision(id)` only — does not accept `FsChange` slice in M2. + +### Algorithm + +1. Collect tracked `ArtifactId`s from `artifact_refs`. +2. For each artifact where `store.revision(id)` differs from last-derived + revision for that artifact: + - Build **new inventory** via `derive_artifact_inventory` (shared with phase 03). +3. Build **old inventory** subset for that artifact from current `entries`. +4. Diff: + - Keys only in old → `removed` (+ schedule artifact ref decrement) + - Keys only in new → `added` + - Keys in both → **changed** rules below +5. Apply inventory to `entries` / `source_index`. +6. Release artifacts whose ref count hit zero. +7. Return merged `NodeDefUpdates`. + +Call with no pending revision bumps → empty updates. + +### Changed rules + +| Entry role | Comparison | → `changed` when | +|------------|------------|------------------| +| Root file def / inline leaf | **Body** (`body_changed`) | Full `NodeDef` differs, error state differs, or kind differs | +| Container (`Project`, `Playlist`, …) | **Shell** (`shell_changed`) | Shell differs (inline invocation **kind** stub included) | + +**Inline child body edit:** child in `changed` only; parent not in `changed`. + +**Entry add/remove:** `added`/`removed` + parent in `changed` when shell differs. + +**Kind change:** child in `changed`; parent in `changed` when shell includes that invocation. + +**Parse error transitions:** `changed`. + +### Shared derive helper + +```rust +fn derive_artifact_inventory( + store: &mut ArtifactStore, + fs: &dyn LpFs, + artifact_id: ArtifactId, + root_path: &LpPath, + frame: Revision, + ctx: &ParseCtx<'_>, +) -> Result, RegistryError>; +``` + +Used by `load_root` path and `sync` re-derive. + +### Tests (this phase) + +All tests: **`load_root`** first, then driver loop. + +1. **T1** — `load_root("/clock.toml")`; modify file; `apply_fs_changes`; `sync` + → root in `changed` only. +2. **T2** — `load_root("/playlist.toml")` inline; edit inline shader slot → child + `changed`, playlist not. +3. **T3** — add `[entries.3]` → `added` + playlist `changed`. +4. **T4** — path-backed entry; modify child file → child `changed`, playlist not. + +Use `LpFsMemory`, `Revision` frames; reference M1 `apply_fs_changes` tests. + +## Validate + +```bash +cargo test -p lpc-node-registry sync +cargo test -p lpc-node-registry node_def_registry +cargo clippy -p lpc-node-registry --all-targets -- -D warnings +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/05-view-tests-cleanup.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/05-view-tests-cleanup.md new file mode 100644 index 000000000..389cf1ee7 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/05-view-tests-cleanup.md @@ -0,0 +1,103 @@ +# Phase 05 — NodeDefView stub + gate tests + cleanup + +**Dispatch:** [sub-agent: supervised, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +Add `NodeDefView` stub, complete gate test coverage (including kind change), +cleanup, fmt, clippy, and write `summary.md`. + +**In scope:** + +- `view/node_def_view.rs` stub +- Gate tests T1–T5 (consolidate/deduplicate with phase 04 tests if needed) +- `lib.rs` exports for view +- `summary.md` +- Remove stray TODOs; ensure warnings clean + +**Out of scope:** `lpc-engine` edits, ChangeSet overlay (M5). + +## Code Organization Reminders + +- View module stays minimal — lookup only. +- Gate tests may live in `node_def_registry.rs` test module or + `registry/integration_tests.rs` if cleaner; prefer single test module at bottom + of `node_def_registry.rs` unless file is too large. +- Test helpers at bottom of test module. + +## Sub-agent Reminders + +- Do **not** commit (supervisor commits after full plan validation). +- Do **not** expand scope into engine or M3. +- Fix warnings; do not suppress lints. +- Report what changed and validation results. + +## Implementation Details + +### `NodeDefView` stub + +```rust +pub struct NodeDefView<'a> { + registry: &'a NodeDefRegistry, +} + +impl<'a> NodeDefView<'a> { + pub fn new(registry: &'a NodeDefRegistry) -> Self; + pub fn get(&self, id: &NodeDefId) -> Option<&NodeDefEntry>; + pub fn state(&self, id: &NodeDefId) -> Option<&NodeDefState>; +} +``` + +Document in module doc: M5 adds ChangeSet overlay; M6 engine reads defs through +view only. + +Update `lib.rs`: + +```rust +pub mod view; +pub use view::NodeDefView; +``` + +### Gate tests T1–T5 + +| Test | Assert | +|------|--------| +| T1 `leaf_file_edit_marks_root_changed` | Root in `changed`; sets empty | +| T2 `inline_child_edit_does_not_mark_parent_changed` | Child in `changed`; parent not | +| T3 `playlist_entry_add_and_remove` | `added`/`removed` + parent `changed` | +| T4 `path_child_file_edit_isolated` | Child `changed`; playlist not | +| T5 `inline_child_kind_change_marks_child_and_parent_changed` | Both in `changed`; comment documents M6 delete/recreate | + +Use concise TOML fixtures; `LpFsMemory` with `/`-rooted paths. Every gate test +follows **`load_root` → (optional fs mutate) → `apply_fs_changes` → `sync`**. + +Optional: one test loading a real file from `examples/basic/clock.toml` if +repo-relative path in test is stable (use `include_str!` or env-free path from +manifest dir via `CARGO_MANIFEST_DIR`). + +### `summary.md` + +Brief bullet summary: + +- Types added +- API surface (`load_root`, `sync`, `NodeDefView`) +- Test count +- Kind-change engine contract note for M6 + +### Cleanup checklist + +- [ ] No `TODO` without ticket/milestone reference +- [ ] `cargo +nightly fmt --all` +- [ ] All `lpc-node-registry` tests pass +- [ ] Clippy clean + +## Validate + +```bash +cargo +nightly fmt --all +cargo test -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets -- -D warnings +``` + +Supervisor after all phases: single commit per `/implement` — not in this phase +unless user requests. diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/summary.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/summary.md new file mode 100644 index 000000000..f0b770ed4 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/summary.md @@ -0,0 +1,32 @@ +# M2 Summary — NodeDefRegistry + NodeDefUpdates + +## What was built + +- **`NodeDefRegistry`** in `lpc-node-registry` with driver API: + - `load_root(store, fs, root_path, frame, ctx)` — bootstrap from any root node TOML + - `sync(store, fs, frame, ctx)` — re-derive after driver `apply_fs_changes` +- Core types: `NodeDefId`, `DefSource`, `NodeDefState`, `NodeDefEntry`, `NodeDefUpdates`, + `ParseCtx`, `RegistryError` +- **Def walker** for `Project` + `Playlist` invocations (`nodes[key]`, `entries[key].node`) +- **Shell/body diff** — inline child edits do not mark parent `changed`; kind flips do +- **`NodeDefView`** stub (base registry lookup; ChangeSet overlay deferred to M5) +- **22 tests** (`cargo test -p lpc-node-registry`), including gate scenarios T1–T5 + +## Decisions for future reference + +#### Driver-owned store + load_root/sync + +- **Decision:** Driver applies `ArtifactStore::apply_fs_changes`; registry exposes + `load_root` + `sync` only. +- **Why:** Same loop for fs reload (M2/M4) and ChangeSet commit (M5). +- **Rejected alternatives:** Public per-file `register_file`; registry calling + `apply_fs_changes` internally. + +#### Kind change → delete/recreate (M6) + +- **Decision:** Registry reports `changed`; engine must delete/recreate runtime + nodes when bound def kind flips. +- **Why:** Kind determines runtime type and lifecycle. +- **Revisit when:** M6 engine cutover. + +Plan: `docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot.md new file mode 100644 index 000000000..405370597 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot.md @@ -0,0 +1,72 @@ +# Milestone 3: SourceFileSlot + SourceFileRef + +## Title And Goal + +Add **`SourceFileSlot`** / **`SourceFileRef`** and materialize API in the +**parallel stack** — authored slot type, resolved ref, on-demand text — without +switching production `ShaderDef` / nodes until **M6**. + +## Parallel Build + +- **`SourceFileSlot`** lands in **`lpc-model`** (new type + custom codec) — additive; + existing `ShaderSource` / `ShaderDef.source` **unchanged until M6**. +- **`SourceFileRef` + materialize** land in **`lpc-node-registry`** — used by + registry parse/harness tests, not by `lpc-engine` nodes yet. +- Fixture/shader **production defs** unchanged until **M6**; M5 uses harness defs only. + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/` + +## Scope + +In scope: + +- `SourceFileSlot` custom codec in `lpc-model` (`$path`, shorthand string, + extension-key inline tables: `glsl`, `svg`, …). +- `SourceFileRef` enum (file artifact / inline / future URL stub) in + `lpc-node-registry`. +- Materialize API in `lpc-node-registry`: `{ version, text, diagnostic_name }` + on demand; register file paths in M1 `ArtifactStore` when resolving refs. +- Tests in **`lpc-node-registry`** (+ `lpc-model` codec round-trips): TOML + encode/decode, materialize version for file bump vs inline edit, diagnostic + names. +- Harness-only or test-only defs using `SourceFileSlot` where needed to exercise + the parallel stack (not production `ShaderDef` yet). + +Out of scope: + +- Replacing `ShaderSource` on production `ShaderDef` / `ComputeShaderDef` (**M6**). +- Replacing fixture `MappingConfig` SVG fields on production defs (**M6**). +- `ShaderNode` / `FixtureNode` compile path wiring (**M6**). +- Any `lpc-engine` changes (**M6**). + +## Key Decisions + +- **Parallel:** new slot type exists alongside old; production unchanged until **M6**. +- Nodes never store source text long-term; refs only in resolved slot data. +- Effective version = f(slot revision, file artifact version) for file mode. +- TOML encoding hard cut for **new** `SourceFileSlot`; all example projects at **M6**. + +## Deliverables + +- `lpc-model/src/slots/source_file.rs` (+ codec). +- `lpc-node-registry/src/source/` — `SourceFileRef`, materialize. +- Unit + registry integration tests. +- `ShaderSource` remains until **M6**. + +## Dependencies + +- M1 ArtifactStore in `lpc-node-registry`. +- M2 NodeDefRegistry (optional for end-to-end materialize tests). + +## Execution Strategy + +Full plan. Custom codec, ref resolution, and materialize span model + registry; +clarify parallel vs **M6** migration in phase notes. + +Suggested chat opener: + +> This milestone needs a full plan — SourceFileSlot codec and materialize in the +> parallel lpc-node-registry stack, without touching production ShaderDef until +> M6. I'll run the plan process then implement. Agree? diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/00-design.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/00-design.md new file mode 100644 index 000000000..6d7c99105 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/00-design.md @@ -0,0 +1,92 @@ +# M3 Design — SourceFileSlot + SourceFileRef + +## Scope + +Add **`SourceFileSlot`** (`lpc-model`) and **`SourceFileRef` + materialize** +(`lpc-node-registry`) in the parallel stack. Production `ShaderSource` unchanged +until M6. + +## Driver flow + +``` +TOML → SourceFileSlot + ↓ resolve_source_file(store, containing_file, slot, frame) + SourceFileRef (no text) + ↓ materialize_source(store, fs, ref, slot, ctx) + MaterializedSource { version, text, diagnostic_name } +``` + +Driver owns `ArtifactStore` + `LpFs`. Resolve acquires file artifacts; materialize +reads bytes transiently via `read_bytes`. + +## File structure + +``` +lp-core/lpc-model/src/slots/ + source_file.rs # SourceFileSlot, backing, FieldSlot, codec hooks + +lp-core/lpc-node-registry/src/source/ + mod.rs + source_file_ref.rs + materialized_source.rs + resolve.rs + materialize.rs +``` + +## Types + +### SourceFileSlot (authored) + +```rust +pub enum SourceFileBacking { + Path(SourcePath), + Inline { extension: String, text: String }, +} + +pub struct SourceFileSlot { backing, revision } +``` + +Custom codec id: `lp::slots::SourceFileCodec`. + +TOML forms: `$path`, shorthand string, extension-key inline table. + +### SourceFileRef (resolved) + +```rust +pub enum SourceFileRef { + File { artifact_id, authored_path, resolved_path, extension }, + Inline { extension, slot_revision }, + Url { .. }, // stub → Unsupported in M3 +} +``` + +### MaterializedSource + +```rust +pub struct MaterializedSource { + pub version: Revision, + pub text: String, + pub diagnostic_name: String, +} +``` + +**Effective version:** `max(slot.revision(), artifact.revision())` for file mode; +slot revision only for inline. + +## Validation + +```bash +cargo +nightly fmt --all +cargo test -p lpc-model source_file +cargo test -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings +``` + +## Plan phases + +| # | Phase | Dispatch | +|---|-------|----------| +| 01 | SourceFileSlot type + codec | composer-2.5-fast | +| 02 | SourceFileRef + resolve | composer-2.5-fast | +| 03 | Materialize + version tests | composer-2.5-fast | +| 04 | Cleanup + summary | supervised | diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/00-notes.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/00-notes.md new file mode 100644 index 000000000..8304fe898 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/00-notes.md @@ -0,0 +1,165 @@ +# M3 Plan Notes — SourceFileSlot + SourceFileRef + +## Scope of work + +Add **`SourceFileSlot`** (authored, `lpc-model`) and **`SourceFileRef` + +materialize** (resolved, `lpc-node-registry`) in the **parallel stack**. Production +`ShaderSource` / `ShaderDef.source` / fixture mapping fields **unchanged until M6**. + +In scope: + +- `SourceFileSlot` custom codec: `$path`, shorthand string, extension-key inline + (`glsl`, `svg`, …). +- `SourceFileRef` enum: file artifact / inline / URL stub (stub only in M3). +- **Resolve** authored slot → ref (acquire file artifact in M1 store). +- **Materialize** ref (+ authored slot for inline body) → + `{ version, text, diagnostic_name }`. +- Tests: codec round-trips (`lpc-model`); resolve + materialize + version bump + (`lpc-node-registry`). +- Optional harness-only test def shape exercising `SourceFileSlot` in a parsed + `NodeDef` (not production `ShaderDef`). + +Out of scope: + +- Production `ShaderDef` / `ComputeShaderDef` / fixture `MappingConfig` migration + (**M6**). +- `lpc-engine` / node compile paths (**M6**). +- `BinaryFileSlot` (**future**). +- ChangeSet / AssetView (**M5**). + +## Current state + +### Authored sources today + +- `ShaderSource` enum: `Path(SourcePathSlot)` | `Glsl(ValueSlot)` in + `lpc-model/src/nodes/shader/shader_source.rs`. +- Fixture mapping uses separate `MappingConfig` variants (`SvgPath`, etc.). +- Engine reads bytes at load time (`read_shader_source`, `resolve_fixture_mapping`). + +### Slot infrastructure + +- Custom codec dispatch in `slot_codec/custom_slot_codec.rs` (pattern: + `NodeInvocation` + `NODE_INVOCATION_CODEC_ID`). +- Semantic leaves live under `lpc-model/src/slots/`. +- `SourcePath` / `SourcePathSlot` exist for path-only leaves. + +### Parallel stack (M1–M2 done) + +- `ArtifactStore`: acquire/release, `read_bytes`, `revision`, `apply_fs_changes`. +- `NodeDefRegistry`: `load_root`, `sync`, `NodeDefUpdates`. +- `lpc-node-registry/src/source/` is a stub. + +### Roadmap encoding (agreed) + +```toml +# file +source = { $path = "./shader.glsl" } +source = "./shader.glsl" + +# inline +[source] +glsl = """ +void main() {} +""" +``` + +Exactly one of `$path`, shorthand string, or extension-key inline table. + +## Architecture sketch (proposed) + +``` +TOML parse ──► SourceFileSlot (authored backing in NodeDef) + │ + ▼ resolve_source_file(store, containing_file, slot, frame) + SourceFileRef (handle — no text) + │ + ▼ materialize_source(store, fs, ref, slot, diagnostic_ctx) + MaterializedSource { version, text, diagnostic_name } +``` + +- **File-backed:** resolve acquires `ArtifactLocation::file(resolved_path)`; + ref holds `ArtifactId` + authored relative path + optional extension hint. +- **Inline:** ref holds extension + slot revision; text read from authored slot + at materialize (not stored in ref). +- **Effective version:** combine slot revision + artifact revision for file mode; + slot revision only for inline. M4 uses version change without def TOML change. + +## Open questions + +### Q1 — Authored vs resolved split + +**Context:** Roadmap: nodes hold `SourceFileRef`, not text. Authored defs hold +`SourceFileSlot`. + +**Suggested:** M3 implements both types. **Resolve** is explicit (not implicit at +parse). **Materialize** takes ref + authored slot (for inline body + slot revision). + +### Q2 — Extension keys + +**Context:** Inline format identified by table key (`glsl`, `svg`, `wgsl`, …). + +**Suggested:** M3 accepts any non-reserved string key as inline extension; no +fixed allowlist yet. `$path` is reserved; `path` field name left free for future +`.path` artifact type. + +### Q3 — Diagnostic names + +**Context:** Compile errors need stable labels. + +**Suggested:** + +| Backing | `diagnostic_name` | +|---------|---------------------| +| File | Project-relative path as authored (e.g. `./shader.glsl`) | +| Inline | `{containing_file}:{slot_path}.{ext}` (e.g. `/shader.toml:source.glsl`) | + +Pass `containing_file` + optional `SlotPath` into materialize/resolve context. + +### Q4 — Test def shape + +**Context:** Milestone allows harness-only defs; production `ShaderDef` untouched. + +**Suggested:** M3 gate tests use: + +1. Direct `SourceFileSlot` codec round-trips in `lpc-model`. +2. `lpc-node-registry` tests with inline TOML fixtures embedding a minimal + **`TestSourceDef`** (`kind = "TestSource"`) registered only for tests — **or** + standalone resolve/materialize tests without a new `NodeKind` if we can avoid + registry coupling. + +Prefer **standalone materialize tests** + codec tests; add `TestSourceDef` only if +needed for end-to-end `load_root` integration. + +### Q5 — URL stub + +**Context:** `SourceFileRef` includes future URL variant. + +**Suggested:** `SourceFileRef::Url { .. }` stub variant; resolve returns +`MaterializeError::Unsupported` in M3. + +### Q6 — Shorthand string disambiguation + +**Context:** `source = "./shader.glsl"` must not collide with inline string that +looks like a path. + +**Suggested:** Shorthand string form is **only** valid when the TOML value is a +**string scalar** (not inline table). Inline must use extension-key table form. + +## Resolved decisions (roadmap) + +- Parallel build; no `lpc-engine` edits until M6. +- No long-lived source text in refs or slot resolved values. +- File artifact registration via M1 store on resolve. +- Hard cut TOML encoding for new slot; example project migration at M6. + +## Dependencies + +- M1 `ArtifactStore` (done). +- M2 `NodeDefRegistry` (done) — optional for integrated tests. + +## Notes + +- M4 scenario: GLSL file edit bumps artifact → materialize version changes, + `NodeDefUpdates` empty if node TOML unchanged. +- M5 `AssetView` will feed materialize for uncommitted asset replaces; M3 uses + store + fs only. diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/01-source-file-slot-codec.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/01-source-file-slot-codec.md new file mode 100644 index 000000000..3fd263bcd --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/01-source-file-slot-codec.md @@ -0,0 +1,48 @@ +# Phase 01 — SourceFileSlot type + codec + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +Add authored **`SourceFileSlot`** in `lpc-model` with custom codec dispatch. + +**In scope:** + +- `SourceFileBacking` (`Path` / `Inline { extension, text }`) +- `SourceFileSlot` with `revision()`, `path_value()`, `inline_value()` +- Custom codec id `lp::slots::SourceFileCodec` +- TOML forms: shorthand string, `$path` table, extension-key inline table +- Wire into `custom_slot_codec.rs` (read / write json / write toml / snapshot) +- Export from `slots/mod.rs` and `lib.rs` +- `ValueReader::is_string_scalar()` for shorthand disambiguation +- Unit tests in `source_file.rs` + +**Out of scope:** `SourceFileRef`, resolve/materialize, production `ShaderDef` migration. + +## Implementation Details + +### `slots/source_file.rs` + +- `SOURCE_FILE_CODEC_ID` = `SlotShapeId::from_static_name("lp::slots::SourceFileCodec")` +- Reserved key: `$path` (not `path`) +- Inline table: exactly one extension key (any non-reserved string) +- `FieldSlot` + `SlotCustomAccess` impls (mirror `NodeInvocation` pattern) + +### `slot_codec/slot_reader.rs` + +Add `ValueReader::is_string_scalar()` using `peek_event()` so path shorthand +does not collide with inline table parsing. + +### Tests + +- Parse `./shader.glsl` shorthand +- Parse `$path = "./shader.glsl"` +- Parse `glsl = "void main() {}"` +- Round-trip path shorthand to TOML + +## Validate + +```bash +cargo test -p lpc-model source_file +cargo check -p lpc-model +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/02-source-file-ref-resolve.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/02-source-file-ref-resolve.md new file mode 100644 index 000000000..2015fa978 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/02-source-file-ref-resolve.md @@ -0,0 +1,44 @@ +# Phase 02 — SourceFileRef + resolve + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +Add resolved handle type and explicit resolve step in `lpc-node-registry`. + +**In scope:** + +- `SourceFileRef` enum (`File` / `Inline` / `Url` stub) +- `ResolveError` +- `resolve_source_file(store, containing_file, slot, frame)` +- Path resolve via `resolve_node_specifier` (reuse registry helper) +- File mode acquires `ArtifactLocation::file(resolved_path)` in store +- `pub(crate) use def_walker::resolve_node_specifier` from registry +- Unit tests: path acquire + inline revision + +**Out of scope:** Reading bytes, version combine, `ShaderDef` cutover. + +## Implementation Details + +### `source/source_file_ref.rs` + +```rust +pub enum SourceFileRef { + File { artifact_id, authored_path, resolved_path, extension }, + Inline { extension, slot_revision }, + Url { url }, +} +``` + +### `source/resolve.rs` + +- `SourceFileBacking::Path` → resolve relative to `containing_file`, acquire artifact +- `SourceFileBacking::Inline` → no store acquire; carry slot revision + extension +- Extension hint from file path suffix for `File` variant + +## Validate + +```bash +cargo test -p lpc-node-registry resolve +cargo check -p lpc-node-registry +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/03-materialize-version-tests.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/03-materialize-version-tests.md new file mode 100644 index 000000000..556708a18 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/03-materialize-version-tests.md @@ -0,0 +1,48 @@ +# Phase 03 — Materialize + version tests + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +Read transient UTF-8 text and compute effective revision for M4 file-bump scenarios. + +**In scope:** + +- `MaterializedSource { version, text, diagnostic_name }` +- `SourceDiagnosticCtx { containing_file, slot_path }` +- `MaterializeError` (`Unsupported`, `Utf8`, `MissingInlineBody`, `Artifact`) +- `materialize_source(store, fs, ref, slot, ctx)` +- Effective version: `max(slot.revision(), artifact.revision())` for file; + `slot.revision()` for inline +- Diagnostic names: authored path (file); `{containing_file}:source.{ext}` (inline) +- `Url` ref → `MaterializeError::Unsupported` +- Tests: file read, inline read, fs modify bump without slot edit + +**Out of scope:** ChangeSet / AssetView (M5), engine integration (M6). + +## Implementation Details + +### File mode + +1. `store.read_bytes(artifact_id, fs)` +2. UTF-8 decode +3. `version = slot.revision().max(store.revision(artifact_id))` +4. `diagnostic_name = authored_path` + +### Inline mode + +1. Text from `slot.inline_value()` at materialize time +2. `version = slot.revision()` +3. `diagnostic_name` from `SourceDiagnosticCtx` + +### Gate test (M4 preview) + +File content changes via `apply_fs_changes` → materialize version increases while +authored slot revision unchanged. + +## Validate + +```bash +cargo test -p lpc-node-registry materialize +cargo test -p lpc-node-registry +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/04-cleanup-summary.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/04-cleanup-summary.md new file mode 100644 index 000000000..0b34d1d44 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/04-cleanup-summary.md @@ -0,0 +1,35 @@ +# Phase 04 — Cleanup + summary + +**Dispatch:** [sub-agent: no, supervised] + +## Scope of phase + +Finalize M3 exports, formatting, clippy, and milestone summary. + +**In scope:** + +- `lpc-node-registry/src/lib.rs` re-exports for source API +- `cargo +nightly fmt --all` +- Clippy on touched crates +- `summary.md` with decisions for M4/M6 +- Confirm production `ShaderSource` / `lpc-engine` untouched + +**Out of scope:** M4 harness, M6 cutover. + +## Validate + +```bash +cargo +nightly fmt --all +cargo test -p lpc-model source_file +cargo test -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings +cargo clippy -p lpc-model --all-targets --no-deps -- -D warnings +``` + +## Summary checklist + +- [x] `SourceFileSlot` codec round-trips +- [x] `resolve_source_file` acquires file artifacts +- [x] `materialize_source` combines revisions correctly +- [x] File bump test passes without def TOML change +- [x] No edits under `lpc-engine` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/summary.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/summary.md new file mode 100644 index 000000000..9c39eea4f --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/summary.md @@ -0,0 +1,35 @@ +# M3 Summary — SourceFileSlot + SourceFileRef + +## What was built + +- **`SourceFileSlot`** (`lpc-model`): authored file-or-inline UTF-8 source with custom + codec (`$path`, shorthand string, extension-key inline table). +- **`SourceFileRef`** + **`resolve_source_file`**: handle-only resolved refs; file mode + acquires artifacts in M1 `ArtifactStore`. +- **`MaterializedSource`** + **`materialize_source`**: transient UTF-8 read via + `read_bytes`; effective version `max(slot, artifact)` for files, slot-only for inline. +- **`SourceDiagnosticCtx`**: stable diagnostic labels for compile errors. +- Tests in `lpc-model` (codec) and `lpc-node-registry` (resolve, materialize, file bump). + +Production `ShaderSource` / `lpc-engine` paths unchanged (M6 cutover). + +## Decisions for future reference + +#### Explicit resolve vs parse-time acquire + +- **Decision:** Driver calls `resolve_source_file` after parse; refs hold handles only. +- **Why:** Separates authored shape from artifact lifecycle; same pattern as M2 registry. +- **Revisit when:** M6 engine wires resolve into node load. + +#### Effective version = max(slot, artifact) for files + +- **Decision:** File edits bump artifact revision even when node TOML unchanged. +- **Why:** M4 fs-change scenario depends on version change without def diff. +- **Revisit when:** M5 AssetView may add a third revision source. + +#### URL stub unsupported in M3 + +- **Decision:** `SourceFileRef::Url` exists; materialize returns `Unsupported`. +- **Why:** Reserve enum shape without committing fetch/cache semantics. + +Plan: `docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness.md new file mode 100644 index 000000000..386e83519 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness.md @@ -0,0 +1,61 @@ +# Milestone 4: Fs-Change Semantics Harness + +## Title And Goal + +Prove end-to-end **reload semantics** in **`lpc-node-registry` test harness** +only — simulated `FsChange` → artifact bumps → `NodeDefUpdates` → expected +node lifecycle actions. **Production `lpc-engine` unchanged.** + +## Parallel Build + +The harness proves **`NodeDefRegistry::sync(changes) -> SyncResult`** in tests +only. Production `lpc-engine` unchanged until M6. + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/` + +## Scope + +In scope: + +- **API refactor:** registry owns state; `sync` takes `RegistryChange` batch + (fs in M4), applies, returns **`SyncResult`** (factual diff). +- Harness fixtures + scenario tests S1–S6. +- **`engine-policy-v1.md`** — how M6 engine would react (not registry output). + +Out of scope: + +- Production engine cutover (**M6**). +- `RegistryChange::ChangeSet` variants — [ChangeSet M5](../2026-05-21-changeset-change-management/m5-commit-promotion.md); enum stub OK. +- ChangeSet / client change management ([promoted roadmap](../2026-05-21-changeset-change-management/overview.md)). +- Server `LpServer` fs routing (**M7**). +- `project.toml` topology changes (**M8**). +- Any edits to `lpc-engine` or `lpa-server`. + +## Key Decisions + +- Harness is prerequisite for [ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md); **M4 + ChangeSet M6** gate **M6**. +- v1 node refresh rule may be coarse (recreate all nodes bound to changed defs). +- Error propagation: no last-good; def error → node destroy → parent error. + +## Deliverables + +- **`sync(changes) -> SyncResult`** API on `NodeDefRegistry` +- Scenario tests S1–S6 +- `engine-policy-v1.md` for M6 + +## Dependencies + +- M1, M2, M3 complete. + +## Execution Strategy + +Full plan. Multiple scenarios, harness API, and action expectations need a +design pass before tests are written. + +Suggested chat opener: + +> This milestone needs a full plan — fs-change harness, scenario matrix, and +> expected engine action assertions. I'll run the plan process then implement. +> Agree? diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/00-design.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/00-design.md new file mode 100644 index 000000000..ab48e4b7f --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/00-design.md @@ -0,0 +1,151 @@ +# M4 Design — Fs-Change Semantics Harness + +## Scope + +Refine **`NodeDefRegistry`** to a simple stateful API: **apply changes → update +state → return diff**. Prove semantics in tests. No `lpc-engine` edits. + +**Registry = past tense.** Engine policy documented in `engine-policy-v1.md` only. + +## Core API (revised from M2) + +M2 split `apply_fs_changes` + `sync` across driver and store. M4 consolidates: + +**Registry owns its state** (including `ArtifactStore`). Caller does not orchestrate +store bumps separately. + +```rust +impl NodeDefRegistry { + /// Bootstrap once. Registry acquires artifacts and registers defs. + pub fn load_root( + &mut self, + fs: &dyn LpFs, + root_path: &LpPath, + frame: Revision, + ctx: &ParseCtx<'_>, + ) -> Result; + + /// Apply incoming changes, update internal state, return summary. + pub fn sync( + &mut self, + fs: &dyn LpFs, + changes: &[RegistryChange], + frame: Revision, + ctx: &ParseCtx<'_>, + ) -> SyncResult; +} +``` + +M4: `RegistryChange::Fs(FsChange)` only. +M5: extend enum with ChangeSet commit ops — **same `sync` entry point**. + +### `sync` contract (functional) + +1. Snapshot minimal **before** state needed for diff (def states, source versions). +2. **Apply** each change to internal artifact store / registry state. +3. **Re-derive** affected defs (paths in change batch + dependents). +4. **Diff** before vs after → `SyncResult`. +5. Return summary; updated state remains in registry. + +No separate public `ArtifactStore`. No caller-side `apply_fs_changes`. No +harness-side source index with cross-call snapshots. + +Internal bookkeeping (e.g. which artifact backs which def) is implementation +detail — not a second API surface. + +## File structure + +``` +lp-core/lpc-node-registry/src/ +├── registry/ +│ ├── node_def_registry.rs # owns ArtifactStore; load_root + sync +│ ├── sync_result.rs # SyncResult, DefChangeDetail, SourceRevisionBump +│ ├── registry_change.rs # RegistryChange enum (Fs in M4) +│ ├── source_deps.rs # per-entry source dep + version (internal) +│ └── source_bridge.rs # ShaderDef/SvgPath → M3 materialize (internal) +├── harness/ # #[cfg(test)] — fixtures + helpers only +│ ├── fixtures.rs +│ └── bindings.rs +└── tests/ + └── fs_change_semantics.rs +``` + +## Types + +### `RegistryChange` + +```rust +pub enum RegistryChange { + Fs(FsChange), + // M5: ChangeSetCommit(...), AssetReplace(...), etc. +} +``` + +### `SyncResult` + +```rust +pub struct SourceRevisionBump { + pub def_id: NodeDefId, + pub before: Revision, + pub after: Revision, +} + +pub enum DefChangeDetail { + Content, + KindChanged { from: NodeKind, to: NodeKind }, + EnteredError, + LeftError, +} + +pub struct SyncResult { + pub def_updates: NodeDefUpdates, + pub source_revisions: Vec, + pub change_details: Vec<(NodeDefId, DefChangeDetail)>, +} +``` + +`NodeDefUpdates` fields → **`Vec`** (embedded-friendly). + +### Source revisions (inside `sync`, not harness) + +When a fs change touches a file artifact (e.g. `/shader.glsl`): + +- Registry finds defs with that source dependency (recorded on entry at load/re-derive). +- Re-materializes via M3 bridge; if version increased → push `SourceRevisionBump`. +- Def TOML unchanged → def **not** in `def_updates.changed`. + +## Memory / `lp-collection` + +Cold path — prefer RAM over CPU. `Vec` in `SyncResult`. Consider `DenseIdMap` for +registry entries when refactoring internals. `lp-collection` optional; add when used. + +## Scenario matrix (gate) + +| ID | Scenario | `sync(changes)` → | +|----|----------|-------------------| +| S1 | Leaf TOML edit | root in `def_updates.changed` | +| S2 | GLSL edit only | empty def updates; shader in `source_revisions` | +| S3 | SVG edit only | empty def updates; fixture in `source_revisions` | +| S4 | Inline child edit | child changed; parent not | +| S5a | Leaf parse error | root changed; `EnteredError` | +| S5b | Inline parse error | child changed; `EnteredError` | +| S6 | Kind change | both changed; `KindChanged` | + +## Validation + +```bash +cargo +nightly fmt --all +cargo test -p lpc-node-registry +cargo test -p lpc-node-registry --test fs_change_semantics +cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings +``` + +## Plan phases + +| # | Phase | Dispatch | +|---|-------|----------| +| 01 | Unified sync API + SyncResult + Vec updates | composer-2.5-fast | +| 02 | Source deps + revisions inside sync | composer-2.5-fast | +| 03 | DefChangeDetail diff in sync | composer-2.5-fast | +| 04 | Scenario tests S1–S6 | composer-2.5-fast | +| 05 | M2 test migration + summary + cleanup | supervised | diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/00-notes.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/00-notes.md new file mode 100644 index 000000000..0ca681464 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/00-notes.md @@ -0,0 +1,67 @@ +# M4 Plan Notes — Fs-Change Semantics Harness + +## Scope + +Refactor registry to **simple stateful sync**: apply changes → update state → +return **`SyncResult`**. Prove fs-change semantics in tests. No `lpc-engine` edits. + +## API direction (user-aligned) + +**Before (M2):** caller owns `ArtifactStore`, calls `apply_fs_changes`, then +`registry.sync()` with no changes parameter. + +**After (M4):** registry owns state; one call: + +```rust +let result = registry.sync(fs, changes, frame, ctx); +// result.def_updates, result.source_revisions, result.change_details +``` + +- Functional: state in, changes in, summary out. +- No external tracking layers or harness-side diff indexes. +- M5 extends `RegistryChange` with ChangeSet ops — same `sync`. + +Engine interprets `SyncResult`; registry does not emit `RefreshNode` etc. + +## What `sync` does internally + +1. Snapshot before (def state + source versions) — **inside** sync only. +2. Apply `RegistryChange` batch to artifact store. +3. Re-derive defs for affected artifacts. +4. Diff → `SyncResult`. + +Remove or fold **`artifact_last_revision`** — change batch drives what to +re-derive (plus source-dependency lookup for file-only edits like GLSL). + +## Source file bumps (S2/S3) + +Not a separate harness index. Registry records source dependencies on entries +(at load / re-derive). When sync applies a change to `/shader.glsl`, registry +finds dependent defs, re-materializes, emits `source_revisions` if version bumped. + +Uses internal `source_bridge` (production `ShaderDef` / fixture `SvgPath` → M3). + +## Memory + +- `SyncResult` and `NodeDefUpdates` use **`Vec`**, not `BTreeSet`/`BTreeMap`. +- Cold path: RAM over CPU; `lp-collection` when it helps (`DenseIdMap` for entries). + +## Open questions — resolved + +| Q | Resolution | +|---|------------| +| ReloadReport name | **`SyncResult`** — nothing "reloads" at registry layer | +| Two-step apply + sync | **Single `sync(changes)`** | +| Harness source index | **Inside registry** as entry deps, not harness module | +| Engine actions | **`engine-policy-v1.md` only** | +| Public ArtifactStore | **Owned by registry**; not caller-facing in sync path | + +## Out of scope + +- `lpc-engine` cutover (M6) +- ChangeSet variant on `RegistryChange` (M5 — enum stub OK in M4) +- Registry internal map rewrite to `DenseIdMap` (optional stretch) + +## Dependencies + +M1–M3 complete. M4 refactors M2 public API (breaking for tests; same crate). diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/01-unified-sync-api.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/01-unified-sync-api.md new file mode 100644 index 000000000..1fa883fac --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/01-unified-sync-api.md @@ -0,0 +1,67 @@ +# Phase 01 — Unified sync API + SyncResult + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +Consolidate M2 two-step flow into **`NodeDefRegistry::sync(changes) -> SyncResult`**. + +**In scope:** + +- Registry **owns** `ArtifactStore` (move field inside `NodeDefRegistry`) +- `registry_change.rs` — `RegistryChange::Fs(FsChange)` +- `sync_result.rs` — `SyncResult { def_updates, source_revisions, change_details }` + (source/details filled in later phases; empty vecs OK initially) +- New `sync(fs, changes, frame, ctx) -> SyncResult`: + - apply fs changes to internal store + - re-derive affected defs (by changed paths) + - diff → `NodeDefUpdates` in result +- `NodeDefUpdates` → **`Vec`** per field +- Update `load_root` signature: no external `store` param +- Migrate existing M2 unit tests to new API +- `harness/fixtures.rs` — TOML constants for scenarios + +**Out of scope:** Source revision bumps, DefChangeDetail, integration test file. + +## API migration + +```rust +// Before +store.apply_fs_changes(&changes, frame); +let updates = registry.sync(&mut store, fs, frame, ctx); + +// After +let result = registry.sync(fs, &changes.iter().map(RegistryChange::Fs).collect(), frame, ctx); +let updates = result.def_updates; +``` + +For M4 ergonomics, also accept: + +```rust +pub fn sync_fs(&mut self, fs, changes: &[FsChange], frame, ctx) -> SyncResult +``` + +as thin wrapper over `sync` with `RegistryChange::Fs` mapping. + +## Internal simplification + +- Drive re-derive from **paths in `changes`**, not `artifact_last_revision` map. +- Keep `entries` / `source_index` maps until DenseIdMap pass (optional). + +## Memory + +- `NodeDefUpdates`: `Vec` not `BTreeSet` +- `SyncResult`: `Vec` fields + +## Sub-agent Reminders + +- Do not commit. +- Breaking API change within crate is OK; fix all tests. +- No `set_current_revision` in tests. + +## Validate + +```bash +cargo test -p lpc-node-registry +cargo check -p lpc-node-registry +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/02-source-deps-in-sync.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/02-source-deps-in-sync.md new file mode 100644 index 000000000..29145fe80 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/02-source-deps-in-sync.md @@ -0,0 +1,48 @@ +# Phase 02 — Source deps + revisions inside sync + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +GLSL/SVG file edits produce **`source_revisions`** from **`sync` itself** — no +harness index. + +**In scope:** + +- `registry/source_deps.rs` — record resolved source paths per def on load/re-derive +- `registry/source_bridge.rs` — internal; Shader/SvgPath → M3 materialize version +- During `sync`: when a changed path is a **source file** (not def TOML), find + dependent defs, re-materialize, append `SourceRevisionBump` to result +- Test: S2 shape — glsl change only → empty `def_updates`, non-empty `source_revisions` + +**Out of scope:** DefChangeDetail, integration scenarios, ChangeSet variant. + +## Design + +On each loaded/re-derived def entry, store: + +```rust +struct SourceDep { + resolved_path: LpPathBuf, // or ArtifactId after acquire + last_version: Revision, +} +// Vec or small inline vec on entry — file-backed sources only +``` + +When `sync` applies `FsChange` to path P: + +1. Re-derive defs if P is a def artifact (existing logic) +2. Else if P matches any entry's `SourceDep.resolved_path`, re-materialize and + compare version → maybe push bump + +## Memory + +- Small **`Vec`** of deps per def (typically 0–1 for M4 fixtures) +- `source_revisions` in result: sparse `Vec` + +## Validate + +```bash +cargo test -p lpc-node-registry source +cargo test -p lpc-node-registry +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/03-sync-change-details.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/03-sync-change-details.md new file mode 100644 index 000000000..ba9ac0254 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/03-sync-change-details.md @@ -0,0 +1,22 @@ +# Phase 03 — DefChangeDetail in sync diff + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +`sync` populates **`change_details`** by diffing def state snapshot (inside sync). + +**In scope:** + +- `DefChangeDetail` enum in `sync_result.rs` +- During sync: snapshot def states before apply; after re-derive, classify each + `changed` id → `Content`, `KindChanged`, `EnteredError`, `LeftError` +- Unit tests for detail classification + +**Out of scope:** Integration file, engine policy. + +## Validate + +```bash +cargo test -p lpc-node-registry sync +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/04-scenario-tests.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/04-scenario-tests.md new file mode 100644 index 000000000..98de5a00d --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/04-scenario-tests.md @@ -0,0 +1,33 @@ +# Phase 04 — Scenario tests S1–S6 + +**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -] + +## Scope of phase + +Integration tests calling **`registry.sync`** directly. + +**In scope:** + +- `tests/fs_change_semantics.rs` +- `harness/fixtures.rs` — load files into `LpFsMemory`, `load_root`, then `sync_fs` +- Assert `SyncResult` fields only (no engine actions) + +## Example + +```rust +let mut registry = NodeDefRegistry::new(); +let fs = fixtures::load_shader_project(); +registry.load_root(&fs, LpPath::new("/shader.toml"), frame, &ctx).unwrap(); + +fixtures::write_file(&mut fs, "/shader.glsl", NEW_GLSL); +let result = registry.sync_fs(&fs, &[fs_change("/shader.glsl")], frame, &ctx); + +assert!(result.def_updates.is_empty()); +assert!(result.source_revisions.iter().any(|b| b.def_id == shader_id)); +``` + +## Validate + +```bash +cargo test -p lpc-node-registry --test fs_change_semantics +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/05-cleanup-summary.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/05-cleanup-summary.md new file mode 100644 index 000000000..1c0d499b0 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/05-cleanup-summary.md @@ -0,0 +1,23 @@ +# Phase 05 — Summary + cleanup + +**Dispatch:** [sub-agent: no, supervised] + +## Scope + +- `summary.md` — API + scenario matrix +- `engine-policy-v1.md` — inputs now `SyncResult` +- Remove obsolete plan files / stale M2 driver docs references +- Clippy + fmt + +## Checklist + +- [ ] `sync(changes) -> SyncResult` is the public steady-state API +- [ ] No public two-step apply + sync +- [ ] S1–S6 pass +- [ ] M2 T1–T5 migrated + +## Validate + +```bash +just check # or cargo test -p lpc-node-registry && clippy +``` diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/engine-policy-v1.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/engine-policy-v1.md new file mode 100644 index 000000000..497e4c1ba --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/engine-policy-v1.md @@ -0,0 +1,27 @@ +# Engine Policy v1 (M6 reference) + +**Not registry output.** How **`lpc-engine`** interprets **`SyncResult`**. + +## Input + +```rust +// After: let result = registry.sync(fs, changes, frame, ctx); +result.def_updates // added / changed / removed def ids +result.source_revisions // file-backed source version bumps +result.change_details // KindChanged, EnteredError, etc. +``` + +Plus engine-owned runtime binding graph (def id → live node). + +## Suggested v1 policy + +| `SyncResult` signal | Engine effect | +|---------------------|---------------| +| `def_updates.added` | Attach / create node | +| `def_updates.removed` | Destroy node | +| `changed` + `KindChanged` | Delete + recreate node | +| `changed` + `Content` / other | Refresh / re-prepare | +| `changed` + `EnteredError` | Destroy node; cascade parent error | +| `source_revisions` (def not in `changed`) | Re-prepare products (recompile GLSL, etc.) | + +M4 tests **`SyncResult` only** — not these effects. diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/summary.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/summary.md new file mode 100644 index 000000000..78efdf98a --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/summary.md @@ -0,0 +1,27 @@ +# M4 Summary — Fs-Change Semantics Harness + + + +## API (target) + +```rust +registry.load_root(fs, root_path, frame, ctx)? ; +let result = registry.sync(fs, changes, frame, ctx); +// result: SyncResult { def_updates, source_revisions, change_details } +``` + +Registry owns state. **`sync`** applies changes, updates state, returns factual diff. +M5 adds ChangeSet variants to `RegistryChange`. + +## Scenarios (gate) + +| ID | Input | `SyncResult` | +|----|-------|--------------| +| S1 | def TOML edit | def changed | +| S2 | GLSL only | source_revisions | +| S3 | SVG only | source_revisions | +| S4 | inline child edit | child changed | +| S5 | parse error | EnteredError | +| S6 | kind flip | KindChanged | + +Engine policy: `engine-policy-v1.md`. diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m5-changeset-change-management.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m5-changeset-change-management.md new file mode 100644 index 000000000..c98cb55cb --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m5-changeset-change-management.md @@ -0,0 +1,22 @@ +# Milestone 5: ChangeSet / Change Management + +**Promoted to standalone roadmap:** + +[`docs/roadmaps/2026-05-21-changeset-change-management/overview.md`](../2026-05-21-changeset-change-management/overview.md) + +ChangeSet change management is no longer tracked as an inline milestone plan here. +Use the promoted roadmap for milestones M1–M7, user stories, and implementation +plans. + +## Gate for this roadmap + +**M6 (engine cutover)** below may proceed — prerequisites are green: + +- **M4** (fs-change harness) here — green +- **M6 (diff + equivalence gate)** on the [ChangeSet roadmap](../2026-05-21-changeset-change-management/m6-diff-equivalence-gate/summary.md) — green ([ChangeSet summary](../2026-05-21-changeset-change-management/summary.md)) + +## Historical note + +Use [`change-language.md`](../2026-05-21-changeset-change-management/change-language.md) +for the v1 edit vocabulary (`ArtifactChange`, implicit create on `Path`, slot +ops). diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md new file mode 100644 index 000000000..4bb1c1186 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md @@ -0,0 +1,20 @@ +# Milestone 6: Engine + Node Cutover + +**Promoted to standalone roadmap:** + +[`docs/roadmaps/2026-05-21-engine-registry-cutover/`](../2026-05-21-engine-registry-cutover/overview.md) + +Engine switchover, wire edit messages, `lpc-model` vocabulary, server registry +apply, SyncResult policy, graph reconciliation, and server fs wire-up are tracked +there (M1–M8). + +## Gate (unchanged) + +- **M4** fs-change harness here — green +- **[ChangeSet M6 diff gate](../2026-05-21-changeset-change-management/m6-diff-equivalence-gate/summary.md)** — green + +## Historical note + +Original M6 scope: delete old `lpc-engine` artifact path; `ProjectLoader` + +`Engine` → `lpc-node-registry` + ChangeSet. See promoted roadmap for expanded +milestones (model + wire + server prerequisites). diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m7-server-fs-change-wireup.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m7-server-fs-change-wireup.md new file mode 100644 index 000000000..fee9e5795 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m7-server-fs-change-wireup.md @@ -0,0 +1,39 @@ +# Milestone 7: Server Fs-Change Wire-Up + +## Title And Goal + +Route filesystem notifications to **`Engine::handle_fs_changes`** on the +**post-M6** stack and stop **`Project::reload()`** on watcher events. + +## Prerequisites + +Parallel build ends at **M6**. Engine uses `lpc-node-registry` + ChangeSet model +from M5. + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m7-server-fs-change-wireup/` + +## Scope + +In scope: + +- `LpServer` → `Engine::handle_fs_changes`. +- End-to-end single-file reload (TOML, assets). +- Explicit `Project::reload()` retained for user-initiated full reload. + +Out of scope: + +- `project.toml` graph reconciliation (**M8**). + +## Dependencies + +- M6 engine cutover. + +## Execution Strategy + +Small plan. Narrow server→engine wiring. + +Suggested chat opener: + +> I suggest a small plan for server fs-change wire-up, then implement. Agree? diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m8-project-graph-reconciliation.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m8-project-graph-reconciliation.md new file mode 100644 index 000000000..eaa5ce4dd --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m8-project-graph-reconciliation.md @@ -0,0 +1,34 @@ +# Milestone 8: project.toml / Graph Reconciliation + +## Title And Goal + +Support **`project.toml`** and invocation wiring changes: engine tree mutation +when parent child lists change. + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m8-project-graph-reconciliation/` + +## Scope + +In scope: + +- Detect invocation diffs; engine add/remove/repoint children. +- Tests for add/remove top-level nodes, playlist inline children. +- ChangeSet may express graph-level node add/remove (extends M5). + +Out of scope: + +- Optimal minimal diff for every edit shape. + +## Dependencies + +- M7 server wire-up (or M6 engine apply path minimum). + +## Execution Strategy + +Full plan. + +Suggested chat opener: + +> M8 graph reconciliation needs a full plan. Agree? diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m9-cleanup-validation.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m9-cleanup-validation.md new file mode 100644 index 000000000..5f68e66bc --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m9-cleanup-validation.md @@ -0,0 +1,30 @@ +# Milestone 9: Cleanup + Validation + +## Title And Goal + +Integration validation, remove scaffolding, CI / firmware gates. + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m9-cleanup-validation/` + +## Scope + +In scope: + +- Remove parallel-build leftovers; confirm old artifact path gone (M6). +- Example projects → `SourceFileSlot` TOML. +- M4/M5 harness tests remain as `lpc-node-registry` regression suite. +- `just check`, fw-esp32 validation. + +## Dependencies + +- M1–M8 complete. + +## Execution Strategy + +Direct execution. + +Suggested chat opener: + +> I can implement M9 cleanup without a separate plan. Agree? diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/notes.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/notes.md new file mode 100644 index 000000000..ce59f7200 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/notes.md @@ -0,0 +1,316 @@ +# Artifact-Routed File Reload — Roadmap Notes + +## Scope + +Deliver incremental file reload for a running LightPlayer project: changed files update only the artifacts and nodes they affect, without `Project::reload()` or reconstructing the whole `Engine`. + +**Immediate goal:** single-file reload routed through the artifact layer. + +**Design constraint:** structure the artifact / definition / runtime stack so +**ChangeSets** sit between registry base and effective reads — proven in the +[promoted ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md) +before engine cutover (M6). + +Target stack (bottom → top): + +``` +Filesystem / library sources + ↓ +ArtifactStore — source identity + freshness (path, version); no long-lived file bytes + ↓ +NodeDefRegistry — parsed NodeDef storage (file-backed + inline), keyed by NodeDefId + ↓ +ChangeSet layer — see [ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md) + ↓ +NodeDefView + AssetView — effective reads (base + active ChangeSets) + ↓ +Engine node tree — live NodeRuntime instances + dependency index +``` + +In scope for this roadmap: + +- Replace server file-change path that calls `Project::reload()`. +- Split **ArtifactStore** (sources) from **NodeDefRegistry** (parsed definitions). +- Register node-to-artifact dependencies during project load. +- Track GLSL, SVG, and node TOML as file-backed artifacts (metadata only for source files). +- Let shader and fixture nodes react to changed dependent artifacts. +- Handle node TOML changes by reloading/repreparing affected nodes in place. +- Error propagation on reload/parse failure (no last-good retention in v1). +- ESP32 heap discipline: no cached source text, no duplicate engines, staged compile/prepare. + +Out of scope (this roadmap): + +- Full **project diff → ChangeSet** automation (see ChangeSet roadmap + [`future.md`](../2026-05-21-changeset-change-management/future.md)). +- Full optimal graph diff for arbitrary `project.toml` edits in the first slice. +- Library artifact specifiers. +- Host precompilation or any weakening of on-device GLSL JIT. +- Byte-level artifact diffing / digest in the first pass. + +## Current State + +### Server reload path (wrong boundary) + +- `lp-app/lpa-server/src/server.rs` — `LpServer::tick` collects project-relative `FsChange`s and calls `project.reload()` for any batch. +- `lp-app/lpa-server/src/project.rs` — `Project::reload()` drops `Engine` and runs `ProjectLoader::load_from_root` again. +- Useful for explicit full reload; must not run on filesystem watcher events. + +### Engine stub + +- `lp-core/lpc-engine/src/engine/engine.rs` — `Engine::handle_fs_changes` is a no-op. +- Engine owns `ArtifactStore`, `artifact_nodes` (`HashMap`), `demand_roots`, node tree. +- `TickContext` exposes only the **owning node-definition artifact** (`artifact_ref`, `artifact_content_frame`, `artifact_changed_since`) — not dependent source artifacts (GLSL, SVG). + +### ArtifactStore today = de facto NodeDef registry + +There is no separate `NodeRegistry` / `NodeDefRegistry` type. Parsed definitions live inside `ArtifactStore`: + +| Piece | Location | Role today | +|-------|----------|------------| +| `ArtifactStore` | `artifact/artifact_store.rs` | Maps `ArtifactLocation` → refcounted entry; `load_with` loads **`NodeDef` only** | +| `ArtifactState` | `artifact/artifact_state.rs` | Payload is always `NodeDef` when loaded | +| `ArtifactLocation` | `artifact/artifact_location.rs` | `File(path)` or `InlineNode { owner, name }` | +| `NodeDefHandle` | `node/node_def_handle.rs` | `(ArtifactId, SlotPath)` — points into artifact store, not a registry index | +| `NodeEntry` | `node/node_entry.rs` | `def_handle` + runtime; `artifact()` → owning artifact id | + +Historical context: milestone `m2.7-node-def-handandles-and-slot-views/02-concrete-artifact-store` intentionally made `ArtifactStore` own `NodeDef` payloads directly. That was correct for the slot-domain cutover; it is now the wrong boundary for file reload + overlays. + +Smells: + +- `ArtifactLocation::InlineNode` — inline defs are derived from an owning artifact, not separately loadable sources. +- `ArtifactStore::load_with` — closure returns `NodeDef`; no generic source-file artifact type. +- Load failure **replaces** payload with error state (no last-good + error split). +- No content version / freshness API beyond `content_frame` bump on successful load. + +### ProjectLoader bypasses artifacts for dependent sources + +`lp-core/lpc-engine/src/engine/project_loader.rs`: + +- Node TOML → `acquire_location(File)` → `load_with` → `NodeDef` in artifact store ✓ +- `ShaderSource::Path` → `read_shader_source` → `String` passed to `ShaderNode::new` ✗ (not tracked) +- `MappingConfig::SvgPath` → `resolve_fixture_mapping` at attach ✗ (not tracked) + +After load, GLSL/SVG paths are invisible to artifact invalidation. + +### Nodes hold inlined source / resolved mapping + +- `shader_node.rs` — long-lived `glsl_source: String`; compile failure drops `self.shader` (no last-good compile). +- `fixture_node.rs` — `SvgPath` resolved at load; runtime sync ignores path changes. + +### Closest existing “incremental update” pattern + +- `slot_mutation.rs` — mutates `NodeDef` in place inside `ArtifactStore`, bumps `content_frame`; nodes observe via `TickContext::artifact_changed_since`. Wire-driven, not filesystem-driven. + +### Overlay prototype (other branch, not ported) + +`lightplayer-app-ui/lp-core/lpc-slot-mockup` explored: + +- `ArtifactStoreModel` — base authored slot roots keyed by path +- `OverlayStore` — UI change sets (`SlotOp`, create/delete root) +- `OverlayProjector` — merges enabled overlays → `ProjectedSlotRoots` +- `commit_overlay` — promote overlay into base store + +That mockup predates current engine structure. Concept (project base + overlay → effective defs) is still the target for UI edits, but implementation will likely be rewritten to fit `NodeDefRegistry` + engine revision model. + +## User Notes + +- File reloads should only reload what they need; routed through artifact manager. +- Server/file-watcher must not special-case GLSL or SVG — they are file artifacts. +- Artifacts are sources; `NodeDef` is derived, not the artifact itself. +- `InlineNode` artifact locations are probably wrong. +- Acceptable initially: reload child nodes when a node file changes; avoid whole-project reload. +- No transactional whole-engine reload (two engines in memory) on ESP32 (~300 KB heap). +- Source-file artifacts: metadata only (path + version); lazy read during prepare/compile. +- Preserve on-device GLSL JIT. + +## Resolved Decisions (design discussion) + +### NodeDefRegistry — core reload orchestrator + +The registry (user: "NodeRegistry"; holds **defs**, not live runtimes) is the bounded, testable center of reload logic. + +**Entry shape** — similar to current artifact entry: + +- Every `NodeDefId` maps to an entry with **source** `{ artifact_id, path }` where `path` is location within the artifact (`SlotPath::root()` / empty path = artifact root). +- **Identity separate from content:** if we know a def should exist, create the entry even when parse/validation fails (`ParseError`, `ValidationError`, etc.). +- Parsed payload lives in entry state when healthy; error state when not. + +**Update protocol:** + +``` +Engine --fs changes to store--> store.apply_fs_changes +Engine --sync--> NodeDefRegistry::sync(&ArtifactStore, …) +NodeDefRegistry --report--> NodeDefUpdates { added, changed, removed, ... } +Engine --applies report--> attach/detach/destroy nodes, propagate parent errors +``` + +- Registry knows which defs came from which artifacts; on artifact change it re-derives affected defs. +- Report is **def-level**: `added` / `changed` / `removed` (name: **`NodeDefUpdates`** or similar). +- **Def content change ≠ child inventory change:** editing an inline child def does not necessarily mark the parent def `changed` — only the child def entry. +- Distinguish (conceptually, may surface in report or engine follow-up): + - **Def payload changed** — same identity, new parsed content or error state. + - **Child defs added/removed** — artifact now derives a different set of inline `NodeDefId`s (e.g. playlist entries). + - **Graph wiring changed** — parent's invocation map / child references changed (engine tree mutation; own milestone). +- **v1 simplification:** when a node binds multiple defs, may refresh all referenced defs — acceptable short-term, not ideal long-term. +- **Later:** stable `NodeDefId` preservation when edits are unambiguous. + +Registry is unit-testable in isolation: feed artifact change → assert `NodeDefUpdates`. Engine response tested separately. + +**Build order:** stand up new `ArtifactStore` + `NodeDefRegistry` and nail `NodeDefUpdates` semantics **before** cutting over loader/engine and fs reload. Graph/`project.toml` reconciliation is big enough for **its own milestone** after foundation. + +**Def access:** expose defs only through a **view** (projection/effective def), not raw registry entries — overlay/change layer may live inside or adjacent to registry; update report may eventually reflect projected defs. Exact change-layer integration TBD. + +### Error semantics (no last-good in v1) + +Propagate errors; do **not** retain last-good parsed defs or last-good compiled runtimes on failure. + +- Parse/validation error on a def → entry enters error state → **nodes bound to that def are destroyed**. +- Parent nodes referencing the def → reference fails → parent enters error state (cascade). +- Same identity/content split for artifacts: artifact entry exists with error state when load/parse of source fails. + +Last-good retention is explicit future work if needed. + +### Node-facing source model + +**Authored:** `SourceFileSlot` on defs (see TOML encoding below). + +**Resolved:** slot values carry a **`SourceFileRef`** handle (inline / file artifact / future URL), **not** file contents — same principle as resources/products (no big data in slot values). + +- Node/fixture runtime holds parsed **products** (compiled shader, mapping points), not source text. +- To compile/prepare: ask context to materialize from `SourceFileRef` → `{ version, text, diagnostic_name }`. +- Fixture: state holds resolved `PathPoints`; SVG text materialized only during mapping prepare. + +Node tracks `last_seen_version` per source slot; recompile/reprepare when resolved version bumps. + +Deprecate / replace: `ShaderSource`, standalone `SourcePathSlot` for this use case. + +**Slot family (naming agreed):** + +- **`SourceFileSlot`** — authored UTF-8 file-or-inline. +- **`BinaryFileSlot`** — future sibling; inline base64. +- **`SourceFileRef`** — resolved handle in slot data; materialize via context. + +### Change layer placement (refined) + +Between registry base defs and engine node tree. May be **inside** `NodeDefRegistry` with the update report / def view reflecting projection — exact wiring TBD. Registry def **view** is the hook either way. + +### Hard cut, not incremental facade + +App is in active dev — do the full ArtifactStore / NodeDefRegistry split now. No temporary facade over the old model. `NodeDefHandle` → **`NodeDefId`** (opaque id, same pattern as `ArtifactId`). + +### SourceFileSlot / BinaryFileSlot TOML encoding (agreed direction) + +File-or-inline slots use a **custom slot codec** (existing `SlotDataAccess::Custom` path in `slot_codec/`). Acceptable because file sources are fundamental. + +**File reference** — `$path` leaves `path` free in the namespace for a future `.path` artifact type: + +```toml +source = { $path = "./shader.glsl" } +# shorthand (also valid): +source = "./shader.glsl" +``` + +**Inline text** — extension key names the inline format (GLSL vs WGSL vs SVG, etc.): + +```toml +[source] +glsl = """ +vec4 render(vec2 pos) { ... } +""" +``` + +Exactly one of `$path` or an extension key must be present. Inline table form and inline-table `$path` form are both valid for the same slot. + +**`BinaryFileSlot`** (future) — same `$path` for file; inline uses extension key + **base64** payload (`png = "..."`, `jpeg = "..."`). Useful for small embedded assets. + +**Authored model (conceptual):** + +```rust +enum SourceFileBacking { + Path(SourcePath), // from $path + Inline { ext: String, text: String }, // ext from table key: glsl, svg, wgsl, ... +} +``` + +Engine resolves to UTF-8 text + effective version regardless of backing. Extension may inform compile/prepare path (GLSL vs WGSL) without nodes caring about file vs inline. + +**Resolved source includes a diagnostic label** for compile/load errors and logging — exact format flexible, e.g.: + +- File-backed: project-relative path as authored (`mapping1.svg`, `./shader.glsl`) +- Inline: synthetic label anchored to def location (`[shader.toml:56].glsl`, `[shader.toml:source].svg`) + +Engine resolves `SourceFileRef` → UTF-8 text + effective version on materialize (not stored in slot value). Extension may inform compile/prepare path. + +**Resolved materialization includes a diagnostic label** for compile/load errors and logging — exact format flexible, e.g.: + +- File-backed: project-relative path as authored (`mapping1.svg`, `./shader.glsl`) +- Inline: synthetic label anchored to def location (`[shader.toml:56].glsl`, `[shader.toml:source].svg`) + +**Migration:** Hard cut — replace `ShaderSource` `{ path = ... }` / `[source] glsl = ...` with `$path` / extension-key forms. + +Registry/engine responsibilities (not node runtime): +- Register file artifacts for file-mode slots at load. +- Resolved slot values carry `SourceFileRef`; combine slot revision + file artifact version → effective version on materialize. + +### SourceRef sketch (superseded) + +Runtime `SourceRef` on nodes — superseded by **`SourceFileRef`** in resolved slot data + context materialization. + +### Naming + +| Today | Target | +|-------|--------| +| `NodeDefHandle` `(ArtifactId, SlotPath)` | `NodeDefId` opaque registry index | +| `ArtifactStore` holds `NodeDef` | `ArtifactStore` holds source freshness only | +| `NodeEntry.def_handle` | `NodeEntry.def_id: NodeDefId` | +| De facto registry in `ArtifactStore` | `NodeDefRegistry` + `NodeDefUpdates` report | + +### Execution order (agreed direction) + +**Prove semantics with tests before cutover.** The new stores exist to support fs-change (and later projections); switching loader/engine over before that shape is validated has little value. + +Phases: + +1. **Parallel build (M1–M4 + ChangeSet roadmap, no `lpc-engine` changes)** — + registry + fs harness (M4) + [ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md). +2. **Cutover (M6)** — delete old path; engine → `lpc-node-registry`. +3. **Wire-up (M7)** — server fs-change. +4. **Graph reconciliation (M8)**. +5. **Cleanup (M9).** + +## Change management (promoted roadmap) + +See [ChangeSet change management](../2026-05-21-changeset-change-management/overview.md). +**ChangeSet**: ordered, id'd, in-memory until commit. User stories drive harness +acceptance: + +- **Compose** — blank project → any `examples/*` project via ChangeSets. +- **Morph** — any example → any other, one edit at a time, never crashing. +- **Actions** — CRUD defs/slots, inline node authoring, inline↔standalone def, + inline↔asset source refactor. + +**Asset** = non-node file; **artifact** = store identity. + +Longer term: **project diff → ChangeSet stream** and **replay stress harness** +(host / emu / device) — see ChangeSet roadmap `future.md`. Story IDs are the +manual seed; automated diff and full-engine replay follow M6. + +## Open Questions + +- **Q1:** `project.toml` graph reconciliation details — M8. + +(Q8 NodeChange vs AssetChange layering — **resolved**: single ChangeSet stream, +`NodeChange` / `AssetChange` variants; see [ChangeSet roadmap](../2026-05-21-changeset-change-management/decisions.md).) + +## Roadmap Artifacts + +`overview.md`, `m1`–`m9`, `decisions.md`, `future.md`. + +## Build Location + +- **`lpc-node-registry`** — **M1** crate bootstrap + `ArtifactStore`; **M2–M4** + registry + source; **ChangeSet roadmap** fills change + view. No `lpc-engine` + edits until M6. +- **Delete `lpc-slot-mockup`** at M1 start. +- **`lpc-model`** — `SourceFileSlot` additive in M3; production `ShaderDef` at **M6**. +- **`lpc-engine`** — **M6** cutover only. diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/overview.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/overview.md new file mode 100644 index 000000000..2c83d14d5 --- /dev/null +++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/overview.md @@ -0,0 +1,129 @@ +# Artifact-Routed File Reload + +## Motivation + +Today any filesystem change triggers `Project::reload()`: the server drops the +running `Engine` and rebuilds it from scratch via `ProjectLoader`. That is the +wrong boundary for hot reload — it spikes memory on ESP32, reloads unrelated +nodes, and bypasses the artifact/refcount model the engine already has. + +The immediate goal is **single-file reload**: a changed TOML, GLSL, or SVG +updates only the artifacts and node defs it affects. The structural goal is a +stack that supports **client-driven ChangeSets** (temporary in-memory edits until +commit) **and** filesystem reload without another rewrite. + +ChangeSets also become the **universal edit vocabulary**: the same ordered op +stream that powers the UI can be generated by **diffing two projects**, replayed +as many small steps, and used to stress the full engine on host, RV32 emulator, +or device — surfacing panics, OOMs, and heap fragmentation that whole-project +reload tests miss. + +Current pain: + +- `ArtifactStore` holds `NodeDef` payloads — artifacts and defs are conflated. +- GLSL/SVG are read once at attach; file paths are not tracked as artifacts. +- `Engine::handle_fs_changes` is a no-op; the server calls `Project::reload()`. +- Shader nodes retain long-lived source strings; fixtures pre-resolve SVG at load. +- Wire `slot_mutation` mutates defs in place — no ChangeSet / commit model. + +## Architecture + +### Parallel build (M1–M4 + ChangeSet roadmap) + +M1–**M4** implement the **registry base + fs reload** stack in +`lpc-node-registry`. **ChangeSet change management** is a +[separate promoted roadmap](../2026-05-21-changeset-change-management/overview.md) +(prerequisite for **M6**). **`lpc-engine` is unchanged** until M6 cutover. + +```text +M1–M4 (this roadmap): + + lpc-node-registry/ NEW — unit + harness tests + ├── artifact/ freshness-only store (all files incl. assets) + ├── registry/ NodeDefRegistry, sync → SyncResult + └── source/ SourceFileRef materialize + +ChangeSet roadmap (parallel, gates M6): + + lpc-node-registry/ + ├── change/ ChangeSet, commit/discard + └── view/ NodeDefView + AssetView + + lpc-engine/ OLD — unchanged until M6 + +M6: delete old path; lpc-engine → lpc-node-registry +``` + +### Target stack (post-M6) + +```text +Filesystem + client ChangeSets (uncommitted) + ↓ +NodeDefRegistry + ├── ArtifactStore — committed bytes + freshness + ├── ChangeOverlay — pending artifact/slot mutations + └── entries / indexes — committed parse cache + ↓ +NodeDefView — effective reads (overlay ∪ base) + ↓ +Engine node tree — bindings → effective def → value +``` + +**ChangeSet** — ordered, id'd client edits: slot patches on defs, add/remove +defs, add/replace/delete **assets** (GLSL, SVG, … — non-node files). Commit +promotes to base; discard drops overlay. + +**Asset** — user-facing name for dependency files that are not node definition +TOMLs. Store entries remain **artifacts** (freshness identity). + +```text +lp-core/lpc-node-registry/ +├── artifact/ # M1 — crate bootstrap + freshness store +├── registry/ # M2 +├── source/ # M3 +├── change/ # ChangeSet roadmap +└── view/ # ChangeSet roadmap + +lp-core/lpc-engine/ # M6 cutover +lp-core/lpc-model/slots/ # M3 SourceFileSlot +``` + +Delete **`lpc-slot-mockup`** at M1 start. + +## Alternatives Considered + +- **Build parallel in `lpc-node-registry` first** — in-place `lpc-engine` refactor + rejected before semantics proven; cutover at M6 only. +- **Defer ChangeSet to post-cutover** — rejected; client edit path is core + architecture; must be proven in [ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md) before M6. +- **Last-good on reload failure** — rejected for v1; errors propagate. + +## Risks + +- **ChangeSet roadmap scope** — node patches + asset ops + story harness; phased + in [promoted roadmap](../2026-05-21-changeset-change-management/overview.md). +- **M6 cutover churn** — cross-cutting after M4 + ChangeSet M6 gate contract clear. +- **ESP32 heap** — ChangeSets and asset overlays must not retain duplicate file + bytes long-term. + +## Scope Estimate + +Nine milestones in this roadmap (M1–M4, M6–M10). **ChangeSet** is a separate +roadmap gating **M6**. M6 cutover only after **M4** here + **ChangeSet M6** +(diff + equivalence gate) green. **M10** (ExplainSlot probes) follows parent M6; +not a cutover gate. + +## Milestones + +| # | Milestone | Gate | +|---|-----------|------| +| M1 | **`lpc-node-registry` + ArtifactStore** | Crate bootstrap; freshness-only store; lpc-engine untouched | +| M2 | NodeDefRegistry + NodeDefUpdates | Unit tests; lpc-engine untouched | +| M3 | SourceFileSlot + SourceFileRef | Unit tests; production defs unchanged until **M6** | +| M4 | Fs-change semantics harness | Harness; no cutover | +| — | **[ChangeSet change management](../2026-05-21-changeset-change-management/overview.md)** | **Separate roadmap; gates M6** | +| M6 | Engine + node cutover | M4 + [ChangeSet M6](../2026-05-21-changeset-change-management/m6-diff-equivalence-gate.md) green | +| M7 | Server fs-change wire-up | E2E reload | +| M8 | project.toml / graph reconciliation | Graph hot reload | +| M9 | Cleanup + validation | CI | +| M10 | [Slot resolution probes](m10-slot-provenance-client.md) | Post-M6; `ExplainSlot` probe on demand; not on tick read path | diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/change-language.md b/docs/roadmaps/2026-05-21-changeset-change-management/change-language.md new file mode 100644 index 000000000..ca1045f23 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/change-language.md @@ -0,0 +1,146 @@ +# ChangeSet Change Language (v1) + +Canonical edit vocabulary for client-driven changes. Lives in +`lpc-node-registry/src/edit/` — **serde types, not part of the slot system**. +Apply uses slot mut access + overlay tables; ops themselves are not `SlotData`. + +## Top level + +```text +EditBatch { id, edits: Vec } +``` + +Changes are **grouped by artifact**. Each block targets one file and is either +**slot-structured** (`.toml` defs) or **asset/opaque** (GLSL, SVG, delete, TOML +import escape hatch) — never both in one block. + +## Target + +```rust +enum EditTarget { + Id(ArtifactId), // committed artifact (optional; harness/wire rarely) + Path(LpPathBuf), // absolute project path — primary authoring form +} +``` + +**Implicit create:** resolving `Path(p)` get-or-creates a pending overlay entry +when `p` is not in base or overlay. No explicit `Create` op. + +Overlay does not use base-store refcount rules; pending paths exist until commit +or discard. + +## Artifact blocks + +```rust +#[serde(tag = "kind", rename_all = "snake_case")] +enum ArtifactEdit { + Slot { target: EditTarget, ops: Vec }, + Asset { target: EditTarget, ops: Vec }, +} +``` + +Wire example: + +```json +{ "kind": "slot", "target": { "path": "/shader.toml" }, "ops": [ … ] } +{ "kind": "asset", "target": { "path": "/shader.glsl" }, "ops": [ … ] } +``` + +### Slot ops (`SlotEdit`) + +Node defs are slots. All normal node editing is slot ops at a `SlotPath` +**within** the target `.toml` artifact: + +| Op | Use | +|----|-----| +| `UseEnumVariant { path, variant }` | Enum variant switch (node kind, `Unset`/`Ref`/`Def`, nested enums) | +| `AssignValue { path, value }` | Value leaves only (scalars, path strings, etc.) | +| `MapInsert { path, key, … }` | Map entry | +| `MapRemove { path, key }` | Map entry | +| `UseOption { path, present }` | Option some/none (`present = true` → shape default) | + +Examples: + +- Standalone shader file: `UseEnumVariant(root, "Shader")` then scalar `AssignValue`s on `/shader.toml` +- Inline child: ops under `entries[2].node.def.Shader…` on `/playlist.toml` +- Wire child to file: `UseEnumVariant(nodes[shader], "Ref")` + `AssignValue(nodes[shader].ref, "./shader.toml")` + +Relative locators in slot values resolve against the **containing artifact path** +(same as `resolve_node_specifier` today). + +### Asset ops (`AssetEdit`) + +Path-level file body edits: + +| Op | Use | +|----|-----| +| `Delete` | Remove this path on commit | +| `ReplaceBody(text)` | Whole-file body — GLSL, SVG, etc.; optional TOML import escape hatch | + +Normal **node TOML** bodies are **not** authored with `ReplaceBody`. They come from +slot ops + slot codec serialize on commit. + +## Node invocation TOML (authored) + +```toml +[nodes.placeholder] +unset = {} + +[nodes.shader] +ref = "./shader.toml" + +[nodes.clock.def] +kind = "Clock" +``` + +Legacy `def = { path = … }` is rejected. + +## Node TOML vs assets + +Same `ArtifactEdit` envelope; **`kind`** selects the op vocabulary: + +- **`.toml`** — `kind: "slot"`; serialize to text on commit +- **`.glsl`, `.svg`, …** — `kind: "asset"` with `ReplaceBody` / `Delete` + +## Creatability + +Every `examples/*` project must be reachable from blank via a finite +`EditBatch` sequence using only: + +- `ArtifactEdit::Slot` / `ArtifactEdit::Asset` (implicit create via `Path`) +- No `CreateDef`; no pre-populated def blobs as the primary path + +New node at artifact root: `UseEnumVariant(root, "Shader")` (applies variant default), +then patch value leaves with `AssignValue`. + +## Apply / commit + +1. **Apply** — merge each `ArtifactEdit` into path-keyed overlay; base untouched +2. **View** — `NodeDefView` / read API resolves `(path, slot_path)` over overlay ∪ base +3. **Commit** — serialize overlay TOML + assets → store/fs; registry re-derive → + `SyncResult` +4. **Discard** — drop overlay; reads = base + +Dangling path refs (wire before target file exists) are OK mid-sequence. + +Unreferenced overlay paths may exist on disk after commit; registry only registers +defs reachable from root (same as filesystem reality). + +## Example (add shader to project) + +```text +ArtifactEdit::Asset(Path("/shader.glsl"), [ ReplaceBody("…") ]) + +ArtifactEdit::Slot(Path("/shader.toml"), [ + UseEnumVariant(root, "Shader"), + AssignValue(root.source.path, "./shader.glsl"), + … +]) + +ArtifactEdit::Slot(Path("/project.toml"), [ + UseEnumVariant(nodes[shader], "Ref"), + AssignValue(nodes[shader].ref, "./shader.toml"), +]) +``` + +Order of blocks may vary. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/decisions.md b/docs/roadmaps/2026-05-21-changeset-change-management/decisions.md new file mode 100644 index 000000000..41fee9d9a --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/decisions.md @@ -0,0 +1,121 @@ +# ChangeSet Change Management — Decisions + +#### Promoted standalone roadmap + +- **Decision:** ChangeSet work lives in + `docs/roadmaps/2026-05-21-changeset-change-management/`. +- **Why:** Scope warrants full roadmap process; gates parent M6. +- **Revisit when:** Unlikely. + +#### Parent M6 gate unchanged + +- **Decision:** Parent **M6** starts when this roadmap **M6 (diff + equivalence + gate)** + parent **M4** are green. +- **Why:** Both fs-reload and client-edit paths must be proven before cutover. + +#### Change language: grouped by artifact + +- **Decision:** `ChangeSet` is `Vec`. Each block has + `target + ops` for one file. +- **Why:** Natural authoring unit; avoids repeating artifact ref on every op. +- **Rejected alternatives:** Flat `ChangeOp` stream with per-op target. + +#### Artifact target: Id or Path; implicit create + +- **Decision:** `ArtifactTarget::Path(p)` get-or-creates overlay entry if absent. + `ArtifactTarget::Id` for committed artifacts only. +- **Why:** Compose-from-blank never needs ids; no explicit Create op. +- **Rejected alternatives:** Explicit `New { path }`; require reference before create. + +#### Node edits are slot ops only + +- **Decision:** Node defs are slots. No `CreateDef`. Author via slot ops at + `SlotPath` within the target artifact (`root()` or inline paths like + `entries[2].node`). Wiring = slot ops on invocation fields. +- **Why:** Matches `lpc-model` and registry `DefSource { artifact, path }`. +- **Rejected alternatives:** Separate invocation ops; pre-populated def on create. + +#### Change ops are not slot-system types + +- **Decision:** `ArtifactOp` / `ChangeSet` types live in `lpc-node-registry/change/`, + serde-serialized. Not part of `SlotData` or slot codec. +- **Why:** Edit vocabulary ≠ payload model; wire/diff/replay friendly. +- **Rejected alternatives:** Embedding ops in slot shapes. + +#### File ops vs slot ops + +- **Decision:** Per artifact: `Delete`, `SetBytes` (whole-file — assets + TOML + import escape hatch); slot ops for normal `.toml` authoring. +- **Why:** TOML node files serialize from slot tree on commit. + +#### Defaults + follow-up slot ops + +- **Decision:** New def at a locus = set kind (slot op) → `KindDef::default()`, + then patch slots. No bundled initial TOML as primary path. +- **Why:** Smaller op set; matches compose/morph stories. + +#### Creatability requirement + +- **Decision:** Op vocabulary must reach any `examples/*` from blank via finite + `ChangeSet` (implicit create + slot ops + `SetBytes`). +- **Why:** Universal edit vocabulary for UI, diff, replay. + +#### Overlay vs base refcount + +- **Decision:** Overlay is path-keyed scratch space; no base-store refcount. + Commit writes overlay paths; registry registers defs reachable from root. +- **Why:** Orphans on disk OK; dangling refs mid-sequence OK. + +#### No lpc-engine edits until parent M6 + +- **Decision:** M1–M6 here touch `lpc-node-registry` + tests only. + +#### Client edits never mutate base directly + +- **Decision:** ChangeSet → view → (optional) commit → `registry.sync`. + +#### v1 whole-file asset edits only + +- **Decision:** `SetBytes` / `Delete` for text assets; no byte-range patches. + +#### Fs vs overlay precedence (v1) + +- **Decision:** Uncommitted overlay wins on overlaid paths until commit/discard. +- **Revisit when:** Remote fs sync. + +#### M6 diff gate scope + +- **Decision:** Gate parent M6 on **A1 + B1 + D1–D3 + D5**, core slot + file ops — + via **diff + equivalence** (`diff(∅, basic)`, `diff(basic, basic2)`), not + hand-curated op lists. Full A2–A4 / B2–B4 / C3 matrix deferred. + +#### DefView is sole read path + +- **Decision:** Effective reads go through view/overlay resolution before M6 + engine cutover. Public reads are **effective only**; `entries` is committed + cache updated on commit/sync. + +#### No provenance on registry read path + +- **Decision:** Registry and engine hot reads return **values only** — no + per-field provenance, no `Production` attribution on tick path. +- **Why:** ESP32 memory; client holds ChangeSet and bindings for UI badges. +- **Provenance later:** Parent roadmap **M10** — `ExplainSlot` probe on + `project_read` (wire exists) or host client local re-derive. See + [`m10-slot-provenance-client.md`](../2026-05-21-artifact-routed-file-reload/m10-slot-provenance-client.md). + +#### Overlay inside NodeDefRegistry + +- **Decision:** `ChangeOverlay` lives inside `NodeDefRegistry`, between + `ArtifactStore` and parsed `entries`. Internal artifact reads go through + overlay; commit promotes to store and re-derives entries → `SyncResult`. +- **Why:** Mutations are artifact-level; registry entries are derived state. +- **Rejected alternatives:** Separate ChangeRegistry; overlay above committed + entries only. + +#### Engine cutover: minimal def-read swap + +- **Decision:** Parent M6 changes engine consumed-slot def fallback to read + effective registry defs. Binding cascade unchanged. +- **Why:** Same resolution shape; overlay invisible to resolver except via + effective def reads. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/dependencies.md b/docs/roadmaps/2026-05-21-changeset-change-management/dependencies.md new file mode 100644 index 000000000..cf6ceac45 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/dependencies.md @@ -0,0 +1,27 @@ +# External Dependencies + +This roadmap depends on the **artifact-routed file reload** parallel stack +(M1–M4). Do not start M1 here until those are complete and passing. + +## Required (parent roadmap) + +| Milestone | Roadmap | Key APIs | +|-----------|---------|----------| +| M1 | [ArtifactStore](../2026-05-21-artifact-routed-file-reload/m1-artifact-store.md) | `acquire_location`, `apply_fs_changes`, `read_bytes` | +| M2 | [NodeDefRegistry](../2026-05-21-artifact-routed-file-reload/m2-node-def-registry.md) | `load_root`, `NodeDefId`, `DefSource`, `NodeDefUpdates` | +| M3 | [SourceFileSlot](../2026-05-21-artifact-routed-file-reload/m3-source-file-slot.md) | `resolve_source_file`, `materialize_source` | +| M4 | [Fs-change harness](../2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness.md) | `sync` → `SyncResult`, `RegistryChange::Fs` | + +Validation baseline: + +```bash +cargo test -p lpc-node-registry +cargo test -p lpc-node-registry --test fs_change_semantics +``` + +## Downstream (blocks) + +| Milestone | Roadmap | Requires from here | +|-----------|---------|-------------------| +| M6 | [Engine cutover](../2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md) | M6 diff + equivalence gate green | +| M7+ | Server / graph / cleanup | M6 | diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/future.md b/docs/roadmaps/2026-05-21-changeset-change-management/future.md new file mode 100644 index 000000000..80490d179 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/future.md @@ -0,0 +1,40 @@ +# Future Work — ChangeSet + +## Project diff → ChangeSet stream + +- **Tracked as [M6](m6-diff-equivalence-gate.md)** in this roadmap — primary + parent M6 gate (`diff(∅, basic)`, `diff(basic, basic2)`). +- Hand-written morph fixtures remain useful as diff regression inputs. + +## ChangeSet replay stress harness (host / emu / device) + +- **Idea:** Replay ChangeSet log through full engine (post parent M6) at + configurable granularity. +- **Why not now:** Requires engine on ChangeSet path; this roadmap proves + registry harness only. +- **Useful context:** Parent `future.md`; catches OOM/fragmentation whole-reload + tests miss. + +## ChangeSet wire protocol + CRDT merge + +- **Idea:** Full `lpc-wire` messages; concurrent edit merge. +- **Why not now:** v1 is ordered in-memory ChangeSet + commit/discard. +- **Useful context:** `lightplayer-app-ui` SlotOp mockup. + +## C3 inline ↔ standalone refactor + +- **Idea:** Extract/inline playlist entry defs; round-trip harness. +- **Why not now:** High complexity; not required for A1/B1 gate. +- **Useful context:** User story IDs C3a–c in `notes.md`. + +## Multi-ChangeSet replay (D4) + +- **Idea:** Stable op ids; replay ordered stack of ChangeSets. +- **Why not now:** Single active ChangeSet sufficient for v1 gate. +- **Revisit when:** Wire batching or undo stacks. + +## Binary file assets + +- **Idea:** `BinaryFileSlot` sibling to M3 text sources. +- **Why not now:** v1 whole-file text assets only. +- **Useful context:** Parent roadmap `future.md`. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay.md new file mode 100644 index 000000000..e8b06985b --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay.md @@ -0,0 +1,76 @@ +# Milestone 1: Change Language + Overlay Lifecycle + +## Title And Goal + +Introduce the v1 **change language** ([`change-language.md`](change-language.md)), +**`ChangeOverlay`** inside **`NodeDefRegistry`**, and **apply / discard** +lifecycle. Bootstrap with a **single `ArtifactChange`** (one op is enough to +start). Prove **D1** and **D3**. + +## Parallel Build + +This milestone touches **`lpc-node-registry` only**. **`lpc-engine` unchanged** +until parent artifact-routed **M6**. + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/` + +## Scope + +In scope: + +- `change/` module — serde types: + - `ChangeSet`, `ChangeSetId` + - `ArtifactChange { target, ops }` + - `ArtifactTarget` (`Id` | `Path`) + - `ArtifactOp` enum shell (variants filled in M3/M4) +- `overlay/` — path-keyed pending state on `NodeDefRegistry` +- `apply(ArtifactChange)` — implicit create on `Path`; bootstrap op e.g. + `SetBytes` shell that stores bytes in overlay +- `apply(ChangeSet)` — `for change in changes { apply(change) }` when batching + needed +- `discard()` — clear overlay; committed `entries` and `ArtifactStore` unchanged +- Harness: overlay populated after apply; base unchanged; discard clears overlay + +Out of scope: + +- Effective projection / `NodeDefView` (**M2**) +- Full `SetBytes` / `Delete` semantics (**M3**) +- Slot op apply (**M4**) +- Commit (**M5**) +- `RegistryChange::ChangeSet` sync variant (stub OK; wired at M5) + +## Key Decisions + +- **Overlay inside registry** — between `ArtifactStore` and `entries`; not a + separate ChangeRegistry. +- **Grouped by artifact** — `ChangeSet` is `Vec`. +- **Implicit create** — `ArtifactTarget::Path(p)` get-or-creates overlay entry. +- **Single-change bootstrap** — full envelope optional until UI/diff need it. + +## User Stories / Gate + +| ID | Story | Covered | +|----|-------|---------| +| D1 | Apply → pending state visible in overlay | **Yes** | +| D3 | Discard → base unchanged | **Yes** | + +## Deliverables + +- `lpc-node-registry/src/change/` +- `lpc-node-registry/src/overlay/` (or `registry/overlay.rs`) +- `NodeDefRegistry::apply_change`, `discard_overlay` (names TBD in plan) +- Unit tests for D1, D3 + +## Dependencies + +- Parent artifact-routed M1–M4 complete ([`dependencies.md`](dependencies.md)) + +## Execution Strategy + +Full plan. Overlay ownership and apply pipeline before projection and op semantics. + +Suggested chat opener: + +> M1 plan: change types, ChangeOverlay in registry, apply/discard. Full plan then implement. Agree? diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/00-design.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/00-design.md new file mode 100644 index 000000000..33a5bd9c5 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/00-design.md @@ -0,0 +1,108 @@ +# M1 Design — Change Language + Overlay Lifecycle + +## Scope + +Add change vocabulary types and path-keyed overlay with apply/discard on +`NodeDefRegistry`. **`lpc-engine` untouched.** + +Spec: [`../change-language.md`](../change-language.md). + +## File structure + +``` +lp-core/lpc-node-registry/ +├── Cargo.toml # + serde (alloc) +├── src/ +│ ├── lib.rs # re-export change + ChangeError +│ ├── change/ +│ │ mod.rs +│ │ change_set.rs # ChangeSet, ChangeSetId +│ │ artifact_change.rs # ArtifactChange +│ │ artifact_target.rs # ArtifactTarget +│ │ artifact_op.rs # ArtifactOp (+ SetBytes, Delete shell) +│ │ change_error.rs # ChangeError +│ │ apply.rs # apply_change(s) on &mut ChangeOverlay +│ │ overlay.rs # ChangeOverlay, OverlayEntry +│ └── registry/ +│ node_def_registry.rs # + overlay field; apply/discard methods +└── tests/ + └── overlay_lifecycle.rs # D1, D3 (+ serde round-trip in unit tests) +``` + +## Architecture + +```text +apply(ArtifactChange | ChangeSet) + │ + ▼ +ChangeOverlay (path → OverlayEntry) + │ + ├─ Deleted (M1: apply Delete sets flag; read in M2/M3) + └─ Bytes(Vec) (M1: SetBytes) + +NodeDefRegistry + store: ArtifactStore ← unchanged on apply/discard + overlay: ChangeOverlay ← mutated on apply/discard + entries: ... ← unchanged on apply/discard (D1/D3) +``` + +### `OverlayEntry` (M1) + +```rust +enum OverlayEntry { + Deleted, + Bytes(alloc::vec::Vec), + // SlotDraft { ... } — M4 +} +``` + +### Apply pipeline + +1. Resolve `ArtifactTarget` → absolute `LpPathBuf` key. +2. `overlay.entry_or_insert(path)` — implicit create. +3. For each op: `SetBytes(b)` → `OverlayEntry::Bytes(b)`; `Delete` → `Deleted`. +4. Other op variants → `ChangeError::UnsupportedOp` in M1. + +### Discard + +`overlay.clear()` — no touch to `store` or `entries`. + +### Registry API (public) + +```rust +impl NodeDefRegistry { + pub fn apply_change(&mut self, change: &ArtifactChange) -> Result<(), ChangeError>; + pub fn apply_changeset(&mut self, changeset: &ChangeSet) -> Result<(), ChangeError>; + pub fn discard_overlay(&mut self); + pub fn overlay_active(&self) -> bool; + pub fn overlay_contains_path(&self, path: &LpPath) -> bool; +} +``` + +Names may adjust in phase implementation; semantics fixed. + +## Types (summary) + +See [`../change-language.md`](../change-language.md). M1 implements full enum +shell; only file ops `SetBytes`/`Delete` apply; slot op variants exist for serde +stability but return `UnsupportedOp` if applied. + +## Tests + +| Test | Story | +|------|-------| +| `serde_roundtrip_changeset` | Types (unit, in change module) | +| `d1_apply_populates_overlay_base_unchanged` | D1 | +| `d3_discard_clears_overlay_entries_unchanged` | D3 | +| `apply_rejects_relative_path` | Path invariant | +| `apply_setbytes_on_unloaded_path` | Implicit create | + +Fixtures: empty registry + `load_root` from existing harness (`clock` or +`shader_project`) to prove `entries` stable across apply/discard. + +## Non-goals (M1) + +- `NodeDefView` effective reads +- `read_effective_bytes` +- Commit / `SyncResult` +- `RegistryChange::ChangeSet` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/00-notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/00-notes.md new file mode 100644 index 000000000..c2842948c --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/00-notes.md @@ -0,0 +1,80 @@ +# M1 Notes — Change Language + Overlay Lifecycle + +## Scope + +Introduce v1 change-language **serde types**, **`ChangeOverlay`** on +`NodeDefRegistry`, and **apply / discard** lifecycle. Bootstrap with a single +`ArtifactChange` + `SetBytes` op. Prove **D1** and **D3**. + +**Out of scope:** effective projection (M2), full file op semantics (M3), slot +ops (M4), commit (M5). + +## Current codebase + +``` +lp-core/lpc-node-registry/src/ +├── registry/node_def_registry.rs # owns ArtifactStore + entries; no overlay +├── registry/registry_change.rs # RegistryChange::Fs only +├── change/mod.rs # stub comment only +└── view/node_def_view.rs # passthrough committed entries +``` + +Parent M1–M4 complete. Tests use `NodeDefRegistry::load_root` + `sync_fs` with +`LpFsMemory` fixtures (`tests/fs_change_semantics.rs`, `harness/fixtures.rs`). + +## Resolved design (2026-05-21) + +- **Overlay inside `NodeDefRegistry`** — field between store and entries. +- **`ChangeSet`** = `Vec` grouped by artifact path/id. +- **Implicit create** on `ArtifactTarget::Path`. +- **M1 op surface:** `SetBytes` only (shell apply); `Delete` and slot ops stubbed + or return `ChangeError::UnsupportedOp` until M3/M4. +- **No effective read path in M1** — tests inspect overlay via test-only or public + introspection helpers (`overlay_has_path`, `overlay_bytes`); M2 wires reads. +- **Serde on types** — `serde` with `alloc` (no_std-compatible); round-trip unit + tests under `#[cfg(test)]`. + +## Open questions + +### Q1: Overlay introspection API for tests / client badges + +- **Context:** D1/D3 need to observe overlay without M2 projection. Client may + query `overlay.contains_path` later. +- **Suggested answer:** Public read-only helpers on registry: + `overlay_is_active()`, `overlay_contains_path(&LpPath)`, optional + `overlay_entry_state(path)` for tests. No slot-level introspection until M4. + +### Q2: Path key normalization + +- **Context:** `ArtifactTarget::Path(LpPathBuf)` must match registry + `artifact_path_to_id` keys (string paths). +- **Suggested answer:** Normalize to absolute path string via `LpPathBuf` as stored + in registry; reject relative paths in `apply` with `ChangeError::InvalidPath`. + +### Q3: `ArtifactTarget::Id` in M1 + +- **Context:** Change language supports `Id(ArtifactId)` for committed targets. +- **Suggested answer:** Implement resolve `Id → path` via `artifact_root_path` map; + if unknown id, error. Needed for parity; tests can use `Path` only initially. + +### Q4: Multiple ops in one `ArtifactChange` + +- **Context:** Language allows `ops: Vec`. +- **Suggested answer:** M1 apply loops ops sequentially on same overlay entry; + `SetBytes` replaces bytes; unknown ops error. + +## User stories (this milestone) + +| ID | Story | How | +|----|-------|-----| +| D1 | Apply → pending visible | Overlay has entry; `entries` unchanged | +| D3 | Discard → base unchanged | Overlay empty; `entries` bit-identical | + +## Validation baseline + +```bash +cargo test -p lpc-node-registry +cargo test -p lpc-node-registry --test fs_change_semantics +``` + +Must remain green after M1. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/01-change-language-types.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/01-change-language-types.md new file mode 100644 index 000000000..a8405fca9 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/01-change-language-types.md @@ -0,0 +1,67 @@ +# Phase 01 — Change Language Types + +**Dispatch:** sub-agent: yes | parallel: - + +## Scope of phase + +Add serde change vocabulary under `src/change/` per +[`change-language.md`](../../change-language.md). + +**In scope:** + +- Add `serde = { workspace = true, features = ["derive"] }` to + `lpc-node-registry/Cargo.toml` (alloc-only, no_std OK). +- Split types one concept per file (see `00-design.md`). +- `ArtifactOp` includes all v1 variants from spec; slot variants are data-only + in M1 (no apply logic here). +- `ChangeError` enum: `InvalidPath`, `UnknownArtifact`, `UnsupportedOp`, … +- `#[cfg(test)]` serde round-trip tests for `ChangeSet` / `ArtifactChange`. +- Replace `change/mod.rs` stub; export types from `lib.rs`. + +**Out of scope:** overlay, registry methods, apply logic. + +## Code organization reminders + +- Public types at top of each file; `#[cfg(test)] mod tests` at bottom. +- Use `LpPathBuf` from `lpfs` for paths. + +## Sub-agent reminders + +- Do not commit. +- Do not expand scope into overlay/apply. +- Fix warnings; no allow without reason. + +## Implementation details + +**Files to create:** + +| File | Contents | +|------|----------| +| `change_set.rs` | `ChangeSetId(u64 or String)`, `ChangeSet { id, changes }` | +| `artifact_target.rs` | `ArtifactTarget::Id(ArtifactId)` \| `Path(LpPathBuf)` | +| `artifact_op.rs` | `Delete`, `SetBytes(String)`, slot op structs per spec | +| `artifact_change.rs` | `{ target, ops }` | +| `change_error.rs` | `Display` + error type | + +Use `String` for `SetBytes` body (text assets + TOML escape hatch). + +**Slot op payloads:** minimal structs with `SlotPath` + value placeholders +(`LpValue` or serde-friendly owned form from `lpc-model` — use existing types +where already serde-enabled). + +**lib.rs exports:** + +```rust +pub use change::{ + ArtifactChange, ArtifactOp, ArtifactTarget, ChangeError, ChangeSet, ChangeSetId, +}; +``` + +Keep `change` as `pub mod change` or re-export only — match crate style. + +## Validate + +```bash +cargo test -p lpc-node-registry change:: +cargo check -p lpc-node-registry +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/02-overlay-store.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/02-overlay-store.md new file mode 100644 index 000000000..7eaea3856 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/02-overlay-store.md @@ -0,0 +1,42 @@ +# Phase 02 — ChangeOverlay Store + +**Dispatch:** sub-agent: yes | parallel: - + +## Scope of phase + +Implement path-keyed **`ChangeOverlay`** and attach to **`NodeDefRegistry`**. + +**In scope:** + +- `change/overlay.rs` — `ChangeOverlay`, `OverlayEntry::{Deleted, Bytes}` +- `BTreeMap` keyed by absolute path string (match + `artifact_path_to_id` convention) +- `ChangeOverlay::clear`, `is_empty`, `contains`, `get` +- Add `overlay: ChangeOverlay` field to `NodeDefRegistry::new()` +- Introspection methods on registry (see design): `overlay_active`, + `overlay_contains_path` +- Unit tests: empty overlay; manual insert/get in overlay tests only + +**Out of scope:** apply pipeline, target resolution, registry `apply_change`. + +## Sub-agent reminders + +- Do not commit. +- Do not mutate `store` or `entries` from overlay module. + +## Implementation details + +**Path key:** use `LpPathBuf::as_str()` or existing registry helper for stable +keys. Document that keys must be absolute. + +**Registry field:** private `overlay` with public read-only introspection; +mutation only via apply/discard in phase 03. + +**Default:** `ChangeOverlay::default()` empty map. + +## Validate + +```bash +cargo test -p lpc-node-registry overlay +cargo check -p lpc-node-registry +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/03-apply-discard-lifecycle.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/03-apply-discard-lifecycle.md new file mode 100644 index 000000000..5c7049db5 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/03-apply-discard-lifecycle.md @@ -0,0 +1,62 @@ +# Phase 03 — Apply + Discard Lifecycle + +**Dispatch:** sub-agent: yes | parallel: - + +## Scope of phase + +Wire **apply** and **discard** from registry to overlay; implement target +resolution and M1 op semantics. + +**In scope:** + +- `change/apply.rs` — `apply_change(overlay, registry, change)` resolving targets: + - `Path(p)` — require absolute; implicit create overlay entry + - `Id(id)` — resolve via `NodeDefRegistry` internal `artifact_root_path` +- Op handling: `SetBytes`, `Delete` only; other ops → `ChangeError::UnsupportedOp` +- Multiple ops in one `ArtifactChange` applied in order +- Registry methods: + - `apply_change(&mut self, change: &ArtifactChange) -> Result<(), ChangeError>` + - `apply_changeset(&mut self, cs: &ChangeSet) -> Result<(), ChangeError>` + - `discard_overlay(&mut self)` +- Integration tests in `tests/overlay_lifecycle.rs`: + - **D1** — load project, snapshot `entries` (ids + states), apply `SetBytes` to + new path `/pending.glsl`, assert overlay contains path, entries unchanged + - **D3** — after D1, discard, overlay empty, entries still match snapshot + - implicit create on path not in store + - relative path rejected + +**Out of scope:** effective bytes read (M2), commit (M5). + +## Sub-agent reminders + +- Do not commit. +- Do not re-parse defs on apply. +- Reuse `harness/fixtures` where possible. + +## Implementation details + +**D1 test sketch:** + +```rust +let snapshot = registry.entries_snapshot_for_test(); // or clone key fields +registry.apply_change(&ArtifactChange { + target: ArtifactTarget::Path(LpPathBuf::from("/pending.glsl")), + ops: vec![ArtifactOp::SetBytes("void main() {}".into())], +})?; +assert!(registry.overlay_contains_path(LpPath::new("/pending.glsl"))); +assert_entries_unchanged(®istry, &snapshot); +``` + +If no test snapshot helper exists, compare `entries.len()`, root `NodeDefState`, +and `source_index` len — avoid brittle full clone unless easy. + +**ChangeSet batch:** `apply_changeset` applies each `ArtifactChange` in vec order; +first error aborts (all-or-nothing apply). + +## Validate + +```bash +cargo test -p lpc-node-registry +cargo test -p lpc-node-registry --test overlay_lifecycle +cargo test -p lpc-node-registry --test fs_change_semantics +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/04-cleanup-validation.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/04-cleanup-validation.md new file mode 100644 index 000000000..cde88671d --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/04-cleanup-validation.md @@ -0,0 +1,41 @@ +# Phase 04 — Cleanup + Validation + +**Dispatch:** sub-agent: main | parallel: - + +## Scope of phase + +Final polish for M1: docs, exports, clippy, remove stubs. + +**In scope:** + +- Update `change/mod.rs` module docs; remove "milestone M5" stub comment in any + remaining files +- Ensure `lib.rs` exports are complete and documented +- `cargo +nightly fmt` on touched files +- Run full crate validation +- Add brief `summary.md` in this plan folder with validation commands + files + touched + +**Out of scope:** M2 projection work. + +## Validate + +```bash +cargo test -p lpc-node-registry +cargo test -p lpc-node-registry --test fs_change_semantics +cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings +``` + +If workspace gate needed before push: + +```bash +just check # optional; run if agent touches wider surface +``` + +## Milestone sign-off + +- [ ] D1 test green +- [ ] D3 test green +- [ ] Serde round-trip tests green +- [ ] No `lpc-engine` changes +- [ ] Parent fs-change tests still green diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/summary.md new file mode 100644 index 000000000..4a12881ab --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/summary.md @@ -0,0 +1,25 @@ +# M1 Summary — Change Language + Overlay Lifecycle + +## Status + +Implemented on branch `codex/incremental-artifact-reload`. + +## Delivered + +- `src/change/` — `ChangeSet`, `ArtifactChange`, `ArtifactOp`, `ArtifactTarget`, `ChangeError` +- `ChangeOverlay` on `NodeDefRegistry` with apply/discard API +- M1 op apply: `SetBytes`, `Delete`; slot ops return `UnsupportedOp` +- Tests: D1, D3 + serde round-trip + path/implicit-create coverage + +## Validation + +```bash +cargo test -p lpc-node-registry +cargo test -p lpc-node-registry --test fs_change_semantics +cargo test -p lpc-node-registry --test overlay_lifecycle +cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings +``` + +## Next + +M2 effective projection — wire `NodeDefView` and artifact reads through overlay. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split.md new file mode 100644 index 000000000..afb6c15e6 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split.md @@ -0,0 +1,22 @@ +# M10 — SlotEdit / AssetEdit Split + +Split `EditOp` into `SlotEdit` + `AssetEdit` and make `ArtifactEdit` a tagged +union. Prerequisite for clean asset-side evolution (partial diffs later). + +**Plan:** [`00-design.md`](00-design.md) | **Notes:** [`00-notes.md`](00-notes.md) + +## Phases + +| # | File | Summary | +|---|------|---------| +| 01 | [01-edit-types-serde.md](01-edit-types-serde.md) | New enums, delete `edit_op.rs`, serde tests | +| 02 | [02-apply-pipeline.md](02-apply-pipeline.md) | `apply.rs`, `slot_apply`, registry dispatch | +| 03 | [03-diff-project-diff.md](03-diff-project-diff.md) | `def_diff`, `project_diff` return shapes | +| 04 | [04-tests-and-docs.md](04-tests-and-docs.md) | Harness tests, `change-language.md` | +| 05 | [05-cleanup-validation.md](05-cleanup-validation.md) | fmt, clippy, CI gate, summary | + +**Parallel:** 02 ∥ 03 after 01. + +## Status + +Complete — types, apply, diff, tests, and docs migrated. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/00-design.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/00-design.md new file mode 100644 index 000000000..60a8b367b --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/00-design.md @@ -0,0 +1,171 @@ +# M10 Design — SlotEdit / AssetEdit Split + +## Scope + +Replace monolithic `EditOp` with two op enums and a tagged `ArtifactEdit`. Align +types with overlay storage (`DefDraft` vs `Bytes` / `Deleted`) before wire or +client work builds on the old shape. + +**In:** `lpc-node-registry` edit module, apply, diff, tests, change-language docs. + +**Out:** Partial text diffs, `lpc-wire`, serde compat shims for flat `{ target, ops }`. + +## File structure + +```text +lp-core/lpc-node-registry/src/edit/ +├── mod.rs # exports SlotEdit, AssetEdit, ArtifactEdit, … +├── slot_edit.rs # NEW — structured slot mutations +├── asset_edit.rs # NEW — path-level file body ops +├── artifact_edit.rs # REWRITE — tagged Slot | Asset +├── edit_batch.rs # unchanged shape: Vec +├── edit_target.rs +├── edit_error.rs # drop UnsupportedOp for cross-kind misuse (keep for unknown variants if needed) +├── apply.rs # match ArtifactEdit::Asset only +├── slot_overlay.rs +├── def_draft.rs +└── edit_op.rs # DELETE + +lp-core/lpc-node-registry/src/ +├── registry/ +│ ├── slot_apply.rs # SlotEdit only; remove asset reject arm +│ └── node_def_registry.rs # match ArtifactEdit::{Slot, Asset} +└── diff/ + ├── def_diff.rs # -> Vec + └── project_diff.rs # -> ArtifactEdit::Slot | Asset +``` + +## Types + +### `SlotEdit` + +Slot-tree mutations within a `.toml` artifact (unchanged semantics from current +slot half of `EditOp`): + +```rust +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SlotEdit { + UseEnumVariant { path: SlotPath, variant: String }, + AssignValue { path: SlotPath, value: LpValue }, + MapInsert { path: SlotPath, key: String, value: LpValue }, + MapRemove { path: SlotPath, key: String }, + UseOption { path: SlotPath, present: bool }, +} +``` + +Each variant gets `op_name()` for logging/errors (same strings as today). + +### `AssetEdit` + +Path-level committed overlay state (whole artifact): + +```rust +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AssetEdit { + Delete, + ReplaceBody(String), // was SetBytes +} +``` + +### `ArtifactEdit` + +```rust +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ArtifactEdit { + Slot { target: EditTarget, ops: Vec }, + Asset { target: EditTarget, ops: Vec }, +} +``` + +Optional convenience constructors (same file or `impl ArtifactEdit`): + +```rust +impl ArtifactEdit { + pub fn slot(target: EditTarget, ops: Vec) -> Self { … } + pub fn asset(target: EditTarget, ops: Vec) -> Self { … } +} +``` + +### Removed + +- `EditOp` — delete; no type alias (ambiguous union). +- Deprecated `ArtifactOp = EditOp` — remove or repoint doc to `SlotEdit`/`AssetEdit`. + +## Apply flow + +```text +EditBatch.edits[] + │ + ▼ +apply_artifact_edit(edit) + │ + ├─ ArtifactEdit::Asset { target, ops } + │ resolve target → path + │ for op in ops: apply_asset_op(overlay, path, op) + │ Delete → overlay.apply_delete + │ ReplaceBody → overlay.apply_bytes + │ + └─ ArtifactEdit::Slot { target, ops } + resolve target → path + ensure .toml (existing ensure_toml_path) + fork DefDraft → apply each SlotEdit → write DefDraft back +``` + +No runtime `UnsupportedOp` for “slot op on asset path” — wrong `kind` is a +client/authoring mistake caught at construction or (for `.glsl` + `Slot`) at +existing `ensure_toml_path`. + +## Diff flow + +| Path kind | Output | +|-----------|--------| +| `.toml` content changed | `ArtifactEdit::Slot { ops: diff_node_defs(...) }` | +| non-`.toml` added/changed | `ArtifactEdit::Asset { ops: [ReplaceBody(text)] }` | +| any path removed | `ArtifactEdit::Asset { ops: [Delete] }` | + +`diff_node_defs` return type: `Vec`. + +## Serde / wire + +Breaking change from flat struct: + +```json +// before +{ "target": { "path": "/a.glsl" }, "ops": [{ "set_bytes": "…" }] } + +// after +{ "kind": "asset", "target": { "path": "/a.glsl" }, "ops": [{ "replace_body": "…" }] } +``` + +Acceptable: no production wire consumers yet. + +## Documentation updates + +- [`change-language.md`](../change-language.md) — two op tables, tagged `ArtifactEdit`, examples +- [`vocabulary.md`](../m8-edit-session-sync/vocabulary.md) — add M10 row: `EditOp` → `SlotEdit` + `AssetEdit` +- [`overview.md`](../overview.md) — summary diagram snippet + +## Validation + +```bash +cargo test -p lpc-node-registry +cargo check -p lpc-node-registry --no-default-features # embedded / no diff +cargo test -p lpc-node-registry --features diff +``` + +Host CI gate when committing: `just check` + `just test`. + +## Phase map + +| Phase | Title | Depends | +|-------|-------|---------| +| 01 | Edit types + serde | — | +| 02 | Apply pipeline | 01 | +| 03 | Diff + project_diff | 01 | +| 04 | Integration tests + docs | 02, 03 | +| 05 | Cleanup + validation | 04 | + +Phases 02 and 03 can run in parallel after 01 (disjoint files). diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/00-notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/00-notes.md new file mode 100644 index 000000000..dd02d2f85 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/00-notes.md @@ -0,0 +1,114 @@ +# M10 Notes — SlotEdit / AssetEdit Split + +## Scope + +Split the monolithic `EditOp` enum into **`SlotEdit`** and **`AssetEdit`**, and make +`ArtifactEdit` a **tagged union** so each artifact block is either slot-structured +or opaque-file, never both. + +**In:** `lpc-node-registry` edit types, apply/diff, in-crate tests, `change-language.md`, +`vocabulary.md`. + +**Out:** `lpc-wire` / client protocol, partial text diff ops, engine cutover. + +## Current state + +Layer-1 edit vocabulary lives in `lpc-node-registry/src/edit/`: + +| File | Role | +|------|------| +| `edit_op.rs` | Single `EditOp` mixing slot + asset variants | +| `artifact_edit.rs` | `{ target, ops: Vec }` | +| `apply.rs` | Handles `Delete` / `SetBytes`; rejects slot ops | +| `registry/slot_apply.rs` | Handles slot ops; rejects `Delete` / `SetBytes` | +| `registry/node_def_registry.rs` | Dispatches per-op in `apply_artifact_edit` | + +Overlay storage already mirrors the split: + +```text +SlotOverlayEntry = Deleted | Bytes | DefDraft +``` + +`def_diff` emits only slot ops. `project_diff` emits slot ops for `.toml` and +`Delete` / `SetBytes` for other paths — but types allow invalid mixes (e.g. serde +roundtrip test combines `SetBytes` + `UseEnumVariant` on one block). + +Recent naming (M9 follow-up, uncommitted or landing soon): + +- `UseEnumVariant`, `AssignValue`, `UseOption` (slot) +- `Delete`, `SetBytes` (asset) + +## User intent + +- Split **before** building more on the edit model. +- Names **`SlotEdit`** and **`AssetEdit`**. +- Asset side should evolve independently (future partial text diffs); not implemented now. +- Prefer language without “set” on slot ops (already done). + +## Open questions + +### Q1 — Serde wire shape for `ArtifactEdit` + +**Context:** Today `ArtifactEdit` is a struct `{ target, ops }`. Mixed op lists +deserialize even when invalid at apply time. + +**Suggested answer:** Externally tagged union: + +```rust +#[serde(tag = "kind", rename_all = "snake_case")] +enum ArtifactEdit { + Slot { target: EditTarget, ops: Vec }, + Asset { target: EditTarget, ops: Vec }, +} +``` + +Wire example: + +```json +{ "kind": "slot", "target": { "path": "/shader.toml" }, "ops": [ … ] } +{ "kind": "asset", "target": { "path": "/shader.glsl" }, "ops": [ … ] } +``` + +**Status:** confirmed. + +### Q2 — Rename `SetBytes` → `ReplaceBody` on asset side? + +**Context:** User suggested `ReplaceBody` when discussing the split; clearer for +whole-file replacement and distinct from future patch ops. + +**Suggested answer:** Yes, rename in this milestone (`AssetEdit::ReplaceBody`). + +**Status:** confirmed. + +### Q3 — Backward-compat for old `EditOp` / flat `ArtifactEdit` serde? + +**Context:** M8 vocabulary renamed types; wire not shipped to production clients. +Harness tests are the only consumers. + +**Suggested answer:** Break wire shape cleanly. Remove `EditOp`. Keep deprecated +`ArtifactOp` alias only if we add a shim — prefer **no shim**; update +`#[deprecated] pub type ArtifactOp` doc to point at `SlotEdit | AssetEdit`. + +**Status:** confirmed — no serde shim. + +### Q4 — `AssetEdit` op list: `Vec` vs single op? + +**Context:** Today asset blocks usually have one op (`Delete` or `ReplaceBody`). +Slot blocks often have many. + +**Suggested answer:** `Vec` for both variants — consistent, and leaves +room for ordered multi-step asset edits without another shape change. + +**Status:** confirmed. + +## Dependencies + +- M9 slot op naming should land first (or in same branch) so phase 01 starts from + `UseEnumVariant` / `AssignValue` / `UseOption`, not legacy `VariantSet` / `SetSlot`. + +## Risks + +- Mechanical churn across ~10 test files and diff helpers — low logic risk. +- `UnsupportedOp` paths should disappear; replace with compile-time enforcement. +- `apply_slot_op_on_non_toml_path_errors` remains valid via `ArtifactEdit::Slot` on + a `.glsl` path. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/01-edit-types-serde.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/01-edit-types-serde.md new file mode 100644 index 000000000..029564eaa --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/01-edit-types-serde.md @@ -0,0 +1,98 @@ +# Phase 01 — Edit Types + Serde + +**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** M9 slot op names landed + +## Scope of phase + +Introduce `SlotEdit` and `AssetEdit`; rewrite `ArtifactEdit` as tagged union; +remove `EditOp`. + +**In scope:** + +- Create `slot_edit.rs`, `asset_edit.rs` +- Rewrite `artifact_edit.rs` +- Delete `edit_op.rs` +- Update `edit/mod.rs` exports and `lib.rs` +- Serde roundtrip tests (slot block, asset block, batch with both) +- Remove deprecated `ArtifactOp = EditOp` alias (or leave deprecated stub with note — prefer remove) + +**Out of scope:** apply, diff, integration tests beyond unit serde. + +## Code organization reminders + +- One concept per file (`slot_edit.rs`, `asset_edit.rs`). +- `op_name()` on each op enum. +- `#[cfg(test)] mod tests` at bottom of `artifact_edit.rs` or `mod.rs`. + +## Sub-agent reminders + +- Do not commit. +- Do not touch `apply.rs`, `slot_apply.rs`, `def_diff.rs` yet — crate will not compile until phase 02/03; that's OK for this phase if executed alone, but prefer doing 01→02→03 in sequence on one branch. +- Fix warnings. + +## Implementation details + +### `slot_edit.rs` + +Move slot variants from current `edit_op.rs`: + +- `UseEnumVariant`, `AssignValue`, `MapInsert`, `MapRemove`, `UseOption` +- `impl SlotEdit { pub fn op_name(&self) -> &'static str }` + +### `asset_edit.rs` + +```rust +pub enum AssetEdit { + Delete, + ReplaceBody(String), +} +``` + +- `impl AssetEdit { pub fn op_name(&self) -> &'static str }` → `"delete"`, `"replace_body"` + +### `artifact_edit.rs` + +```rust +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ArtifactEdit { + Slot { target: EditTarget, ops: Vec }, + Asset { target: EditTarget, ops: Vec }, +} +``` + +Add helpers: + +```rust +impl ArtifactEdit { + pub fn target(&self) -> &EditTarget { … } + pub fn slot(target: EditTarget, ops: Vec) -> Self { … } + pub fn asset(target: EditTarget, ops: Vec) -> Self { … } +} +``` + +### Exports (`edit/mod.rs`, `lib.rs`) + +```rust +pub use asset_edit::AssetEdit; +pub use slot_edit::SlotEdit; +// remove EditOp export +``` + +### Tests + +Replace invalid mixed-op roundtrip in `edit/mod.rs`: + +- Test A: `ArtifactEdit::asset(Path("/shader.glsl"), [ReplaceBody(...)])` +- Test B: `ArtifactEdit::slot(Path("/shader.toml"), [UseEnumVariant(...)])` +- Test C: `EditBatch` with one slot + one asset edit + +Assert JSON contains `"kind":"slot"` / `"kind":"asset"`. + +## Validate + +```bash +# After 02+03 land, full crate passes. For 01-only: +cargo test -p lpc-node-registry edit::tests +``` + +If crate fails to compile due to downstream references, note in handoff — expected until phase 02. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/02-apply-pipeline.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/02-apply-pipeline.md new file mode 100644 index 000000000..2fe0a5c3d --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/02-apply-pipeline.md @@ -0,0 +1,107 @@ +# Phase 02 — Apply Pipeline + +**Dispatch:** sub-agent: yes | parallel: 03 | **Depends on:** 01 + +## Scope of phase + +Route apply through `ArtifactEdit::{Slot, Asset}`; eliminate cross-kind +`UnsupportedOp` guards. + +**In scope:** + +- `edit/apply.rs` — apply `ArtifactEdit::Asset` (and batch loop) +- `registry/slot_apply.rs` — `SlotEdit` only +- `registry/node_def_registry.rs` — `apply_artifact_edit` / `apply_edit_batch` +- `edit/edit_error.rs` — remove `UnsupportedOp` if unused; keep if still needed elsewhere + +**Out of scope:** diff, integration test updates (phase 04), docs. + +## Code organization reminders + +- `apply_asset_op(overlay, path, &AssetEdit)` in `apply.rs` (private). +- Slot apply stays in `slot_apply.rs`. + +## Sub-agent reminders + +- Do not commit. +- Do not weaken `ensure_toml_path` — slot edits on `.glsl` still error. +- Do not suppress warnings. + +## Implementation details + +### `apply.rs` + +Replace `apply_op(..., &EditOp)` with: + +```rust +pub fn apply_artifact_edit(...) { + let path = resolve_path(...)?; + match edit { + ArtifactEdit::Asset { ops, .. } => { + for op in ops { apply_asset_op(slot_overlay, path.clone(), op)?; } + } + ArtifactEdit::Slot { .. } => { + return Err(EditError::UnsupportedOp { … }); // OR delegate to registry only + } + } +} +``` + +**Decision:** `edit/apply.rs` is the low-level overlay API used without registry +context. Options: + +1. **Preferred:** `apply_artifact_edit` in `apply.rs` handles **Asset only**; + `NodeDefRegistry::apply_artifact_edit` matches full `ArtifactEdit` and calls + slot path for `Slot`. +2. Registry method remains the public entry for both kinds. + +Update `apply_edit_batch` to match. + +`apply_asset_op`: + +```rust +match op { + AssetEdit::Delete => slot_overlay.apply_delete(path), + AssetEdit::ReplaceBody(text) => slot_overlay.apply_bytes(path, text.into_bytes()), +} +``` + +### `slot_apply.rs` + +- Change signatures: `&EditOp` → `&SlotEdit`, `&[EditOp]` → `&[SlotEdit]` +- Remove arm: `EditOp::Delete | EditOp::SetBytes(_) => Err(UnsupportedOp)` +- `apply_op_to_def` matches only `SlotEdit` variants + +### `node_def_registry.rs` + +```rust +pub fn apply_artifact_edit(&mut self, change: &ArtifactEdit, ...) { + let path = self.resolve_edit_target(...)?; + match change { + ArtifactEdit::Asset { ops, .. } => { + for op in ops { + apply_asset_op(&mut self.slot_overlay, path.clone(), op)?; + } + } + ArtifactEdit::Slot { ops, .. } => { + for op in ops { + self.apply_slot_op(path.clone(), op, fs, ctx, frame)?; + } + } + } +} +``` + +Remove per-op `match` on `EditOp`. + +### `SyncOp::Apply(ArtifactEdit)` + +No signature change — compiles once call sites updated in phase 04. + +## Validate + +```bash +cargo check -p lpc-node-registry +``` + +Full tests may fail until phase 04 updates call sites. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/03-diff-project-diff.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/03-diff-project-diff.md new file mode 100644 index 000000000..7affa0d78 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/03-diff-project-diff.md @@ -0,0 +1,68 @@ +# Phase 03 — Diff + project_diff + +**Dispatch:** sub-agent: yes | parallel: 02 | **Depends on:** 01 + +## Scope of phase + +Update diff helpers to emit `SlotEdit` and tagged `ArtifactEdit`. + +**In scope:** + +- `diff/def_diff.rs` — return `Vec`; update `push_*` helpers +- `diff/project_diff.rs` — wrap results in `ArtifactEdit::Slot` / `::Asset` +- `registry/slot_apply.rs` — `apply_ops_to_node_def` takes `&[SlotEdit]` (if not done in 02) + +**Out of scope:** integration tests (phase 04), docs. + +## Sub-agent reminders + +- Do not commit. +- Preserve diff verify loop (apply ops to clone, compare TOML). +- Empty slot diff → omit `ArtifactEdit` entry (unchanged behavior). + +## Implementation details + +### `def_diff.rs` + +```rust +pub fn diff_node_defs(...) -> Result, DiffError> +``` + +- All `EditOp::…` → `SlotEdit::…` +- `ops: &mut Vec` in internal helpers + +### `project_diff.rs` + +```rust +(Some(_), None) => changes.push(ArtifactEdit::asset( + EditTarget::Path(...), + vec![AssetEdit::Delete], +)), + +// .toml +changes.push(ArtifactEdit::slot( + EditTarget::Path(...), + ops, +)); + +// non-.toml +changes.push(ArtifactEdit::asset( + EditTarget::Path(...), + vec![AssetEdit::ReplaceBody(text)], +)); +``` + +Remove `EditOp` import. + +### `def_diff` unit test + +`diff_shader_from_default` — update assertions if op type names changed only. + +## Validate + +```bash +cargo test -p lpc-node-registry diff:: +cargo test -p lpc-node-registry --test project_diff +``` + +Integration tests outside `diff` module may fail until phase 04. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/04-tests-and-docs.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/04-tests-and-docs.md new file mode 100644 index 000000000..2af297ef0 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/04-tests-and-docs.md @@ -0,0 +1,94 @@ +# Phase 04 — Integration Tests + Docs + +**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** 02, 03 + +## Scope of phase + +Update all harness/integration tests and canonical change-language docs. + +**In scope:** + +- Tests under `lpc-node-registry/tests/`: + - `asset_overlay.rs` + - `commit_promotion.rs` + - `effective_projection.rs` + - `overlay_lifecycle.rs` + - `pending_sync.rs` + - `slot_overlay.rs` +- `change-language.md` +- `m8-edit-session-sync/vocabulary.md` (M10 table) +- `overview.md` change-language summary (optional one-liner) + +**Out of scope:** archived plan docs, M9 phase files (historical). + +## Sub-agent reminders + +- Do not commit. +- Use `ArtifactEdit::slot(...)` / `::asset(...)` helpers for readability. +- Fix test names/comments referencing `SetBytes` → `ReplaceBody` where user-facing. + +## Implementation details + +### Test migration pattern + +```rust +// before +ArtifactEdit { + target: EditTarget::Path(...), + ops: vec![EditOp::SetBytes("…".into())], +} + +// after +ArtifactEdit::asset( + EditTarget::Path(...), + vec![AssetEdit::ReplaceBody("…".into())], +) +``` + +```rust +// before +ArtifactEdit { target, ops: vec![EditOp::AssignValue { … }] } + +// after +ArtifactEdit::slot(target, vec![SlotEdit::AssignValue { … }]) +``` + +```rust +// Delete +ArtifactEdit::asset(target, vec![AssetEdit::Delete]) +``` + +### Imports + +Replace `EditOp` with `SlotEdit`, `AssetEdit` in test use lines. + +### `change-language.md` + +Rewrite ops section: + +```rust +enum ArtifactEdit { + Slot { target: EditTarget, ops: Vec }, + Asset { target: EditTarget, ops: Vec }, +} +``` + +Two op tables (slot vs asset). Update all examples (including creatability + add-shader example). + +### `vocabulary.md` + +Add section: + +| Old | New | +|-----|-----| +| `EditOp` | removed — use `SlotEdit` or `AssetEdit` | +| flat `ArtifactEdit { ops }` | tagged `ArtifactEdit::Slot` / `::Asset` | +| `SetBytes` | `AssetEdit::ReplaceBody` | + +## Validate + +```bash +cargo test -p lpc-node-registry +``` + +All tests must pass. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/05-cleanup-validation.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/05-cleanup-validation.md new file mode 100644 index 000000000..38a0565b4 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/05-cleanup-validation.md @@ -0,0 +1,60 @@ +# Phase 05 — Cleanup + Validation + +**Dispatch:** main | parallel: - | **Depends on:** 04 + +## Scope of phase + +Final pass: grep for stale symbols, fmt, clippy, summary. + +**In scope:** + +- `cargo +nightly fmt` +- Grep cleanup: + - no `EditOp` in `lp-core/lpc-node-registry/` + - no `SetBytes` in edit vocabulary (except comments/history if any) + - no flat `ArtifactEdit { target, ops` struct literals +- `UnsupportedOp` — remove from `EditError` if dead +- Write `m10-slot-asset-edit-split/summary.md` +- Update `docs/roadmaps/.../changeset-change-management/summary.md` — M10 entry + +**Out of scope:** wire crate, engine, partial diff implementation. + +## Cleanup checklist + +- [ ] `EditOp` deleted; not exported from `lib.rs` +- [ ] `ArtifactOp` deprecated alias removed or documents split +- [ ] Serde tests cover slot + asset kinds +- [ ] `apply_slot_op_on_non_toml_path_errors` still passes +- [ ] `project_diff` equivalence tests green +- [ ] Warnings fixed + +## Validate + +```bash +rustup update nightly +just check +cargo test -p lpc-node-registry +``` + +Optional (if touching nothing outside registry): + +```bash +cargo test -p lpc-engine --lib +``` + +## Commit message (when user asks) + +``` +refactor(lpc-node-registry): split EditOp into SlotEdit and AssetEdit + +- Tagged ArtifactEdit::{Slot, Asset} replaces flat ops list +- Rename SetBytes to AssetEdit::ReplaceBody +- Align apply/diff with overlay DefDraft vs Bytes split +``` + +## summary.md template + +- Status: complete +- Delivered: typed split, apply/diff migration, docs +- Breaking: serde wire shape for `ArtifactEdit`; `EditOp` removed +- Validation commands diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/future.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/future.md new file mode 100644 index 000000000..5da372718 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/future.md @@ -0,0 +1,18 @@ +# M10 Future — Asset edit extensions + +## Partial text diffs + +- **Idea:** Add patch-style variants to `AssetEdit` (range replace, splice, unified diff hunks). +- **Why not now:** Overlay and apply already work with whole-body `ReplaceBody`; slot path covers structured TOML edits. +- **Useful context:** `AssetEdit` enum in `asset_edit.rs`; `SlotOverlayEntry::Bytes` may need merge semantics instead of last-write-wins for incremental patches. + +## Binary assets + +- **Idea:** `ReplaceBody(Vec)` or separate `ReplaceBytes` when non-UTF-8 assets matter. +- **Why not now:** Current model uses `String` for text assets; UTF-8 assumption matches GLSL/SVG/TOML escape hatch. +- **Useful context:** `apply_bytes` in `slot_overlay.rs`. + +## TOML import escape hatch typing + +- **Idea:** Explicit `ArtifactEdit::Asset` on `.toml` paths for bulk import vs `Slot` for normal authoring — already enforced by kind tag; could add apply-time warning in debug builds. +- **Why not now:** Convention + type split is sufficient for v1. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection.md new file mode 100644 index 000000000..5fc6c4386 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection.md @@ -0,0 +1,66 @@ +# Milestone 2: Effective Projection + +## Title And Goal + +Wire **effective reads** through overlay ∪ base. All public registry/view reads +return **effective** state — never a committed-only shortcut. Prove the read +contract parent **M6** engine cutover will rely on. + +## Parallel Build + +**`lpc-node-registry` only.** **`lpc-engine` unchanged.** + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/` + +## Scope + +In scope: + +- `read_effective_bytes(path)` — overlay before `ArtifactStore` / fs +- `NodeDefView::get` — effective `NodeDefState` (committed entry ∪ overlay draft) +- Internal artifact queries on registry route through overlay +- Eager re-parse of touched `.toml` def artifacts (same shape as parent M4 + `sync`, touched paths only) +- Harness: + - apply overlay change → view ≠ committed `entries` + - discard → view == committed `entries` again + +Out of scope: + +- Full file/slot op apply (**M3**, **M4**) +- Commit (**M5**) +- Typed `*DefView` against effective `SlotShapeLookup` — optional; materialized + effective `NodeDef` OK for M2 +- Provenance / mutated badges — client or parent **M10** probes + +## Key Decisions + +- **`entries` = committed cache** — updated only on commit / `sync_fs`. +- **Public reads = effective only** — callers need not know pending vs committed. +- **No provenance on read path** — values only; memory constraint on ESP32. + +## User Stories / Gate + +| ID | Story | Covered | +|----|-------|---------| +| D1 | Apply → effective view ≠ committed base | **Extends M1** | + +## Deliverables + +- Effective read path on `NodeDefRegistry` +- `NodeDefView` updated from passthrough stub +- Projection harness tests + +## Dependencies + +- M1 change language + overlay lifecycle + +## Execution Strategy + +Full plan. Projection is the engine-integration contract. + +Suggested chat opener: + +> M2 plan: effective NodeDefView + artifact reads through overlay. Full plan then implement. Agree? diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/00-design.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/00-design.md new file mode 100644 index 000000000..a9a3bbd9e --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/00-design.md @@ -0,0 +1,100 @@ +# M2 Design — Effective Projection + +## Scope + +Effective artifact byte reads and effective def projection via `NodeDefView`. +**`lpc-engine` untouched.** + +Depends on M1 (`ChangeOverlay`, apply/discard). + +## File structure + +``` +lp-core/lpc-node-registry/src/ +├── registry/ +│ ├── effective_read.rs # read_effective_bytes, parse_effective_state +│ └── node_def_registry.rs # delegate / thin wrappers +├── view/ +│ └── node_def_view.rs # effective get(state) +└── tests/ + └── effective_projection.rs # D1 view vs committed +``` + +## Architecture + +```text +read_effective_bytes(path, fs) + │ + ├─ overlay.contains(path)? + │ ├─ Deleted → None (parse → error state) + │ └─ Bytes → return bytes + │ + └─ else artifact_path_to_id → store.read_bytes + +parse_effective_state(artifact_id, fs, ctx) + └─ read_effective_bytes(artifact_root_path) → NodeDef::read_toml + +NodeDefView::get(id, fs, ctx) -> Option + └─ committed entry metadata + effective state (owned clone) +``` + +### API + +```rust +impl NodeDefRegistry { + /// Bytes for `path` from overlay if present, else committed store/fs. + pub fn read_effective_bytes( + &mut self, + path: &LpPath, + fs: &dyn LpFs, + ) -> Result>, RegistryError>; + + pub fn view(&self) -> NodeDefView<'_>; +} + +impl NodeDefView<'_> { + /// Effective def entry (overlay ∪ base). Always owned. + pub fn get( + &self, + id: &NodeDefId, + fs: &dyn LpFs, + ctx: &ParseCtx<'_>, + ) -> Option; + + pub fn state( + &self, + id: &NodeDefId, + fs: &dyn LpFs, + ctx: &ParseCtx<'_>, + ) -> Option; +} +``` + +**Unchanged:** `NodeDefRegistry::get` returns committed `entries` (internal/sync +cache). Callers wanting effective state use `view().get(...)`. + +### M2 overlay → def semantics + +Only whole-file overlay bytes (M1 `SetBytes` / `Delete`). Replacing +`/clock.toml` bytes replaces the entire parsed tree for all `DefSource` rows on +that artifact until discard. + +Slot-level overlay draft (partial TOML merge) is **M4**. + +## Tests + +| Test | Asserts | +|------|---------| +| `effective_view_differs_after_toml_setbytes` | apply SetBytes on `/clock.toml`; view rate=2, committed rate=1 | +| `effective_view_matches_committed_without_overlay` | load + view.get == committed | +| `discard_restores_effective_view_to_committed` | after discard, view matches committed | +| `effective_deleted_overlay_yields_parse_error` | Delete on loaded `.toml`; view error, committed loaded | + +Use `fixtures::load_clock()` + `ParseCtx`. + +## Non-goals + +- `materialize_source` overlay (M3) +- Slot op overlay merge (M4) +- Commit / SyncResult (M5) +- Effective cache across frames diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/00-notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/00-notes.md new file mode 100644 index 000000000..7af8bbe36 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/00-notes.md @@ -0,0 +1,81 @@ +# M2 Notes — Effective Projection + +## Scope + +Wire **effective reads** (overlay ∪ committed base) through `NodeDefRegistry` and +`NodeDefView`. Prove D1 extension: apply changes what callers **see**, not what +`entries` stores. + +**Out of scope:** slot-draft overlay (M4), commit (M5), materialize overlay +(M3), `*DefView` typed accessors, provenance. + +## Current codebase (post-M1) + +``` +lp-core/lpc-node-registry/src/ +├── change/overlay.rs # OverlayEntry::{Deleted, Bytes} +├── registry/node_def_registry.rs +│ ├── store.read_bytes # committed artifact bytes only +│ ├── read_artifact_state # parse via store (no overlay) +│ └── apply_change / discard_overlay +└── view/node_def_view.rs # passthrough registry.get (committed) +``` + +M1 tests prove overlay + committed `entries` diverge on apply, but **no read path +uses overlay yet**. + +## Resolved design (2026-05-21) + +- **`entries` / `registry.get`** — committed cache only (unchanged on apply). +- **`NodeDefView`** — **effective only**; requires `fs` + `ParseCtx` to parse + overlay bytes when present. +- **M2 overlay semantics** — whole-file `SetBytes` / `Delete` only (same as M1 + apply). Slot-draft merge deferred to M4. +- **Parse strategy** — on-read: if overlay touches artifact path, parse overlay + bytes → `NodeDefState`; else clone committed entry state. No persistent + effective cache in M2 (discard/apply is cheap enough for harness). +- **`read_effective_bytes(path)`** — single choke point: overlay → store/fs. + +## Open questions + +### Q1: NodeDefView::get signature + +- **Context:** Effective parse needs `fs` + `ParseCtx`. Current `get(&NodeDefId) + -> Option<&NodeDefEntry>` borrows committed entries. +- **Suggested answer:** Change to owned effective entry: + `get(id, fs, ctx) -> Option`. Crate has no external callers yet; + engine cutover is M6. + +### Q2: Deleted overlay path + +- **Context:** `OverlayEntry::Deleted` on a loaded artifact. +- **Suggested answer:** Effective parse returns `NodeDefState::ParseError` with + read failure shape (same as missing file from store). Harness asserts view + shows error/parse failure vs committed loaded state. + +### Q3: Path-only overlay (implicit create) + +- **Context:** M1 allows overlay on paths not in `artifact_path_to_id`. +- **Suggested answer:** `NodeDefView` only resolves existing `NodeDefId`s. + Implicit-create paths are visible via `overlay_bytes` until commit registers + them. No phantom defs in view until M5 commit + register. + +### Q4: materialize_source in M2? + +- **Context:** Asset reads should eventually use overlay (M3). +- **Suggested answer:** **Defer to M3.** M2 only routes def parse + + `read_effective_bytes`; materialize keeps store path until M3. + +## User stories + +| ID | Story | How | +|----|-------|-----| +| D1 ext | Apply → **view** ≠ committed `entries` | SetBytes on `/clock.toml`; view new rate, `registry.get` old | + +## Validation baseline + +```bash +cargo test -p lpc-node-registry +cargo test -p lpc-node-registry --test overlay_lifecycle +cargo test -p lpc-node-registry --test fs_change_semantics +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/01-effective-bytes-read.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/01-effective-bytes-read.md new file mode 100644 index 000000000..1dd5d483c --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/01-effective-bytes-read.md @@ -0,0 +1,45 @@ +# Phase 01 — Effective Bytes Read + +**Dispatch:** sub-agent: yes | parallel: - + +## Scope of phase + +Implement `NodeDefRegistry::read_effective_bytes` — overlay before store. + +**In scope:** + +- New `registry/effective_read.rs` with byte resolution logic +- `read_effective_bytes(&mut self, path: &LpPath, fs) -> Result>, RegistryError>` +- Handle overlay `Bytes`, `Deleted` (return `Ok(None)`) +- Fall through to `artifact_path_to_id` + `store.read_bytes` when no overlay +- Path not in store and not in overlay → `Ok(None)` +- Unit tests on registry or `effective_read` module + +**Out of scope:** NodeDefView, TOML parse, materialize. + +## Implementation details + +**Overlay precedence:** + +1. If `overlay.contains_path(path)`: + - `Deleted` → `Ok(None)` + - `Bytes(b)` → `Ok(Some(b))` +2. Else if `artifact_path_to_id` has path → `store.read_bytes` +3. Else → `Ok(None)` + +**Requires `&mut self`** because `store.read_bytes` mutates read state (matches +existing `read_artifact_state` pattern). + +**Tests:** + +- overlay SetBytes wins over store for loaded shader.glsl +- no overlay delegates to store +- overlay Delete returns None +- implicit-create overlay path returns bytes without store entry + +## Validate + +```bash +cargo test -p lpc-node-registry effective +cargo check -p lpc-node-registry +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/02-effective-def-parse.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/02-effective-def-parse.md new file mode 100644 index 000000000..7f1fb743f --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/02-effective-def-parse.md @@ -0,0 +1,37 @@ +# Phase 02 — Effective Def Parse + +**Dispatch:** sub-agent: yes | parallel: - + +## Scope of phase + +Parse effective TOML for a committed artifact and expose via registry helper. + +**In scope:** + +- `parse_effective_state(artifact_id, fs, ctx) -> Result` + in `effective_read.rs` +- Uses `artifact_root_path` + `read_effective_bytes` +- UTF-8 + `NodeDef::read_toml` — same as `read_artifact_state` but effective bytes +- Refactor `read_artifact_state` to call `parse_effective_state` **or** keep + committed path separate (committed = store only; effective = overlay path). + **Do not** change committed `sync`/`load_root` parse to use overlay. + +**Out of scope:** NodeDefView API change (phase 03). + +## Key rule + +| Path | Bytes source | +|------|----------------| +| `sync` / `load_root` / `entries` | store only (committed) | +| effective parse / view | overlay ∪ store | + +## Tests + +- Unit: apply SetBytes on `/clock.toml`; `parse_effective_state` shows new field + values; direct `read_artifact_state` (if still exposed internally) unchanged + +## Validate + +```bash +cargo test -p lpc-node-registry +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/03-node-def-view-projection.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/03-node-def-view-projection.md new file mode 100644 index 000000000..af145a2ba --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/03-node-def-view-projection.md @@ -0,0 +1,39 @@ +# Phase 03 — NodeDefView Projection + +**Dispatch:** sub-agent: yes | parallel: - + +## Scope of phase + +Wire `NodeDefView` to effective parse; add integration tests. + +**In scope:** + +- `NodeDefRegistry::view(&self) -> NodeDefView` +- Update `NodeDefView::get(id, fs, ctx) -> Option` — owned effective +- Update `NodeDefView::state(id, fs, ctx) -> Option` +- Clone committed entry shell (`id`, `source`, `last_seen_revision`); replace + `state` with effective parse at entry's artifact root when overlay active on + that artifact path; else clone committed state +- `tests/effective_projection.rs` — D1 extension tests from design +- Update `view/mod.rs` docs (remove M5 stub comment) + +**Out of scope:** materialize (M3), changing `registry.get` semantics. + +## Test scenarios + +1. **toml setbytes** — apply new clock.toml body; view shows new rate; `registry.get` old +2. **no overlay** — view matches committed +3. **discard** — view matches committed after discard +4. **delete overlay** — view parse error on that def; committed still loaded + +## Sub-agent reminders + +- Do not commit. +- `registry.get` stays committed-only. + +## Validate + +```bash +cargo test -p lpc-node-registry --test effective_projection +cargo test -p lpc-node-registry +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/04-cleanup-validation.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/04-cleanup-validation.md new file mode 100644 index 000000000..589241c78 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/04-cleanup-validation.md @@ -0,0 +1,33 @@ +# Phase 04 — Cleanup + Validation + +**Dispatch:** sub-agent: main | parallel: - + +## Scope of phase + +Docs, fmt, clippy, milestone summary. + +**In scope:** + +- `summary.md` in this plan folder +- Module docs on `effective_read`, `NodeDefView` +- `cargo +nightly fmt` +- Full crate validation + +**Out of scope:** M3 materialize overlay. + +## Validate + +```bash +cargo test -p lpc-node-registry +cargo test -p lpc-node-registry --test fs_change_semantics +cargo test -p lpc-node-registry --test overlay_lifecycle +cargo test -p lpc-node-registry --test effective_projection +cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings +``` + +## Milestone sign-off + +- [ ] `read_effective_bytes` overlay precedence +- [ ] `NodeDefView::get` effective only (requires fs + ctx) +- [ ] Committed `entries` unchanged on apply (regression) +- [ ] D1 view-vs-committed test green diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay.md b/docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay.md new file mode 100644 index 000000000..e175f3c0d --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay.md @@ -0,0 +1,65 @@ +# Milestone 3: File Ops + Asset Reads + +## Title And Goal + +Implement file-level **`ArtifactOp`** (`SetBytes`, `Delete`) on overlay and wire +**effective asset byte reads** (including `materialize_source` from overlay). +Prove **C4*** stories. + +## Parallel Build + +**`lpc-node-registry` only.** **`lpc-engine` unchanged.** + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay/` + +## Scope + +In scope: + +- Apply `SetBytes` / `Delete` on path-keyed overlay (implicit create on `Path`) +- `read_effective_bytes` for asset paths (`.glsl`, `.svg`, …) +- `materialize_source` reads overlay when present (before store/fs) +- Harness spot tests: + - **C4c** — replace `.glsl` via overlay; def slot unchanged; source revision + bumps after commit + - **C4a/b/d** — add asset, delete asset, replace without touching def TOML + +Out of scope: + +- Slot op apply on `.toml` (**M4**) +- Binary assets ([`future.md`](future.md)) +- Commit promotion (**M5**) — may test bytes in overlay only until M5 + +## Key Decisions + +- **Assets use file ops** — not slot ops; same `ArtifactChange` grouping as TOML. +- **Whole-file text only** in v1 — no byte-range patches. +- **User term "asset"** = non-node file; store identity remains **artifact**. + +## User Stories / Gate + +| ID | Story | Covered | +|----|-------|---------| +| C4c | Replace GLSL; def unchanged; source revision after commit | **Yes** | +| C4a/b/d | Add / delete / replace asset files | Spot tests | + +## Deliverables + +- File op apply on overlay +- Asset effective read + materialize integration +- `tests/changeset/` asset scenarios + +## Dependencies + +- M1 overlay lifecycle +- M2 effective projection + +## Execution Strategy + +Full plan. + +Suggested chat opener: + +> M3 plan: SetBytes/Delete + overlay asset reads for materialize. Full plan then implement. Agree? diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay/summary.md new file mode 100644 index 000000000..7c83664e1 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay/summary.md @@ -0,0 +1,43 @@ +# M3 Summary — File Ops + Asset Reads + +## Status + +Implemented (`81ff051b`) on branch `codex/incremental-artifact-reload`. + +Plan folder backfilled post-hoc; execution skipped numbered phase files. + +## Delivered + +- `SetBytes` / `Delete` apply on overlay (via `change/apply.rs` + registry routing) +- `read_effective_bytes` — overlay before committed store/fs +- `materialize_source` — checks `ChangeOverlay` before store read +- `NodeDefRegistry::materialize_source` wrapper +- `source_bridge` passes overlay into materialize path + +## Tests + +`lp-core/lpc-node-registry/tests/asset_overlay.rs` — C4a–d: + +- C4a — add asset via overlay implicit create +- C4b — replace asset bytes +- C4c — replace GLSL; def TOML unchanged in committed cache +- C4d — delete asset via overlay + +Also covered indirectly: `overlay_lifecycle.rs` (SetBytes/Delete lifecycle). + +## Validation + +```bash +cargo test -p lpc-node-registry --test asset_overlay +cargo test -p lpc-node-registry --test overlay_lifecycle +cargo test -p lpc-node-registry +``` + +## Deferred to M5+ + +- Source revision bump after commit (C4c post-commit expectation) +- Commit promotion of overlay assets to store/fs + +## Next + +M4 slot ops + TOML serialize; M5 commit promotion. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches.md b/docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches.md new file mode 100644 index 000000000..94860caba --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches.md @@ -0,0 +1,68 @@ +# Milestone 4: Slot Ops + TOML Serialize + +## Title And Goal + +Implement slot-level **`ArtifactOp`** (`SetSlot`, `MapInsert`, `MapRemove`, +`OptionSet`, …) on overlay TOML artifacts and **serialize overlay slot trees to +TOML bytes** (commit path prep — promotion itself is **M5**). Prove **C1*** and +**C2*** stories. + +## Parallel Build + +**`lpc-node-registry` only.** **`lpc-engine` unchanged.** + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches/` + +## Scope + +In scope: + +- Apply slot ops at `SlotPath` within target artifact ([`change-language.md`](change-language.md)) +- Overlay holds slot draft per `.toml` path (lazy fork from committed parse OK) +- **Serialize** effective slot tree → TOML text (slot codec); used by M5 commit +- Harness: + - **C1a–f** — kind + defaults + slot patches; wiring via slot ops on parent + - **C2a–c** — inline child edits at nested paths (`entries[n].node`, …) + - Inline child edit marks child changed in `NodeDefUpdates` shape (post-commit + expectation documented for M5) + +Out of scope: + +- **C3** inline ↔ standalone refactor ([`future.md`](future.md)) +- Commit flush to store (**M5**) +- `CreateDef` op — kind + defaults via slot ops only + +## Key Decisions + +- **Node defs are slots** — all TOML authoring via slot ops at `root()` or inline + paths. +- **Wiring = slot ops** — e.g. `SetSlot` on `nodes[shader].def` path locator. +- **Normal TOML not via `SetBytes`** — serialize from slot tree; `SetBytes` is + import escape hatch only. + +## User Stories / Gate + +| ID | Story | Covered | +|----|-------|---------| +| C1a–f | Standalone def slot + file ops | **Yes** (minimum c/d for gate via diff) | +| C2a–c | Inline nested slot ops | **b/c in M4** | + +## Deliverables + +- Slot op apply on overlay drafts +- TOML serialize helper for overlay → bytes +- `tests/changeset/` slot scenarios (C1/C2) + +## Dependencies + +- M1–M3 + +## Execution Strategy + +Full plan. Largest milestone — slot mut access on overlay copies. + +Suggested chat opener: + +> M4 plan: slot ops on overlay + TOML serialize path. Full plan then implement. Agree? diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches/summary.md new file mode 100644 index 000000000..2deaec835 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches/summary.md @@ -0,0 +1,52 @@ +# M4 Summary — Slot Ops + TOML Serialize + +## Status + +Implemented (`9234bb94`) on branch `codex/incremental-artifact-reload`. + +Plan folder backfilled post-hoc; execution skipped numbered phase files. + +## Delivered + +### lpc-model + +- `slot_mutation.rs` — `set_slot_value`, map/option remove helpers +- `slot_mut_access.rs` — `MapSlotMutAccess::remove_entry`, `SlotOptionMutAccess::clear_presence` + +### lpc-node-registry + +- `OverlayEntry::SlotDraft` + `change/slot_draft.rs` +- `registry/slot_apply.rs` — apply slot ops, fork draft, `serialize_slot_draft` +- `apply_change` / `apply_changeset` take `(fs, ctx, frame)`; route file vs slot ops +- `effective_read.rs` — slot draft projection + inline child `def_state_at_source` +- Inline child path routing — `entries[n].node.def.*` mutates invocation body + +## Tests + +`lp-core/lpc-node-registry/tests/slot_overlay.rs`: + +- C1 — SetSlot patches clock rate in view; committed unchanged +- C1 — slot draft serializes to TOML (round-trip via `NodeDef::read_toml`) +- C2 — playlist parent slot patch; committed children unchanged +- C2 — inline child slot patch visible in view; committed child unchanged + +Updated for new API: `overlay_lifecycle.rs`, `effective_projection.rs`, `asset_overlay.rs`. + +## Validation + +```bash +cargo test -p lpc-node-registry --test slot_overlay +cargo test -p lpc-node-registry +cargo test -p lpc-model slot_mutation +``` + +## Known limits (documented for M5/M6) + +- New `.toml` paths fork from `NodeDef::default()` (Project) until kind SetSlot +- Custom slot paths (general `NodeInvocation` descent) use inline-specific router +- `MapInsert` / `MapRemove` / `OptionSet` implemented but not integration-tested +- Post-commit `NodeDefUpdates` for inline children — M5 + +## Next + +M5 commit promotion — flush overlay to fs/store, re-derive entries, `SyncResult`. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion.md new file mode 100644 index 000000000..8a85da992 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion.md @@ -0,0 +1,66 @@ +# Milestone 5: Commit Promotion + +## Title And Goal + +Implement **commit**: promote overlay → `ArtifactStore` (+ optional fs write) → +re-derive `entries` → **`SyncResult`** (same shape as parent M4 fs sync) → clear +overlay. Prove **D2** and **D5**. + +## Parallel Build + +**`lpc-node-registry` only.** **`lpc-engine` unchanged.** + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/` + +## Scope + +In scope: + +- `NodeDefRegistry::commit` (or equivalent facade) +- Flush overlay paths: asset bytes + serialized TOML from M4 slot trees +- Re-derive affected defs; populate `SyncResult` / `NodeDefUpdates` +- `RegistryChange::ChangeSet` or dedicated commit entry point (plan decides) +- All-or-nothing commit; failure leaves base untouched +- **D5** harness — uncommitted overlay wins on effective read; after commit, fs + `sync` on same path follows committed rules; overlay cleared + +Out of scope: + +- Engine cutover (parent **M6**) +- Project diff (**M6**) +- Wire `slot_mutation` alignment (parent **M6**) + +## Key Decisions + +- **Commit reuses parent M4 re-derive path** where possible. +- **`discard`** = overlay clear only; **`commit`** = only path that mutates + committed `entries` from client edits. +- **Failed commit** — base unchanged; overlay may retain pending state (plan + specifies). + +## User Stories / Gate + +| ID | Story | Milestone | +|----|-------|-----------| +| D2 | Commit → base updated; overlay clear | **M5** | +| D5 | Overlay vs fs-change precedence | **M5** | + +## Deliverables + +- Commit API on registry +- D2 + D5 harness tests +- `commit-contract.md` design note in plan folder + +## Dependencies + +- M1–M4 (meaningful commit requires file + slot ops + serialize) + +## Execution Strategy + +Full plan. Commit touches registry ownership after op semantics land. + +Suggested chat opener: + +> M5 plan: commit promotion to base + SyncResult + D5 precedence. Full plan then implement. Agree? diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/00-design.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/00-design.md new file mode 100644 index 000000000..6f9864a0b --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/00-design.md @@ -0,0 +1,147 @@ +# M5 Design — Commit Promotion + +## Scope + +Promote `ChangeOverlay` to committed state: fs write → store revision bump → +re-derive `entries` → `SyncResult` → clear overlay. Prove **D2**, **D5**, and +C2 post-commit `NodeDefUpdates` shape. + +**`lpc-engine` untouched.** + +Depends on M1–M4 (overlay, effective reads, file ops, slot ops + serialize). + +## File structure + +``` +lp-core/lpc-node-registry/src/ +├── change/ +│ ├── commit_error.rs # NEW — CommitError +│ └── overlay.rs # add path iteration helper if needed +├── registry/ +│ ├── commit.rs # NEW — flush + commit impl (#[path] from node_def_registry) +│ ├── node_def_registry.rs # pub fn commit(...) +│ ├── slot_apply.rs # reuse serialize_slot_draft +│ └── sync_result.rs # unchanged shape +└── tests/ + └── commit_promotion.rs # NEW — D2, D5, C2 post-commit +``` + +``` +docs/roadmaps/.../m5-commit-promotion/ +├── commit-contract.md # behavioral contract (this milestone) +├── 00-notes.md +├── 00-design.md +└── 01–06 phase files +``` + +## Architecture + +```text +Client NodeDefRegistry + │ │ + ├─ apply_changeset ───────────────► ChangeOverlay (pending) + ├─ view().get() ──────────────────► effective_read (overlay ∪ base) + │ + └─ commit(fs, frame, ctx) ────────► commit.rs + │ + ┌────────────────────┴────────────────────┐ + │ 1. Early exit if overlay empty │ + │ 2. Resolve each path → bytes/action │ + │ SlotDraft → serialize_slot_draft │ + │ Bytes → raw │ + │ Deleted → fs delete │ + │ 3. Write LpFs (create/modify/delete) │ + │ 4. acquire_file_artifact (new paths) │ + │ 5. store.apply_fs_changes (bump rev) │ + │ 6. snapshot_def_states (before) │ + │ 7. sync_def_artifact (each .toml) │ + │ 8. sync_source_path (asset deps) │ + │ 9. reconcile_artifact_refs │ + │10. build_change_details → SyncResult │ + │11. overlay.clear() │ + └─────────────────────────────────────────┘ + │ + get() / entries ◄──────────┘ committed cache +``` + +### API + +```rust +impl NodeDefRegistry { + /// Promote all pending overlay entries to committed store + entries. + /// Returns factual SyncResult. Clears overlay on success. + pub fn commit( + &mut self, + fs: &mut dyn LpFs, + frame: Revision, + ctx: &ParseCtx<'_>, + ) -> Result; +} +``` + +**Unchanged:** `sync` / `sync_fs` remain fs-reload only. `discard_overlay` unchanged. + +### Overlay → bytes resolution + +| `OverlayEntry` | Fs action | Store | +|----------------|-----------|-------| +| `Bytes(v)` | write `v` | acquire/bump via fs change | +| `SlotDraft(d)` | write `serialize_slot_draft(&d.def, ctx)?` | same | +| `Deleted` | delete file | `ChangeType::Delete` bump | + +### Re-derive strategy + +Reuse existing helpers from `node_def_registry.rs`: + +- `sync_def_artifact` for each touched `.toml` artifact id +- `sync_source_path` for touched asset paths referenced by loaded defs +- `build_change_details` + `snapshot_def_states` for `SyncResult` + +Classify touched paths: + +- `.toml` → def artifact sync set +- other → source path sync set (if path appears in `source_path_index` or affects materialized deps after def sync) + +For overlay paths not yet in `artifact_path_to_id`: `acquire_file_artifact` after fs write. + +### Failure semantics + +- Validate serialize + fs writes in an order that allows rollback where practical. +- If re-derive fails after fs write: document behavior in `commit-contract.md`; + prefer **fail before mutating `entries`** when validation catches errors early. +- On `CommitError`: **`entries` unchanged**, **overlay retained**. +- Empty overlay: return `Ok(SyncResult::default())`. + +### D5 precedence + +While overlay active on path `P`: + +- `read_effective_bytes`, `view().get`, `materialize_source` → overlay (already M2–M4) +- `sync_fs` on `P` → bumps store revision but **does not** replace overlay reads +- After successful `commit` → overlay cleared; `sync_fs` on `P` follows fs/store rules + +## Tests + +| Test | Story | +|------|-------| +| `d2_commit_updates_committed_and_clears_overlay` | SetSlot on clock; commit; `get()` matches view; overlay empty | +| `d2_commit_slot_draft_serializes_to_fs` | After commit, fs file contains serialized TOML | +| `d5_overlay_wins_over_fs_until_commit` | overlay + fs diverge; view=overlay; sync_fs doesn't clobber view | +| `d5_post_commit_fs_sync_applies` | after commit, fs change updates committed | +| `c2_inline_child_in_sync_result_after_commit` | playlist inline patch; child in `def_updates.changed`, not root | + +Fixtures: `load_clock`, `load_playlist_with_inline_child`, `load_shader_project`. + +## Non-goals + +- `RegistryChange::ChangeSet` batch type +- Compose-from-blank / `load_root` without pre-existing project (M6) +- Engine cutover +- Writing committed bytes without fs (embedded store-only path) + +## Validation + +```bash +cargo test -p lpc-node-registry +cargo test -p lpc-node-registry --test commit_promotion +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/00-notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/00-notes.md new file mode 100644 index 000000000..e63dc8d05 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/00-notes.md @@ -0,0 +1,105 @@ +# M5 Notes — Commit Promotion + +## Scope + +Implement **commit**: promote overlay → durable bytes (fs write in harness) → +`ArtifactStore` revision bump → re-derive `entries` → **`SyncResult`** → clear +overlay. Prove **D2** and **D5**. + +**Out of scope:** engine cutover (parent M6), diff/equivalence (M6 here), +`RegistryChange::ChangeSet` wire integration (optional; plan decides), compose-from-blank +(A1 — M6 gate). + +## Plan directory gap (M3, M4) + +M1 and M2 have full plan folders (`m1-change-language-overlay/`, +`m2-effective-projection/`) with `00-notes.md`, `00-design.md`, and numbered +phases. **M3 and M4 were implemented without plan directories** — only milestone +overview stubs exist: + +- `m3-asset-overlay.md` (suggested `m3-asset-overlay/` never created) +- `m4-node-slot-patches.md` (suggested `m4-node-slot-patches/` never created) + +Implementation landed via direct execution (commits `81ff051b` M3, `9234bb94` M4). +Tests live under `lp-core/lpc-node-registry/tests/` (`asset_overlay.rs`, +`slot_overlay.rs`, etc.) rather than a unified `tests/changeset/` tree mentioned +in overview docs. + +**Optional follow-up (not M5):** backfill lightweight `summary.md` in +`m3-asset-overlay/` and `m4-node-slot-patches/` documenting what shipped and +where tests live. Not blocking M5. + +## Current codebase (post-M4) + +``` +lp-core/lpc-node-registry/src/ +├── change/ +│ ├── overlay.rs # Bytes | SlotDraft | Deleted +│ ├── apply.rs # SetBytes/Delete only (slot ops via registry) +│ └── slot_draft.rs +├── registry/ +│ ├── node_def_registry.rs # apply_change/changeset, discard; NO commit +│ ├── effective_read.rs # overlay ∪ store reads for preview +│ ├── slot_apply.rs # slot op apply + serialize_slot_draft +│ ├── sync_result.rs # SyncResult shape (reuse on commit) +│ └── registry_change.rs # Fs only +└── artifact/artifact_store.rs # freshness cache; read_bytes from fs; no write API + +Tests (changeset-related): +├── overlay_lifecycle.rs # D1, D3, implicit create +├── effective_projection.rs # view vs committed +├── asset_overlay.rs # C4* +├── slot_overlay.rs # C1, C2 +└── fs_change_semantics.rs # S1–S6 (fs sync path — commit should mirror) +``` + +### Apply vs commit today + +| Step | Status | +|------|--------| +| `apply_change` / `apply_changeset` → overlay | Done | +| `view().get()` effective preview | Done | +| `discard_overlay()` | Done | +| `commit()` → base + SyncResult | **Missing** | +| `read_artifact_state` uses overlay | **No** — reads store/fs only | +| New overlay paths in store | **No** — implicit create is overlay-only until commit | + +Commit must bridge: overlay bytes → fs (harness) → store revision → +`sync_def_artifact` / `derive_inventory` (existing fs-sync path). + +## Resolved decisions (from roadmap) + +- Commit reuses parent M4 re-derive path (`sync_def_artifact`, `derive_inventory`). +- `discard` = overlay clear only; **commit** = only path that mutates committed + `entries` from client edits. +- All-or-nothing commit; failure leaves base untouched (overlay may retain pending). +- D5: uncommitted overlay wins on effective read; fs bump marks stale but does + not clobber overlay until commit/discard; on commit, client ChangeSet wins. + +## Resolved questions (2026-05-21) + +| # | Decision | +|---|----------| +| Q1 | Dedicated `NodeDefRegistry::commit(...) -> Result`; do not overload `sync()` in M5 | +| Q2 | Commit writes overlay content to `LpFs`, then bumps store via existing fs-change pattern | +| Q3 | Flush all overlay paths → fs → acquire/bump store → re-derive → clear overlay on success | +| Q4 | M5 tests assume `load_root` already ran; compose-from-blank deferred to M6 | +| Q5 | Failed commit: base unchanged, overlay retained | +| Q6 | Commit uses overlay entry variant as-is; no merge between `SlotDraft` and `Bytes` | +| Q7 | D5 harness: overlay wins over fs until commit; post-commit fs sync follows committed rules | + +## User stories (this milestone) + +| ID | Story | How | +|----|-------|-----| +| D2 | Commit → base updated; overlay clear | `get()` matches post-commit; `overlay_active()` false | +| D5 | Overlay vs fs-change precedence | Harness above | +| C2 post-commit | Inline child in `NodeDefUpdates.changed` | Commit slot patch on playlist; child id in SyncResult | + +## Validation baseline + +```bash +cargo test -p lpc-node-registry +``` + +Must remain green; add `tests/commit_promotion.rs` or extend `overlay_lifecycle.rs`. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/01-commit-errors-and-flush-helper.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/01-commit-errors-and-flush-helper.md new file mode 100644 index 000000000..70c8d83a8 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/01-commit-errors-and-flush-helper.md @@ -0,0 +1,39 @@ +# Phase 01 — Commit Errors + Overlay Flush Helpers + +**Dispatch:** sub-agent: main | parallel: - + +## Scope of phase + +Add `CommitError`, overlay iteration/resolution helpers, and unit-level flush +logic without wiring public `commit()` yet. + +**In scope:** + +- `change/commit_error.rs` — `CommitError` variants (empty overlay ok, serialize, + fs write, re-derive) +- Export from `change/mod.rs` and `lib.rs` +- `registry/commit.rs` (included from `node_def_registry.rs` via `#[path]`) with: + - `resolve_overlay_bytes(path, entry, ctx) -> Result>, CommitError>` + - `overlay_paths(overlay) -> Vec` iterator + - `is_def_artifact_path(path) -> bool` (`.toml` suffix) +- Tests for serialize resolution (SlotDraft → bytes) in commit module unit tests + +**Out of scope:** public `commit()`, integration tests, fs writes. + +## Implementation details + +- Reuse `serialize_slot_draft` from `slot_apply.rs`. +- `Deleted` → `Ok(None)` from byte resolver; caller maps to fs delete. +- `CommitError` should convert from `ChangeError` / `RegistryError` where helpful. + +## Sub-agent reminders + +- Do not commit. +- Do not expand scope into engine or lpc-model beyond existing serialize path. + +## Validate + +```bash +cargo test -p lpc-node-registry commit +cargo check -p lpc-node-registry +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/02-commit-impl.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/02-commit-impl.md new file mode 100644 index 000000000..8b6078e97 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/02-commit-impl.md @@ -0,0 +1,46 @@ +# Phase 02 — `commit()` Implementation + +**Dispatch:** sub-agent: main | parallel: - + +## Scope of phase + +Implement `NodeDefRegistry::commit` using flush helpers from phase 01. + +**In scope:** + +- `NodeDefRegistry::commit(&mut self, fs: &mut dyn LpFs, frame, ctx) -> Result` +- Fs writes: create/modify via `write_file_mut`, delete via fs delete API +- `acquire_file_artifact` for paths not in `artifact_path_to_id` +- `store.apply_fs_changes` with `ChangeType::Create` / `Modify` / `Delete` +- Re-derive: `sync_def_artifact` for touched `.toml` artifacts; + `sync_source_path` for affected asset paths +- `snapshot_def_states` + `build_change_details` → `SyncResult` +- `overlay.clear()` on success only +- Empty overlay → `Ok(SyncResult::default())` + +**Out of scope:** integration tests (phase 03+), `RegistryChange::ChangeSet`. + +## Implementation details + +Follow `commit-contract.md` ordering: resolve → fs write → store bump → re-derive. + +Key symbols in `node_def_registry.rs`: + +- `sync_def_artifact`, `sync_source_path`, `acquire_file_artifact` +- `classify_changed_path` (reuse or mirror for touched path sets) + +Prefer validate-all-serialize before first fs write to reduce partial failure. + +## Sub-agent reminders + +- Do not commit. +- `sync_fs` behavior unchanged; overlay reads unchanged (already effective_read). + +## Validate + +```bash +cargo check -p lpc-node-registry +cargo test -p lpc-node-registry +``` + +Integration tests may not exist yet; existing tests must stay green. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/03-d2-harness.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/03-d2-harness.md new file mode 100644 index 000000000..47052c027 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/03-d2-harness.md @@ -0,0 +1,32 @@ +# Phase 03 — D2 Harness (Commit Updates Base) + +**Dispatch:** sub-agent: yes | parallel: - + +## Scope of phase + +Integration tests proving **D2**: commit updates committed cache and clears overlay. + +**In scope:** + +- `tests/commit_promotion.rs` (new) or extend `overlay_lifecycle.rs` — prefer dedicated file +- Tests: + 1. **`d2_commit_updates_committed_and_clears_overlay`** — `load_clock`, SetSlot + `controls.rate = 2.0`, commit, assert `get()` rate 2.0, `!overlay_active()` + 2. **`d2_commit_setbytes_updates_committed`** — SetBytes on `/clock.toml`, commit, + committed matches overlay + 3. **`d2_commit_writes_slot_draft_to_fs`** — after SetSlot + commit, read fs file, + contains `rate = 2` (or equivalent) + +**Out of scope:** D5, inline child SyncResult (phases 04–05). + +## Sub-agent reminders + +- Do not commit. +- Use `fixtures::load_clock`, existing `apply_change` helpers from sibling tests. + +## Validate + +```bash +cargo test -p lpc-node-registry --test commit_promotion +cargo test -p lpc-node-registry +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/04-d5-harness.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/04-d5-harness.md new file mode 100644 index 000000000..f69a24676 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/04-d5-harness.md @@ -0,0 +1,31 @@ +# Phase 04 — D5 Harness (Overlay vs Fs Precedence) + +**Dispatch:** sub-agent: yes | parallel: - + +## Scope of phase + +Integration tests proving **D5** precedence from `commit-contract.md`. + +**In scope:** + +- Add to `tests/commit_promotion.rs`: + 1. **`d5_overlay_wins_over_stale_fs`** — load clock, apply SetSlot (overlay rate + 2.0), write fs with rate 9.0 directly, assert `view()` still 2.0, `get()` still 1.0 + 2. **`d5_sync_fs_does_not_clobber_overlay_view`** — with overlay active, call + `sync_fs` on same path; assert view still overlay value + 3. **`d5_post_commit_fs_sync_updates_committed`** — commit overlay, then fs write + + `sync_fs`; assert committed updates to fs value + +**Out of scope:** compose-from-blank, engine. + +## Sub-agent reminders + +- Do not commit. +- Document in test names what each step proves (overlay > fs pre-commit). + +## Validate + +```bash +cargo test -p lpc-node-registry --test commit_promotion +cargo test -p lpc-node-registry +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/05-c2-post-commit-sync-result.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/05-c2-post-commit-sync-result.md new file mode 100644 index 000000000..97e631e7e --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/05-c2-post-commit-sync-result.md @@ -0,0 +1,36 @@ +# Phase 05 — C2 Post-Commit SyncResult + +**Dispatch:** sub-agent: yes | parallel: - + +## Scope of phase + +Verify inline child edits appear in `SyncResult.def_updates` after commit (M4 +deferral documented in `m4-node-slot-patches/summary.md`). + +**In scope:** + +- Add to `tests/commit_promotion.rs`: + - **`c2_inline_child_changed_after_commit`** — `load_playlist_with_inline_child`, + apply SetSlot on `entries[2].node.def.render_order = 7`, commit, assert: + - child id in `result.def_updates.changed` + - root **not** in changed (mirror `fs_change_semantics` S4 pattern) + - committed child render_order is 7 + +Optional: + +- Asset commit bumps `source_revisions` (C4c post-commit) if straightforward via + existing `sync_source_path`. + +**Out of scope:** full C4 matrix, A1 compose. + +## Sub-agent reminders + +- Do not commit. +- Reuse `inline_child_id` helper pattern from `slot_overlay.rs` / `fs_change_semantics.rs`. + +## Validate + +```bash +cargo test -p lpc-node-registry --test commit_promotion +cargo test -p lpc-node-registry --test fs_change_semantics +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/06-cleanup-validation.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/06-cleanup-validation.md new file mode 100644 index 000000000..72daa21b8 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/06-cleanup-validation.md @@ -0,0 +1,39 @@ +# Phase 06 — Cleanup + Validation + +**Dispatch:** sub-agent: main | parallel: - + +## Scope of phase + +Final polish for M5: docs, exports, fmt, full validation, milestone summary. + +**In scope:** + +- `cargo +nightly fmt` on touched Rust files +- Update `change/mod.rs` / registry module docs referencing commit +- `summary.md` in this plan folder (status, deliverables, validation commands) +- Fix clippy warnings in touched code +- Confirm no `lpc-engine` changes + +**Out of scope:** M6 diff work, M3/M4 backfill beyond existing `summary.md`. + +## Milestone sign-off + +- [ ] D2 tests green +- [ ] D5 tests green +- [ ] C2 post-commit test green +- [ ] All existing `lpc-node-registry` tests green +- [ ] `commit-contract.md` matches implementation + +## Validate + +```bash +cargo test -p lpc-node-registry +cargo test -p lpc-node-registry --test commit_promotion +cargo test -p lpc-node-registry --test overlay_lifecycle +cargo test -p lpc-node-registry --test slot_overlay +cargo test -p lpc-node-registry --test asset_overlay +cargo test -p lpc-node-registry --test fs_change_semantics +cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings +``` + +Optional before push: `just check` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/commit-contract.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/commit-contract.md new file mode 100644 index 000000000..f121b3806 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/commit-contract.md @@ -0,0 +1,78 @@ +# Commit Contract (v1) + +Behavioral contract for `NodeDefRegistry::commit` in M5. Normative for harness +tests; engine cutover is a separate milestone. + +## Preconditions + +- Registry may have called `load_root` (M5 tests require this). +- Overlay may be empty (no-op commit). +- `fs` must be writable (`LpFs::write_file` / `delete_file`). + +## Success path + +1. **Resolve** every overlay path to a commit action (bytes write or delete). +2. **Write fs** for all paths before mutating `entries`. +3. **Register** new file paths in `ArtifactStore` (`acquire_file_artifact`). +4. **Bump** store revisions (`apply_fs_changes` or per-path equivalent). +5. **Re-derive** affected defs via `sync_def_artifact` / `sync_source_path`. +6. **Return** `SyncResult` with factual `def_updates`, `source_revisions`, `change_details`. +7. **Clear** overlay. + +After success: + +- `overlay_active()` is false. +- `registry.get(id)` reflects committed state for affected defs. +- `view().get(id)` equals `registry.get(id)` when overlay is empty. +- Fs files match committed bytes for overlay paths written. + +## Failure path + +On any error after overlay was non-empty: + +- **`entries` / `get()` unchanged** from pre-commit snapshot. +- **Overlay retained** (same pending edits). +- Return `Err(CommitError)`. + +Fs may be partially updated on failure; M5 harness should use fixtures where +validation fails before destructive steps, or restore fs in test. Implementation +should serialize/validate before writing when possible. + +## Overlay entry mapping + +| Entry | Fs | Notes | +|-------|-----|-------| +| `Bytes(b)` | write `b` | assets and TOML escape hatch | +| `SlotDraft(d)` | write `serialize_slot_draft(&d.def, ctx)?` | normal `.toml` authoring | +| `Deleted` | delete path | store marks deleted | + +No merge between entry kinds; last apply wins in overlay. + +## Precedence (D5) + +| Operation | Overlay active on path P | After commit | +|-----------|-------------------------|--------------| +| `view().get` / effective bytes | overlay wins | committed (= fs) | +| `registry.get` | committed only (unchanged pre-commit) | committed updated | +| `sync_fs` on P | bumps store; **does not** clobber overlay reads | normal fs-sync | +| `commit` | promotes overlay → fs + entries | overlay cleared | + +## Scope limits (M5) + +- Committing new `.toml` files writes fs + store but **does not** add defs to the + graph unless reachable from existing root via `derive_inventory`. +- Compose-from-blank (A1) is **M6**, not M5. +- Source revision bumps after asset commit follow existing `sync_source_path` rules. + +## API surface + +```rust +pub fn commit( + &mut self, + fs: &dyn LpFs, + frame: Revision, + ctx: &ParseCtx<'_>, +) -> Result; +``` + +`sync()` / `sync_fs()` remain filesystem reload only. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/summary.md new file mode 100644 index 000000000..88c8e7407 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/summary.md @@ -0,0 +1,60 @@ +# M5 Summary — Commit Promotion + +## Status + +Implemented on branch `codex/incremental-artifact-reload`. + +## Delivered + +### lpc-node-registry + +- `change/commit_error.rs` — `CommitError` (Fs / Serialize / Registry) +- `change/overlay.rs` — `iter_entries()` for commit flush +- `registry/commit.rs` — `commit_overlay`: flush overlay → fs → store bump → re-derive → clear overlay +- `NodeDefRegistry::commit()` — public entry point returning `SyncResult` +- `restore_entry_states()` — rollback `entries` on failed commit; overlay retained + +### Flow + +``` +apply_changeset → ChangeOverlay +view().get() → effective (overlay ∪ base) +commit(fs) → write fs → apply_fs_changes → sync_def_artifact/sync_source_path → SyncResult → clear overlay +sync_fs() → fs-reload only (unchanged) +``` + +## Tests + +`lp-core/lpc-node-registry/tests/commit_promotion.rs`: + +- D2 — commit updates `get()`, clears overlay, fs has serialized TOML +- D2 — SetBytes commit path +- D5 — overlay wins over stale fs until commit +- D5 — `sync_fs` does not clobber overlay view +- D5 — post-commit `sync_fs` updates committed state +- C2 — inline child in `SyncResult.def_updates.changed` after commit +- empty overlay commit is no-op + +Unit: `OverlayCommitPlan` slot-draft serialization in `registry/commit.rs`. + +## Validation + +```bash +cargo test -p lpc-node-registry +cargo test -p lpc-node-registry --test commit_promotion +cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings +``` + +68 integration tests pass (60 pre-M5 + 8 commit). + +## Known limits (M6+) + +- Compose-from-blank (A1) not yet proven — requires M6 diff gate +- New overlay `.toml` paths fork `NodeDef::default()` (Project) until kind SetSlot +- `MapInsert` / `MapRemove` / `OptionSet` not integration-tested +- `RegistryChange` still `Fs` only — no `ChangeSet` variant +- Failed commit may leave fs partially written (documented in `commit-contract.md`) + +## Next + +M6 — compose project from changes alone (A1 blank→basic proof). diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate.md b/docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate.md new file mode 100644 index 000000000..2056968c7 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate.md @@ -0,0 +1,75 @@ +# Milestone 6: Diff + Equivalence Gate + +## Title And Goal + +Add **`diff(base, target) → ChangeSet`** and prove **apply + commit ≡ target**. +This milestone **gates parent artifact-routed M6** (engine cutover). + +Primary regression harness — replaces hand-curated op lists for compose/morph. + +## Parallel Build + +**`lpc-node-registry` only.** **`lpc-engine` unchanged** until parent **M6** +starts after this gate is green. + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate/` + +## Scope + +In scope: + +- `ProjectSnapshot` — fs walk or in-memory `BTreeMap` +- `diff(base, target) -> ChangeSet`: + - new/removed paths → `ArtifactChange` with file ops + - assets → `SetBytes` / `Delete` + - `.toml` → slot-tree diff → minimal slot ops (not `SetBytes` for normal path) +- `assert_equivalent(reg, snapshot)` — path set + asset bytes + parsed def/slot + equality (TOML need not be byte-identical) +- **Gate tests:** + - `diff(∅, examples/basic)` → apply on empty registry → commit → ≡ basic (**A1**) + - `diff(basic, basic2)` → apply → commit → ≡ basic2 (**B1**) +- Empty base snapshot = **truly no files** (creatability) + +Out of scope: + +- Full A2–A4 / B4 N×N matrix ([`notes.md`](notes.md)) +- **C3** inline ↔ standalone ([`future.md`](future.md)) +- Engine runtime assertions (parent **M6**) +- Post-M6 replay stress harness (parent [`future.md`](../2026-05-21-artifact-routed-file-reload/future.md)) + +## Key Decisions + +- **Diff output = change language** — same `ArtifactChange` vocabulary as UI edits. +- **Equivalence ≠ byte-identical TOML** — parsed def + slot snapshot equality. +- **Hand-written A1/B1 op lists** — optional once diff gate is green. + +## User Stories / Gate + +| ID | Story | Covered | +|----|-------|---------| +| A1 | Blank → `basic` | **Via diff(∅, basic)** | +| B1 | `basic` → `basic2` | **Via diff(basic, basic2)** | +| D1–D3, D5 | Lifecycle | **M1, M5** (prerequisite) | +| C1/C4 core | Slot + file ops | **M3–M4** (prerequisite) | + +**Sign-off:** this milestone green → unblocks [parent M6](../2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md). + +## Deliverables + +- `lpc-node-registry/src/diff/` +- `tests/changeset/project_diff.rs` (or equivalent) +- Gate sign-off note in plan `summary.md` + +## Dependencies + +- M1–M5 complete + +## Execution Strategy + +Full plan. Diff depends on full apply + commit path. + +Suggested chat opener: + +> M6 plan: ProjectSnapshot + diff + empty→basic equivalence gate. Full plan then implement. Agree? diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate/summary.md new file mode 100644 index 000000000..2b30950c4 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate/summary.md @@ -0,0 +1,47 @@ +# M6 Summary — Diff + Equivalence Gate + +## Status + +Implemented on branch `codex/incremental-artifact-reload`. **Gate satisfied** — +parent M6 engine cutover may proceed. + +## Delivered + +### lpc-node-registry + +- `diff` Cargo feature (default on host/CI; omit for embedded: `default-features = false`) +- `diff/` module — `ProjectSnapshot`, `diff()`, `assert_equivalent()`, `DiffError` +- `diff/def_diff.rs` — slot-tree diff between parsed `NodeDef`s (kind preflight, map/option/enum/value ops) +- `diff/project_diff.rs` — path union diff: `.toml` → slot ops, assets → `SetBytes`/`Delete` +- `registry/slot_apply.rs` — `apply_ops_to_node_def`, project `nodes[*].def` SetSlot routing, record-map MapInsert fix, nested enum SetSlot + +### Gate tests + +`tests/project_diff.rs`: + +- **A1** — `diff(∅, basic)` → apply → commit → equivalent +- **A1** — load_root roundtrip after blank compose +- **B1** — `diff(basic, basic2)` → apply → commit → equivalent +- identical snapshots → empty changeset + +## Validation + +```bash +cargo test -p lpc-node-registry --test project_diff +cargo test -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings +# Embedded-shaped build (no diff harness): +cargo check -p lpc-node-registry --no-default-features +``` + +## Known limits + +- Equivalence uses parsed `NodeDef` equality (not byte-identical TOML) +- Slot diff verify compares serialized TOML (ignores revision metadata) +- Project node defs: path-backed invocations only in custom diff helper +- Inline child defs in project nodes fall back to nested slot diff +- Generic custom-slot diff incomplete beyond `NodeInvocation` + +## Unblocks + +Parent artifact-routed **M6 engine cutover** (ChangeSet apply in `lpc-engine`). diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation.md b/docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation.md new file mode 100644 index 000000000..63ae04865 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation.md @@ -0,0 +1,54 @@ +# Milestone 7: Cleanup + Validation + +## Title And Goal + +Cross-milestone validation, doc updates, and removal of temporary scaffolding. +Confirm parent roadmap cross-links and CI gate for this roadmap. + +## Parallel Build + +Validation only — no new features. Parent **M6** engine cutover is a **separate** +milestone after M6 diff gate here. + +## Suggested Plan Location + +`docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation/` + +## Scope + +In scope: + +- `cargo test -p lpc-node-registry` — all unit, fs-change, changeset, diff tests green +- `cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings` +- Roadmap `summary.md` for this promotion +- Update parent artifact-routed overview / M6 stub with gate status +- Remove dead stubs (`change/mod.rs` placeholder comments, view passthrough notes) +- Public API docs on `ChangeSet`, overlay, commit, `NodeDefView` + +Out of scope: + +- Parent M6 engine cutover +- Wire ChangeSet protocol ([`future.md`](future.md)) +- Parent M10 ExplainSlot probes + +## Key Decisions + +- CI gate matches AGENTS.md `just check` for touched crates. + +## Deliverables + +- `docs/roadmaps/2026-05-21-changeset-change-management/summary.md` +- Clean exports in `lpc-node-registry/lib.rs` +- Validation commands recorded in summary + +## Dependencies + +- M6 diff + equivalence gate green + +## Execution Strategy + +Small plan. Checklist-driven verification and doc pass. + +Suggested chat opener: + +> M7 cleanup: validation sweep + summary + parent cross-link update. Agree? diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation/summary.md new file mode 100644 index 000000000..2c81e43d5 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation/summary.md @@ -0,0 +1,25 @@ +# M7 Summary — Cleanup + Validation + +## Status + +Complete on branch `codex/incremental-artifact-reload`. + +## Delivered + +- Removed milestone/process comments from `lpc-node-registry` module and type docs +- Expanded crate and public API docs (`ChangeSet`, `ChangeOverlay`, `NodeDefView`, + `NodeDefRegistry` lifecycle) +- Integration test module docs describe behavior, not milestone IDs +- Roadmap [`summary.md`](../summary.md) for the full ChangeSet promotion + +## Validation + +```bash +cargo test -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings +cargo check -p lpc-node-registry --no-default-features +``` + +## Unblocks + +Parent artifact-routed **M6 engine cutover** (no remaining ChangeSet roadmap work). diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync.md b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync.md new file mode 100644 index 000000000..d6c20b88f --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync.md @@ -0,0 +1,50 @@ +# Milestone 8: Edit Vocabulary, Session Log, and Unified Sync + +## Title And Goal + +Rename the edit vocabulary (`Edit*` / `SlotOverlay`), add a **versioned session +log** for pending client edits, and unify registry ingress through **`sync(&[SyncOp])`** +returning **`SyncOutcome`**. Align filesystem notifications with **`FsEvent`**. + +**Gates parent artifact-routed M6 engine cutover** — engine and server need a +single sync boundary and client-visible pending state before cutover is meaningful. + +## Parallel Build + +`lpc-node-registry` + `lpfs` + server/cli callers only. **No `lpc-engine` edits** +in this milestone. + +## Suggested Plan Location + +[`m8-edit-session-sync/`](m8-edit-session-sync/) + +## Scope + +In scope: + +- Layer 1 renames: `change/` → `edit/`, `EditOp`, `ArtifactEdit`, `EditBatch`, … +- Layer 2: `SlotOverlay`, `SessionLog`, `SessionVersion`, `SessionDelta` +- Layer 3: `SyncOp`, extended `sync()`, `SyncOutcome` +- Layer 0: `FsChange` → `FsEvent` in `lpfs` and direct callers +- Harness tests updated; session + sync integration tests +- Roadmap docs + `decisions.md` vocabulary section + +Out of scope: + +- `lpc-engine` cutover (parent M6) +- Full `lpc-wire` protocol messages +- CRDT / multi-writer merge +- Effective-state `SyncResult` on apply-only (defer; document in `future.md`) + +## Dependencies + +- ChangeSet M1–M7 complete (overlay, commit, diff gate) + +## Execution Strategy + +Phased: vocabulary rename → overlay rename → FsEvent → session log → SyncOp. +Single commit at plan end unless a rename checkpoint is needed mid-plan. + +Suggested chat opener: + +> M8 plan: Edit* rename + SessionLog + SyncOp unified sync. Review phases? diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-design.md b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-design.md new file mode 100644 index 000000000..048a77bc4 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-design.md @@ -0,0 +1,172 @@ +# M8 Design — Edit Session + Unified Sync + +## Scope + +Rename edit vocabulary, introduce versioned **SessionLog**, materialize **SlotOverlay** +from the log, route all registry ingress through **`sync(&[SyncOp])`**, return +**`SyncOutcome`**, and rename **`FsChange` → `FsEvent`**. + +**Out of scope:** `lpc-engine`, wire protocol, CRDT, effective `SyncResult` on +apply-only. + +## File structure + +``` +lp-base/lpfs/src/ +├── fs_event.rs # UPDATE: FsEvent, FsEventKind (was FsChange, ChangeType) +├── lp_fs.rs # UPDATE: get_events_since naming (alias ok) +└── impls/ # UPDATE: lp_fs_mem, lp_fs_std, lp_fs_view, … + +lp-app/lpa-server/src/ +└── server.rs # UPDATE: FsEvent + +lp-cli/src/commands/dev/ +├── watcher.rs # UPDATE: FsEvent +├── sync.rs # UPDATE +└── fs_loop.rs # UPDATE + +lp-core/lpc-node-registry/src/ +├── lib.rs # UPDATE: re-exports +├── edit/ # RENAME from change/ +│ ├── mod.rs +│ ├── edit_op.rs # RENAME artifact_op.rs +│ ├── artifact_edit.rs # RENAME artifact_change.rs +│ ├── edit_batch.rs # RENAME change_set.rs +│ ├── edit_target.rs # RENAME artifact_target.rs +│ ├── edit_error.rs # RENAME change_error.rs +│ ├── apply.rs # UPDATE: Edit* types +│ ├── slot_overlay.rs # RENAME overlay.rs (SlotOverlay) +│ ├── slot_overlay_entry.rs # SPLIT if needed +│ ├── def_draft.rs # RENAME slot_draft.rs +│ └── commit_error.rs # keep (or sync_error.rs later) +├── registry/ +│ ├── sync_op.rs # NEW: SyncOp (was registry_change.rs) +│ ├── sync_outcome.rs # NEW: SyncOutcome +│ ├── session/ +│ │ mod.rs +│ │ session_version.rs # NEW +│ │ session_event.rs # NEW: Append, Remove, Commit marker, … +│ │ session_log.rs # NEW: append + since(version) +│ │ session_delta.rs # NEW +│ │ session_entry_id.rs # NEW +│ ├── node_def_registry.rs # UPDATE: sync applies SyncOp batch +│ ├── commit.rs # UPDATE: invoked from SyncOp::Commit +│ ├── slot_apply.rs # UPDATE: Edit* types +│ └── effective_read.rs # UPDATE: SlotOverlay +├── diff/ # UPDATE: returns EditBatch +└── tests/ # UPDATE all integration tests + +docs/roadmaps/2026-05-21-changeset-change-management/ +├── edit-language.md # RENAME from change-language.md +├── decisions.md # UPDATE: vocabulary + session decisions +├── summary.md # UPDATE: M8 gate for parent M6 +└── m8-edit-session-sync/ # this plan +``` + +## Architecture + +```text +LAYER 0 — Committed disk notifications + FsVersion → get_events_since → FsEvent + +LAYER 1 — Edit vocabulary (serde / diff / wire) + EditBatch { EditBatchId, edits: Vec } + +LAYER 2 — Session + materialized pending + SessionLog (append-only, SessionVersion) + │ fold + ▼ + SlotOverlay (path → SlotOverlayEntry: Bytes | DefDraft | Deleted) + +LAYER 3 — Unified ingress + sync(fs, &[SyncOp], frame, ctx) → SyncOutcome + + SyncOp: + Fs(FsEvent) + Append { base: SessionVersion, batch: EditBatch } + Remove { base, entry_ids } + Commit { base } + Discard { base, scope } + +LAYER 4 — Outcomes + SyncOutcome { + session: SessionDelta, // for clients since last SessionVersion + committed: SyncResult, // for engine (fs + commit legs) + session_version: SessionVersion, + } + +READS + registry.get() → committed entries + NodeDefView.get() → SlotOverlay ∪ committed (effective) +``` + +## Main components + +### SessionLog + +- Monotonic `SessionVersion` (starts 0; increments on each meta-op). +- Append stores `(SessionEntryId, SessionEvent::Append(EditBatch))`. +- `session_since(v) -> SessionDelta` for client pull. +- `Append` / `Remove` / `Discard` require `base == current_version` (optimistic lock). +- **`Commit`**: run existing commit promotion, **clear log**, bump version (fresh draft). + +### SlotOverlay + +- Derived from SessionLog (rebuild or incremental — implementation choice in phase 4). +- Same semantics as today's `ChangeOverlay`; rename only in phase 2 unless log rebuild forces refactor. + +### sync() + +Process `SyncOp` batch in order: + +1. **Fs** — existing `sync` fs path → merge into `SyncResult.committed` +2. **Append** — validate base, append log, update SlotOverlay +3. **Remove** — tombstone log entries, rebuild overlay +4. **Discard** — clear log entries (scoped), rebuild overlay +5. **Commit** — flush overlay → fs → re-derive → `SyncResult.committed`, clear session + +Return combined `SyncOutcome`. + +### Thin wrappers (compat) + +```rust +pub fn apply_edit_batch(...) -> Result { + sync(fs, &[SyncOp::Append { base: session_version(), batch }], ...) +} + +pub fn commit(...) -> Result { + sync(fs, &[SyncOp::Commit { base: session_version() }], ...) +} +``` + +### FsEvent rename + +- `FsChange` → `FsEvent`; `ChangeType` → `FsEventKind`. +- `get_changes_since` may alias to `get_events_since` or rename with deprecated alias. + +## Validation + +```bash +cargo test -p lpc-node-registry +cargo test -p lpfs +cargo test -p lpa-server --no-run +cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings +cargo check -p lpc-node-registry --no-default-features +just check # before plan commit +``` + +## Test scenarios (new / updated) + +| Test | Behavior | +|------|----------| +| Session append | two Append ops; `session_since` returns both; SlotOverlay reflects last | +| Stale base | Append with wrong `SessionVersion` → error | +| Sync batch | `sync([Append, Commit])` → committed SyncResult + empty session | +| Fs + commit | `sync([Fs(modify glsl), Commit])` in one batch | +| Diff roundtrip | `diff` → EditBatch → sync Append + Commit → equivalent | + +## Non-goals + +- Engine interpreting `SyncOutcome` (parent M6) +- Wire message types in `lpc-wire` +- Per-`EditOp` log entries (v1 = per `EditBatch` append) diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-notes.md new file mode 100644 index 000000000..c237827d1 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-notes.md @@ -0,0 +1,91 @@ +# M8 Plan Notes — Edit Session + Unified Sync + +## Scope of work + +Replace the overloaded **Change\*** vocabulary with four clear layers, and +implement the **session log** that was deferred from M5: + +1. **Edit vocabulary** — wire/serde authoring types (`EditOp`, `ArtifactEdit`, `EditBatch`) +2. **Session + SlotOverlay** — versioned pending log; materialized overlay derived from log +3. **Sync ingress** — `sync(&[SyncOp])` replaces split `apply_changeset` / `commit` / fs-only `RegistryChange` +4. **Sync outcomes** — `SyncOutcome { session, committed, session_version }` +5. **FsEvent** — rename `FsChange` → `FsEvent` in `lpfs` and callers + +This milestone completes the registry contract the engine and server need before +parent **M6 engine cutover**. + +## Current state + +### Implemented (M1–M7) + +- `lpc-node-registry/src/change/` — `ArtifactOp`, `ArtifactChange`, `ChangeSet`, `ChangeOverlay` +- `NodeDefRegistry::apply_changeset` → overlay; `commit` → fs + `SyncResult`; `sync` → `RegistryChange::Fs` only +- No pending version cursor; no `since(version)` for client edits +- `lpfs::FsChange` + `FsVersion` + `get_changes_since` used by `lpa-server`, `lp-cli`, registry tests +- `diff` feature generates `ChangeSet` (host harness) + +### Design gap (from M4/M5) + +M4 design intended `RegistryChange` to grow ChangeSet variants on **`sync`**. M5 +implemented dedicated `apply_changeset` / `commit` instead (harness shortcut). +Engine policy (`engine-policy-v1.md`) assumes `sync` → `SyncResult` for all +change sources. + +### Rename blast radius (approximate) + +| Symbol | Files touching (repo-wide grep) | +|--------|----------------------------------| +| ChangeSet / ArtifactChange / ChangeOverlay | ~40+ under `lpc-node-registry`, docs | +| FsChange | `lpfs`, `lpa-server`, `lp-cli`, `fw-esp32`, registry tests | + +## Agreed vocabulary (from design discussion) + +| Layer | Old | New | +|-------|-----|-----| +| 0 | `FsChange` | `FsEvent` | +| 0 | `ChangeType` | `FsEventKind` (optional) | +| 1 | `ArtifactOp` | `EditOp` | +| 1 | `ArtifactChange` | `ArtifactEdit` | +| 1 | `ChangeSet` | `EditBatch` | +| 1 | `ChangeSetId` | `EditBatchId` | +| 1 | `ArtifactTarget` | `EditTarget` | +| 1 | `ChangeError` | `EditError` | +| 1 | `change/` module | `edit/` | +| 2 | `ChangeOverlay` | `SlotOverlay` | +| 2 | `OverlayEntry` | `SlotOverlayEntry` | +| 2 | `SlotDraft` | `DefDraft` | +| 2 | (new) | `SessionVersion`, `SessionEvent`, `SessionLog`, `SessionDelta` | +| 3 | `RegistryChange` | `SyncOp` | +| 4 | (new) | `SyncOutcome` | +| 4 | `SyncResult` | keep (committed effects) | + +## Open questions + +| # | Question | Context | Suggested answer | +|---|----------|---------|------------------| +| Q1 | Gate parent M6 on M8 (not just M6 diff)? | Parent cutover needs SyncOp contract | **Yes** — update parent M6 deps | +| Q2 | `SlotDraft` → `DefDraft`? | Pairs with SlotOverlay | **Yes** | +| Q3 | `FsEvent` rename in same milestone? | Touches lpfs, server, cli | **Yes**, dedicated phase after registry renames | +| Q4 | Temporary type aliases (`type ChangeSet = EditBatch`)? | Eases doc/test migration | **Yes**, one release; grep CI for deprecated uses | +| Q5 | Session log granularity | Append whole `EditBatch` vs per `ArtifactEdit` | **Append EditBatch** for v1; log entry gets stable `SessionEntryId` | +| Q6 | After `SyncOp::Commit`, reset session log? | Client `since(version)` semantics | **Clear log, bump SessionVersion** (fresh draft baseline) | +| Q7 | Keep `apply_edit_batch` / `commit` as wrappers? | Ergonomics for tests | **Yes**, thin delegates to `sync([...])` | +| Q8 | `SyncOp::Append` requires matching `SessionVersion`? | Optimistic concurrency | **Yes** — return `EditError::StaleSession { expected, actual }` | +| Q9 | Apply-only returns committed `SyncResult`? | Live preview before commit | **No in M8** — `SyncOutcome.committed` empty on Append-only; engine uses `NodeDefView` for preview until parent M6 policy | +| Q10 | Rename roadmap title "ChangeSet" → "Edit session"? | Docs only | **Defer** — update `change-language.md` → `edit-language.md`, keep roadmap folder name | + +## Notes + +- User prefers **Edit\*** over Author\* for layer 1. +- **SlotOverlay** holds Bytes + DefDraft + Deleted (assets included); name accepted with that caveat. +- M7 doc pass (uncommitted) cleaned milestone comments; M8 will update vocabulary in docs. +- Single commit at plan end per `/plan` process unless rename checkpoint needed. + +## Answers + +*(Fill as user confirms Q1–Q10.)* + +### Nomenclature phase (started) + +- **Done:** Layer 1 + Layer 2 renames in `lpc-node-registry` (see [`vocabulary.md`](vocabulary.md)). +- **Pending:** Session log, `SyncOp`, `FsEvent`. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/future.md b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/future.md new file mode 100644 index 000000000..54b75a2ed --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/future.md @@ -0,0 +1,25 @@ +# M8 Future Work + +## Effective SyncResult on apply-only + +- **Idea:** `SyncOp::Append` also diffs effective parse state and returns partial `SyncResult` for live engine preview before commit. +- **Why not now:** Engine cutover can use `NodeDefView` for preview; doubles diff logic. +- **Useful context:** `engine-policy-v1.md`; parent M6 policy decision. + +## Per-ArtifactEdit log entries + +- **Idea:** Finer session log than whole `EditBatch` append — enables single-op undo. +- **Why not now:** v1 batches match client wire shape; coarser log is enough for `since(version)`. +- **Revisit when:** Undo/redo UI needs op-level granularity. + +## CRDT / multi-writer session merge + +- **Idea:** Concurrent Append from multiple clients with merge. +- **Why not now:** v1 single-writer server + optimistic `SessionVersion` base check. +- **Useful context:** `future.md` on ChangeSet roadmap. + +## Session history across commits + +- **Idea:** Keep committed session log for audit/undo stack instead of clear-on-commit. +- **Why not now:** Simpler client semantics — commit resets draft baseline. +- **Revisit when:** Persistent undo or collaborative editing. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/summary.md new file mode 100644 index 000000000..9a0abceb7 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/summary.md @@ -0,0 +1,33 @@ +# M8 Summary — Unified Sync + Pending Edit Map + +**Status:** complete + +## Delivered + +- **Pending map** — `SlotOverlay` holds current uncommitted edits keyed by path (no history) +- **Unified sync** — `SyncOp` batch: `Fs`, `Apply`, `Remove`, `ClearPending`, `Commit` +- **CRUD** — `apply_artifact_edit`, `remove_pending_edit`, `discard_slot_overlay`, `apply_edit_batch` +- **Outcomes** — `SyncOutcome { committed, pending_changed }` +- **FsEvent rename** — `FsEvent` / `FsEventKind` in `lpfs` (deprecated aliases kept) +- **Legacy** — `RegistryChange` = `SyncOp` (deprecated) + +## Explicitly not on device + +- Session log, version cursor, incremental pull, undo history — client-side if ever needed + +## Tests + +- `tests/pending_sync.rs` — apply, remove, apply+commit, fs+commit batch +- Existing overlay/commit/fs tests unchanged in behavior + +## Validation + +```bash +cargo test -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings +cargo check -p lpc-node-registry --no-default-features +``` + +## Gate + +Parent **M6 engine cutover** may wire `sync(&[SyncOp])` when ready. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/vocabulary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/vocabulary.md new file mode 100644 index 000000000..feb2586f6 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/vocabulary.md @@ -0,0 +1,57 @@ +# Edit Session Vocabulary + +Canonical names for M8 and beyond. Layer 0 (`FsEvent`) and layers 3–4 (`SyncOp`, +`SyncOutcome`) land in later M8 phases. + +## Layer 1 — Edit vocabulary (`lpc-node-registry/src/edit/`) + +| Old | New | +|-----|-----| +| `change/` module | `edit/` | +| `ArtifactOp` | `EditOp` | +| `ArtifactChange` | `ArtifactEdit` | +| `ChangeSet` | `EditBatch` | +| `ChangeSetId` | `EditBatchId` | +| `ArtifactTarget` | `EditTarget` | +| `ChangeError` | `EditError` | + +`EditBatch` field: `edits: Vec` (serde alias `changes` for wire compat). + +## M10 — Slot / asset split + +| Old | New | +|-----|-----| +| `EditOp` | removed — use `SlotEdit` or `AssetEdit` | +| flat `ArtifactEdit { target, ops }` | tagged `ArtifactEdit::Slot` / `::Asset` | +| `SetBytes` | `AssetEdit::ReplaceBody` | +| `Delete` (in mixed enum) | `AssetEdit::Delete` | + +## Layer 2 — Slot overlay (registry pending state) + +| Old | New | +|-----|-----| +| `ChangeOverlay` | `SlotOverlay` | +| `OverlayEntry` | `SlotOverlayEntry` | +| `SlotDraft` | `DefDraft` | +| `NodeDefRegistry.overlay` | `NodeDefRegistry.slot_overlay` | +| `apply_changeset` | `apply_edit_batch` | +| `apply_change` | `apply_artifact_edit` | +| `discard_overlay` | `discard_slot_overlay` | +| `overlay_active` | `slot_overlay_active` | +| `overlay_contains_path` | `slot_overlay_contains_path` | +| `overlay_bytes` | `slot_overlay_bytes` | + +## Legacy aliases + +Deprecated type aliases live in `edit/mod.rs` and `lib.rs` (`change` module re-export). + +## Layer 3–4 — Sync ingress + +| Symbol | Role | +|--------|------| +| `SyncOp` | `Fs`, `Apply`, `Remove`, `ClearPending`, `Commit` | +| `SyncOutcome` | `{ committed, pending_changed }` | +| `SlotOverlay` | Current pending map (path → draft/bytes/deleted) | +| `FsEvent` | Committed filesystem notification | + +History/undo lives on the **client**, not in the registry. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops.md new file mode 100644 index 000000000..c3f67d28e --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops.md @@ -0,0 +1,43 @@ +# M9 — NodeInvocation Ref|Def + Generic Slot Edit Ops + +Collapse `NodeInvocation` to a slotted **`Ref | Def`** enum, add **`VariantSet`** +edit op, and remove registry shortcuts that paper over the old custom codec. + +**Prerequisite:** M8 unified sync (done). + +**Plan:** [`m9-invocation-ref-def-generic-slot-ops/`](m9-invocation-ref-def-generic-slot-ops/) + +## Phases + +| # | Title | Focus | +|---|--------|--------| +| 01 | Invocation Ref\|Def model | `lpc-model` enum + TOML codec | +| 02 | Model tests + remove NodeDefRef | Tests, exports, callers in `lpc-model` | +| 03 | Engine + registry consumers | `def_walker`, `def_shell`, `effective_read`, registration | +| 04 | VariantSet + thin slot_apply | `lpc-node-registry` edit apply | +| 05 | Generic diff + test TOML | `def_diff`, harness/fixtures, integration tests | +| 06 | Cross-crate validation | `lpc-model`, `lpc-engine`, `lpc-node-registry`, `fw-tests` | +| 07 | Examples + docs | `examples/`, `change-language.md`, roadmap summaries | +| 08 | Cleanup + CI gate | `just check`, plan `summary.md` | + +Rust and in-repo tests through phase 06; examples and docs in phase 07. + +## TOML wire (breaking, no dual-read) + +```toml +[nodes.shader] +ref = "./shader.toml" + +[nodes.clock.def] +kind = "Clock" +``` + +Playlist: + +```toml +[entries.2.node] +ref = "./active.toml" + +[entries.2.node.def] +kind = "Shader" +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/00-design.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/00-design.md new file mode 100644 index 000000000..0c3efaeed --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/00-design.md @@ -0,0 +1,123 @@ +# M9 Design — Invocation Ref|Def + Generic Slot Edit Ops + +## Scope + +Make child-node references honest slot data and make the edit language fully generic. + +**In:** `lpc-model` invocation reshape, `VariantSet`, registry apply/diff cleanup, +in-repo tests/fixtures, `examples/`, `change-language.md`. + +**Out:** `lpc-wire`, `lpa-server` client protocol, parent engine M6 cutover. + +## TOML wire + +```toml +# Project — external def +[nodes.shader] +ref = "./shader.toml" + +# Project — inline def +[nodes.clock.def] +kind = "Clock" + +# Playlist — external +[entries.2.node] +ref = "./active.toml" + +# Playlist — inline +[entries.2.node.def] +kind = "Shader" +source = { path = "active.glsl" } +``` + +Reject: `def = { path = ... }`, `artifact = ...`. + +## Rust model + +```rust +pub enum NodeInvocation { + Ref(ArtifactSpecifier), + Def(NodeDef), +} +``` + +- **`Slotted` enum** — generic `set_slot_variant_default` / `set_slot_value` apply +- **Remove:** `NodeDefRef`, `def_slot`, `NODE_INVOCATION_CODEC_ID` whole-record custom path +- **Helpers:** `ref_specifier()`, `inline_def()` → match on `Ref` / `Def` + +## Edit ops (layer 1) + +```rust +VariantSet { path: SlotPath, variant: String } // enum switch incl. root kind, Ref/Def +SetSlot { path: SlotPath, value: LpValue } // value leaves only +// MapInsert, MapRemove, OptionSet unchanged +``` + +Apply = thin dispatch to `lpc-model` slot mutation only. + +## Slot path examples + +| Intent | Path / ops | +|--------|------------| +| Root kind | `VariantSet(root(), "Clock")` | +| Wire child to file | `VariantSet(nodes.shader, "Ref")` + `SetSlot(nodes.shader.ref, "./x.toml")` | +| Inline child kind | `VariantSet(nodes.clock, "Def")` + `VariantSet(nodes.clock.def, "Clock")` | +| Inline field | `SetSlot(nodes.clock.def.Clock.controls.rate, 2.0)` | +| Playlist inline patch | `SetSlot(entries[2].node.def.Shader...., ...)` | + +Exact paths follow slotted enum field names (`ref`, `def`, variant names). + +## File structure + +``` +lp-core/lpc-model/src/ +├── node/ +│ ├── node_invocation.rs # REWRITE: Ref|Def slotted enum + TOML +│ └── mod.rs # drop NodeDefRef export +├── nodes/project/project_def.rs # tests: ref= wire +├── nodes/playlist/playlist_entry.rs +├── slot_codec/custom_slot_codec.rs # remove invocation custom branch (if unused) +└── lib.rs + +lp-core/lpc-node-registry/src/ +├── edit/edit_op.rs # + VariantSet +├── registry/slot_apply.rs # thin apply; delete shortcuts +├── registry/def_shell.rs # match Ref|Def +├── registry/def_walker.rs # match Ref|Def +├── registry/effective_read.rs # match Ref|Def +├── registry/node_def_registry.rs # register_invocations match +├── diff/def_diff.rs # VariantSet; drop CustomDef +└── tests/ + harness/fixtures.rs # new TOML + +lp-core/lpc-engine/src/ +├── engine/project_loader.rs # if NodeDefRef references remain +└── ... # grep NodeDefRef / def_locator / inline_def + +examples/**/project.toml, playlist.toml, … # phase 07 + +docs/roadmaps/.../change-language.md # phase 07 +``` + +## Architecture + +```text +NodeDef (enum slot) — artifact root + └─ Project.nodes: Map + └─ NodeInvocation (enum slot) + ├─ Ref → locator string leaf + └─ Def → NodeDef enum (inline body) + +EditBatch → apply → set_slot_variant_default | set_slot_value + (no registry domain knowledge) +``` + +## Validation (full gate) + +```bash +cargo test -p lpc-model +cargo test -p lpc-node-registry +cargo test -p lpc-engine +cargo test -p fw-tests --test scene_render_emu --no-run +cargo clippy -p lpc-model -p lpc-node-registry -p lpc-engine --all-targets --no-deps -- -D warnings +just check # phase 08 +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/00-notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/00-notes.md new file mode 100644 index 000000000..fbd858396 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/00-notes.md @@ -0,0 +1,36 @@ +# M9 Plan Notes — Invocation Ref|Def + Generic Slot Edit Ops + +## Scope + +1. **`NodeInvocation`** becomes `Ref(ArtifactSpecifier) | Def(NodeDef)` slotted enum +2. **TOML** — `ref = "..."` vs `[....def] kind = ...` (breaking; no dual-read) +3. **Delete** `NodeDefRef`, `def_slot`, whole-invocation custom codec hack +4. **`VariantSet`** edit op; **`SetSlot`** = value leaves only +5. **Remove** registry shortcuts: `project_node_def_mutation`, `inline_body_mutation`, + `apply_node_invocation_def`, diff `CustomDef` / `invocation_def_value` +6. **Examples + docs** after Rust/tests green + +**Out of scope:** wire protocol, client undo/history, engine cutover to `SyncOp`. + +## Current state + +- `NodeInvocation { def: NodeDefRef, def_slot: ArtifactPathSlot }` — duplicate path state +- Slot shape lies: `def` field typed as `ArtifactPathSlot` even for inline defs +- `slot_apply.rs` string heuristics + invocation-specific routers +- `def_diff.rs` emits `SetSlot + String` for enum/kind; `CustomDef` for wiring +- Tests/examples use `def = { path = "..." }` and `[node.def]` inline form + +## Agreed decisions + +| # | Decision | +|---|----------| +| D1 | No backwards compat — update tests and examples | +| D2 | Variants **`Ref`** and **`Def`** (not Path/Inline/Artifact) | +| D3 | TOML: top-level `ref` key; inline body under `def` subtable | +| D4 | Edit op **`VariantSet { path, variant }`** (not SetKind) | +| D5 | **`SetSlot`** = scalar/string leaf values only | +| D6 | Phases 01–06 Rust/tests; phase 07 examples/docs | + +## Open questions + +*(None blocking — user confirmed naming and no compat.)* diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/01-invocation-ref-def-model.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/01-invocation-ref-def-model.md new file mode 100644 index 000000000..ec75fb63d --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/01-invocation-ref-def-model.md @@ -0,0 +1,93 @@ +# Phase 01 — NodeInvocation Ref|Def Model + +**Dispatch:** sub-agent: yes | parallel: - + +## Scope of phase + +Rewrite `NodeInvocation` as a **`Slotted` enum** with `Ref` and `Def` variants and +new TOML read/write. + +**In scope:** + +- `lpc-model/src/node/node_invocation.rs` — enum, helpers, TOML codec +- Remove `NodeDefRef` type from this file +- Remove `def_slot: ArtifactPathSlot` and record/custom shape hack +- `node/mod.rs` — stop exporting `NodeDefRef` +- `lib.rs` — drop `NodeDefRef` re-export + +**Out of scope:** registry, engine, examples, `VariantSet`, diff. + +## TOML contract + +```toml +ref = "./shader.toml" # Ref variant at current table + +[....def] # Def variant +kind = "Clock" +``` + +Read: if table has `ref` key → `Ref`; if has `def` subtable or inline def fields → `Def`. +Error if both. + +## Implementation details + +**Enum shape (target):** + +```rust +#[derive(Clone, Debug, PartialEq, Slotted)] +pub enum NodeInvocation { + Ref(ArtifactSpecifier), // or Ref { locator } if slotted derive needs named fields + Def(NodeDef), +} +``` + +Use `#[slotted(...)]` / derive patterns consistent with `NodeDef` enum in +`nodes/node_def.rs`. Variant wire names: **`Ref`**, **`Def`**. + +**TOML mapping:** + +| Variant | Authored form | +|---------|----------------| +| `Ref` | `ref = ""` (string at invocation table) | +| `Def` | `[parent.def]` table with `kind = ...` | + +Implement via slotted enum codec if possible; otherwise minimal custom +`FieldSlot` read/write on the enum only (not whole fake record). + +**Helpers (replace old API):** + +```rust +impl NodeInvocation { + pub fn path(locator: ArtifactSpecifier) -> Self; + pub fn inline(def: NodeDef) -> Self; + pub fn ref_specifier(&self) -> Option<&ArtifactSpecifier>; + pub fn inline_def(&self) -> Option<&NodeDef>; +} +``` + +Keep `path()` / `inline()` constructors for call-site ergonomics. + +**Delete from `custom_slot_codec.rs`:** `NODE_INVOCATION_CODEC_ID` branches if enum +no longer uses custom codec. + +**Default:** `Ref(ArtifactSpecifier::path(""))` or `Def(NodeDef::default())` — match +prior default behavior. + +## Tests (this phase) + +Add/update in `node_invocation.rs` `#[cfg(test)]`: + +- `ref = "./texture.toml"` loads `Ref` +- `[def] kind = "Clock"` loads `Def` +- reject `ref` + `[def]` together +- reject legacy `def = { path = ... }` +- round-trip write/read for both variants + +## Validate + +```bash +cargo test -p lpc-model node_invocation +cargo check -p lpc-model +``` + +Expect compile failures in other crates until phase 03 — OK. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/02-model-tests-remove-nodedefref.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/02-model-tests-remove-nodedefref.md new file mode 100644 index 000000000..e3e82a7ef --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/02-model-tests-remove-nodedefref.md @@ -0,0 +1,49 @@ +# Phase 02 — Model Tests + NodeDefRef Removal + +**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** 01 + +## Scope of phase + +Fix all **`lpc-model`** callers and tests for `Ref | Def` API. Remove remaining +`NodeDefRef` references inside `lpc-model`. + +**In scope:** + +- `nodes/project/project_def.rs` tests — `ref =` wire +- `nodes/playlist/playlist_entry.rs` tests — `node.ref` / `[node.def]` +- `nodes/node_def.rs` if it references `NodeDefRef` +- Grep `lpc-model` for `NodeDefRef`, `.def`, `def_locator`, `inline_def` on old struct + +**Out of scope:** `lpc-node-registry`, `lpc-engine`, examples. + +## TOML test migrations + +| Old | New | +|-----|-----| +| `def = { path = "./x.toml" }` | `ref = "./x.toml"` | +| `def = { kind = "Clock" }` | `[nodes.clock.def]` or nested def table | +| `[entries.2.node.def] kind = ...` | unchanged path for inline | + +## Implementation details + +Update match sites: + +```rust +// before +match &invocation.def { NodeDefRef::Path(l) => ..., NodeDefRef::Inline(d) => ... } +// after +match &invocation { NodeInvocation::Ref(l) => ..., NodeInvocation::Def(d) => ... } +``` + +Update `def_shell.rs` in model if any — likely none in lpc-model. + +Ensure `collect_invocations` consumers in model tests use new helpers. + +## Validate + +```bash +cargo test -p lpc-model +cargo clippy -p lpc-model --all-targets --no-deps -- -D warnings +``` + +Other workspace crates may still fail — OK until phase 03. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/03-engine-registry-consumers.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/03-engine-registry-consumers.md new file mode 100644 index 000000000..8f1803a3f --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/03-engine-registry-consumers.md @@ -0,0 +1,57 @@ +# Phase 03 — Engine + Registry Consumers + +**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** 02 + +## Scope of phase + +Migrate **`lpc-node-registry`** and **`lpc-engine`** off `NodeDefRef` / old +invocation struct field access. Update TOML in unit tests embedded in these crates. + +**In scope:** + +- `lpc-node-registry/src/registry/def_walker.rs` +- `lpc-node-registry/src/registry/def_shell.rs` +- `lpc-node-registry/src/registry/effective_read.rs` +- `lpc-node-registry/src/registry/node_def_registry.rs` (`register_invocations`, etc.) +- `lpc-engine/src/engine/project_loader.rs` +- Grep both crates: `NodeDefRef`, `NodeInvocation::path`, `.def`, `def_locator`, `inline_def` + +**Out of scope:** `VariantSet`, `slot_apply` shortcut removal, examples/, diff generic ops. + +## Implementation details + +**Registration / walking:** + +```rust +match &invocation { + NodeInvocation::Ref(locator) => { resolve path, acquire artifact ... } + NodeInvocation::Def(body) => { register inline at DefSource ... } +} +``` + +**def_shell:** kind stubs for inline children — match `NodeInvocation::Def`. + +**effective_read:** inline `def_state_at_source` — paths may shift from +`entries[n].node.def.*` to `entries[n].node.def.*` (still valid if Def variant +uses `def` sub-record) — verify against new slot paths. + +**Inline slot paths:** After slotted enum lands, confirm generic path for inline +edits is e.g. `entries[2].node.def.Clock.controls.rate` (not custom router). + +Update test TOML strings in: + +- `def_walker.rs` tests +- `def_shell.rs` tests +- `fs_change_semantics.rs`, `node_def_registry.rs` tests (project/playlist snippets) +- `lpc-engine` tests referencing `NodeDefRef` + +## Validate + +```bash +cargo test -p lpc-model +cargo test -p lpc-node-registry +cargo test -p lpc-engine +cargo check -p fw-esp32 --target riscv32imac-unknown-none-elf --features esp32c6,server +``` + +Registry apply/diff may still use old SetSlot heuristics — OK until phases 04–05. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/04-variant-set-thin-slot-apply.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/04-variant-set-thin-slot-apply.md new file mode 100644 index 000000000..d553be0a5 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/04-variant-set-thin-slot-apply.md @@ -0,0 +1,67 @@ +# Phase 04 — VariantSet + Thin slot_apply + +**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** 03 + +## Scope of phase + +Add **`VariantSet`** to edit vocabulary and reduce `slot_apply.rs` to generic slot +mutation only. + +**In scope:** + +- `edit/edit_op.rs` — add `VariantSet { path, variant }` +- `registry/slot_apply.rs` — dispatch table; delete shortcuts +- `edit/mod.rs` serde roundtrip test + +**Delete from `slot_apply.rs`:** + +- `apply_set_slot_on_def` string→variant heuristic +- `project_node_def_mutation` +- `apply_node_invocation_def` +- `inline_body_mutation` / `matching_inline_inner_path` / `invocation_at_mut` (if generic paths work) + +**Keep:** + +- `apply_op_to_def` match arms for Map/Option +- `mutate_def` wrapper +- `ensure_toml_path`, `parse_def_bytes`, `serialize_slot_draft` + +## EditOp + +```rust +VariantSet { path: SlotPath, variant: String }, +SetSlot { path: SlotPath, value: LpValue }, // doc: value leaves only +``` + +`op_name()` → `"variant_set"`. + +## Apply + +```rust +EditOp::VariantSet { path, variant } => mutate_def(def, |root| { + set_slot_variant_default(root, ctx.shapes, path, frame, variant) +}), +EditOp::SetSlot { path, value } => mutate_def(def, |root| { + set_slot_value(root, ctx.shapes, path, frame, value.clone()) +}), +``` + +If generic mutation fails on paths that worked via shortcuts, **fix slot shapes in +phase 01/03** — do not re-add registry routers. + +## Tests + +Update hand-written ops in tests still using `SetSlot + String` for kind/wiring: + +- `slot_overlay.rs`, `commit_promotion.rs`, `pending_sync.rs`, etc. + +Use `VariantSet` for kind changes; `VariantSet(…, "Ref")` + `SetSlot(…ref, …)` for wiring. + +## Validate + +```bash +cargo test -p lpc-node-registry --test slot_overlay --test overlay_lifecycle +cargo test -p lpc-node-registry +``` + +Diff may still emit old ops — phase 05. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/05-generic-diff-test-toml.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/05-generic-diff-test-toml.md new file mode 100644 index 000000000..88cdd8272 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/05-generic-diff-test-toml.md @@ -0,0 +1,53 @@ +# Phase 05 — Generic Diff + Test TOML + +**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** 04 + +## Scope of phase + +Make **`def_diff.rs`** emit generic ops and migrate **all `lpc-node-registry` test +/fixture TOML** to `ref`/`def` wire. + +**In scope:** + +- `diff/def_diff.rs` — remove `CustomDef`, `invocation_def_value`, kind-at-root special case +- Enum branch → `push_variant_set` not `push_set_slot + String` +- `tests/*.rs`, `harness/fixtures.rs`, `harness/snapshot*.rs` if any +- `tests/project_diff.rs` snapshot TOML in memory + +**Out of scope:** `examples/` tree (phase 07). + +## Diff changes + +| Before | After | +|--------|-------| +| `SlotKind::Enum` → `SetSlot(String)` | `VariantSet { path, variant }` | +| Root kind change special case | `VariantSet(root(), variant)` | +| `SlotKind::CustomDef` + path string | `VariantSet(..., "Ref")` + `SetSlot(...ref, path)` or recurse into Def | + +Add `push_variant_set` mirroring `push_set_slot` (apply-then-push for diff simulation). + +Remove `classify_slot` → `CustomDef` arm if `NodeInvocation` is normal enum in tree. + +## Fixture TOML grep + +Replace across `lpc-node-registry`: + +``` +def = { path = → ref = +node = { def = { path = → ref = (flatten) +``` + +Playlist inline `[entries.N.node.def]` stays for Def variant body. + +## Tests + +- `project_diff` equivalence still passes +- Add/adjust test: diff project wiring change emits `VariantSet` + `SetSlot`, not CustomDef +- `def_walker` / `def_shell` tests green with new wire + +## Validate + +```bash +cargo test -p lpc-node-registry +cargo test -p lpc-node-registry --features diff +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/06-cross-crate-validation.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/06-cross-crate-validation.md new file mode 100644 index 000000000..0ce493cbf --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/06-cross-crate-validation.md @@ -0,0 +1,41 @@ +# Phase 06 — Cross-Crate Validation + +**Dispatch:** sub-agent: supervised | parallel: - | **Depends on:** 05 + +## Scope of phase + +Fix remaining workspace references and run broader test gate **before** touching +`examples/`. + +**In scope:** + +- Grep repo for `NodeDefRef`, `def = { path`, `NodeInvocation { def` +- Fix: `lpc-wire/tests`, `lpa-server/template.rs`, any stray engine nodes +- `fw-tests` / `scene_render_emu` compile if example projects referenced — may still + fail until phase 07; note which failures are example-TOML-only + +**Out of scope:** `examples/` (phase 07). + +## Grep commands + +```bash +rg 'NodeDefRef|def = \{ path' lp-core lp-app lp-cli lp-fw +rg 'inline_def|def_locator' lp-core +``` + +## Expected fixes + +- `lpc-wire` source slot sync test paths if node path strings changed +- Engine runtime tests constructing `NodeInvocation::path(...)` — should still work + +## Validate + +```bash +cargo test -p lpc-model +cargo test -p lpc-node-registry +cargo test -p lpc-engine +cargo test -p fw-tests --test scene_render_emu 2>&1 | tail -30 # may fail on examples +cargo clippy -p lpc-model -p lpc-node-registry -p lpc-engine --all-targets --no-deps -- -D warnings +``` + +Document any remaining failures blocked on phase 07 in phase PR notes. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/07-examples-docs.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/07-examples-docs.md new file mode 100644 index 000000000..d7b8fb925 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/07-examples-docs.md @@ -0,0 +1,78 @@ +# Phase 07 — Examples + Docs + +**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** 06 + +## Scope of phase + +Update **example projects** and **documentation** to `ref`/`def` TOML wire and +`VariantSet` edit vocabulary. + +**In scope:** + +- `examples/**/project.toml`, `playlist.toml`, and nested defs with `def = { path` +- `docs/roadmaps/.../change-language.md` — `VariantSet`, Ref|Def TOML, remove SetSlot overload doc +- `docs/design/source-artifacts.md` if invocation syntax documented +- Roadmap `decisions.md` / M9 summary stub +- Re-run `fw-tests` that load examples + +**Out of scope:** `docs/plans-old/`, archived roadmaps (optional one-line note only). + +## TOML migration pattern + +```toml +# before +[nodes.shader] +def = { path = "./shader.toml" } + +# after +[nodes.shader] +ref = "./shader.toml" +``` + +Project inline one-liner: + +```toml +# before +[nodes.clock] +def = { kind = "Clock" } + +# after +[nodes.clock.def] +kind = "Clock" +``` + +Playlist: + +```toml +# before +node = { def = { path = "./active.toml" } } + +# after +node = { ref = "./active.toml" } +# or +[entries.2.node] +ref = "./active.toml" +``` + +## Example inventory (~15 project.toml + playlist.toml under `examples/`) + +Use `rg 'def = \{ path' examples/` for full list. + +## Docs + +Update `change-language.md` slot op table: + +| Op | Use | +|----|-----| +| `VariantSet { path, variant }` | Enum variant (node kind, Ref/Def, nested enums) | +| `SetSlot { path, value }` | Value leaves only | + +Remove "SetSlot includes kind, path locators, wiring". + +## Validate + +```bash +cargo test -p fw-tests --test scene_render_emu +cargo test -p fw-tests --test profile_alloc_emu +rg 'def = \{ path' examples/ # expect zero +``` diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/08-cleanup-ci-gate.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/08-cleanup-ci-gate.md new file mode 100644 index 000000000..c9f8eb8d5 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/08-cleanup-ci-gate.md @@ -0,0 +1,46 @@ +# Phase 08 — Cleanup + CI Gate + +**Dispatch:** main | parallel: - | **Depends on:** 07 + +## Scope of phase + +Final pass: formatting, clippy, full CI gate, plan `summary.md`. + +**In scope:** + +- `cargo +nightly fmt` +- `just check` +- `just test` or minimum `cargo test -p lpc-node-registry` + `fw-tests` emu tests +- Write `m9-invocation-ref-def-generic-slot-ops/summary.md` +- Update `docs/roadmaps/.../changeset-change-management/summary.md` — M9 entry + +**Out of scope:** new features, engine M6 cutover. + +## Cleanup checklist + +- [ ] No `NodeDefRef` in `lp-core/` (except deprecated alias if explicitly kept — prefer none) +- [ ] No `def_slot` field +- [ ] No `project_node_def_mutation` / `CustomDef` in registry +- [ ] No `def = { path` in `examples/` or active tests +- [ ] `VariantSet` in `edit_op.rs` + serde test +- [ ] Warnings fixed + +## Validate + +```bash +rustup update nightly +just check +just test +cargo test -p fw-tests --test scene_render_emu --test profile_alloc_emu +``` + +Commit (when user asks) with message along: + +`feat(lpc-model): NodeInvocation Ref|Def enum + VariantSet edit ops` + +## summary.md template + +- Status: complete +- Delivered: enum model, TOML wire, VariantSet, generic apply/diff +- Breaking: `def = { path }` → `ref =` +- Validation commands diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/future.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/future.md new file mode 100644 index 000000000..819098a0a --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/future.md @@ -0,0 +1,11 @@ +## Wire protocol VariantSet + +- **Idea:** Expose `VariantSet` on client wire alongside `SetSlot`. +- **Why not now:** Registry/model only; `lpc-wire` unchanged in M9. +- **Useful context:** Same serde shape as `EditOp` in `edit_op.rs`. + +## Slotted derive for NodeInvocation enum + +- **Idea:** Full `#[derive(Slotted)]` on `Ref|Def` if manual codec proves brittle. +- **Why not now:** Phase 01 tries slotted enum first; revisit only if TOML edge cases block. +- **Useful context:** Compare `NodeDef` enum in `nodes/node_def.rs`. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/notes.md new file mode 100644 index 000000000..6516b9c85 --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/notes.md @@ -0,0 +1,125 @@ +# ChangeSet Change Management — Notes + +## Scope + +Prove client-driven edits in `lpc-node-registry` harness: ChangeSet apply → +effective view → commit/discard → `SyncResult`. No `lpc-engine` changes until +parent artifact-routed reload **M6**. + +## Prerequisites (parent roadmap) + +Complete before starting M1 here: + +| Parent | Deliverable | +|--------|-------------| +| M1 | `ArtifactStore` — freshness-only | +| M2 | `NodeDefRegistry`, `NodeDefId`, `DefSource` | +| M3 | `SourceFileSlot`, `SourceFileRef`, materialize | +| M4 | `registry.sync` → `SyncResult`, fs-change scenarios S1–S6 | + +See [`dependencies.md`](dependencies.md). + +## Current Codebase State + +``` +lp-core/lpc-node-registry/src/ +├── artifact/ # M1 — done +├── registry/ # M2 + M4 — done; RegistryChange::Fs only +├── source/ # M3 — done +├── change/mod.rs # stub — implement in ChangeSet M1 +└── view/node_def_view.rs # base passthrough; effective overlay in M2 +``` + +M4 left **`RegistryChange::Fs`** only; commit from this roadmap extends the +registry sync path. See [`change-language.md`](change-language.md). + +## Change language (summary) + +See [`change-language.md`](change-language.md) for the full spec. + +- `ChangeSet` → `Vec` (grouped by artifact) +- `ArtifactTarget`: `Id` | `Path` — **path implies implicit create** +- `ArtifactOp`: file (`Delete`, `SetBytes`) + slot (`SetSlot`, map ops, …) +- Node defs authored only via slot ops; no `CreateDef` + +## Global Invariants (all stories) + +- **No panic / no corrupt base** until commit. +- **Intermediate uselessness OK** — mid-morph views may have parse errors, + dangling refs, missing bindings. +- **Commit contract** — all-or-nothing promotion or explicit error. +- **Discard** — reads restore to base exactly. + +## User Story Matrix + +Reference projects: `examples/basic`, `basic2`, `events`, `button-playlist`, +`fyeah-sign`, `fluid`, … + +### A — Compose from blank + +| ID | Story | M6 gate? | +|----|-------|----------| +| A1 | Blank → `basic` | **Yes** (via diff) | +| A2 | Blank → `events` | Later | +| A3 | Blank → `button-playlist` | Later | +| A4 | Blank → `fyeah-sign` | Later | + +### B — Morph between examples + +| ID | Story | M6 gate? | +|----|-------|----------| +| B1 | `basic` → `basic2` | **Yes** (via diff) | +| B2 | `basic` → `button` | Spot-check | +| B3 | `events` → `basic` | Spot-check | +| B4 | Cross-family morph | Aspirational | + +### C — Atomic author operations + +| ID | Area | M6 gate? | +|----|------|----------| +| C1a–f | Slot + file ops on artifacts | c/d minimum (via diff) | +| C2a–c | Inline via slot ops at nested paths | b/c in M4 | +| C3a–c | Inline ↔ standalone refactor | **Defer** ([`future.md`](future.md)) | +| C4a–d | Source ↔ asset file | c minimum (`SetBytes`); a/b/d in M3 | + +### D — Lifecycle + +| ID | Story | Milestone | +|----|-------|-----------| +| D1 | Apply → effective view ≠ committed base | M1–M2 | +| D2 | Commit → base updated | M5 | +| D3 | Discard → base unchanged | M1 | +| D4 | Multi-ChangeSet replay | [`future.md`](future.md) | +| D5 | ChangeSet + fs-change precedence | M5 | + +## Resolved design (2026-05-21) + +Change language locked — see [`change-language.md`](change-language.md) and +[`decisions.md`](decisions.md). + +## Open Questions + +### C3 inline ↔ standalone timing + +- **Context:** Extract/inline playlist entries is complex and not required for + A1/B1. +- **Suggested answer:** Defer C3 to post diff-gate or parent M8; document in + `future.md`. + +### Fs vs overlay precedence (D5) + +- **Context:** Parent M4 `sync` applies fs changes to internal store. +- **Suggested answer (v1):** Uncommitted ChangeSet wins for reads on overlaid + paths; fs bump marks stale but does not clobber overlay until commit/discard; + on commit, client ChangeSet wins over stale fs read. + +### Asserting "matches example" + +- **Suggested answer:** Parsed def equality + slot snapshots + asset bytes after + final commit; shared blank-project fixture for A* stories. + +## Process Notes + +- Promoted from parent M5 (2026-05-21). +- Change language v1 locked — [`change-language.md`](change-language.md). +- Parent M6 gated on M6 diff + equivalence gate here. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/overview.md b/docs/roadmaps/2026-05-21-changeset-change-management/overview.md new file mode 100644 index 000000000..7a4f5503b --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/overview.md @@ -0,0 +1,122 @@ +# ChangeSet Change Management + +## Motivation + +Client edits today mutate node defs in place (`slot_mutation`) with no commit +model, no overlay, and no shared vocabulary with filesystem reload. That blocks +incremental hot reload (artifact-routed file reload) from covering the full edit +loop the UI needs. + +This roadmap proves **client-driven change management** in `lpc-node-registry`: +ordered, id'd **ChangeSets** that express authorable edits in memory until +**commit**. All future client edits should flow through this model (overlay → +view → optional commit). + +ChangeSets also become the **universal edit vocabulary**: the same ordered op +stream powers the UI, **project diff**, and incremental stress replay on +host, RV32 emulator, and device. + +## Relationship to Artifact-Routed File Reload + +This roadmap was **promoted from M5** of +[`2026-05-21-artifact-routed-file-reload`](../2026-05-21-artifact-routed-file-reload/overview.md) +for process clarity. It is a **prerequisite** for that roadmap's **M6 engine +cutover** — not a fork. + +```text +Artifact-routed reload (parent) ChangeSet (this roadmap) +───────────────────────────────── ───────────────────────── +M1 ArtifactStore ──┐ +M2 NodeDefRegistry ──┼── prerequisites (complete) +M3 SourceFileSlot ──┤ +M4 fs-change sync → SyncResult ──┘ + │ +M6 engine cutover ◄── gated on ──────┘ M1–M6 here green +M7–M10 server / graph / probes / cleanup +``` + +**Parallel build rule unchanged:** this roadmap does **not** modify +`lpc-engine` until parent M6. + +## Architecture + +```text +NodeDefRegistry (owns committed + pending) + store: ArtifactStore — committed bytes + freshness + overlay: ChangeOverlay — pending artifact mutations (path-keyed) + entries, indexes — committed parse cache; re-derived on commit/sync + + Internal reads: overlay ∪ store → effective artifact bytes / slot trees + Public reads (NodeDefView): effective only — always base ∪ overlay + entries + SyncResult: committed truth after commit/sync + +ChangeSet (wire / UI / diff) + ChangeSet { id, changes: Vec } + apply → overlay; discard → clear overlay; commit → flush → SyncResult + +Engine (parent M6 — minimal change) + consumed slot: bindings → effective registry def read → value + provenance: not on tick path; parent M10 ExplainSlot probe when client asks +``` + +## Change language + +Full spec: [`change-language.md`](change-language.md). + +Summary: + +```text +ChangeSet → Vec // grouped by artifact + +ArtifactChange { + target: Id(ArtifactId) | Path(LpPathBuf), // Path → implicit create if missing + ops: Vec, +} + +ArtifactOp: + file: Delete | SetBytes // assets; TOML import escape hatch only + slot: SetSlot | MapInsert | … // node defs are slots; wiring included +``` + +**Asset** (user term) = non-node file (GLSL, SVG). **Artifact** = store path +identity (any file, including `.toml`). + +## Alternatives Considered + +- **Defer ChangeSet until after engine cutover** — rejected. +- **Flat op stream with per-op artifact ref** — rejected; group by artifact. +- **Explicit Create / New artifact target** — rejected; implicit create on `Path`. +- **CreateDef / pre-populated def blobs** — rejected; slot ops + defaults. +- **Ops as slot-system types** — rejected; serde change vocabulary in `change/`. +- **CRDT / concurrent merge in v1** — deferred to `future.md`. + +## Risks + +- Slot op apply touches `lpc-model` mut paths — phased in M4. +- C3 inline ↔ standalone refactor — defer past diff gate. +- ESP32 heap — overlays must not retain duplicate file bytes long-term. +- Diff engine complexity — M6 gate; hand-written stories not required once diff green. + +## Scope Estimate + +Seven milestones. Parent **M6** starts only when **M6 (diff + equivalence +gate)** here is green. + +## Milestones + +| # | Milestone | Gate | +|---|-----------|------| +| M1 | [Change language + overlay](m1-change-language-overlay.md) | Types, `ChangeOverlay` in registry, apply/discard, D1/D3 | +| M2 | [Effective projection](m2-effective-projection.md) | `NodeDefView` effective reads; overlay ∪ base | +| M3 | [File ops + asset reads](m3-asset-overlay.md) | `SetBytes`/`Delete`, materialize from overlay; C4* | +| M4 | [Slot ops + serialize](m4-node-slot-patches.md) | Slot ops, TOML serialize path; C1*, C2* | +| M5 | [Commit promotion](m5-commit-promotion.md) | Commit → base + `SyncResult`; D2, D5 | +| M6 | [Diff + equivalence gate](m6-diff-equivalence-gate.md) | `diff(∅, basic)`, `diff(basic, basic2)`; **parent M6 gate** | +| M7 | [Cleanup + validation](m7-cleanup-validation.md) | CI, docs, parent cross-links | + +## User Story Index + +Full story matrix: [`notes.md`](notes.md). + +**M6 gate (minimum):** D1–D3, D5, C1 slot ops + C4c, **A1** via diff, **B1** via +diff. Hand-written story tests optional once diff covers compose/morph. diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/summary.md new file mode 100644 index 000000000..2af57a60b --- /dev/null +++ b/docs/roadmaps/2026-05-21-changeset-change-management/summary.md @@ -0,0 +1,63 @@ +# ChangeSet Change Management — Summary + +## Status + +Complete on branch `codex/incremental-artifact-reload` (M1–M6 implemented; M7 doc +and validation pass). + +## Delivered + +`lpc-node-registry` now supports client-driven edits as ordered +[`ChangeSet`](../../lp-core/lpc-node-registry/src/change/change_set.rs) batches: + +```text +apply_changeset → ChangeOverlay +NodeDefView.get() → effective (overlay ∪ committed) +commit(fs) → write fs → re-derive entries → SyncResult → clear overlay +discard_overlay → drop pending edits +``` + +| Milestone | Summary | +|-----------|---------| +| M1 | Change language types, `ChangeOverlay`, apply/discard | +| M2 | Effective projection via `NodeDefView` | +| M3 | Asset `SetBytes`/`Delete`, materialize from overlay | +| M4 | Slot ops, TOML serialize for slot drafts | +| M5 | Commit promotion to filesystem + `SyncResult` | +| M6 | `diff(base, target)` + equivalence gate (`diff` feature) | + +Per-milestone notes: `m1-change-language-overlay/summary.md` through +`m6-diff-equivalence-gate/summary.md`. + +## Public API + +- **Read:** `NodeDefView`, `ParseCtx`, committed `NodeDefRegistry::get` +- **Client edit:** `ChangeSet`, `NodeDefRegistry::apply_changeset`, + `discard_overlay`, `commit` +- **Filesystem reload:** `sync` / `sync_fs` (unchanged from artifact-routed M4) +- **Harness (`diff` feature):** `ProjectSnapshot`, `diff`, `assert_equivalent` + +Embedded consumers should depend with `default-features = false` to omit the diff +harness. + +## Validation + +```bash +cargo test -p lpc-node-registry +cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings +cargo check -p lpc-node-registry --no-default-features +``` + +73 integration + unit tests at last count. + +## Unblocks + +Parent [artifact-routed M6 engine cutover](../2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md) +— ChangeSet M6 diff + equivalence gate is green. + +## Out of scope (see `future.md`) + +- Wire ChangeSet protocol to clients +- `RegistryChange::ChangeSet` variant (fs notifications only today) +- Concurrent merge / CRDT +- Parent M7–M10 server, graph, provenance diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover.md b/docs/roadmaps/2026-05-21-engine-registry-cutover.md new file mode 100644 index 000000000..4c58f58e7 --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover.md @@ -0,0 +1,30 @@ +# Engine–Registry Cutover + +**Promoted from** [artifact-routed M6](../2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md). + +- [Overview](overview.md) +- [Notes](notes.md) +- [Decisions](decisions.md) +- [Future](future.md) + +## Status + +**M1 next** — API hardening and readiness (no cutover code until shape is signed off). + +## Prerequisites + +- [Artifact-routed M1–M4](../2026-05-21-artifact-routed-file-reload/overview.md) +- [ChangeSet M1–M10](../2026-05-21-changeset-change-management/overview.md) + +## Milestones + +| # | Doc | +|---|-----| +| M1 | [**API hardening + readiness**](m1-api-hardening.md) | +| M2 | [Wire edit messages](m2-wire-edit-messages.md) | +| M3 | [Server registry + apply](m3-server-registry-apply.md) | +| M4 | [Engine loader cutover](m4-engine-loader-cutover.md) | +| M5 | [SyncResult engine policy](m5-sync-result-engine-policy.md) | +| M6 | [Graph reconciliation](m6-graph-reconciliation.md) | +| M7 | [Server fs wire-up](m7-server-fs-wireup.md) | +| M8 | [Cleanup + legacy mutation removal](m8-cleanup-validation.md) | diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/decisions.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/decisions.md new file mode 100644 index 000000000..0c0906dd2 --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/decisions.md @@ -0,0 +1,78 @@ +#### Promoted from artifact-routed M6 + +- **Decision:** Engine switchover + prerequisites live in + `docs/roadmaps/2026-05-21-engine-registry-cutover/`. +- **Why:** Large enough for its own roadmap. +- **Revisit when:** Unlikely. + +#### M1 is API hardening, not a blind type move + +- **Decision:** M1 resolves shape, UI parity, mutation inventory, and M3/M4 + sequencing **before** committing to implementation. +- **Why:** Avoid wire/registry churn; user-requested gate. +- **Revisit when:** M1 exit criteria met. + +#### Edit vocabulary in lpc-model + +- **Decision:** Shared serde edit types live in **`lpc-model::edit`**. +- **Why:** Wire + registry need one vocabulary; wire cannot depend on registry. +- **Status:** Implemented with canonical overlay vocabulary: `ProjectOverlay`, + `ArtifactOverlay`, `SlotOverlay`, `SlotEdit`, `SlotEditOp`, + `ArtifactBodyEdit`, `OverlayMutation`, mutation results, commit summaries, + and portable definition locations. + +#### Edit types are still not SlotData + +- **Decision:** `lpc-model::edit` is dedicated — not slot shapes / codec. +- **Why:** Preserves ChangeSet intent. +- **Supersedes:** ChangeSet “ops in registry only” for **location**; not for semantics. + +#### Registry keeps runtime logic + +- **Decision:** Overlay, apply, commit, `NodeDefRegistry`, `SyncResult`, + `NodeDefView` stay in **`lpc-node-registry`**. + +#### Wire addressing + +- **Decision:** Authored edits use artifact path + `SlotPath`; read/UI metadata + will bridge runtime nodes to those edit addresses during cutover. +- **Rejected for now:** Keeping `node..def` as the edit wire root. +- **Revisit when:** M1 UI parity doc — may require read metadata, not second root. + +#### Artifact body edit naming + +- **Decision:** New shared/wire-facing byte-level operations use + `ArtifactBodyEdit`, not `AssetEdit`. +- **Why:** The operation can replace or delete any artifact body, including + `.toml` definitions. "Asset" remains useful for non-def referenced files such + as GLSL/SVG bodies. +- **Status:** Implemented. Registry-local `AssetEdit` compatibility state was + removed; registry stores `ProjectOverlay` from `lpc-model`. + +#### Overlay mutations, not registry SyncOp on wire + +- **Decision:** Client-authored overlay changes use ordered + `OverlayMutationBatch`; registry `SyncOp::Fs` stays server-local. +- **Why:** `SyncOp` mixes client edit intent with filesystem watcher events. + Exposing it would leak registry mechanics into the wire contract. +- **Status:** Implemented for the POC with `WireOverlayRead*`, + `WireOverlayMutation*`, and `WireOverlayCommit*` wrappers. + +#### Legacy mutation cleanup + +- **Decision:** Inventory in M1; **delete** `WireSlotMutation*` path, engine + `slot_mutation`, client pending queue in **M8** (after cutover works on new path). +- **Why:** No dual mutation APIs in production long-term. + +#### M3 / M4 sequencing + +- **Decision:** **Open** — options documented in M1; not preset to server-first. +- **Why:** User preference to think through; cutover not feared. + +#### Session log deferred + +- **Decision:** No server session log v1; path-keyed overlay (ChangeSet M8). + +#### M10 provenance out of scope + +- **Decision:** ExplainSlot probes stay artifact-routed M10. diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/future.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/future.md new file mode 100644 index 000000000..b0d6f144e --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/future.md @@ -0,0 +1,23 @@ +## Slot provenance / ExplainSlot + +- **Idea:** Post-cutover client probe for effective slot resolution (artifact-routed M10). +- **Why not now:** Diagnostic feature; cutover does not require it. +- **Useful context:** `lpc-wire` probe types; engine effective read path after M5. + +## ChangeSet replay stress harness + +- **Idea:** Replay `EditBatch` streams through full engine on host/emu/device. +- **Why not now:** Needs M4–M5 engine on registry path. +- **Useful context:** ChangeSet `diff` + `assert_equivalent`. + +## Server-side session log + +- **Idea:** Versioned append log for multi-tab / undo. +- **Why not now:** ChangeSet M8 explicitly dropped session log. +- **Revisit when:** Client needs pull-based pending sync. + +## CRDT / multi-writer edits + +- **Idea:** Concurrent edit merge on shared projects. +- **Why not now:** Single-writer server v1. +- **Useful context:** ChangeSet roadmap `future.md`. diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m0.1-pre-m1-stabilization/00-notes.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m0.1-pre-m1-stabilization/00-notes.md new file mode 100644 index 000000000..cef4274c2 --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m0.1-pre-m1-stabilization/00-notes.md @@ -0,0 +1,155 @@ +# M0.1 — Pre-M1 stabilization + +Small refactors and design notes before M1 API hardening sign-off. + +### Store-owned artifact catalog (M0.1b) + +`ArtifactStore` now owns durable path ↔ [`ArtifactId`] registration: + +- `register_file` / `unregister` — catalog lifetime (not refcount cache pins) +- `id_for_path` / `path_for_id` — lookups +- Registry duplicate maps removed; `reconcile_artifacts` unregisters ids not + referenced by defs or source deps + +Ids remain stable for the same path until explicit unregister. + +### `ArtifactStore` keys use `ArtifactId` + +`by_handle: BTreeMap` and `location_to_handle: BTreeMap<_, u32>` were +wrong — maps now use `ArtifactId` directly: + +- `by_id: BTreeMap` +- `location_to_id: BTreeMap` + +Allocation counter stays `next_id: u32`; `alloc_id()` returns `ArtifactId`. + +### Terminology: id, not handle + +- `ArtifactId::handle()` → `ArtifactId::raw()` (inner u32 for logging/errors) +- `ArtifactError::UnknownHandle` → `UnknownArtifact { id: ArtifactId }` +- `ArtifactError::InvalidRelease { handle }` → `{ id: ArtifactId }` +- `EditError::UnknownArtifact { artifact_id: u32 }` → `{ id: ArtifactId }` +- `ArtifactId` serde is `#[serde(transparent)]` (wire: plain number) + +### Serde: PascalCase `kind` tags + +For internally tagged enums where `kind` names a **type**, variant names stay +PascalCase on the wire: + +```json +{ "kind": "Slot", "target": { ... }, "ops": [ ... ] } +{ "kind": "Asset", "target": { ... }, "ops": [ ... ] } +``` + +No `rename_all = "snake_case"` on the enum when `tag = "kind"`. Field names and +nested op variant names may still use snake_case where they are not type tags. + +--- + +## Open for M1: edit batch shape + +### Problem: `EditTarget` mixes wire and storage concerns + +Today each `ArtifactEdit` carries `target: EditTarget`: + +```rust +EditTarget::Id(ArtifactId) // registry-internal +EditTarget::Path(LpPathBuf) // authoring / implicit overlay create +``` + +That is convenient for a single apply path, but **stored pending edits on the +server should always be keyed by `ArtifactId`**. Path is a client authoring +concern: resolve once at apply ingress, then never persist path in the batch. + +**Proposal:** + +| Layer | Target form | +|-------|-------------| +| Wire / client apply | Path only (M1 default A3) | +| Registry overlay + pending batch | `ArtifactId` only | +| `EditTarget` | Keep for wire ingress helper, or split into `WireArtifactTarget` vs drop from stored types | + +Apply flow: `Path` → register/acquire artifact if needed → merge into pending +map by id. + +### Problem: `EditBatch` is `Vec` + +A vec allows multiple blocks for the same artifact (duplicate targets, ambiguous +merge order). That does not match mental model: **one pending body per artifact +per batch**. + +**Proposal — stored form:** + +```rust +pub struct EditBatch { + pub id: EditBatchId, + pub artifacts: BTreeMap, +} + +pub enum ArtifactBodyEdit { + Slot(Vec), + Asset(Vec), +} +``` + +Properties: + +- At most one entry per `ArtifactId` (map invariant) +- Slot vs asset is explicit on the value — no repeated `target` per block +- Ops within one artifact still ordered (`Vec` / `Vec`) + +**Wire form** (optional separate type or same with path key before resolution): + +```rust +// ingress only — converted before persistence +pub struct WireEditBatch { + pub id: EditBatchId, + pub edits: Vec, +} + +pub enum WireArtifactEdit { + Slot { path: LpPathBuf, ops: Vec }, + Asset { path: LpPathBuf, ops: Vec }, +} +``` + +Server: resolve each path → id, merge ops into `BTreeMap`, reject duplicate +path/id in one batch. + +### Overlay alignment + +`SlotOverlay` is still path-keyed today. For server storage consistency, +consider: + +- Overlay keyed by `ArtifactId` with path lookup via `artifact_path_to_id`, or +- Keep path keys in overlay but ensure batch/pending metadata is id-keyed + +Decision deferred to M1 — note dependency on implicit-create semantics (new +path before first commit). + +### Migration from current `ArtifactEdit` + +Current shape can be mechanically translated: + +```text +Vec → fold into BTreeMap + Slot { target, ops } → resolve(target) → ArtifactBodyEdit::Slot(ops) + Asset { target, ops } → resolve(target) → ArtifactBodyEdit::Asset(ops) +``` + +Reject batch if two blocks resolve to the same id with conflicting kinds +(Slot vs Asset on same file is an error). + +--- + +## M1 questions to add + +| # | Question | +|---|----------| +| E1 | Stored `EditBatch`: `BTreeMap` vs vec + validation? | +| E2 | Separate wire ingress type vs single type with optional path/id? | +| E3 | Overlay key: path vs `ArtifactId`? | +| E4 | Duplicate artifact in one batch: error vs last-wins merge? | + +Suggested defaults: **map + error on duplicate**, **wire path-only ingress**, +**overlay id-keyed when server owns registry** (path ok for harness-only until M3). diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening.md new file mode 100644 index 000000000..853669769 --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening.md @@ -0,0 +1,138 @@ +# Milestone 1: API Hardening + Cutover Readiness + +## Title and goal + +**Do not move code yet.** Decide and document the committed API shape for edit +vocabulary, wire messages, client parity, and cutover sequencing — then implement +only what M1 needs to prove the shape (small spikes optional). + +M1 is the gate: we should be confident in the types and boundaries before M2+ +implementation churn. + +## Suggested plan location + +`docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/` + +Deliverables live in that plan directory: + +- `00-notes.md` — question log + answers +- `00-design.md` — frozen API (after review) +- `ui-parity.md` — debug UI / client capability matrix +- `mutation-inventory.md` — legacy mutation stack to delete post-cutover +- `m3-m4-sequencing.md` — server-first vs combined cutover options + +## Scope + +**In:** + +- All open design questions (below) resolved or explicitly deferred with owner +- UI parity analysis vs current `WireSlotMutation` + debug UI +- Legacy mutation inventory + cleanup checklist (execution in M8; list in M1) +- Target module layout (`lpc-model::edit`, `lpc-wire`, registry boundaries) +- Optional: type move spike **only if** design is signed off mid-M1 + +**Out:** + +- Full wire implementation (M2) +- Server or engine cutover (M3+) +- Graph reconciliation + +## Question catalog (resolve in M1) + +### A. Model vs registry split + +| # | Question | Context | Suggested default | +|---|----------|---------|-------------------| +| A1 | Which types live in `lpc-model::edit`? | Portable edit vocabulary must be shared by wire and registry | `ProjectOverlay`, `ArtifactOverlay`, `SlotOverlay`, `SlotEdit`, `SlotEditOp`, `ArtifactBodyEdit`, `OverlayMutation`, mutation ids/results, portable definition locations | +| A2 | Does `SyncOp` live in model? | `SyncOp` mixes client edits with server-local fs events | **No** — client wire uses overlay mutations; registry `SyncOp::Fs` remains local | +| A3 | `EditTarget::Id(ArtifactId)` on wire? | Id is registry-internal | **Path only** on wire; registry resolves id locally if kept in model | +| A4 | `EditError` in model vs wire-only rejections? | Two layers today | Model: apply errors; wire: request rejections + mapping table | +| A5 | `schemars` on model edit types? | Wire has schema-gen | Mirror `lpc-wire` pattern via model feature flag | +| A6 | `EditBatchId` semantics | Unused in sync today | Define: client correlation id vs server idempotency (or neither v1) | + +### B. Wire envelope + +| # | Question | Context | Suggested default | +|---|----------|---------|-------------------| +| B1 | Piggyback on `ProjectReadRequest` vs new message? | Mutations already piggybacked | TBD — document tradeoffs in M1 | +| B2 | Wire op set = full `SyncOp` or subset? | Fs events are server-local | Client: read overlay, mutate overlay, commit overlay; no Fs on wire | +| B3 | Response carries `SyncOutcome`? | Today only mutation accept/reject | Extend `ProjectReadResponse` with pending + commit summary | +| B4 | Optimistic concurrency model? | Slot mutation uses shape/data `Revision` CAS | Overlay model: pending until commit; define conflict rules for concurrent Apply | + +### C. Addressing + +| # | Question | Context | Suggested default | +|---|----------|---------|-------------------| +| C1 | Artifact path vs `node..def`? | Two vocabularies today | **Path + SlotPath** for edits; see UI parity | +| C2 | How does UI resolve node → artifact path? | Debug UI uses string roots | Project read metadata: `NodeId` → `(artifact_path, path_prefix)` | +| C3 | Inline playlist children | Edits use paths on parent `.toml` | Document prefix rules; no separate wire root | + +### D. M3 / M4 sequencing (open — user TBD) + +| # | Question | Context | Options | +|---|----------|---------|---------| +| D1 | Server registry without engine update? | M3 applies/commits to fs; engine stale until M4 | **A:** M3 server-only staging · **B:** merge M3+M4 · **C:** M4 first on host harness | +| D2 | When does UI see effective edits? | Overlay ≠ engine tree until cutover | Document UX per option; may accept lag if cutover is fast | +| D3 | Single project open on server? | Registry per project | Confirm lifecycle: load root, unload on close | + +### E. Commit / pending UX + +| # | Question | Context | Suggested default | +|---|----------|---------|-------------------| +| E1 | Explicit Commit on wire? | Registry commit is exposed through `WireOverlayCommit*` | **Yes** — client drives commit; server does not auto-commit edits | +| E2 | Discard / ClearPending exposure? | Registry supports both | Wire both for editor reset | +| E3 | Read effective vs committed in project read? | `NodeDefView` vs `get()` | Define query flag or always effective for editor | + +## UI parity (document in `ui-parity.md`) + +Current production edit path (debug UI): + +| Capability | Today (`WireSlotMutation`) | Edit language equivalent | M1 note | +|------------|---------------------------|--------------------------|---------| +| Value leaf edit | `SetValue` on `node..def` + `SlotPath` | `AssignValue` on artifact path + path | Needs C2 mapping | +| Enum / kind change | Not on wire | `EnsurePresent` on variant path | POC | +| Map insert/remove | Not on wire | `EnsurePresent` / `Remove` | POC | +| Option some/none | Not on wire | `EnsurePresent` / `Remove` | POC | +| Artifact body replace | Not on wire | `ArtifactBodyEdit::ReplaceBody` | POC | +| Artifact delete | Not on wire | `ArtifactBodyEdit::Delete` | POC | +| Pending indicator | `SlotMirrorView.pending` + mutation id | Client overlay mirror TBD | Redesign in M2/M3 | +| Conflict handling | shape/data revision CAS | TBD (B4) | M1 must decide | +| Commit | Immediate apply to engine memory | `WireOverlayCommitRequest` | **Behavior change** — UI must add commit | +| Error display | `WireSlotMutationRejection` | `EditError` / wire rejection | Map in M1 | + +**M1 deliverable:** explicit v1 parity target — which rows are cutover blockers vs +post-cutover enhancements. + +## Legacy mutation cleanup inventory (document in `mutation-inventory.md`) + +Remove after cutover (execution **M8**; inventory **M1**): + +| Area | Symbols / files | +|------|-----------------| +| `lpc-wire` | `WireSlotMutationRequest`, `WireSlotMutationOp`, responses, rejections | +| `lpc-view` | `SlotMirrorView::prepare_set_value`, `PendingSlotMutation`, pending queue | +| `lpc-engine` | `slot_mutation.rs`, `mutate_project_slots` | +| `lpa-server` | `apply_project_mutations`, mutation logging | +| `lp-cli` | `SlotEditIntent`, `prepare_queued_mutations`, mutation status UI | +| `lpc-shared` | server trait mutation hook | + +Also: engine in-memory def mutation path vs overlay+commit. + +## M1 exit criteria (gate for M2) + +- [ ] `00-design.md` reviewed — frozen type list and crate boundaries +- [ ] All A–E questions answered or deferred with milestone tag +- [ ] `ui-parity.md` — v1 blocker list agreed +- [ ] `mutation-inventory.md` — complete +- [ ] `m3-m4-sequencing.md` — chosen option (A/B/C) +- [ ] ChangeSet `change-language.md` + decisions updated to point at model home +- [ ] Optional: types moved to model if design closed early + +## Dependencies + +- ChangeSet M10 slot/asset split landed on branch + +## Execution strategy + +**Full plan** — design review milestone; implementation is mostly docs + small +spikes. Type move can be last phase of M1 or first task of M2 depending on review. diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/m3-m4-sequencing.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/m3-m4-sequencing.md new file mode 100644 index 000000000..ca35ac040 --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/m3-m4-sequencing.md @@ -0,0 +1,60 @@ +# M3 / M4 Sequencing Options + +**Status:** open — pick in M1 review + +## Option A — Server staging first (original proposal) + +```text +M2 wire → M3 server registry (apply/commit to fs, engine unchanged) + → M4 engine loader cutover + → M5 SyncResult policy +``` + +**Pros:** Prove wire + registry on real server without engine risk. +**Cons:** UI edits commit to disk but running scene stale until M4; awkward demo window. + +## Option B — Combined server + loader cutover + +```text +M2 wire → M4+M3 single milestone (registry on server + engine loads from registry) + → M5 SyncResult policy +``` + +**Pros:** No stale-engine period; matches "not scared of cutover." +**Cons:** Larger bang; harder to bisect failures. + +## Option C — Engine harness first, server second + +```text +M2 wire (types only) → M4 engine cutover on host tests / examples + → M3 server wire-up + → M5 policy +``` + +**Pros:** Validates loader on CI before server lifecycle work. +**Cons:** Client still can't edit via server until M3. + +## Option D — Wire + engine cutover; server follows + +```text +M2 wire → M4 engine (no lpa-server edit path yet) + → M3 server + → M5 +``` + +**Pros:** Engine is the hard part; server is thin wiring. +**Cons:** No E2E client path until M3. + +## Questions to answer + +1. Is a temporary stale-engine window (A) acceptable at all? +2. Do we need client E2E before or after engine loader lands? +3. Can `lpa-server` integration tests use registry without full engine cutover? + +## Recommendation for discussion + +Lean **B or D** if cutover risk is low: avoid building two production mutation +paths. Use **A** only if we want a server-only integration test milestone with +clear pass/fail before touching `ProjectLoader`. + +**User note (2026-05-21):** not 100% sure — keep open until M1 review. diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/mutation-inventory.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/mutation-inventory.md new file mode 100644 index 000000000..f9abffbac --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/mutation-inventory.md @@ -0,0 +1,63 @@ +# Legacy Mutation Stack — Deletion Inventory + +**Status:** draft for M1 review +**Execution:** M8 (after new edit path works) +**M1 job:** confirm list complete; no deletion in M1. + +## lpc-wire + +| File / symbol | Role | +|---------------|------| +| `slot/mutation.rs` | `WireSlotMutationRequest`, `WireSlotMutationOp`, responses, rejections | +| `messages/project_read/project_read_request.rs` | `mutations: Vec` | +| `messages/project_read/project_read_response.rs` | `mutations: Vec` | + +## lpc-view + +| File / symbol | Role | +|---------------|------| +| `slot/mirror.rs` | `prepare_set_value`, `apply_mutation_response`, pending map | +| `slot/pending.rs` | `PendingSlotMutation` | + +## lpc-engine + +| File / symbol | Role | +|---------------|------| +| `engine/slot_mutation.rs` | `mutate_project_slots`, in-memory def mutation | +| `engine/mod.rs` | module export | + +## lpa-server + +| File / symbol | Role | +|---------------|------| +| `project_read_source.rs` | `apply_project_mutations` → engine | + +## lpc-shared / server trait + +| Symbol | Role | +|--------|------| +| `TransportServer` mutation hook | passes mutations to server impl | + +## lp-cli (debug UI) + +| File / symbol | Role | +|---------------|------| +| `debug_ui/ui.rs` | `queued_mutations`, `prepare_queued_mutations` | +| `debug_ui/slot_edit.rs` | `SlotEditIntent`, mutation status by id | + +## Replacement (target) + +| Old | New | +|-----|-----| +| `WireSlotMutationRequest` | `WireOverlayMutationRequest` / `OverlayMutation` | +| `prepare_set_value` | build `SlotEdit { path, op: AssignValue }` + mutate overlay | +| `mutate_project_slots` | `registry.sync` + engine refresh on commit | +| immediate accept/reject | apply errors + commit outcome | + +## Grep commands (M8 verification) + +```bash +rg 'WireSlotMutation|prepare_set_value|mutate_project_slots|slot_mutation' lp-core lp-app lp-cli +``` + +Expect zero hits in production paths after M8. diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/ui-parity.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/ui-parity.md new file mode 100644 index 000000000..5b1f65e43 --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/ui-parity.md @@ -0,0 +1,69 @@ +# UI Parity — WireSlotMutation vs Edit Language + +**Status:** draft for M1 review +**Goal:** Define v1 cutover blockers vs post-cutover enhancements. + +## Current debug UI flow + +```text +User edits control in lp-cli debug UI + → SlotEditIntent { root: "node..def", path, value } + → SlotMirrorView.prepare_set_value (client-side validation + revision CAS) + → WireSlotMutationRequest piggybacked on ProjectReadRequest + → lpa-server → engine.mutate_project_slots (immediate in-memory write) + → WireSlotMutationResponse accept/reject + → SlotMirrorView.apply_mutation_response +``` + +No commit step. No overlay. Effective = committed = engine memory. + +## Target flow (after cutover) + +```text +User edits control + → Resolve (artifact_path, slot_path) ← NEW: needs read metadata (M1 C2) + → Build OverlayMutation::PutSlotEdit { SlotEdit { path, op } } + → WireOverlayMutationRequest (pending overlay) + → Optional: read effective via project read + → User commits → WireOverlayCommitRequest → fs + engine refresh +``` + +## Capability matrix + +| # | User action | Today | Edit language | v1 blocker? | Notes | +|---|-------------|-------|---------------|-------------|-------| +| 1 | Edit scalar on node def (clock rate, shader param) | `SetValue` | `AssignValue` | **Yes** | Core debug UI | +| 2 | See edit errors inline | `WireSlotMutationRejection` | TBD wire rejection | **Yes** | Map error enums in M1 | +| 3 | Pending / in-flight indicator | per-slot mutation id | client pending overlay | **Yes** | Redesign mirror model | +| 4 | Optimistic local preview | pending queue, no local write | overlay mirror or wait for read | **TBD** | M1 B4 | +| 5 | Change node kind | not in UI | `EnsurePresent` on variant path | POC | Default-based kind switch | +| 6 | Map / playlist entry edits | not in UI | `EnsurePresent` / `Remove` | POC | SlotPath identity creates map keys | +| 7 | Option fields | not in UI | `EnsurePresent` / `Remove` | POC | No separate option op | +| 8 | Edit GLSL source | not via mutation | `ArtifactBodyEdit::ReplaceBody` | POC | Future asset editor | +| 9 | Commit / discard | N/A (instant) | commit request / clear overlay mutation | **Yes** | UX change — add UI affordance? | +| 10 | Node tree slot read | `node..def` roots in project read | effective defs from registry | **Yes** | Read path must use NodeDefView | + +## Addressing migration + +Today `SlotEditKey` = `{ root: "node.3.def", path }`. + +Target key = `{ artifact_path: "/clock.toml", path }` or keep logical root if read +continues to expose `node..def` snapshots but **edits** use paths. + +**M1 decision needed:** dual keys during transition vs one-time UI rewrite. + +## Read metadata needed (if path-centric edits) + +Project read should expose per node (minimum): + +- `node_id` +- `def_artifact_path` (absolute) +- optional `slot_path_prefix` for inline children (e.g. `entries[2].node.def`) + +Without this, UI cannot build overlay mutations from the node panel. + +## Suggested v1 parity bar + +**Blockers:** rows 1, 2, 3, 9, 10 +**Defer:** rows 5–8 +**Decide in M1:** row 4 (optimistic preview strategy) diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m2-wire-edit-messages.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m2-wire-edit-messages.md new file mode 100644 index 000000000..fbf11811e --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m2-wire-edit-messages.md @@ -0,0 +1,24 @@ +# Milestone 2: Wire Edit / Sync Messages + +## Title and goal + +Implement **`lpc-wire`** messages per **M1 `00-design.md`**. + +## Suggested plan location + +`docs/roadmaps/2026-05-21-engine-registry-cutover/m2-wire-edit-messages/` + +## Scope + +**In:** Types, serde, schema-gen, roundtrip tests, rejection enums, mapping to +model `SyncOp` / edit types. + +**Out:** Server dispatch (M3); engine (M4+). + +## Dependencies + +- **M1 exit criteria** complete. + +## Execution strategy + +**Full plan** diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m3-server-registry-apply.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m3-server-registry-apply.md new file mode 100644 index 000000000..53cd3a2b3 --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m3-server-registry-apply.md @@ -0,0 +1,30 @@ +# Milestone 3: Server Registry + Apply + +## Title and goal + +**`lpa-server`** owns **`NodeDefRegistry`**; wire edits → `registry.sync()`. + +## Suggested plan location + +`docs/roadmaps/2026-05-21-engine-registry-cutover/m3-server-registry-apply/` + +## Scope + +**In:** Registry lifecycle; wire → sync translation; commit to project fs; +integration tests. + +**Out:** Engine tree updates — **unless M1 chooses combined M3+M4**. + +## Key decisions (from M1) + +See **`m1-api-hardening/m3-m4-sequencing.md`**. This milestone's scope shifts if +we merge server + engine cutover. + +## Dependencies + +- M2 complete +- M1 sequencing choice recorded + +## Execution strategy + +**Full plan** diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m4-engine-loader-cutover.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m4-engine-loader-cutover.md new file mode 100644 index 000000000..1ccc2ae79 --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m4-engine-loader-cutover.md @@ -0,0 +1,31 @@ +# Milestone 4: Engine Loader Cutover + +## Title and goal + +**Hard cut:** `ProjectLoader` / `Engine` → **`NodeDefRegistry`**; delete old +artifact payload store. + +## Suggested plan location + +`docs/roadmaps/2026-05-21-engine-registry-cutover/m4-engine-loader-cutover/` + +## Scope + +**In:** `lpc-engine` depends on registry; loader via registry + `NodeDefView`; +remove `lpc-engine/src/artifact/`. + +**Out:** SyncResult incremental policy (M5) unless merged forward. + +## Key decisions (from M1) + +May run **before, with, or immediately after M3** per M1 sequencing doc. + +## Dependencies + +- M1 sequencing choice +- M2 wire types (if client edits during cutover testing) +- Artifact-routed M4 + ChangeSet M6 diff gate + +## Execution strategy + +**Full plan** diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m5-sync-result-engine-policy.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m5-sync-result-engine-policy.md new file mode 100644 index 000000000..9dd4295b8 --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m5-sync-result-engine-policy.md @@ -0,0 +1,30 @@ +# Milestone 5: SyncResult Engine Policy + +## Title and goal + +Wire **`Engine::handle_fs_changes`** and **commit** to **`registry.sync()`**; +apply [engine-policy-v1](../2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/engine-policy-v1.md). + +## Suggested plan location + +`docs/roadmaps/2026-05-21-engine-registry-cutover/m5-sync-result-engine-policy/` + +## Scope + +**In:** `SyncResult` → node add/remove/refresh; shader/fixture **`SourceFileRef`** +materialize; client commit triggers engine refresh. + +**Out:** Server watcher routing (M7); graph-level child list (M6). + +## Deliverables + +- Fs-change + commit integration tests on engine path. +- M4 harness scenarios re-run on production stack. + +## Dependencies + +- M4 complete. + +## Execution strategy + +**Full plan** — policy table + node kind matrix. diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m6-graph-reconciliation.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m6-graph-reconciliation.md new file mode 100644 index 000000000..72f67ea1f --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m6-graph-reconciliation.md @@ -0,0 +1,27 @@ +# Milestone 6: Graph Reconciliation + +## Title and goal + +When **`project.toml`** / playlist invocation wiring changes, engine mutates the +node tree (add/remove/repoint) — not only in-def slot patches. + +Promoted from [artifact-routed M8](../2026-05-21-artifact-routed-file-reload/m8-project-graph-reconciliation.md). + +## Suggested plan location + +`docs/roadmaps/2026-05-21-engine-registry-cutover/m6-graph-reconciliation/` + +## Scope + +**In:** Detect invocation graph diffs from `SyncResult`; engine child attach/detach; +tests for top-level and playlist inline children. + +**Out:** Minimal diff optimality for every edit shape. + +## Dependencies + +- M5 complete. + +## Execution strategy + +**Full plan** diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m7-server-fs-wireup.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m7-server-fs-wireup.md new file mode 100644 index 000000000..d275a8fb8 --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m7-server-fs-wireup.md @@ -0,0 +1,27 @@ +# Milestone 7: Server Fs Wire-Up + +## Title and goal + +Route filesystem watcher events to **`Engine::handle_fs_changes`** (registry +sync) instead of **`Project::reload()`**. + +Promoted from [artifact-routed M7](../2026-05-21-artifact-routed-file-reload/m7-server-fs-change-wireup.md). + +## Suggested plan location + +`docs/roadmaps/2026-05-21-engine-registry-cutover/m7-server-fs-wireup/` + +## Scope + +**In:** `LpServer` → incremental sync; E2E single-file reload (TOML, GLSL). +Explicit full reload retained for user-initiated reset. + +**Out:** Graph reconciliation (M6 if not done — ordering flexible). + +## Dependencies + +- M5 minimum (engine handles fs sync). + +## Execution strategy + +**Small plan** diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m8-cleanup-validation.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m8-cleanup-validation.md new file mode 100644 index 000000000..c36db2c0c --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m8-cleanup-validation.md @@ -0,0 +1,32 @@ +# Milestone 8: Cleanup + Validation + +## Title and goal + +Remove deprecated paths, **delete legacy mutation stack**, CI gate, summary. + +## Suggested plan location + +`docs/roadmaps/2026-05-21-engine-registry-cutover/m8-cleanup-validation/` + +## Scope + +**In:** + +- Execute **`mutation-inventory.md`** from M1 (grep-clean): + - `lpc-wire` slot mutation types + - `lpc-view` `prepare_set_value` / pending queue + - `lpc-engine` `slot_mutation.rs` + - `lpa-server` mutation dispatch + - `lp-cli` debug UI mutation queue (replace with edit sync + commit UX) +- Old `lpc-engine` artifact module (if any remnants post-M4) +- `just ci`; fw-esp32 check; roadmap summaries + +**Out:** M10 provenance probes. + +## Dependencies + +- New edit path working end-to-end (M5 minimum) + +## Execution strategy + +**Full plan** with checklist derived from M1 inventory. diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/notes.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/notes.md new file mode 100644 index 000000000..8dfc98522 --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/notes.md @@ -0,0 +1,51 @@ +# Engine–Registry Cutover — Notes + +## Scope + +Promote the old **artifact-routed M6** (engine switchover) into a standalone +roadmap. **M1 is API hardening** — resolve shape, UI parity, and sequencing before +implementation churn. + +Registry **behavior** stays in `lpc-node-registry`. Shared **vocabulary** moves to +**`lpc-model::edit`** (pending M1 sign-off). Engine **policy** stays in +`lpc-engine`. + +## Current state + +See [overview.md](overview.md). Registry harness green; production stack unchanged. + +## User direction (2026-05-21) + +- Promote parent M6 into its own roadmap. +- **M1 = cleanup + hardening** — answer open questions; commit to API shape before + moving types / wire / cutover. +- **M3/M4 split** — not decided; not blocked on fear of cutover. +- **Mutation cleanup** — inventory in M1; delete legacy path after cutover (M8). +- **UI parity** — M1 documents what debug UI needs vs edit language. + +## Open questions → M1 + +All question catalogs live in [m1-api-hardening.md](m1-api-hardening.md). Summary: + +| Area | Status | +|------|--------| +| Model vs registry split | M1 | +| SyncOp in model? | M1 (lean yes) | +| Wire envelope | M1 | +| Path vs node-id addressing | M1 | +| M3 vs M4 sequencing | M1 — user TBD | +| UI parity / commit UX | M1 | +| Legacy mutation inventory | M1 doc → M8 delete | + +## Dependencies (entry criteria) + +- Artifact-routed M4 green +- ChangeSet M6 diff gate green +- ChangeSet M8 sync green +- ChangeSet M10 slot/asset split on branch + +## Risks + +- Skipping M1 and moving types too early → wire churn +- UI commit model vs instant mutation — product decision in M1 +- Graph reconciliation scope (M6) diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/overview.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/overview.md new file mode 100644 index 000000000..01edde1ed --- /dev/null +++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/overview.md @@ -0,0 +1,65 @@ +# Engine–Registry Cutover + +## Motivation + +Two parallel stacks exist today: + +```text +PRODUCTION PROVEN (harness only) +────────── ───────────────────── +ProjectLoader → Engine tree NodeDefRegistry → SlotOverlay → commit +WireSlotMutation → Engine memory SyncOp → overlay → NodeDefView +Project::reload() on fs change registry.sync() → SyncResult +``` + +This roadmap **ends the dual stack** after **M1 hardens the API shape**, then wire, +server, and engine cutover land in order. + +Promoted from [artifact-routed M6](../2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md) +(absorbs old M7/M8; M10 provenance stays separate). + +## Relationship to other roadmaps + +```text +Artifact-routed M1–M4 ChangeSet M1–M10 + │ │ + └──────────┬───────────────────┘ + ▼ + M1 API hardening (this roadmap) ← gate everything else + │ + ├── M2 wire + ├── M3 server apply (sequencing TBD in M1) + ├── M4–M5 engine cutover + SyncResult policy + ├── M6–M7 graph + fs wire-up + └── M8 cleanup (incl. legacy mutation deletion) +``` + +## Architecture (target — shape frozen in M1) + +```text +lpc-model::edit shared serde vocabulary (TBD details in M1) +lpc-wire project sync messages (M2) +lpa-server NodeDefRegistry (M3+) +lpc-node-registry overlay, commit, NodeDefView +lpc-engine SyncResult policy (M4+) +``` + +## Milestones + +| # | Milestone | Gate | +|---|-----------|------| +| M0.1 | [**Pre-M1 stabilization**](m0.1-pre-m1-stabilization/00-notes.md) | ArtifactId/store fixes; edit batch design notes | +| M1 | [**API hardening + readiness**](m1-api-hardening.md) | Design signed off; UI parity + mutation inventory | +| M2 | [Wire edit/sync messages](m2-wire-edit-messages.md) | M1 exit criteria | +| M3 | [Server registry + apply](m3-server-registry-apply.md) | M2; sequencing per M1 | +| M4 | [Engine loader cutover](m4-engine-loader-cutover.md) | M1 sequencing decision | +| M5 | [SyncResult engine policy](m5-sync-result-engine-policy.md) | M4 | +| M6 | [Graph reconciliation](m6-graph-reconciliation.md) | M5 | +| M7 | [Server fs wire-up](m7-server-fs-wireup.md) | M5 | +| M8 | [Cleanup + validation](m8-cleanup-validation.md) | Delete legacy mutation stack | + +**M3/M4 order is intentionally open** until M1 `m3-m4-sequencing.md` closes. + +## Entry criteria + +See [notes.md](notes.md). diff --git a/docs/toolchain-notes.md b/docs/toolchain-notes.md index fda1ec353..7ef5ca393 100644 --- a/docs/toolchain-notes.md +++ b/docs/toolchain-notes.md @@ -22,6 +22,56 @@ Three features used by `fw-esp32` and `fw-emu` are unstable: All three are needed for OOM recovery via stack unwinding on ESP32 and the RISC-V emulator. See `docs/reports/2026-03-13-esp32-unwinding-implementation.md` for details. +## Why the nightly is pinned (and why it's coupled to `unwinding`) + +The toolchain is pinned to a dated nightly (e.g. `nightly-2026-04-27`), **not** a +rolling `nightly`. The pin lives in three places that must stay in sync: + +- `rust-toolchain.toml` (workspace root) — drives local dev and any in-repo + `cargo`/`rustc` call. +- `lp-fw/fw-esp32/rust-toolchain.toml` — a per-crate pin. Several recipes `cd` into + `lp-fw/fw-esp32`, so *this* file wins there; it also carries `rust-src` for + `-Zbuild-std`. If it drifts from the root pin, the firmware build resolves a + different (possibly unpinned) toolchain — which is exactly how CI broke once: the + root was pinned but this file still said `nightly`, so the build-std step ran on a + rolling nightly with no `rust-src`. +- `.github/workflows/pre-merge.yml` — the `dtolnay/rust-toolchain` step (CI checks + out into a subdirectory, so the action can't auto-read the toml; the date is + passed explicitly). + +The reason it's pinned rather than rolling: this is a `-Zbuild-std` project, and the +[`unwinding`](https://crates.io/crates/unwinding) crate (our `eh_personality` +provider) is bound to the nightly `core::intrinsics::catch_unwind` ABI. That +intrinsic changed its return type from an integer to `bool`: + +- `unwinding` **0.2.8** expects the integer form (`catch_unwind(...) == 0`). +- `unwinding` **0.2.9** expects the `bool` form (`if catch_unwind(...) { ... }`). + +So the `unwinding` version and the nightly are a matched pair — there is no single +`unwinding` that builds on both an old and a new nightly. With an unpinned `nightly`, +CI silently drifts onto a newer toolchain than local dev and the build-std compile +breaks (`E0308: expected bool, found integer`). Pinning keeps CI reproducible and in +lockstep with local. + +## Bumping the toolchain + +Use the helper — it updates both pins, moves `unwinding` only if the new nightly +requires it, and validates before you commit: + +```sh +just bump-nightly 2026-06-01 # pin to a specific dated nightly +just bump-nightly # pin to today's nightly (UTC) +``` + +It (1) rewrites the pin in every `rust-toolchain.toml` (root + per-crate) and the +workflow, (2) runs `just check` with the current `unwinding` (this compiles +`unwinding` under build-std +via `clippy-rv32`, and also surfaces any new clippy lints from the newer nightly), +(3) only if that fails, advances `unwinding` to the latest `0.2.x` and re-checks, and +(4) leaves everything in the working tree for review — it never commits. If the new +nightly can't be made to build (e.g. `unwinding` needs a new *major*, or a new lint +fires), it reports what to try and reverts only the speculative `unwinding` bump. + ## Alternatives considered Keeping the workspace on stable with per-crate nightly overrides (`lp-fw/fw-esp32/rust-toolchain.toml`, diff --git a/examples/basic/project.toml b/examples/basic/project.toml index cc9afe55d..8a96142f7 100644 --- a/examples/basic/project.toml +++ b/examples/basic/project.toml @@ -2,13 +2,13 @@ kind = "Project" name = "basic" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.shader] -def = { path = "./shader.toml" } +ref = "./shader.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" diff --git a/examples/basic2/project.toml b/examples/basic2/project.toml index cd1aeef53..a254cc658 100644 --- a/examples/basic2/project.toml +++ b/examples/basic2/project.toml @@ -2,16 +2,16 @@ kind = "Project" name = "basic2" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.texture] -def = { path = "./texture.toml" } +ref = "./texture.toml" [nodes.shader] -def = { path = "./shader.toml" } +ref = "./shader.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" diff --git a/examples/button-playlist/playlist.toml b/examples/button-playlist/playlist.toml index d1a83a8f1..58d482268 100644 --- a/examples/button-playlist/playlist.toml +++ b/examples/button-playlist/playlist.toml @@ -8,13 +8,13 @@ source = "bus#time.seconds" [entries.1] name = "idle" fade_after = 0.12 -node = { def = { path = "./idle.toml" } } +node = { ref = "./idle.toml" } [entries.2] name = "active" duration = 4.0 fade_after = 0.8 -node = { def = { path = "./active.toml" } } +node = { ref = "./active.toml" } [entries.2.bindings.trigger] source = "bus#trigger" diff --git a/examples/button-playlist/project.toml b/examples/button-playlist/project.toml index 6d9313467..4a958cacb 100644 --- a/examples/button-playlist/project.toml +++ b/examples/button-playlist/project.toml @@ -2,16 +2,16 @@ kind = "Project" name = "button-playlist" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.button] -def = { path = "./button.toml" } +ref = "./button.toml" [nodes.playlist] -def = { path = "./playlist.toml" } +ref = "./playlist.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" diff --git a/examples/button-sign/playlist.toml b/examples/button-sign/playlist.toml index d1a83a8f1..58d482268 100644 --- a/examples/button-sign/playlist.toml +++ b/examples/button-sign/playlist.toml @@ -8,13 +8,13 @@ source = "bus#time.seconds" [entries.1] name = "idle" fade_after = 0.12 -node = { def = { path = "./idle.toml" } } +node = { ref = "./idle.toml" } [entries.2] name = "active" duration = 4.0 fade_after = 0.8 -node = { def = { path = "./active.toml" } } +node = { ref = "./active.toml" } [entries.2.bindings.trigger] source = "bus#trigger" diff --git a/examples/button-sign/project.toml b/examples/button-sign/project.toml index 237872902..7213a6285 100644 --- a/examples/button-sign/project.toml +++ b/examples/button-sign/project.toml @@ -2,19 +2,19 @@ kind = "Project" name = "button-sign" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.button] -def = { path = "./button.toml" } +ref = "./button.toml" [nodes.radio] -def = { path = "./radio.toml" } +ref = "./radio.toml" [nodes.playlist] -def = { path = "./playlist.toml" } +ref = "./playlist.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" diff --git a/examples/button/project.toml b/examples/button/project.toml index 76f08a8b9..5642ded72 100644 --- a/examples/button/project.toml +++ b/examples/button/project.toml @@ -2,13 +2,13 @@ kind = "Project" name = "button" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.button] -def = { path = "./button.toml" } +ref = "./button.toml" [nodes.shader] -def = { path = "./shader.toml" } +ref = "./shader.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" diff --git a/examples/events/project.toml b/examples/events/project.toml index 3345f480b..aff26579e 100644 --- a/examples/events/project.toml +++ b/examples/events/project.toml @@ -2,19 +2,19 @@ kind = "Project" name = "events" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.event_a] -def = { path = "./event_a.toml" } +ref = "./event_a.toml" [nodes.event_b] -def = { path = "./event_b.toml" } +ref = "./event_b.toml" [nodes.shader] -def = { path = "./shader.toml" } +ref = "./shader.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" diff --git a/examples/fast/project.toml b/examples/fast/project.toml index e53c393aa..eba897aa7 100644 --- a/examples/fast/project.toml +++ b/examples/fast/project.toml @@ -2,16 +2,16 @@ kind = "Project" name = "fast" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.texture] -def = { path = "./texture.toml" } +ref = "./texture.toml" [nodes.shader] -def = { path = "./shader.toml" } +ref = "./shader.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" diff --git a/examples/fluid/project.toml b/examples/fluid/project.toml index 7c5a87a97..908835147 100644 --- a/examples/fluid/project.toml +++ b/examples/fluid/project.toml @@ -2,16 +2,16 @@ kind = "Project" name = "fluid" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.compute] -def = { path = "./compute.toml" } +ref = "./compute.toml" [nodes.fluid] -def = { path = "./fluid.toml" } +ref = "./fluid.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" diff --git a/examples/fyeah-button/playlist.toml b/examples/fyeah-button/playlist.toml index c31a6a23f..13944ff34 100644 --- a/examples/fyeah-button/playlist.toml +++ b/examples/fyeah-button/playlist.toml @@ -8,13 +8,13 @@ source = "bus#time.seconds" [entries.1] name = "attract" fade_after = 0.12 -node = { def = { path = "./attract.toml" } } +node = { ref = "./attract.toml" } [entries.2] name = "blast" duration = 6.0 fade_after = 2.0 -node = { def = { path = "./blast.toml" } } +node = { ref = "./blast.toml" } [entries.2.bindings.trigger] source = "bus#trigger" diff --git a/examples/fyeah-button/project.toml b/examples/fyeah-button/project.toml index 0a8672824..cfb51da21 100644 --- a/examples/fyeah-button/project.toml +++ b/examples/fyeah-button/project.toml @@ -2,19 +2,19 @@ kind = "Project" name = "fyeah-button" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.button] -def = { path = "./button.toml" } +ref = "./button.toml" [nodes.radio] -def = { path = "./radio.toml" } +ref = "./radio.toml" [nodes.playlist] -def = { path = "./playlist.toml" } +ref = "./playlist.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" diff --git a/examples/fyeah-sign/playlist.toml b/examples/fyeah-sign/playlist.toml index d053f58e9..63c2e2c10 100644 --- a/examples/fyeah-sign/playlist.toml +++ b/examples/fyeah-sign/playlist.toml @@ -8,13 +8,13 @@ source = "bus#time.seconds" [entries.1] name = "idle" fade_after = 0.12 -node = { def = { path = "./idle.toml" } } +node = { ref = "./idle.toml" } [entries.2] name = "blast" duration = 6.0 fade_after = 2.0 -node = { def = { path = "./blast.toml" } } +node = { ref = "./blast.toml" } [entries.2.bindings.trigger] source = "bus#trigger" diff --git a/examples/fyeah-sign/project.toml b/examples/fyeah-sign/project.toml index 2c6694e02..2749647be 100644 --- a/examples/fyeah-sign/project.toml +++ b/examples/fyeah-sign/project.toml @@ -2,19 +2,19 @@ kind = "Project" name = "fyeah-sign" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.button] -def = { path = "./button.toml" } +ref = "./button.toml" [nodes.radio] -def = { path = "./radio.toml" } +ref = "./radio.toml" [nodes.playlist] -def = { path = "./playlist.toml" } +ref = "./playlist.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" diff --git a/examples/perf/baseline/project.toml b/examples/perf/baseline/project.toml index fd7b5678e..240c4859e 100644 --- a/examples/perf/baseline/project.toml +++ b/examples/perf/baseline/project.toml @@ -2,16 +2,16 @@ kind = "Project" name = "perf-baseline" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.texture] -def = { path = "./texture.toml" } +ref = "./texture.toml" [nodes.shader] -def = { path = "./shader.toml" } +ref = "./shader.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" diff --git a/examples/perf/fastmath/project.toml b/examples/perf/fastmath/project.toml index ec882dc0c..6438f31ec 100644 --- a/examples/perf/fastmath/project.toml +++ b/examples/perf/fastmath/project.toml @@ -2,16 +2,16 @@ kind = "Project" name = "perf-fastmath" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.texture] -def = { path = "./texture.toml" } +ref = "./texture.toml" [nodes.shader] -def = { path = "./shader.toml" } +ref = "./shader.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" diff --git a/examples/rocaille/project.toml b/examples/rocaille/project.toml index dd00ff15a..d3cc45fd1 100644 --- a/examples/rocaille/project.toml +++ b/examples/rocaille/project.toml @@ -2,16 +2,16 @@ kind = "Project" name = "rocaille" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.texture] -def = { path = "./texture.toml" } +ref = "./texture.toml" [nodes.shader] -def = { path = "./shader.toml" } +ref = "./shader.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" diff --git a/justfile b/justfile index d1731d571..cf4b59fb4 100644 --- a/justfile +++ b/justfile @@ -28,6 +28,10 @@ install-rv32-target: echo "Target {{ rv32_target }} already installed"; \ fi +# Pin the nightly toolchain + ABI-coupled `unwinding` in lockstep and validate (date defaults to today UTC; see docs/toolchain-notes.md) +bump-nightly date="": + scripts/bump-nightly.sh {{ date }} + # Generate builtin boilerplate code generate-builtins: cargo run --bin lps-builtins-gen-app -p lps-builtins-gen-app diff --git a/lp-app/lpa-client/src/client.rs b/lp-app/lpa-client/src/client.rs index af76d866b..4ac0bc95e 100644 --- a/lp-app/lpa-client/src/client.rs +++ b/lp-app/lpa-client/src/client.rs @@ -5,7 +5,11 @@ use anyhow::{Error, Result}; use lpc_model::{LpPath, LpPathBuf}; use lpc_wire::{ - ProjectReadRequest, ProjectReadResponse, WireProjectHandle as ProjectHandle, WireServerMessage, + ProjectReadRequest, ProjectReadResponse, WireOverlayCommitRequest, WireOverlayCommitResponse, + WireOverlayMutationRequest, WireOverlayMutationResponse, WireOverlayReadRequest, + WireOverlayReadResponse, WireProjectCommand, WireProjectCommandResponse, + WireProjectHandle as ProjectHandle, WireProjectInventoryReadRequest, + WireProjectInventoryReadResponse, WireServerMessage, messages::{ClientMessage, ClientRequest}, server::{AvailableProject, FsResponse, LoadedProject, ServerMsgBody}, }; @@ -443,6 +447,98 @@ impl LpClient { .await } + pub async fn project_command( + &self, + handle: ProjectHandle, + command: WireProjectCommand, + ) -> Result { + let request = ClientRequest::ProjectCommand { handle, command }; + let response = self.send_request(request).await?; + match response.msg { + ServerMsgBody::ProjectCommand { response } => Ok(response), + _ => Err(Error::msg(format!( + "Unexpected response type for project_command: {:?}", + response.msg + ))), + } + } + + pub async fn project_overlay_read( + &self, + handle: ProjectHandle, + ) -> Result { + match self + .project_command( + handle, + WireProjectCommand::ReadOverlay { + request: WireOverlayReadRequest, + }, + ) + .await? + { + WireProjectCommandResponse::ReadOverlay { response } => Ok(response), + other => Err(Error::msg(format!( + "Unexpected project command response for overlay read: {other:?}" + ))), + } + } + + pub async fn project_overlay_mutate( + &self, + handle: ProjectHandle, + request: WireOverlayMutationRequest, + ) -> Result { + match self + .project_command(handle, WireProjectCommand::MutateOverlay { request }) + .await? + { + WireProjectCommandResponse::MutateOverlay { response } => Ok(response), + other => Err(Error::msg(format!( + "Unexpected project command response for overlay mutate: {other:?}" + ))), + } + } + + pub async fn project_overlay_commit( + &self, + handle: ProjectHandle, + ) -> Result { + match self + .project_command( + handle, + WireProjectCommand::CommitOverlay { + request: WireOverlayCommitRequest, + }, + ) + .await? + { + WireProjectCommandResponse::CommitOverlay { response } => Ok(response), + other => Err(Error::msg(format!( + "Unexpected project command response for overlay commit: {other:?}" + ))), + } + } + + pub async fn project_inventory_read( + &self, + handle: ProjectHandle, + ) -> Result { + match self + .project_command( + handle, + WireProjectCommand::ReadInventory { + request: WireProjectInventoryReadRequest, + }, + ) + .await? + { + WireProjectCommandResponse::ReadInventory { response } => Ok(response), + other => Err(Error::msg(format!( + "Unexpected project command response for inventory read: {other:?}" + ))), + } + } + /// List available projects on the server filesystem /// /// # Returns diff --git a/lp-app/lpa-server/Cargo.toml b/lp-app/lpa-server/Cargo.toml index 6568a9680..1a389032c 100644 --- a/lp-app/lpa-server/Cargo.toml +++ b/lp-app/lpa-server/Cargo.toml @@ -12,6 +12,7 @@ default = ["std"] panic-recovery = ["lpc-engine/panic-recovery"] naga = ["lpc-engine/naga"] std = [ + "lpc-hardware/std", "lpc-shared/std", "lpc-engine/std", "lpc-wire/std", @@ -21,7 +22,9 @@ std = [ [dependencies] lpc-engine = { path = "../../lp-core/lpc-engine", default-features = false } +lpc-hardware = { path = "../../lp-core/lpc-hardware", default-features = false } lpc-model = { path = "../../lp-core/lpc-model", default-features = false } +lpc-registry = { path = "../../lp-core/lpc-registry", default-features = false } lpc-wire = { path = "../../lp-core/lpc-wire", default-features = false } lpc-shared = { path = "../../lp-core/lpc-shared", default-features = false } lpfs = { path = "../../lp-base/lpfs", default-features = false } @@ -30,7 +33,9 @@ hashbrown = { workspace = true } log = { workspace = true, default-features = false } [dev-dependencies] +lpc-hardware = { path = "../../lp-core/lpc-hardware", default-features = false, features = ["std"] } lpc-engine = { path = "../../lp-core/lpc-engine", default-features = false, features = ["std"] } +lpc-registry = { path = "../../lp-core/lpc-registry", default-features = false, features = ["std"] } lpc-view = { path = "../../lp-core/lpc-view", default-features = false, features = ["std"] } lpc-shared = { path = "../../lp-core/lpc-shared", default-features = false, features = ["std"] } lpfs = { path = "../../lp-base/lpfs", default-features = false, features = ["std"] } diff --git a/lp-app/lpa-server/src/handlers.rs b/lp-app/lpa-server/src/handlers.rs index 03a4d52e2..3c2c6a9ae 100644 --- a/lp-app/lpa-server/src/handlers.rs +++ b/lp-app/lpa-server/src/handlers.rs @@ -13,7 +13,8 @@ use lpc_shared::backtrace; use lpc_shared::output::OutputProvider; use lpc_shared::time::TimeProvider; use lpc_wire::{ - WireServerMessage, WireServerMsgBody as ServerMessagePayload, + WireProjectCommand, WireProjectCommandResponse, WireServerMessage, + WireServerMsgBody as ServerMessagePayload, messages::ClientMessage, server::{AvailableProject, FsRequest, FsResponse}, }; @@ -70,6 +71,11 @@ pub fn handle_client_message( "ProjectRequest must be handled by streaming transport".into(), )); } + lpc_wire::ClientRequest::ProjectCommand { handle, command } => { + ServerMessagePayload::ProjectCommand { + response: handle_project_command(project_manager, handle, command)?, + } + } lpc_wire::ClientRequest::ListAvailableProjects => { handle_list_available_projects(project_manager, base_fs)? } @@ -84,6 +90,39 @@ pub fn handle_client_message( Ok(WireServerMessage { id, msg: response }) } +fn handle_project_command( + project_manager: &mut ProjectManager, + handle: lpc_wire::WireProjectHandle, + command: WireProjectCommand, +) -> Result { + let project = project_manager + .get_project_mut(handle) + .ok_or_else(|| ServerError::ProjectNotFound(format!("handle {}", handle.id())))?; + + match command { + WireProjectCommand::ReadOverlay { request: _ } => { + Ok(WireProjectCommandResponse::ReadOverlay { + response: project.read_overlay(), + }) + } + WireProjectCommand::MutateOverlay { request } => { + Ok(WireProjectCommandResponse::MutateOverlay { + response: project.mutate_overlay(request)?, + }) + } + WireProjectCommand::CommitOverlay { request } => { + Ok(WireProjectCommandResponse::CommitOverlay { + response: project.commit_overlay(request)?, + }) + } + WireProjectCommand::ReadInventory { request: _ } => { + Ok(WireProjectCommandResponse::ReadInventory { + response: project.read_inventory(), + }) + } + } +} + /// Handle a filesystem request fn handle_fs_request(fs: &mut dyn LpFs, request: FsRequest) -> Result { match request { diff --git a/lp-app/lpa-server/src/project.rs b/lp-app/lpa-server/src/project.rs index 11fd234a2..156ffa452 100644 --- a/lp-app/lpa-server/src/project.rs +++ b/lp-app/lpa-server/src/project.rs @@ -7,12 +7,18 @@ use crate::server::MemoryStatsFn; use alloc::{boxed::Box, format, rc::Rc, string::String, sync::Arc}; use core::cell::RefCell; use lpc_engine::{ButtonService, Engine, EngineServices, LpGraphics, ProjectLoader, RadioService}; -use lpc_model::{LpPath, LpPathBuf, TreePath}; +use lpc_hardware::HwEndpointSpec; +use lpc_model::{LpPath, LpPathBuf, TreePath, current_revision}; +use lpc_registry::{ParseCtx, ProjectRegistry}; use lpc_shared::backtrace; -use lpc_shared::hardware::HardwareEndpointSpec; use lpc_shared::output::{OutputChannelHandle, OutputDriverOptions, OutputFormat, OutputProvider}; use lpc_shared::time::TimeProvider; -use lpfs::{FsVersion, LpFs}; +use lpc_wire::{ + ProjectReadRequest, ProjectReadResponse, WireOverlayCommitRequest, WireOverlayCommitResponse, + WireOverlayMutationRequest, WireOverlayMutationResponse, WireOverlayReadResponse, + WireProjectInventoryReadResponse, +}; +use lpfs::{FsEvent, FsVersion, LpFs}; /// A project instance wrapping one loaded engine. pub struct Project { @@ -22,18 +28,20 @@ pub struct Project { path: LpPathBuf, /// Chrooted filesystem for this project. fs: Rc>, - /// Shared output provider used when rebuilding engine services. + /// Shared output provider used by engine services and manual recovery reloads. output_provider: Rc>, - /// Shared time provider used when rebuilding engine services. + /// Shared time provider used by engine services and manual recovery reloads. time_provider: Option>, - /// Shared button service used when rebuilding engine services. + /// Shared button service used by engine services and manual recovery reloads. button_service: Option>, - /// Shared radio service used when rebuilding engine services. + /// Shared radio service used by engine services and manual recovery reloads. radio_service: Option>, /// Optional memory stats callback for project load/reload checkpoints. memory_stats: Option, /// Graphics backend used by shader runtime nodes. graphics: Arc, + /// Canonical project registry: artifacts, overlay, effective defs/assets. + registry: ProjectRegistry, /// The loaded project engine. runtime: Option, /// Last filesystem version processed by this project @@ -62,20 +70,21 @@ impl Project { let root_path = project_root_path(&name)?; log_memory(memory_stats, "project new after root path"); backtrace::set_oom_context("project new: engine services"); - let mut services = EngineServices::new(root_path); - services.set_output_provider(Some(Box::new(SharedOutputProvider( + let services = build_engine_services( + root_path, output_provider.clone(), - )))); - services.set_time_provider(time_provider.clone()); - services.set_button_service(button_service.clone()); - services.set_radio_service(radio_service.clone()); + time_provider.clone(), + button_service.clone(), + radio_service.clone(), + ); log_memory(memory_stats, "project new after services"); backtrace::set_oom_context("project new: load core project"); - let mut runtime = { + let (mut runtime, registry) = { let fs_ref = fs.borrow(); ProjectLoader::load_from_root(&*fs_ref, services) .map_err(|e| ServerError::Core(format!("Failed to load core project: {e}")))? + .into_parts() }; log_memory(memory_stats, "project new after core project"); backtrace::set_oom_context("project new: set graphics"); @@ -93,6 +102,7 @@ impl Project { radio_service, memory_stats, graphics, + registry, runtime: Some(runtime), last_fs_version: loaded_fs_version.next(), }; @@ -125,7 +135,121 @@ impl Project { .expect("project runtime is only absent while reloading") } - /// Reload the project from the filesystem. + pub fn registry(&self) -> &ProjectRegistry { + &self.registry + } + + pub fn registry_mut(&mut self) -> &mut ProjectRegistry { + &mut self.registry + } + + pub(crate) fn runtime_read_parts(&mut self) -> (&mut Engine, &ProjectRegistry) { + let runtime = self + .runtime + .as_mut() + .expect("project runtime is only absent while reloading"); + (runtime, &self.registry) + } + + pub fn tick(&mut self, delta_ms: u32) -> Result<(), ServerError> { + let registry = &self.registry; + let runtime = self + .runtime + .as_mut() + .expect("project runtime is only absent while reloading"); + runtime + .tick(registry, delta_ms) + .map_err(|e| ServerError::Core(format!("{e}"))) + } + + pub fn read_project(&mut self, request: ProjectReadRequest) -> ProjectReadResponse { + let registry = &self.registry; + let runtime = self + .runtime + .as_mut() + .expect("project runtime is only absent while reloading"); + runtime.read_project(registry, request) + } + + pub fn read_overlay(&self) -> WireOverlayReadResponse { + WireOverlayReadResponse::new(self.registry.overlay().get().clone()) + } + + pub fn read_inventory(&self) -> WireProjectInventoryReadResponse { + let index = self.engine().project_runtime_index(); + WireProjectInventoryReadResponse::from_inventory_with_runtime_ids( + self.registry.inventory(), + |use_location| index.node_id(use_location), + ) + } + + pub fn mutate_overlay( + &mut self, + request: WireOverlayMutationRequest, + ) -> Result { + let frame = current_revision(); + let shapes = self.engine().slot_shapes().clone(); + let ctx = ParseCtx { shapes: &shapes }; + let result = { + let fs_ref = self.fs.borrow(); + self.registry + .mutate_batch(&*fs_ref, request.batch, frame, &ctx) + }; + { + let fs_ref = self.fs.borrow(); + self.runtime + .as_mut() + .expect("project runtime is only absent while reloading") + .apply_project_changes(&*fs_ref, &mut self.registry, &result.changes) + .map_err(|e| ServerError::Core(format!("apply project changes: {e}")))?; + } + Ok(WireOverlayMutationResponse::new(result.commands)) + } + + pub fn commit_overlay( + &mut self, + _request: WireOverlayCommitRequest, + ) -> Result { + let frame = current_revision(); + let shapes = self.engine().slot_shapes().clone(); + let ctx = ParseCtx { shapes: &shapes }; + let (result, committed_fs_version) = { + let fs_ref = self.fs.borrow(); + let result = self + .registry + .commit_overlay(&*fs_ref, frame, &ctx) + .map_err(|e| ServerError::Core(format!("commit overlay: {e:?}")))?; + (result, fs_ref.current_version()) + }; + self.last_fs_version = committed_fs_version.next(); + Ok(WireOverlayCommitResponse::new(result)) + } + + pub fn refresh_artifacts(&mut self, events: &[FsEvent]) -> Result<(), ServerError> { + let frame = current_revision(); + let shapes = self.engine().slot_shapes().clone(); + let ctx = ParseCtx { shapes: &shapes }; + let changes = { + let fs_ref = self.fs.borrow(); + self.registry + .refresh_artifacts(&*fs_ref, events, frame, &ctx) + }; + { + let fs_ref = self.fs.borrow(); + self.runtime + .as_mut() + .expect("project runtime is only absent while reloading") + .apply_project_changes(&*fs_ref, &mut self.registry, &changes) + .map_err(|e| ServerError::Core(format!("apply project changes: {e}")))?; + } + Ok(()) + } + + /// Manually reload the registry and runtime from durable artifacts. + /// + /// Normal overlay mutation and filesystem refresh paths use incremental + /// registry-driven apply. This is a recovery path for callers that want to + /// discard live runtime state and rebuild from the committed filesystem. pub fn reload(&mut self) -> Result<(), ServerError> { log_memory(self.memory_stats, "project reload start"); backtrace::set_oom_context("project reload: drop old runtime"); @@ -135,24 +259,26 @@ impl Project { let root_path = project_root_path(&self.name)?; log_memory(self.memory_stats, "project reload after root path"); backtrace::set_oom_context("project reload: engine services"); - let mut services = EngineServices::new(root_path); - services.set_output_provider(Some(Box::new(SharedOutputProvider( + let services = build_engine_services( + root_path, self.output_provider.clone(), - )))); - services.set_time_provider(self.time_provider.clone()); - services.set_button_service(self.button_service.clone()); - services.set_radio_service(self.radio_service.clone()); + self.time_provider.clone(), + self.button_service.clone(), + self.radio_service.clone(), + ); log_memory(self.memory_stats, "project reload after services"); backtrace::set_oom_context("project reload: load core project"); - let mut runtime = { + let (mut runtime, registry) = { let fs_ref = self.fs.borrow(); ProjectLoader::load_from_root(&*fs_ref, services) .map_err(|e| ServerError::Core(format!("Failed to reload core project: {e}")))? + .into_parts() }; 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())); + self.registry = registry; self.runtime = Some(runtime); log_memory(self.memory_stats, "project reload after swap"); backtrace::clear_oom_context(); @@ -182,16 +308,31 @@ fn log_memory(memory_stats: Option, label: &str) { } } +fn build_engine_services( + root_path: TreePath, + output_provider: Rc>, + time_provider: Option>, + button_service: Option>, + radio_service: Option>, +) -> EngineServices { + let mut services = EngineServices::new(root_path); + services.set_output_provider(Some(Box::new(SharedOutputProvider(output_provider)))); + services.set_time_provider(time_provider); + services.set_button_service(button_service); + services.set_radio_service(radio_service); + services +} + struct SharedOutputProvider(Rc>); impl OutputProvider for SharedOutputProvider { fn open( &self, - endpoint: &HardwareEndpointSpec, + endpoint: &HwEndpointSpec, byte_count: u32, format: OutputFormat, options: Option, - ) -> Result { + ) -> Result { self.0.borrow().open(endpoint, byte_count, format, options) } @@ -199,11 +340,11 @@ impl OutputProvider for SharedOutputProvider { &self, handle: OutputChannelHandle, data: &[u16], - ) -> Result<(), lpc_shared::error::OutputError> { + ) -> Result<(), lpc_hardware::OutputError> { self.0.borrow().write(handle, data) } - fn close(&self, handle: OutputChannelHandle) -> Result<(), lpc_shared::error::OutputError> { + fn close(&self, handle: OutputChannelHandle) -> Result<(), lpc_hardware::OutputError> { self.0.borrow().close(handle) } } diff --git a/lp-app/lpa-server/src/project_read_source.rs b/lp-app/lpa-server/src/project_read_source.rs index 2cbf233f5..d030d2da3 100644 --- a/lp-app/lpa-server/src/project_read_source.rs +++ b/lp-app/lpa-server/src/project_read_source.rs @@ -1,134 +1,54 @@ //! Server-decorated project-read source. -extern crate alloc; - -use alloc::vec::Vec; -use lpc_engine::Engine; +use lpc_engine::EngineProjectReadSource; use lpc_shared::transport::ProjectReadJsonSource; -use lpc_wire::json::json_write::JsonWrite; -use lpc_wire::json::json_writer::{JsonWriter, JsonWriterError}; -use lpc_wire::{ - ProjectProbeRequest, ProjectReadQuery, ProjectReadResult, RuntimeReadQuery, - ServerRuntimeStatus, WireSlotMutationRequest, WireSlotMutationResponse, WireSlotMutationResult, - write_project_read_result_json, -}; +use lpc_wire::ServerRuntimeStatus; + +use crate::project::Project; /// Project-read source that adds server-loop status to runtime queries. pub(crate) struct ServerProjectReadSource<'a> { - engine: &'a mut Engine, - server_status: Option, + source: EngineProjectReadSource<'a>, } impl<'a> ServerProjectReadSource<'a> { - pub(crate) fn new(engine: &'a mut Engine, server_status: Option) -> Self { + pub(crate) fn new( + project: &'a mut Project, + server_status: Option, + ) -> Self { + let (engine, registry) = project.runtime_read_parts(); Self { - engine, - server_status, + source: EngineProjectReadSource::with_server_status(engine, registry, server_status), } } } impl ProjectReadJsonSource for ServerProjectReadSource<'_> { fn project_read_revision(&self) -> lpc_model::Revision { - self.engine.revision() - } - - fn apply_project_mutations( - &mut self, - mutations: Vec, - ) -> Vec { - log_project_mutations(&mutations); - let responses = self.engine.mutate_project_slots(mutations); - log_project_mutation_responses(&responses); - responses + self.source.project_read_revision() } fn write_project_read_result_json( &mut self, since: Option, - query: ProjectReadQuery, + query: lpc_wire::ProjectReadQuery, out: W, - ) -> Result> + ) -> Result> where - W: JsonWrite, + W: lpc_wire::json::json_write::JsonWrite, { - match query { - ProjectReadQuery::Runtime(query) => self.write_project_runtime_result_json(query, out), - other => ProjectReadJsonSource::write_project_read_result_json( - self.engine, - since, - other, - out, - ), - } + self.source + .write_project_read_result_json(since, query, out) } fn write_project_probe_result_json( &mut self, - probe: ProjectProbeRequest, - out: W, - ) -> Result> - where - W: JsonWrite, - { - ProjectReadJsonSource::write_project_probe_result_json(self.engine, probe, out) - } -} - -fn log_project_mutations(mutations: &[WireSlotMutationRequest]) { - if mutations.is_empty() { - return; - } - log::info!("received {} project slot mutation(s)", mutations.len()); - for mutation in mutations { - log::info!( - "slot mutation id={} root={} path={} op={:?}", - mutation.id.id(), - mutation.root, - mutation.path, - mutation.op, - ); - log::info!( - "slot mutation id={} expected shape_rev={} data_rev={}", - mutation.id.id(), - mutation.expected_shape_version.0, - mutation.expected_data_version.0, - ); - } -} - -fn log_project_mutation_responses(responses: &[WireSlotMutationResponse]) { - for response in responses { - match &response.result { - WireSlotMutationResult::Accepted => { - log::info!("slot mutation id={} accepted", response.id.id()); - } - WireSlotMutationResult::Rejected(rejection) => { - log::warn!( - "slot mutation id={} rejected: {:?}", - response.id.id(), - rejection, - ); - } - } - } -} - -impl ServerProjectReadSource<'_> { - fn write_project_runtime_result_json( - &mut self, - query: RuntimeReadQuery, + probe: lpc_wire::ProjectProbeRequest, out: W, - ) -> Result> + ) -> Result> where - W: JsonWrite, + W: lpc_wire::json::json_write::JsonWrite, { - let result = ProjectReadResult::Runtime( - self.engine - .read_project_runtime(query, self.server_status.clone()), - ); - let mut writer = JsonWriter::new(out); - write_project_read_result_json(&mut writer, &result)?; - Ok(writer.into_inner()) + self.source.write_project_probe_result_json(probe, out) } } diff --git a/lp-app/lpa-server/src/server.rs b/lp-app/lpa-server/src/server.rs index b12804cb4..9dead4797 100644 --- a/lp-app/lpa-server/src/server.rs +++ b/lp-app/lpa-server/src/server.rs @@ -16,7 +16,7 @@ use lpc_shared::output::OutputProvider; use lpc_shared::time::TimeProvider; use lpc_shared::transport::ServerTransport; use lpc_wire::{ClientRequest, WireMessage, WireServerMessage}; -use lpfs::{FsChange, LpFs}; +use lpfs::{FsEvent, LpFs}; /// Optional callback returning (free_bytes, used_bytes) for memory logging. /// Platforms without heap stats (e.g. fw-emu) pass `None`. @@ -160,7 +160,7 @@ impl LpServer { ); // Collect changes per project - let mut project_changes_map: HashMap<_, Vec> = HashMap::new(); + let mut project_changes_map: HashMap<_, Vec> = HashMap::new(); for (handle, project_path) in &project_info { if let Some(project) = self.project_manager.get_project(*handle) { @@ -178,14 +178,14 @@ impl LpServer { // Build project prefix path using join - ensure it ends with / let project_prefix_buf = LpPathBuf::from("/").join(project_path.as_str()).join(""); let project_prefix = project_prefix_buf.as_str(); - let project_changes: Vec = base_changes + let project_changes: Vec = base_changes .into_iter() .filter_map(|change| { // Use LpPath to strip prefix and normalize if let Some(stripped) = change.path.strip_prefix(project_prefix) { - Some(FsChange { + Some(FsEvent { path: stripped.to_path_buf(), - change_type: change.change_type, + kind: change.kind, }) } else { None @@ -199,23 +199,20 @@ impl LpServer { } } - // Get current_version AFTER collecting all changes but BEFORE processing - // This represents the version that will be assigned to the NEXT change - // So all changes we're about to process have versions < current_version + // Capture the next version before refresh applies anything. The events + // in this batch are all older than this marker. let current_version = self.base_fs().current_version(); // Now apply changes to projects (mutable borrows) - for (handle, _project_changes) in project_changes_map { + for (handle, project_changes) in project_changes_map { if let Some(project) = self.project_manager.get_project_mut(handle) { - if let Err(_e) = project.reload() { + if let Err(_e) = project.refresh_artifacts(&project_changes) { // Log error but continue with other projects // Note: In no_std context, errors are silently ignored // Errors will be visible when clients read project state. } else { - // Update last processed version to current_version.next() (one more than the next version) - // This ensures that get_changes_since(current_version.next()) will return nothing next time - // because get_changes_since uses >=, and current_version.next() is beyond all changes we processed - // All changes we processed have versions < current_version, so >= current_version.next() returns nothing + // Advance past the batch marker so the same events are not + // returned again by get_changes_since, which is inclusive. project.update_fs_version(current_version.next()); } } @@ -235,7 +232,7 @@ impl LpServer { ); // Ignore errors and continue with other projects // Errors will be visible when clients sync or query project state - match project.engine_mut().tick(delta_ms) { + match project.tick(delta_ms) { Ok(()) => { log::trace!("LpServer::tick: Project {} tick succeeded", project.name()); } @@ -309,10 +306,8 @@ impl LpServer { response_count += 1; continue; }; - let mut source = ServerProjectReadSource::new( - project.engine_mut(), - Some(server_status), - ); + let mut source = + ServerProjectReadSource::new(project, Some(server_status)); transport .send_project_read(msg_id, handle, &mut source, request) .await diff --git a/lp-app/lpa-server/src/template.rs b/lp-app/lpa-server/src/template.rs index dcc53cdef..4ea636d6b 100644 --- a/lp-app/lpa-server/src/template.rs +++ b/lp-app/lpa-server/src/template.rs @@ -16,19 +16,19 @@ use lpfs::LpFs; const PROJECT_TOML: &[u8] = br#"kind = "Project" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.texture] -def = { path = "./texture.toml" } +ref = "./texture.toml" [nodes.shader] -def = { path = "./shader.toml" } +ref = "./shader.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" "#; /// TOML for the default clock node. diff --git a/lp-app/lpa-server/tests/project_fs_refresh.rs b/lp-app/lpa-server/tests/project_fs_refresh.rs new file mode 100644 index 000000000..e85880171 --- /dev/null +++ b/lp-app/lpa-server/tests/project_fs_refresh.rs @@ -0,0 +1,168 @@ +extern crate alloc; + +use alloc::format; +use alloc::rc::Rc; +use alloc::sync::Arc; +use core::cell::RefCell; + +use lpa_server::{Graphics, LpGraphics, LpServer, Project}; +use lpc_model::{ + ArtifactLocation, AsLpPath, LpPathBuf, LpValue, MutationCmd, MutationCmdBatch, MutationCmdId, + MutationOp, NodeDef, NodeDefLocation, NodeUseLocation, SlotEdit, SlotPath, +}; +use lpc_shared::output::MemoryOutputProvider; +use lpc_wire::{WireOverlayCommitRequest, WireOverlayMutationRequest}; +use lpfs::LpFsMemory; + +#[test] +fn server_tick_refreshes_referenced_artifact_without_recreating_runtime_node() { + let (mut server, project_path) = server_with_clock_project("fs-refresh"); + let handle = server.load_project(project_path.as_path()).expect("load"); + let before_id = clock_runtime_node_id(project(&server, handle)); + + server + .base_fs_mut() + .write_file( + project_file("fs-refresh", "clock.toml").as_path(), + clock_toml_with_rate(2.0).as_bytes(), + ) + .expect("write clock"); + + server.advance_frame(16).expect("tick"); + + let project = project(&server, handle); + assert_eq!(clock_rate(project), 2.0); + assert_eq!(clock_runtime_node_id(project), before_id); +} + +#[test] +fn overlay_commit_does_not_echo_as_external_fs_change() { + let (mut server, project_path) = server_with_clock_project("commit-echo"); + let handle = server.load_project(project_path.as_path()).expect("load"); + let before_id = clock_runtime_node_id(project(&server, handle)); + + let version_after_commit = { + let project = server + .project_manager_mut() + .get_project_mut(handle) + .expect("project"); + project + .mutate_overlay(WireOverlayMutationRequest::new(MutationCmdBatch::new( + vec![MutationCmd { + id: MutationCmdId::new(1), + mutation: MutationOp::PutSlotEdit { + artifact: ArtifactLocation::file("/clock.toml"), + edit: SlotEdit::assign_value( + SlotPath::parse("controls.rate").expect("rate path"), + LpValue::F32(3.0), + ), + }, + }], + ))) + .expect("mutate overlay"); + assert_eq!(clock_rate(project), 3.0); + assert_eq!(clock_runtime_node_id(project), before_id); + + project + .commit_overlay(WireOverlayCommitRequest) + .expect("commit overlay"); + project.last_fs_version() + }; + + assert!( + server + .base_fs() + .get_changes_since(version_after_commit) + .is_empty() + ); + + server.advance_frame(16).expect("tick"); + + let project = project(&server, handle); + assert_eq!(project.last_fs_version(), version_after_commit); + assert_eq!(clock_rate(project), 3.0); + assert_eq!(clock_runtime_node_id(project), before_id); +} + +fn server_with_clock_project(name: &str) -> (LpServer, LpPathBuf) { + let output_provider = Rc::new(RefCell::new(MemoryOutputProvider::new())); + let graphics: Arc = Arc::new(Graphics::new()); + 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_file(name, "project.toml").as_path(), + br#" +kind = "Project" + +[nodes.clock] +ref = "./clock.toml" +"#, + ) + .expect("write project"); + server + .base_fs_mut() + .write_file( + project_file(name, "clock.toml").as_path(), + clock_toml_with_rate(1.0).as_bytes(), + ) + .expect("write clock"); + + (server, project_path) +} + +fn project<'a>(server: &'a LpServer, handle: lpc_wire::WireProjectHandle) -> &'a Project { + server + .project_manager() + .get_project(handle) + .expect("loaded project") +} + +fn clock_runtime_node_id(project: &Project) -> lpc_model::NodeId { + project + .engine() + .project_runtime_index() + .node_id(&clock_use()) + .expect("clock runtime node") +} + +fn clock_rate(project: &Project) -> f32 { + let entry = project + .registry() + .def(&NodeDefLocation::artifact_root(ArtifactLocation::file( + "/clock.toml", + ))) + .expect("clock definition"); + let NodeDef::Clock(def) = entry.state.loaded_def().expect("loaded clock") else { + panic!("expected clock definition"); + }; + *def.controls.rate.value() +} + +fn clock_use() -> NodeUseLocation { + NodeUseLocation::root().child(SlotPath::parse("nodes[clock]").expect("clock use path")) +} + +fn project_file(project: &str, file: &str) -> LpPathBuf { + LpPathBuf::from("/projects").join(project).join(file) +} + +fn clock_toml_with_rate(rate: f32) -> alloc::string::String { + format!( + r#" +kind = "Clock" + +[controls] +rate = {rate} +"# + ) +} diff --git a/lp-base/lp-collection/Cargo.toml b/lp-base/lp-collection/Cargo.toml new file mode 100644 index 000000000..00c7e2802 --- /dev/null +++ b/lp-base/lp-collection/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "lp-collection" +version = "1.0.0" +authors.workspace = true +edition.workspace = true +license = "Apache-2.0 WITH LLVM-exception" +rust-version.workspace = true +description = "Embedded/low-memory friendly collections: chunked structures plus sorted-vec map/set" + +[features] +default = [] +serde = ["dep:serde"] + +[dependencies] +rustc-hash = { version = "2.0.0", default-features = false } +serde = { version = "1", default-features = false, features = ["alloc"], optional = true } + +[dev-dependencies] +serde_json = { version = "1", default-features = false, features = ["alloc"] } diff --git a/lp-base/lp-collection/src/chunked_hashmap.rs b/lp-base/lp-collection/src/chunked_hashmap.rs new file mode 100644 index 000000000..7c6066c9c --- /dev/null +++ b/lp-base/lp-collection/src/chunked_hashmap.rs @@ -0,0 +1,577 @@ +//! Chunked hash map for allocation-sensitive contexts (e.g. embedded). +//! +//! Uses fixed 64 buckets, each backed by a ChunkedVec, to avoid large contiguous +//! hash table allocations that cause OOM on fragmented heaps. Allocates in small +//! chunks (16 entries per chunk) instead of one large SwissTable-style allocation. + +use core::hash::{Hash, Hasher}; +use rustc_hash::FxHasher; + +use crate::chunked_vec::ChunkedVec; + +/// Number of buckets. Fixed to avoid any resize allocation. +const NUM_BUCKETS: usize = 12; + +/// Hash a key to a bucket index using FxHasher. +#[inline] +fn bucket_index(key: &Q) -> usize { + let mut hasher = FxHasher::default(); + key.hash(&mut hasher); + (hasher.finish() as usize) % NUM_BUCKETS +} + +/// A hash map backed by chunked allocations. +/// +/// Uses 64 fixed buckets, each containing a ChunkedVec of (K, V) pairs. +/// Collisions are resolved by linear scan within the bucket. Lookup is O(n/64) +/// on average. No large contiguous allocation—each ChunkedVec grows in 16-entry +/// chunks. +#[derive(Debug)] +pub struct ChunkedHashMap { + buckets: [ChunkedVec<(K, V)>; NUM_BUCKETS], + len: usize, +} + +impl Default for ChunkedHashMap { + fn default() -> Self { + Self { + buckets: core::array::from_fn(|_| ChunkedVec::new()), + len: 0, + } + } +} + +impl ChunkedHashMap { + /// Create an empty ChunkedHashMap. + pub fn new() -> Self { + Self::default() + } + + /// Create with capacity hint. Currently a no-op; buckets allocate on demand. + pub fn with_capacity(_capacity: usize) -> Self { + Self::new() + } + + /// Number of key-value pairs. + pub fn len(&self) -> usize { + self.len + } + + /// Returns true if the map is empty. + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Inserts a key-value pair. Returns the previous value if the key existed. + pub fn insert(&mut self, key: K, value: V) -> Option + where + K: Eq + Hash, + { + let bi = bucket_index(&key); + let bucket = &mut self.buckets[bi]; + for i in 0..bucket.len() { + if bucket.get(i).map(|(k, _)| k == &key).unwrap_or(false) { + let (_, v) = bucket.get_mut(i).unwrap(); + return Some(core::mem::replace(v, value)); + } + } + bucket.push((key, value)); + self.len += 1; + None + } + + /// Gets a reference to the value for the key. + pub fn get(&self, key: &Q) -> Option<&V> + where + K: Eq + Hash + core::borrow::Borrow, + Q: Hash + Eq + ?Sized, + { + let bi = bucket_index(key); + let bucket = &self.buckets[bi]; + for i in 0..bucket.len() { + let (k, v) = bucket.get(i).unwrap(); + if k.borrow() == key { + return Some(v); + } + } + None + } + + /// Gets a mutable reference to the value for the key. + pub fn get_mut(&mut self, key: &Q) -> Option<&mut V> + where + K: Eq + Hash + core::borrow::Borrow, + Q: Hash + Eq + ?Sized, + { + let bi = bucket_index(key); + let bucket = &mut self.buckets[bi]; + let mut found = None; + for i in 0..bucket.len() { + let (k, _) = bucket.get(i).unwrap(); + if k.borrow() == key { + found = Some(i); + break; + } + } + match found { + Some(i) => { + let (_, v) = bucket.get_mut(i).unwrap(); + Some(v) + } + None => None, + } + } + + /// Returns true if the map contains the key. + pub fn contains_key(&self, key: &Q) -> bool + where + K: Eq + Hash + core::borrow::Borrow, + Q: Hash + Eq + ?Sized, + { + self.get(key).is_some() + } + + /// Removes the key from the map, returning the previous value if present. + pub fn remove(&mut self, key: &Q) -> Option + where + K: Eq + Hash + core::borrow::Borrow, + Q: Hash + Eq + ?Sized, + { + let bi = bucket_index(key); + let bucket = &mut self.buckets[bi]; + for i in 0..bucket.len() { + let (k, _) = bucket.get(i).unwrap(); + if k.borrow() == key { + let (_, v) = bucket.swap_remove(i).unwrap(); + self.len -= 1; + return Some(v); + } + } + None + } + + /// Gets the entry for the given key. + pub fn entry(&mut self, key: K) -> Entry<'_, K, V> + where + K: Eq + Hash, + { + let bi = bucket_index(&key); + let bucket = &mut self.buckets[bi]; + for i in 0..bucket.len() { + let (k, _) = bucket.get(i).unwrap(); + if k == &key { + return Entry::Occupied(OccupiedEntry { + map: self, + bucket_index: bi, + slot_index: i, + }); + } + } + Entry::Vacant(VacantEntry { + map: self, + key, + bucket_index: bi, + }) + } + + /// Retains only the entries for which the predicate returns true. + pub fn retain(&mut self, mut f: F) + where + K: Eq + Hash, + F: FnMut(&K, &mut V) -> bool, + { + for bucket in &mut self.buckets { + let mut i = 0; + while i < bucket.len() { + let keep = { + let (k, v) = bucket.get_mut(i).unwrap(); + f(k, v) + }; + if !keep { + let _ = bucket.swap_remove(i); + self.len -= 1; + } else { + i += 1; + } + } + } + } + + /// Returns an iterator over (key, value) references. + pub fn iter(&self) -> impl Iterator { + self.buckets.iter().flat_map(|b| { + (0..b.len()).map(move |i| { + let (k, v) = b.get(i).unwrap(); + (k, v) + }) + }) + } +} + +/// A view into a single entry in a map, which may be vacant or occupied. +pub enum Entry<'a, K, V> { + /// An occupied entry. + Occupied(OccupiedEntry<'a, K, V>), + /// A vacant entry. + Vacant(VacantEntry<'a, K, V>), +} + +impl<'a, K: Eq + Hash, V> Entry<'a, K, V> { + /// Ensures a value is in the entry by inserting the default if vacant. + pub fn or_insert(self, default: V) -> &'a mut V { + match self { + Entry::Occupied(o) => o.into_mut(), + Entry::Vacant(v) => v.insert(default), + } + } + + /// Ensures a value is in the entry by inserting the result of the closure if vacant. + pub fn or_insert_with(self, default: F) -> &'a mut V + where + F: FnOnce() -> V, + { + match self { + Entry::Occupied(o) => o.into_mut(), + Entry::Vacant(v) => v.insert(default()), + } + } +} + +/// A view into an occupied entry in a ChunkedHashMap. +pub struct OccupiedEntry<'a, K, V> { + map: &'a mut ChunkedHashMap, + bucket_index: usize, + slot_index: usize, +} + +impl<'a, K, V> OccupiedEntry<'a, K, V> { + /// Gets a reference to the value. + pub fn get(&self) -> &V { + let (_, v) = self.map.buckets[self.bucket_index] + .get(self.slot_index) + .unwrap(); + v + } + + /// Gets a mutable reference to the value. + pub fn get_mut(&mut self) -> &mut V { + let (_, v) = self.map.buckets[self.bucket_index] + .get_mut(self.slot_index) + .unwrap(); + v + } + + /// Consumes the entry and returns a mutable reference to the value. + /// Used by or_insert to return a ref with the map's lifetime. + fn into_mut(self) -> &'a mut V { + let (_, v) = self.map.buckets[self.bucket_index] + .get_mut(self.slot_index) + .unwrap(); + v + } + + /// Sets the value of the entry, returning the old value. + pub fn insert(&mut self, value: V) -> V { + let (_, v) = self.map.buckets[self.bucket_index] + .get_mut(self.slot_index) + .unwrap(); + core::mem::replace(v, value) + } +} + +/// A view into a vacant entry in a ChunkedHashMap. +pub struct VacantEntry<'a, K, V> { + map: &'a mut ChunkedHashMap, + key: K, + bucket_index: usize, +} + +impl<'a, K, V> VacantEntry<'a, K, V> { + /// Sets the value of the entry with the vacant entry's key. + pub fn insert(self, value: V) -> &'a mut V { + self.map.buckets[self.bucket_index].push((self.key, value)); + self.map.len += 1; + let (_, v) = self.map.buckets[self.bucket_index] + .get_mut(self.map.buckets[self.bucket_index].len() - 1) + .unwrap(); + v + } + + /// Sets the value of the entry with the vacant entry's key, or inserts with default. + pub fn or_insert(self, default: V) -> &'a mut V + where + K: Eq + Hash, + { + self.insert(default) + } + + /// Sets the value of the entry with the vacant entry's key, or inserts with the result of the closure. + pub fn or_insert_with(self, default: F) -> &'a mut V + where + K: Eq + Hash, + F: FnOnce() -> V, + { + self.insert(default()) + } +} + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use super::*; + + fn chunked_hashmap() -> ChunkedHashMap { + ChunkedHashMap::new() + } + + #[test] + fn empty_len() { + let m: ChunkedHashMap = chunked_hashmap(); + assert_eq!(m.len(), 0); + } + + #[test] + fn default_empty() { + let m: ChunkedHashMap = ChunkedHashMap::default(); + assert_eq!(m.len(), 0); + } + + #[test] + fn insert_and_get() { + let mut m = chunked_hashmap(); + assert_eq!(m.get(&1), None); + m.insert(1, 10); + assert_eq!(m.get(&1), Some(&10)); + assert_eq!(m.get(&2), None); + } + + #[test] + fn insert_overwrite() { + let mut m = chunked_hashmap(); + assert_eq!(m.insert(1, 10), None); + assert_eq!(m.insert(1, 20), Some(10)); + assert_eq!(m.get(&1), Some(&20)); + } + + #[test] + fn contains_key() { + let mut m = chunked_hashmap(); + assert!(!m.contains_key(&1)); + m.insert(1, 10); + assert!(m.contains_key(&1)); + assert!(!m.contains_key(&2)); + } + + #[test] + fn entry_vacant_insert() { + let mut m = chunked_hashmap(); + m.entry(1).or_insert(10); + assert_eq!(m.get(&1), Some(&10)); + } + + #[test] + fn entry_vacant_or_insert_with() { + let mut m = chunked_hashmap(); + m.entry(1).or_insert_with(|| 42); + assert_eq!(m.get(&1), Some(&42)); + } + + #[test] + fn entry_occupied_get() { + let mut m = chunked_hashmap(); + m.insert(1, 10); + match m.entry(1) { + Entry::Occupied(o) => assert_eq!(*o.get(), 10), + Entry::Vacant(_) => panic!("expected occupied"), + } + } + + #[test] + fn entry_occupied_insert() { + let mut m = chunked_hashmap(); + m.insert(1, 10); + match m.entry(1) { + Entry::Occupied(mut o) => { + assert_eq!(o.insert(20), 10); + } + Entry::Vacant(_) => panic!("expected occupied"), + } + assert_eq!(m.get(&1), Some(&20)); + } + + #[test] + fn iter_yields_all_pairs() { + let mut m = chunked_hashmap(); + m.insert(1, 10); + m.insert(2, 20); + m.insert(3, 30); + let mut pairs: Vec<_> = m.iter().map(|(k, v)| (*k, *v)).collect(); + pairs.sort_by_key(|(k, _)| *k); + assert_eq!(pairs, [(1, 10), (2, 20), (3, 30)]); + } + + #[test] + fn retain_removes_matching() { + let mut m = chunked_hashmap(); + m.insert(1, 10); + m.insert(2, 20); + m.insert(3, 30); + m.retain(|k, _| *k != 2); + assert_eq!(m.len(), 2); + assert_eq!(m.get(&1), Some(&10)); + assert_eq!(m.get(&2), None); + assert_eq!(m.get(&3), Some(&30)); + } + + #[test] + fn many_insertions_no_rehash() { + let mut m = chunked_hashmap(); + for i in 0..600 { + m.insert(i, i * 2); + } + assert_eq!(m.len(), 600); + for i in 0..600 { + assert_eq!(m.get(&i), Some(&(i * 2))); + } + } + + #[test] + fn collisions_same_bucket() { + let mut m: ChunkedHashMap = chunked_hashmap(); + for i in 0..200 { + let k = i * 64; + m.insert(k, i); + } + assert_eq!(m.len(), 200); + for i in 0..200 { + assert_eq!(m.get(&(i * 64)), Some(&i)); + } + } + + #[test] + fn string_keys() { + let mut m: ChunkedHashMap = chunked_hashmap(); + m.insert(alloc::string::String::from("foo"), 1); + m.insert(alloc::string::String::from("bar"), 2); + assert_eq!(m.get("foo"), Some(&1)); + assert_eq!(m.get("bar"), Some(&2)); + } + + #[test] + fn tuple_keys() { + let mut m: ChunkedHashMap<(i32, i32), i32> = chunked_hashmap(); + m.insert((1, 2), 10); + m.insert((3, 4), 20); + assert_eq!(m.get(&(1, 2)), Some(&10)); + assert_eq!(m.get(&(3, 4)), Some(&20)); + } + + #[test] + fn drop_cleans_up() { + let mut m: ChunkedHashMap> = chunked_hashmap(); + for i in 0..300 { + m.insert(i, (0..100).collect()); + } + drop(m); + } +} + +// --- ChunkedHashSet --- + +/// Hash set backed by ChunkedHashMap; no large contiguous allocations. +#[derive(Debug)] +pub struct ChunkedHashSet { + map: ChunkedHashMap, +} + +impl Default for ChunkedHashSet { + fn default() -> Self { + Self { + map: ChunkedHashMap::default(), + } + } +} + +impl ChunkedHashSet { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, key: K) -> bool + where + K: Eq + Hash, + { + self.map.insert(key, ()).is_none() + } + + pub fn contains(&self, key: &Q) -> bool + where + K: Eq + Hash + core::borrow::Borrow, + Q: Hash + Eq + ?Sized, + { + self.map.contains_key(key) + } + + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + pub fn iter(&self) -> impl Iterator { + self.map.iter().map(|(k, _)| k) + } +} + +impl From<[K; N]> for ChunkedHashSet +where + K: Eq + Hash, +{ + fn from(arr: [K; N]) -> Self { + let mut set = ChunkedHashSet::new(); + for k in arr { + set.insert(k); + } + set + } +} + +impl core::iter::FromIterator for ChunkedHashSet +where + K: Eq + Hash, + I: Into, +{ + fn from_iter>(iter: T) -> Self { + let mut set = ChunkedHashSet::new(); + for k in iter { + set.insert(k.into()); + } + set + } +} + +#[cfg(test)] +mod set_tests { + use super::*; + + #[test] + fn set_insert_contains() { + let mut s: ChunkedHashSet = ChunkedHashSet::new(); + assert!(!s.contains(&1)); + s.insert(1); + assert!(s.contains(&1)); + assert_eq!(s.len(), 1); + } + + #[test] + fn set_from_iter() { + let s: ChunkedHashSet = [1, 2, 3, 2, 1].into_iter().collect(); + assert_eq!(s.len(), 3); + assert!(s.contains(&1)); + assert!(s.contains(&2)); + assert!(s.contains(&3)); + } +} diff --git a/lp-base/lp-collection/src/chunked_vec.rs b/lp-base/lp-collection/src/chunked_vec.rs new file mode 100644 index 000000000..999a41324 --- /dev/null +++ b/lp-base/lp-collection/src/chunked_vec.rs @@ -0,0 +1,541 @@ +//! Chunked vector for allocation-sensitive contexts (e.g. embedded). +//! +//! Allocates in small chunks instead of one large contiguous block to reduce +//! OOM risk from heap fragmentation when compiling on constrained heaps. +//! Shared by regalloc2 (fastalloc) and cranelift-codegen (VCode). + +use alloc::vec; +use alloc::vec::Vec; +use core::cmp::Ordering; +use core::ops::{Index, IndexMut}; +use core::ptr; + +/// Maximum chunk size in elements. Inner allocations never exceed this. +/// The last chunk grows naturally (4→8→16→32→64) until it hits this limit, +/// then a new chunk is allocated. Keeps peak allocation bounded (~2–4KB typical) +/// while reducing overhead for small vecs. +const CHUNK_SIZE: usize = 64; + +/// A vector backed by multiple smaller allocations. +/// +/// All chunks except the last have exactly CHUNK_SIZE elements. The last chunk +/// is a Vec that grows naturally up to CHUNK_SIZE, then a new chunk is started. +/// This limits peak allocation while avoiding over-allocation for small collections. +#[derive(Clone, Debug)] +pub struct ChunkedVec { + chunks: Vec>, + len: usize, +} + +impl ChunkedVec { + /// Create an empty ChunkedVec. + pub fn new() -> Self { + Self { + chunks: Vec::new(), + len: 0, + } + } + + /// Create with given length, filling with `default`. + /// Used by fastalloc for pre-allocated VReg state. + pub fn with_capacity_and_default(len: usize, default: T) -> Self + where + T: Clone, + { + let mut chunks = Vec::new(); + let mut remaining = len; + while remaining > 0 { + let chunk_len = remaining.min(CHUNK_SIZE); + chunks.push(vec![default.clone(); chunk_len]); + remaining -= chunk_len; + } + Self { chunks, len } + } + + /// Create a ChunkedVec with capacity for at least `cap` elements. + /// The last chunk starts empty and grows normally up to CHUNK_SIZE. + pub fn with_capacity(cap: usize) -> Self { + let n_chunks = cap.div_ceil(CHUNK_SIZE); + let mut chunks = Vec::with_capacity(n_chunks.max(1)); + if cap > 0 { + chunks.push(Vec::new()); + } + Self { chunks, len: 0 } + } + + pub fn len(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Asserts all inner Vecs have len and capacity ≤ CHUNK_SIZE. + #[cfg(test)] + fn assert_inner_vecs_within_limit(&self) { + for (i, chunk) in self.chunks.iter().enumerate() { + assert!( + chunk.len() <= CHUNK_SIZE, + "chunk {i} len {} > CHUNK_SIZE", + chunk.len() + ); + assert!( + chunk.capacity() <= CHUNK_SIZE, + "chunk {i} capacity {} > CHUNK_SIZE", + chunk.capacity() + ); + } + } + + #[inline] + fn chunk_and_offset(&self, i: usize) -> (usize, usize) { + if self.chunks.len() == 1 { + (0, i) + } else if i < (self.chunks.len() - 1) * CHUNK_SIZE { + (i / CHUNK_SIZE, i % CHUNK_SIZE) + } else { + let full_chunk_elems = (self.chunks.len() - 1) * CHUNK_SIZE; + (self.chunks.len() - 1, i - full_chunk_elems) + } + } + + pub fn push(&mut self, value: T) { + let need_new_chunk = self.chunks.is_empty() + || self + .chunks + .last() + .map(|c| c.len() >= CHUNK_SIZE) + .unwrap_or(false); + if need_new_chunk { + self.chunks.push(Vec::new()); + } + let last = self.chunks.len() - 1; + self.chunks[last].push(value); + self.len += 1; + } + + /// Resize to `new_len`, filling new slots with `value` if growing. + pub fn resize(&mut self, new_len: usize, value: T) + where + T: Clone, + { + if new_len <= self.len { + self.len = new_len; + let keep_chunks = new_len.div_ceil(CHUNK_SIZE); + if keep_chunks == 0 { + self.chunks.clear(); + return; + } + self.chunks.truncate(keep_chunks); + let last_offset = new_len % CHUNK_SIZE; + let last_len = if last_offset == 0 && new_len > 0 { + CHUNK_SIZE + } else { + last_offset + }; + self.chunks[keep_chunks - 1].truncate(last_len); + return; + } + while self.len < new_len { + self.push(value.clone()); + } + } + + /// Removes and returns the element at `index`, replacing it with the last element. + /// Returns `None` if index is out of bounds. + pub fn swap_remove(&mut self, index: usize) -> Option { + if index >= self.len { + return None; + } + let last = self.len - 1; + if index != last { + let a = self.get_mut(index).expect("valid index") as *mut T; + let b = self.get_mut(last).expect("valid index") as *mut T; + unsafe { ptr::swap(a, b) }; + } + let (ci, o) = self.chunk_and_offset(last); + let chunk = self.chunks.get_mut(ci)?; + let val = chunk.swap_remove(o); + self.len -= 1; + if chunk.is_empty() { + self.chunks.pop(); + } + Some(val) + } + + pub fn get(&self, i: usize) -> Option<&T> { + if i >= self.len { + return None; + } + let (ci, o) = self.chunk_and_offset(i); + self.chunks.get(ci).and_then(|c| c.get(o)) + } + + pub fn get_mut(&mut self, i: usize) -> Option<&mut T> { + if i >= self.len { + return None; + } + let (ci, o) = self.chunk_and_offset(i); + self.chunks.get_mut(ci).and_then(|c| c.get_mut(o)) + } + + pub fn iter(&self) -> Iter<'_, T> { + let mut chunks = self.chunks.iter(); + let current = chunks.next().map(|c| c.iter()); + Iter { chunks, current } + } + + pub fn iter_mut(&mut self) -> impl Iterator { + self.chunks.iter_mut().flat_map(|c| c.iter_mut()) + } + + /// Reverse the sequence in place. O(n) time, no allocation. + /// Uses element swap to preserve the chunk layout required by get/index. + pub fn reverse(&mut self) { + let len = self.len; + for i in 0..len / 2 { + let j = len - 1 - i; + let a = self.get_mut(i).expect("valid index") as *mut T; + let b = self.get_mut(j).expect("valid index") as *mut T; + unsafe { ptr::swap(a, b) }; + } + } + + /// Binary search with a comparator. Returns `Ok(idx)` if found, `Err(idx)` for insertion point. + pub fn binary_search_by(&self, mut f: F) -> Result + where + F: FnMut(&T) -> Ordering, + { + let mut size = self.len; + let mut left = 0; + let mut right = size; + while left < right { + let mid = left + size / 2; + let cmp = f(self.get(mid).expect("mid in bounds")); + match cmp { + Ordering::Less => left = mid + 1, + Ordering::Greater => right = mid, + Ordering::Equal => return Ok(mid), + } + size = right - left; + } + Err(left) + } + + /// Iterator over elements from `start` index to the end. + pub fn iter_from(&self, start: usize) -> IterFrom<'_, T> { + IterFrom { + vec: self, + index: start, + } + } + + /// Sort by key. Uses temporary allocation (size = len); for small collections this is acceptable. + pub fn sort_by_key(&mut self, f: F) + where + T: Clone, + F: FnMut(&T) -> K, + K: Ord, + { + let mut elems: Vec = self.iter().cloned().collect(); + elems.sort_by_key(f); + self.chunks.clear(); + self.len = 0; + for item in elems { + self.push(item); + } + } +} + +/// Iterator over `ChunkedVec` elements. +pub struct Iter<'a, T> { + chunks: core::slice::Iter<'a, Vec>, + current: Option>, +} + +impl<'a, T> Iterator for Iter<'a, T> { + type Item = &'a T; + + fn next(&mut self) -> Option { + loop { + if let Some(ref mut it) = self.current + && let Some(x) = it.next() + { + return Some(x); + } + self.current = self.chunks.next().map(|c| c.iter()); + self.current.as_ref()?; + } + } +} + +impl<'a, T> IntoIterator for &'a ChunkedVec { + type Item = &'a T; + type IntoIter = Iter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +/// Iterator over `ChunkedVec` elements from a given index. +pub struct IterFrom<'a, T> { + vec: &'a ChunkedVec, + index: usize, +} + +impl<'a, T> Iterator for IterFrom<'a, T> { + type Item = &'a T; + + fn next(&mut self) -> Option { + if self.index >= self.vec.len() { + return None; + } + let item = self.vec.get(self.index); + self.index += 1; + item + } +} + +impl Default for ChunkedVec { + fn default() -> Self { + Self { + chunks: Vec::new(), + len: 0, + } + } +} + +impl Index for ChunkedVec { + type Output = T; + + #[inline] + fn index(&self, i: usize) -> &Self::Output { + self.get(i).expect("ChunkedVec index out of bounds") + } +} + +impl IndexMut for ChunkedVec { + #[inline] + fn index_mut(&mut self, i: usize) -> &mut Self::Output { + self.get_mut(i).expect("ChunkedVec index out of bounds") + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for ChunkedVec { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeSeq; + let mut seq = serializer.serialize_seq(Some(self.len()))?; + for item in self.iter() { + seq.serialize_element(item)?; + } + seq.end() + } +} + +#[cfg(feature = "serde")] +impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for ChunkedVec { + fn deserialize>(deserializer: D) -> Result { + let v = Vec::::deserialize(deserializer)?; + let mut chunked = ChunkedVec::new(); + for item in v { + chunked.push(item); + } + Ok(chunked) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chunked_vec_new() -> ChunkedVec { + ChunkedVec::new() + } + + #[test] + fn push_and_len() { + let mut v = chunked_vec_new(); + assert_eq!(v.len(), 0); + v.push(1); + v.push(2); + v.push(3); + assert_eq!(v.len(), 3); + assert_eq!(v[0], 1); + assert_eq!(v[1], 2); + assert_eq!(v[2], 3); + v.assert_inner_vecs_within_limit(); + } + + #[test] + fn with_capacity_and_default() { + let v = ChunkedVec::with_capacity_and_default(130, -1); + assert_eq!(v.len(), 130); + for i in 0..130 { + assert_eq!(v[i], -1); + } + v.assert_inner_vecs_within_limit(); + } + + #[test] + fn get_and_get_mut() { + let mut v = chunked_vec_new(); + v.push(10); + v.push(20); + assert_eq!(v.get(0), Some(&10)); + assert_eq!(v.get(1), Some(&20)); + assert_eq!(v.get(2), None); + *v.get_mut(1).unwrap() = 99; + assert_eq!(v[1], 99); + } + + #[test] + fn resize_grow() { + let mut v = chunked_vec_new(); + v.resize(5, -1); + assert_eq!(v.len(), 5); + for i in 0..5 { + assert_eq!(v[i], -1); + } + } + + #[test] + fn resize_shrink() { + let mut v = chunked_vec_new(); + v.resize(10, 0); + v.resize(3, 0); + assert_eq!(v.len(), 3); + } + + #[test] + fn iter_yields_sequence() { + let mut v = chunked_vec_new(); + for i in 0..10 { + v.push(i as i32); + } + let collected: Vec = v.iter().copied().collect(); + assert_eq!(collected, (0..10).collect::>()); + } + + #[test] + fn reverse_preserves_layout_and_order() { + let mut v = chunked_vec_new(); + for i in 0..130 { + v.push(i as i32); + } + v.reverse(); + for i in 0..130 { + assert_eq!(v[i], (129 - i) as i32, "index {i} after reverse"); + } + v.assert_inner_vecs_within_limit(); + } + + #[test] + fn reverse_small_partial_chunk() { + let mut v = chunked_vec_new(); + for i in 0..70 { + v.push(i as i32); + } + v.reverse(); + for i in 0..70 { + assert_eq!(v[i], (69 - i) as i32); + } + } + + #[test] + fn with_capacity_then_many_pushes() { + let mut v = ChunkedVec::with_capacity(600); + for i in 0..600 { + v.push(i as i32); + } + assert_eq!(v.len(), 600); + assert_eq!(v[0], 0); + assert_eq!(v[299], 299); + assert_eq!(v[599], 599); + v.assert_inner_vecs_within_limit(); + } + + #[test] + fn swap_remove() { + let mut v = chunked_vec_new(); + for i in 0..5 { + v.push(i as i32); + } + let removed = v.swap_remove(1); + assert_eq!(removed, Some(1)); + assert_eq!(v.len(), 4); + assert_eq!(v[0], 0); + assert_eq!(v[1], 4); + assert_eq!(v[2], 2); + assert_eq!(v[3], 3); + } + + #[test] + fn binary_search_by() { + let mut v = chunked_vec_new(); + for i in [1, 3, 5, 7, 9] { + v.push(i); + } + assert_eq!(v.binary_search_by(|x| x.cmp(&5)), Ok(2)); + assert_eq!(v.binary_search_by(|x| x.cmp(&4)), Err(2)); + assert_eq!(v.binary_search_by(|x| x.cmp(&0)), Err(0)); + assert_eq!(v.binary_search_by(|x| x.cmp(&10)), Err(5)); + } + + #[test] + fn iter_from() { + let mut v = chunked_vec_new(); + for i in 0..25 { + v.push(i as i32); + } + let from_10: Vec = v.iter_from(10).copied().collect(); + assert_eq!(from_10, (10..25).collect::>()); + let from_0: Vec = v.iter_from(0).copied().collect(); + assert_eq!(from_0, (0..25).collect::>()); + } + + #[test] + fn sort_by_key() { + let mut v = chunked_vec_new(); + v.push(30); + v.push(10); + v.push(20); + v.sort_by_key(|&x| x); + assert_eq!(v[0], 10); + assert_eq!(v[1], 20); + assert_eq!(v[2], 30); + v.assert_inner_vecs_within_limit(); + } + + #[test] + fn inner_vecs_stay_under_limit_push_across_boundaries() { + let mut v = chunked_vec_new(); + for i in 0..200 { + v.push(i as i32); + v.assert_inner_vecs_within_limit(); + } + } + + #[test] + fn inner_vecs_stay_under_limit_resize() { + let mut v = chunked_vec_new(); + v.resize(150, 0); + v.assert_inner_vecs_within_limit(); + v.resize(50, 0); + v.assert_inner_vecs_within_limit(); + } + + #[test] + fn inner_vecs_stay_under_limit_swap_remove() { + let mut v = chunked_vec_new(); + for i in 0..100 { + v.push(i as i32); + } + for _ in 0..50 { + v.swap_remove(v.len() / 2); + v.assert_inner_vecs_within_limit(); + } + } +} diff --git a/lp-base/lp-collection/src/entry.rs b/lp-base/lp-collection/src/entry.rs new file mode 100644 index 000000000..b511aff27 --- /dev/null +++ b/lp-base/lp-collection/src/entry.rs @@ -0,0 +1,58 @@ +//! Entry API for [`VecMap`]. + +use crate::map::VecMap; + +/// A view into a single map slot, occupied or vacant. +pub enum Entry<'a, K: Ord, V> { + Occupied { + map: &'a mut VecMap, + index: usize, + }, + Vacant { + map: &'a mut VecMap, + index: usize, + key: K, + }, +} + +impl<'a, K: Ord, V> Entry<'a, K, V> { + pub fn or_insert(self, default: V) -> &'a mut V { + self.or_insert_with(|| default) + } + + pub fn or_insert_with V>(self, default: F) -> &'a mut V { + match self { + Entry::Occupied { map, index } => &mut map.entries[index].1, + Entry::Vacant { map, index, key } => { + map.entries.insert(index, (key, default())); + &mut map.entries[index].1 + } + } + } + + pub fn or_default(self) -> &'a mut V + where + V: Default, + { + self.or_insert_with(V::default) + } + + #[must_use] + pub fn key(&self) -> &K { + match self { + Entry::Occupied { map, index } => &map.entries[*index].0, + Entry::Vacant { key, .. } => key, + } + } + + #[must_use] + pub fn and_modify(self, f: F) -> Self { + match self { + Entry::Occupied { map, index } => { + f(&mut map.entries[index].1); + Entry::Occupied { map, index } + } + vacant => vacant, + } + } +} diff --git a/lp-base/lp-collection/src/lib.rs b/lp-base/lp-collection/src/lib.rs new file mode 100644 index 000000000..e764451d3 --- /dev/null +++ b/lp-base/lp-collection/src/lib.rs @@ -0,0 +1,32 @@ +//! Embedded/low-memory friendly collections. +//! +//! Two families: +//! +//! - **Chunked** structures ([`ChunkedVec`], [`ChunkedHashMap`]) allocate in +//! small chunks to reduce OOM risk from heap fragmentation on constrained +//! heaps (vendored from `lp-common` at rev `c27c24b`). +//! - **Sorted-vec** structures ([`VecMap`], [`VecSet`]) store entries in one +//! sorted `Vec` and expose the `BTreeMap`/`BTreeSet` API subset this +//! workspace uses. Lookups are binary search; insert/remove shift the tail. +//! For the small maps that dominate project/runtime state (tens to low +//! hundreds of entries) this matches or beats B-trees on speed and RAM, and +//! generates far less code per key/value instantiation — the ESP32 flash +//! budget pays ~2-5 KB of B-tree node machinery for every distinct `(K, V)` +//! pair. Iteration order and key uniqueness match `BTreeMap`. Not suited to +//! maps that grow beyond a few thousand entries under steady random insert. + +#![no_std] + +extern crate alloc; + +pub mod chunked_hashmap; +pub mod chunked_vec; +mod entry; +mod map; +mod set; + +pub use chunked_hashmap::{ChunkedHashMap, ChunkedHashSet, Entry as ChunkedEntry}; +pub use chunked_vec::ChunkedVec; +pub use entry::Entry; +pub use map::VecMap; +pub use set::VecSet; diff --git a/lp-base/lp-collection/src/map.rs b/lp-base/lp-collection/src/map.rs new file mode 100644 index 000000000..78fc83361 --- /dev/null +++ b/lp-base/lp-collection/src/map.rs @@ -0,0 +1,449 @@ +//! Sorted-vec map with the `BTreeMap` API subset used in this workspace. + +use alloc::vec::Vec; +use core::borrow::Borrow; +use core::fmt; + +use crate::entry::Entry; + +/// Map backed by a `Vec<(K, V)>` kept sorted by key. +pub struct VecMap { + pub(crate) entries: Vec<(K, V)>, +} + +impl VecMap { + #[must_use] + pub const fn new() -> Self { + Self { + entries: Vec::new(), + } + } + + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + Self { + entries: Vec::with_capacity(capacity), + } + } + + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn clear(&mut self) { + self.entries.clear(); + } + + pub fn iter(&self) -> Iter<'_, K, V> { + self.entries.iter().map(pair_refs) + } + + pub fn iter_mut(&mut self) -> IterMut<'_, K, V> { + self.entries.iter_mut().map(pair_refs_mut) + } + + pub fn keys(&self) -> impl DoubleEndedIterator + ExactSizeIterator + Clone { + self.entries.iter().map(|(k, _)| k) + } + + pub fn values(&self) -> impl DoubleEndedIterator + ExactSizeIterator + Clone { + self.entries.iter().map(|(_, v)| v) + } + + pub fn values_mut(&mut self) -> impl DoubleEndedIterator + ExactSizeIterator { + self.entries.iter_mut().map(|(_, v)| v) + } + + pub fn into_keys(self) -> impl DoubleEndedIterator + ExactSizeIterator { + self.entries.into_iter().map(|(k, _)| k) + } + + pub fn into_values(self) -> impl DoubleEndedIterator + ExactSizeIterator { + self.entries.into_iter().map(|(_, v)| v) + } + + #[must_use] + pub fn first_key_value(&self) -> Option<(&K, &V)> { + self.entries.first().map(pair_refs) + } + + #[must_use] + pub fn last_key_value(&self) -> Option<(&K, &V)> { + self.entries.last().map(pair_refs) + } +} + +impl VecMap { + pub(crate) fn search(&self, key: &Q) -> Result + where + K: Borrow, + Q: Ord + ?Sized, + { + self.entries.binary_search_by(|(k, _)| k.borrow().cmp(key)) + } + + #[must_use] + pub fn get(&self, key: &Q) -> Option<&V> + where + K: Borrow, + Q: Ord + ?Sized, + { + let index = self.search(key).ok()?; + Some(&self.entries[index].1) + } + + #[must_use] + pub fn get_mut(&mut self, key: &Q) -> Option<&mut V> + where + K: Borrow, + Q: Ord + ?Sized, + { + let index = self.search(key).ok()?; + Some(&mut self.entries[index].1) + } + + #[must_use] + pub fn get_key_value(&self, key: &Q) -> Option<(&K, &V)> + where + K: Borrow, + Q: Ord + ?Sized, + { + let index = self.search(key).ok()?; + Some(pair_refs(&self.entries[index])) + } + + #[must_use] + pub fn contains_key(&self, key: &Q) -> bool + where + K: Borrow, + Q: Ord + ?Sized, + { + self.search(key).is_ok() + } + + pub fn insert(&mut self, key: K, value: V) -> Option { + match self.search(&key) { + Ok(index) => Some(core::mem::replace(&mut self.entries[index].1, value)), + Err(index) => { + self.entries.insert(index, (key, value)); + None + } + } + } + + pub fn remove(&mut self, key: &Q) -> Option + where + K: Borrow, + Q: Ord + ?Sized, + { + let index = self.search(key).ok()?; + Some(self.entries.remove(index).1) + } + + pub fn remove_entry(&mut self, key: &Q) -> Option<(K, V)> + where + K: Borrow, + Q: Ord + ?Sized, + { + let index = self.search(key).ok()?; + Some(self.entries.remove(index)) + } + + pub fn entry(&mut self, key: K) -> Entry<'_, K, V> { + match self.search(&key) { + Ok(index) => Entry::Occupied { map: self, index }, + Err(index) => Entry::Vacant { + map: self, + index, + key, + }, + } + } + + pub fn retain(&mut self, mut f: F) + where + F: FnMut(&K, &mut V) -> bool, + { + self.entries.retain_mut(|(k, v)| f(k, v)); + } + + /// Move all entries from `other` into `self`; `other` keys win on clash. + pub fn append(&mut self, other: &mut Self) { + for (key, value) in other.entries.drain(..) { + self.insert(key, value); + } + } + + /// Iterate the entries whose keys fall within `range`, in key order. + pub fn range(&self, range: R) -> Iter<'_, K, V> + where + K: Borrow, + Q: Ord + ?Sized, + R: core::ops::RangeBounds, + { + use core::ops::Bound; + let start = match range.start_bound() { + Bound::Unbounded => 0, + Bound::Included(b) => self.search(b).unwrap_or_else(|i| i), + Bound::Excluded(b) => match self.search(b) { + Ok(i) => i + 1, + Err(i) => i, + }, + }; + let end = match range.end_bound() { + Bound::Unbounded => self.entries.len(), + Bound::Included(b) => match self.search(b) { + Ok(i) => i + 1, + Err(i) => i, + }, + Bound::Excluded(b) => self.search(b).unwrap_or_else(|i| i), + }; + self.entries[start..end.max(start)].iter().map(pair_refs) + } +} + +impl core::ops::Index<&Q> for VecMap +where + K: Borrow + Ord, + Q: Ord + ?Sized, +{ + type Output = V; + + fn index(&self, key: &Q) -> &V { + self.get(key).expect("no entry found for key") + } +} + +fn pair_refs(entry: &(K, V)) -> (&K, &V) { + (&entry.0, &entry.1) +} + +fn pair_refs_mut(entry: &mut (K, V)) -> (&K, &mut V) { + (&entry.0, &mut entry.1) +} + +/// Borrowed iterator: sorted `(&K, &V)` pairs. +pub type Iter<'a, K, V> = + core::iter::Map, fn(&'a (K, V)) -> (&'a K, &'a V)>; + +/// Mutable iterator: sorted `(&K, &mut V)` pairs. +pub type IterMut<'a, K, V> = + core::iter::Map, fn(&'a mut (K, V)) -> (&'a K, &'a mut V)>; + +impl Default for VecMap { + fn default() -> Self { + Self::new() + } +} + +impl Clone for VecMap { + fn clone(&self) -> Self { + Self { + entries: self.entries.clone(), + } + } +} + +impl fmt::Debug for VecMap { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_map().entries(self.iter()).finish() + } +} + +impl PartialEq for VecMap { + fn eq(&self, other: &Self) -> bool { + self.entries == other.entries + } +} + +impl Eq for VecMap {} + +impl PartialOrd for VecMap { + fn partial_cmp(&self, other: &Self) -> Option { + self.entries.partial_cmp(&other.entries) + } +} + +impl Ord for VecMap { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.entries.cmp(&other.entries) + } +} + +impl core::hash::Hash for VecMap { + fn hash(&self, state: &mut H) { + self.entries.hash(state); + } +} + +impl FromIterator<(K, V)> for VecMap { + fn from_iter>(iter: I) -> Self { + let mut map = Self::new(); + map.extend(iter); + map + } +} + +impl Extend<(K, V)> for VecMap { + fn extend>(&mut self, iter: I) { + for (key, value) in iter { + self.insert(key, value); + } + } +} + +impl From<[(K, V); N]> for VecMap { + fn from(entries: [(K, V); N]) -> Self { + entries.into_iter().collect() + } +} + +impl IntoIterator for VecMap { + type Item = (K, V); + type IntoIter = alloc::vec::IntoIter<(K, V)>; + + fn into_iter(self) -> Self::IntoIter { + self.entries.into_iter() + } +} + +impl<'a, K, V> IntoIterator for &'a VecMap { + type Item = (&'a K, &'a V); + type IntoIter = Iter<'a, K, V>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +impl<'a, K, V> IntoIterator for &'a mut VecMap { + type Item = (&'a K, &'a mut V); + type IntoIter = IterMut<'a, K, V>; + + fn into_iter(self) -> Self::IntoIter { + self.iter_mut() + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for VecMap { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_map(self.iter()) + } +} + +#[cfg(feature = "serde")] +impl<'de, K, V> serde::Deserialize<'de> for VecMap +where + K: serde::Deserialize<'de> + Ord, + V: serde::Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + struct MapVisitor { + marker: core::marker::PhantomData<(K, V)>, + } + + impl<'de, K, V> serde::de::Visitor<'de> for MapVisitor + where + K: serde::Deserialize<'de> + Ord, + V: serde::Deserialize<'de>, + { + type Value = VecMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map") + } + + fn visit_map>( + self, + mut access: A, + ) -> Result { + let mut map = VecMap::with_capacity(access.size_hint().unwrap_or(0)); + while let Some((key, value)) = access.next_entry()? { + map.insert(key, value); + } + Ok(map) + } + } + + deserializer.deserialize_map(MapVisitor { + marker: core::marker::PhantomData, + }) + } +} + +#[cfg(test)] +mod tests { + extern crate std; + + use super::*; + use alloc::string::{String, ToString}; + + #[test] + fn insert_get_remove_keep_sorted_unique_keys() { + let mut map = VecMap::new(); + assert_eq!(map.insert("b".to_string(), 2), None); + assert_eq!(map.insert("a".to_string(), 1), None); + assert_eq!(map.insert("c".to_string(), 3), None); + assert_eq!(map.insert("b".to_string(), 20), Some(2)); + + assert_eq!(map.len(), 3); + assert_eq!(map.get("b"), Some(&20)); + assert_eq!(map.get("missing"), None); + let keys: alloc::vec::Vec<&String> = map.keys().collect(); + assert_eq!(keys, ["a", "b", "c"]); + + assert_eq!(map.remove("a"), Some(1)); + assert_eq!(map.remove("a"), None); + assert_eq!(map.len(), 2); + } + + #[test] + fn entry_api_matches_btreemap_semantics() { + let mut map: VecMap> = VecMap::new(); + map.entry(5).or_default().push(1); + map.entry(5).or_default().push(2); + map.entry(7).or_insert_with(alloc::vec::Vec::new).push(9); + map.entry(5).and_modify(|v| v.push(3)); + map.entry(11).and_modify(|v| v.push(0)).or_default(); + + assert_eq!(map.get(&5).unwrap().as_slice(), &[1, 2, 3]); + assert_eq!(map.get(&7).unwrap().as_slice(), &[9]); + assert!(map.get(&11).unwrap().is_empty()); + } + + #[test] + fn iteration_is_key_ordered() { + let map: VecMap = [(3, 30), (1, 10), (2, 20)].into(); + let pairs: alloc::vec::Vec<(u32, u32)> = map.iter().map(|(k, v)| (*k, *v)).collect(); + assert_eq!(pairs, [(1, 10), (2, 20), (3, 30)]); + } + + #[test] + fn retain_and_append() { + let mut map: VecMap = (0..6).map(|n| (n, n * 10)).collect(); + map.retain(|k, _| k % 2 == 0); + let mut other: VecMap = [(2, 999), (8, 80)].into(); + map.append(&mut other); + + assert!(other.is_empty()); + let pairs: alloc::vec::Vec<(u32, u32)> = map.into_iter().collect(); + assert_eq!(pairs, [(0, 0), (2, 999), (4, 40), (8, 80)]); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_round_trips_as_json_object() { + let map: VecMap = [("b".to_string(), 2), ("a".to_string(), 1)].into(); + let json = serde_json::to_string(&map).unwrap(); + assert_eq!(json, r#"{"a":1,"b":2}"#); + let back: VecMap = serde_json::from_str(&json).unwrap(); + assert_eq!(back, map); + } +} diff --git a/lp-base/lp-collection/src/set.rs b/lp-base/lp-collection/src/set.rs new file mode 100644 index 000000000..3c41fed79 --- /dev/null +++ b/lp-base/lp-collection/src/set.rs @@ -0,0 +1,267 @@ +//! Sorted-vec set with the `BTreeSet` API subset used in this workspace. + +use alloc::vec::Vec; +use core::borrow::Borrow; +use core::fmt; + +/// Set backed by a sorted `Vec`. +pub struct VecSet { + items: Vec, +} + +impl VecSet { + #[must_use] + pub const fn new() -> Self { + Self { items: Vec::new() } + } + + #[must_use] + pub fn len(&self) -> usize { + self.items.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn clear(&mut self) { + self.items.clear(); + } + + pub fn iter(&self) -> core::slice::Iter<'_, T> { + self.items.iter() + } + + #[must_use] + pub fn first(&self) -> Option<&T> { + self.items.first() + } + + #[must_use] + pub fn last(&self) -> Option<&T> { + self.items.last() + } +} + +impl VecSet { + fn search(&self, item: &Q) -> Result + where + T: Borrow, + Q: Ord + ?Sized, + { + self.items + .binary_search_by(|probe| probe.borrow().cmp(item)) + } + + #[must_use] + pub fn contains(&self, item: &Q) -> bool + where + T: Borrow, + Q: Ord + ?Sized, + { + self.search(item).is_ok() + } + + #[must_use] + pub fn get(&self, item: &Q) -> Option<&T> + where + T: Borrow, + Q: Ord + ?Sized, + { + let index = self.search(item).ok()?; + Some(&self.items[index]) + } + + pub fn insert(&mut self, item: T) -> bool { + match self.search(&item) { + Ok(_) => false, + Err(index) => { + self.items.insert(index, item); + true + } + } + } + + pub fn remove(&mut self, item: &Q) -> bool + where + T: Borrow, + Q: Ord + ?Sized, + { + match self.search(item) { + Ok(index) => { + self.items.remove(index); + true + } + Err(_) => false, + } + } + + pub fn take(&mut self, item: &Q) -> Option + where + T: Borrow, + Q: Ord + ?Sized, + { + let index = self.search(item).ok()?; + Some(self.items.remove(index)) + } + + pub fn retain(&mut self, f: F) + where + F: FnMut(&T) -> bool, + { + self.items.retain(f); + } +} + +impl Default for VecSet { + fn default() -> Self { + Self::new() + } +} + +impl Clone for VecSet { + fn clone(&self) -> Self { + Self { + items: self.items.clone(), + } + } +} + +impl fmt::Debug for VecSet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_set().entries(self.iter()).finish() + } +} + +impl PartialEq for VecSet { + fn eq(&self, other: &Self) -> bool { + self.items == other.items + } +} + +impl Eq for VecSet {} + +impl PartialOrd for VecSet { + fn partial_cmp(&self, other: &Self) -> Option { + self.items.partial_cmp(&other.items) + } +} + +impl Ord for VecSet { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.items.cmp(&other.items) + } +} + +impl core::hash::Hash for VecSet { + fn hash(&self, state: &mut H) { + self.items.hash(state); + } +} + +impl FromIterator for VecSet { + fn from_iter>(iter: I) -> Self { + let mut set = Self::new(); + set.extend(iter); + set + } +} + +impl Extend for VecSet { + fn extend>(&mut self, iter: I) { + for item in iter { + self.insert(item); + } + } +} + +impl From<[T; N]> for VecSet { + fn from(items: [T; N]) -> Self { + items.into_iter().collect() + } +} + +impl IntoIterator for VecSet { + type Item = T; + type IntoIter = alloc::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.items.into_iter() + } +} + +impl<'a, T> IntoIterator for &'a VecSet { + type Item = &'a T; + type IntoIter = core::slice::Iter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for VecSet { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_seq(self.iter()) + } +} + +#[cfg(feature = "serde")] +impl<'de, T> serde::Deserialize<'de> for VecSet +where + T: serde::Deserialize<'de> + Ord, +{ + fn deserialize>(deserializer: D) -> Result { + struct SetVisitor { + marker: core::marker::PhantomData, + } + + impl<'de, T> serde::de::Visitor<'de> for SetVisitor + where + T: serde::Deserialize<'de> + Ord, + { + type Value = VecSet; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a sequence") + } + + fn visit_seq>( + self, + mut access: A, + ) -> Result { + let mut set = VecSet::new(); + while let Some(item) = access.next_element()? { + set.insert(item); + } + Ok(set) + } + } + + deserializer.deserialize_seq(SetVisitor { + marker: core::marker::PhantomData, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_contains_remove_keep_sorted_unique() { + let mut set = VecSet::new(); + assert!(set.insert(3)); + assert!(set.insert(1)); + assert!(!set.insert(3)); + + assert!(set.contains(&1)); + assert!(!set.contains(&2)); + let items: alloc::vec::Vec = set.iter().copied().collect(); + assert_eq!(items, [1, 3]); + + assert!(set.remove(&1)); + assert!(!set.remove(&1)); + } +} diff --git a/lp-base/lpfs/src/fs_event.rs b/lp-base/lpfs/src/fs_event.rs index 0944dfc14..6f8f7ac5f 100644 --- a/lp-base/lpfs/src/fs_event.rs +++ b/lp-base/lpfs/src/fs_event.rs @@ -53,18 +53,10 @@ mod tests { } } -/// Represents an event caused by a file or directory change -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FsChange { - /// Path affected by the change - pub path: LpPathBuf, - /// Type of change - pub change_type: ChangeType, -} - -/// Type of file change -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChangeType { +/// Kind of filesystem event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FsEventKind { /// File was created Create, /// File was modified @@ -72,3 +64,18 @@ pub enum ChangeType { /// File was deleted Delete, } + +/// Represents an event caused by a file or directory change +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FsEvent { + /// Path affected by the change + pub path: LpPathBuf, + /// Kind of change + pub kind: FsEventKind, +} + +#[deprecated(note = "renamed to FsEventKind")] +pub type ChangeType = FsEventKind; + +#[deprecated(note = "renamed to FsEvent")] +pub type FsChange = FsEvent; diff --git a/lp-base/lpfs/src/impls/lp_fs_mem.rs b/lp-base/lpfs/src/impls/lp_fs_mem.rs index 69a32ad4b..ffc41faf5 100644 --- a/lp-base/lpfs/src/impls/lp_fs_mem.rs +++ b/lp-base/lpfs/src/impls/lp_fs_mem.rs @@ -3,8 +3,8 @@ use crate::error::FsError; use crate::{ LpFs, - fs_event::ChangeType, - fs_event::{FsChange, FsVersion}, + fs_event::FsEventKind, + fs_event::{FsEvent, FsVersion}, lp_fs_view::LpFsView, }; use crate::{LpPath, LpPathBuf}; @@ -18,8 +18,8 @@ pub struct LpFsMemory { files: Rc>>>, /// Version counter (increments on each change) current_version: RefCell, - /// Map of path -> (version, ChangeType) - only latest change per path - changes: RefCell>, + /// Map of path -> (version, FsEventKind) - only latest change per path + changes: RefCell>, } impl LpFsMemory { @@ -33,7 +33,7 @@ impl LpFsMemory { } /// Record a filesystem change - fn record_change(&self, path: &LpPath, change_type: ChangeType) { + fn record_change(&self, path: &LpPath, kind: FsEventKind) { let mut current = self.current_version.borrow_mut(); *current = current.next(); let version = *current; @@ -41,7 +41,7 @@ impl LpFsMemory { self.changes .borrow_mut() - .insert(path.to_path_buf(), (version, change_type)); + .insert(path.to_path_buf(), (version, kind)); } /// Write a file (mutable version) @@ -56,12 +56,12 @@ impl LpFsMemory { drop(files); // Release borrow before recording change // Record change - let change_type = if existed { - ChangeType::Modify + let kind = if existed { + FsEventKind::Modify } else { - ChangeType::Create + FsEventKind::Create }; - self.record_change(normalized.as_path(), change_type); + self.record_change(normalized.as_path(), kind); Ok(()) } @@ -90,7 +90,7 @@ impl LpFsMemory { drop(files); // Release borrow before recording change // Record change - self.record_change(normalized.as_path(), ChangeType::Delete); + self.record_change(normalized.as_path(), FsEventKind::Delete); Ok(()) } @@ -133,7 +133,7 @@ impl LpFsMemory { // Record changes for file_path in files_to_remove_clone { - self.record_change(file_path.as_path(), ChangeType::Delete); + self.record_change(file_path.as_path(), FsEventKind::Delete); } Ok(()) @@ -195,12 +195,12 @@ impl LpFs for LpFsMemory { drop(files); // Release borrow before recording change // Record change - let change_type = if existed { - ChangeType::Modify + let kind = if existed { + FsEventKind::Modify } else { - ChangeType::Create + FsEventKind::Create }; - self.record_change(normalized.as_path(), change_type); + self.record_change(normalized.as_path(), kind); Ok(()) } @@ -319,7 +319,7 @@ impl LpFs for LpFsMemory { drop(files); // Release borrow before recording change // Record change - self.record_change(normalized.as_path(), ChangeType::Delete); + self.record_change(normalized.as_path(), FsEventKind::Delete); Ok(()) } @@ -361,7 +361,7 @@ impl LpFs for LpFsMemory { // Record changes for file_path in files_to_remove { - self.record_change(file_path.as_path(), ChangeType::Delete); + self.record_change(file_path.as_path(), FsEventKind::Delete); } Ok(()) @@ -405,15 +405,15 @@ impl LpFs for LpFsMemory { *self.current_version.borrow() } - fn get_changes_since(&self, since_version: FsVersion) -> Vec { + fn get_changes_since(&self, since_version: FsVersion) -> Vec { self.changes .borrow() .iter() - .filter_map(|(path, (version, change_type))| { + .filter_map(|(path, (version, kind))| { if *version >= since_version { - Some(FsChange { + Some(FsEvent { path: path.clone(), - change_type: *change_type, + kind: *kind, }) } else { None @@ -428,16 +428,16 @@ impl LpFs for LpFsMemory { .retain(|_, (version, _)| *version >= before_version); } - fn record_changes(&mut self, changes: Vec) { + fn record_changes(&mut self, changes: Vec) { for change in changes { - self.record_change(change.path.as_path(), change.change_type); + self.record_change(change.path.as_path(), change.kind); } } } impl LpFsMemory { /// Get all changes (convenience method) - pub fn get_changes(&self) -> Vec { + pub fn get_changes(&self) -> Vec { self.get_changes_since(FsVersion::default()) } diff --git a/lp-base/lpfs/src/impls/lp_fs_std.rs b/lp-base/lpfs/src/impls/lp_fs_std.rs index 539844b75..ea3edaa2c 100644 --- a/lp-base/lpfs/src/impls/lp_fs_std.rs +++ b/lp-base/lpfs/src/impls/lp_fs_std.rs @@ -3,7 +3,7 @@ use crate::error::FsError; use crate::{ LpFs, - fs_event::{ChangeType, FsChange, FsVersion}, + fs_event::{FsEvent, FsEventKind, FsVersion}, lp_fs_view::LpFsView, }; use crate::{LpPath, LpPathBuf}; @@ -23,9 +23,9 @@ pub struct LpFsStd { /// Version counter (increments on each change) /// Uses Mutex for thread-safety (required for Send + Sync) current_version: Mutex, - /// Map of path -> (version, ChangeType) - only latest change per path + /// Map of path -> (version, FsEventKind) - only latest change per path /// Uses Mutex for thread-safety (required for Send + Sync) - changes: Mutex>, + changes: Mutex>, } impl LpFsStd { @@ -46,16 +46,13 @@ impl LpFsStd { } /// Record a filesystem change - fn record_change(&self, path: LpPathBuf, change_type: ChangeType) { + fn record_change(&self, path: LpPathBuf, kind: FsEventKind) { let mut current = self.current_version.lock().unwrap(); *current = current.next(); let version = *current; drop(current); - self.changes - .lock() - .unwrap() - .insert(path, (version, change_type)); + self.changes.lock().unwrap().insert(path, (version, kind)); } /// Resolve a path relative to the root and validate it stays within root @@ -441,16 +438,16 @@ impl LpFs for LpFsStd { *self.current_version.lock().unwrap() } - fn get_changes_since(&self, since_version: FsVersion) -> Vec { + fn get_changes_since(&self, since_version: FsVersion) -> Vec { self.changes .lock() .unwrap() .iter() - .filter_map(|(path, (version, change_type))| { + .filter_map(|(path, (version, kind))| { if *version >= since_version { - Some(FsChange { + Some(FsEvent { path: path.clone(), - change_type: *change_type, + kind: *kind, }) } else { None @@ -466,10 +463,10 @@ impl LpFs for LpFsStd { .retain(|_, (version, _)| *version >= before_version); } - fn record_changes(&mut self, changes: Vec) { + fn record_changes(&mut self, changes: Vec) { for change in changes { // Path is already LpPathBuf, just record it - self.record_change(change.path, change.change_type); + self.record_change(change.path, change.kind); } } } diff --git a/lp-base/lpfs/src/lib.rs b/lp-base/lpfs/src/lib.rs index b237b840d..5f749e104 100644 --- a/lp-base/lpfs/src/lib.rs +++ b/lp-base/lpfs/src/lib.rs @@ -23,7 +23,9 @@ pub mod lp_path; pub use lp_path::{AsLpPath, AsLpPathBuf, LpPath, LpPathBuf}; pub use error::FsError; -pub use fs_event::{ChangeType, FsChange, FsVersion}; +#[allow(deprecated, reason = "legacy fs event type aliases for migration")] +pub use fs_event::{ChangeType, FsChange}; +pub use fs_event::{FsEvent, FsEventKind, FsVersion}; pub use impls::lp_fs_mem::LpFsMemory; pub use lp_fs::LpFs; pub use lp_fs_view::LpFsView; diff --git a/lp-base/lpfs/src/lp_fs.rs b/lp-base/lpfs/src/lp_fs.rs index e2267069a..f4b19a314 100644 --- a/lp-base/lpfs/src/lp_fs.rs +++ b/lp-base/lpfs/src/lp_fs.rs @@ -8,7 +8,7 @@ //! provide security by preventing access outside the project directory. use crate::error::FsError; -use crate::fs_event::{FsChange, FsVersion}; +use crate::fs_event::{FsEvent, FsVersion}; use crate::{LpPath, LpPathBuf}; /// Platform-agnostic filesystem trait @@ -100,7 +100,15 @@ pub trait LpFs { /// Changes are returned with paths relative to the filesystem root. /// Only the latest change per path is returned (if a file was modified /// multiple times, only the most recent change is included). - fn get_changes_since(&self, since_version: FsVersion) -> alloc::vec::Vec; + /// + /// This reports changes tracked or recorded by the filesystem implementation; + /// it is not a guarantee that host-native external edits are watched. + fn get_changes_since(&self, since_version: FsVersion) -> alloc::vec::Vec; + + /// Alias for [`Self::get_changes_since`]. + fn get_events_since(&self, since_version: FsVersion) -> alloc::vec::Vec { + self.get_changes_since(since_version) + } /// Clear changes older than the specified version /// @@ -113,5 +121,5 @@ pub trait LpFs { /// Used by filesystem implementations that don't directly track changes /// (e.g., `LpFsStd` receiving changes from `FileWatcher`). /// Each change is assigned the next version number. - fn record_changes(&mut self, changes: alloc::vec::Vec); + fn record_changes(&mut self, changes: alloc::vec::Vec); } diff --git a/lp-base/lpfs/src/lp_fs_view.rs b/lp-base/lpfs/src/lp_fs_view.rs index fa736962e..44dffb14d 100644 --- a/lp-base/lpfs/src/lp_fs_view.rs +++ b/lp-base/lpfs/src/lp_fs_view.rs @@ -5,7 +5,7 @@ use crate::LpFs; use crate::error::FsError; -use crate::fs_event::{FsChange, FsVersion}; +use crate::fs_event::{FsEvent, FsVersion}; use crate::{LpPath, LpPathBuf}; use alloc::{ format, @@ -235,7 +235,7 @@ impl LpFs for LpFsView { self.parent.borrow().current_version() } - fn get_changes_since(&self, since_version: FsVersion) -> Vec { + fn get_changes_since(&self, since_version: FsVersion) -> Vec { let parent_changes = self.parent.borrow().get_changes_since(since_version); let prefix = &self.prefix; @@ -245,9 +245,9 @@ impl LpFs for LpFsView { if change.path.as_str().starts_with(prefix.as_str()) { // Translate to chrooted-relative path if let Some(chrooted_path) = self.chrooted_path(&change.path) { - Some(FsChange { + Some(FsEvent { path: chrooted_path, - change_type: change.change_type, + kind: change.kind, }) } else { None @@ -263,7 +263,7 @@ impl LpFs for LpFsView { // No-op for views (parent manages versions) } - fn record_changes(&mut self, _changes: Vec) { + fn record_changes(&mut self, _changes: Vec) { // No-op for views (parent manages versions) } } diff --git a/lp-base/lpfs/src/lp_path.rs b/lp-base/lpfs/src/lp_path.rs index bd108ce8d..502eae98b 100644 --- a/lp-base/lpfs/src/lp_path.rs +++ b/lp-base/lpfs/src/lp_path.rs @@ -311,7 +311,7 @@ impl Eq for LpPath {} /// /// Supports both absolute (starting with `/`) and relative paths. /// Paths are automatically normalized on construction. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct LpPathBuf(String); impl LpPathBuf { diff --git a/lp-cli/Cargo.toml b/lp-cli/Cargo.toml index cb037011b..d9b030c87 100644 --- a/lp-cli/Cargo.toml +++ b/lp-cli/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +lp-collection = { workspace = true, features = ["serde"] } clap = { version = "4", features = ["derive"] } log = { workspace = true } anyhow = "1" @@ -25,6 +26,7 @@ notify = "6" lpc-model = { path = "../lp-core/lpc-model" } lpc-wire = { path = "../lp-core/lpc-wire" } lpa-server = { path = "../lp-app/lpa-server" } +lpc-hardware = { path = "../lp-core/lpc-hardware" } lpc-shared = { path = "../lp-core/lpc-shared" } lpfs = { path = "../lp-base/lpfs", features = ["std"] } lpc-view = { path = "../lp-core/lpc-view" } @@ -51,6 +53,7 @@ rustc-demangle = "0.1" tempfile = "3" lpc-engine = { path = "../lp-core/lpc-engine" } lpa-server = { path = "../lp-app/lpa-server" } +lpc-hardware = { path = "../lp-core/lpc-hardware" } lpc-shared = { path = "../lp-core/lpc-shared" } lpfs = { path = "../lp-base/lpfs", features = ["std"] } lpc-model = { path = "../lp-core/lpc-model" } diff --git a/lp-cli/src/commands/create/project.rs b/lp-cli/src/commands/create/project.rs index b63202165..f7d470240 100644 --- a/lp-cli/src/commands/create/project.rs +++ b/lp-cli/src/commands/create/project.rs @@ -3,7 +3,7 @@ //! Functions for creating new projects with sensible defaults. use anyhow::{Context, Result}; -use std::collections::BTreeMap; +use lp_collection::VecMap; use std::path::Path; use lpc_model::nodes::fixture::{ColorOrder, FixtureDef, MappingConfig}; @@ -11,10 +11,10 @@ use lpc_model::nodes::output::OutputDef; use lpc_model::nodes::shader::{ShaderDef, ShaderSlotDef}; use lpc_model::nodes::texture::TextureDef; use lpc_model::{ - Affine2d, Affine2dSlot, AsLpPath, BindingDef, BindingDefs, BindingRef, BusSlotRef, Dim2u, - Dim2uSlot, EnumSlot, FixtureDiagnosticMode, FixtureSamplingConfig, HardwareEndpointSpec, - MapSlot, NodeDef, OptionSlot, RenderOrder, RenderOrderSlot, ShaderSource, SlotPath, - SlotShapeRegistry, ValueSlot, + Affine2d, Affine2dSlot, AsLpPath, AssetSlot, BindingDef, BindingDefs, BindingRef, BusSlotRef, + Dim2u, Dim2uSlot, EnumSlot, FixtureDiagnosticMode, FixtureSamplingConfig, HwEndpointSpec, + MapSlot, NodeDef, OptionSlot, RenderOrder, RenderOrderSlot, SlotPath, SlotShapeRegistry, + ValueSlot, }; use lpfs::LpFs; @@ -82,7 +82,7 @@ pub fn create_default_template(fs: &dyn LpFs) -> Result<()> { // Create shader node let shader_config = ShaderDef { - source: EnumSlot::new(ShaderSource::path("shader.glsl")), + source: AssetSlot::path("shader.glsl"), render_order: RenderOrderSlot::new(RenderOrder(0)), bindings: bus_output_binding_defs("visual.out"), glsl_opts: lpc_model::GlslOpts::default(), @@ -164,7 +164,7 @@ vec4 render(vec2 pos) { // Create output node let output_config = OutputDef { - endpoint: ValueSlot::new(HardwareEndpointSpec::from_static("ws281x:rmt:D10")), + endpoint: ValueSlot::new(HwEndpointSpec::from_static("ws281x:rmt:D10")), bindings: bus_input_binding_defs("control.out"), options: OptionSlot::none(), }; @@ -232,7 +232,7 @@ fn slot_shape_registry() -> SlotShapeRegistry { } fn default_visual_consumed_slots() -> MapSlot { - let mut slots = BTreeMap::new(); + let mut slots = VecMap::new(); slots.insert( String::from("time"), ShaderSlotDef::value_f32("Time", "Project clock time in seconds", 0.0, None), @@ -259,7 +259,7 @@ fn bus_output_binding_defs(slot: &str) -> BindingDefs { } fn fixture_binding_defs() -> BindingDefs { - let mut entries = std::collections::BTreeMap::new(); + let mut entries = lp_collection::VecMap::new(); entries.insert( String::from("input"), BindingDef::source(BindingRef::Bus(BusSlotRef::new( @@ -276,7 +276,7 @@ fn fixture_binding_defs() -> BindingDefs { } fn single_binding_defs(slot: &str, binding: BindingDef) -> BindingDefs { - let mut entries = std::collections::BTreeMap::new(); + let mut entries = lp_collection::VecMap::new(); entries.insert(String::from(slot), binding); BindingDefs::new(entries) } @@ -381,7 +381,11 @@ mod tests { panic!("expected shader node TOML"); }; assert_eq!( - shader_config.shader_source().path_value().unwrap().as_str(), + shader_config + .shader_source() + .artifact_value() + .unwrap() + .to_string(), "shader.glsl" ); assert!(matches!( diff --git a/lp-cli/src/commands/dev/fs_loop.rs b/lp-cli/src/commands/dev/fs_loop.rs index d07254bc4..d62bf28b4 100644 --- a/lp-cli/src/commands/dev/fs_loop.rs +++ b/lp-cli/src/commands/dev/fs_loop.rs @@ -3,7 +3,7 @@ //! Monitors file changes in the project directory and syncs them to the server. use anyhow::{Context, Result}; -use lpfs::{FsChange, LpFs}; +use lpfs::{FsEvent, LpFs}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -46,7 +46,7 @@ pub async fn fs_loop( FileWatcher::new(project_dir.clone()).context("Failed to create file watcher")?; // Debouncing state - let mut pending_changes: HashMap = HashMap::new(); + let mut pending_changes: HashMap = HashMap::new(); let mut last_change_time: Option = None; // Main loop @@ -78,7 +78,7 @@ pub async fn fs_loop( if should_sync { // Sync all pending changes - let changes: Vec = pending_changes.values().cloned().collect(); + let changes: Vec = pending_changes.values().cloned().collect(); pending_changes.clear(); last_change_time = None; @@ -105,9 +105,9 @@ pub async fn fs_loop( /// /// Deduplicates changes by path (later changes override earlier ones). pub fn add_pending_change( - pending_changes: &mut HashMap, + pending_changes: &mut HashMap, last_change_time: &mut Option, - change: FsChange, + change: FsEvent, ) { pending_changes.insert(change.path.as_str().to_string(), change); *last_change_time = Some(Instant::now()); diff --git a/lp-cli/src/commands/dev/sync.rs b/lp-cli/src/commands/dev/sync.rs index 5689aff45..9e8e25226 100644 --- a/lp-cli/src/commands/dev/sync.rs +++ b/lp-cli/src/commands/dev/sync.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result}; use lpc_model::AsLpPath; -use lpfs::{ChangeType, FsChange, LpFs}; +use lpfs::{FsEvent, FsEventKind, LpFs}; use std::sync::Arc; use crate::client::LpClient; @@ -26,7 +26,7 @@ use crate::client::LpClient; /// * `Err` if syncing failed pub async fn sync_file_change( client: &Arc, - change: &FsChange, + change: &FsEvent, project_uid: &str, _project_dir: &std::path::Path, local_fs: &Arc, @@ -43,12 +43,12 @@ pub async fn sync_file_change( log::info!( "Sending local fs change to server: {:?} {}", - change.change_type, + change.kind, change.path.as_str() ); - match change.change_type { - ChangeType::Create | ChangeType::Modify => { + match change.kind { + FsEventKind::Create | FsEventKind::Modify => { // Check if file still exists (it might have been deleted by the time we sync) if !local_fs.file_exists(change.path.as_path()).unwrap_or(false) { // File doesn't exist anymore, skip sync (likely a temporary file) @@ -66,7 +66,7 @@ pub async fn sync_file_change( .await .with_context(|| format!("Failed to write file to server: {server_path}"))?; } - ChangeType::Delete => { + FsEventKind::Delete => { // Delete file from server client .fs_delete_file(server_path.as_path()) diff --git a/lp-cli/src/commands/dev/watcher.rs b/lp-cli/src/commands/dev/watcher.rs index 85475c816..b2b3ff61d 100644 --- a/lp-cli/src/commands/dev/watcher.rs +++ b/lp-cli/src/commands/dev/watcher.rs @@ -1,18 +1,18 @@ //! File system watcher //! //! Wraps the `notify` crate to provide file change events for the dev command. -//! Converts OS-level file events into `FsChange` events compatible with the sync system. +//! Converts OS-level file events into `FsEvent` events compatible with the sync system. use anyhow::{Context, Result}; -use lpfs::{ChangeType, FsChange}; +use lpfs::{FsEvent, FsEventKind}; use notify::Watcher; use std::path::{Path, PathBuf}; use tokio::sync::mpsc; -/// File system watcher that converts OS events to FsChange events +/// File system watcher that converts OS events to FsEvent events pub struct FileWatcher { /// Receiver for file change events - event_receiver: mpsc::UnboundedReceiver, + event_receiver: mpsc::UnboundedReceiver, /// Root path of the project (for path normalization) #[allow(dead_code, reason = "Stored for path normalization")] root_path: PathBuf, @@ -74,17 +74,17 @@ impl FileWatcher { } }; - // Map notify event kind to ChangeType - let change_type = match event.kind { - notify::EventKind::Create(_) => ChangeType::Create, - notify::EventKind::Modify(_) => ChangeType::Modify, - notify::EventKind::Remove(_) => ChangeType::Delete, + // Map notify event kind to FsEventKind + let kind = match event.kind { + notify::EventKind::Create(_) => FsEventKind::Create, + notify::EventKind::Modify(_) => FsEventKind::Modify, + notify::EventKind::Remove(_) => FsEventKind::Delete, notify::EventKind::Any | notify::EventKind::Other => { // For Any/Other, try to determine from file existence if path.exists() { - ChangeType::Modify + FsEventKind::Modify } else { - ChangeType::Delete + FsEventKind::Delete } } _ => { @@ -93,10 +93,10 @@ impl FileWatcher { } }; - // Create FsChange and send to channel (non-blocking) - let change = FsChange { + // Create FsEvent and send to channel (non-blocking) + let change = FsEvent { path: lpc_model::LpPathBuf::from(normalized_path), - change_type, + kind, }; // Send event (non-blocking, drop if channel is full) @@ -197,9 +197,9 @@ impl FileWatcher { /// /// # Returns /// - /// * `Some(FsChange)` if an event is available + /// * `Some(FsEvent)` if an event is available /// * `None` if no events are available or the channel is closed - pub async fn next_change(&mut self) -> Option { + pub async fn next_change(&mut self) -> Option { self.event_receiver.recv().await } } @@ -237,7 +237,7 @@ mod tests { assert!(change.is_some(), "Expected file create event"); let change = change.unwrap(); assert_eq!(change.path.as_str(), "/test.txt"); - assert_eq!(change.change_type, ChangeType::Create); + assert_eq!(change.kind, FsEventKind::Create); } #[tokio::test] @@ -267,9 +267,9 @@ mod tests { assert_eq!(change.path.as_str(), "/test.txt"); // Some OSes report modify as Create, so accept either assert!( - change.change_type == ChangeType::Modify || change.change_type == ChangeType::Create, + change.kind == FsEventKind::Modify || change.kind == FsEventKind::Create, "Expected Modify or Create, got {:?}", - change.change_type + change.kind ); } @@ -298,12 +298,12 @@ mod tests { tokio::time::timeout(Duration::from_secs(1), watcher.next_change()).await { if change.path.as_str() == "/test.txt" { - if change.change_type == ChangeType::Delete { + if change.kind == FsEventKind::Delete { found_delete = true; break; } // Some OSes report delete as Modify when file doesn't exist - if change.change_type == ChangeType::Modify && !test_file.exists() { + if change.kind == FsEventKind::Modify && !test_file.exists() { found_delete = true; break; } diff --git a/lp-cli/src/commands/hardware/args.rs b/lp-cli/src/commands/hardware/args.rs index c8e0381f2..2241583f8 100644 --- a/lp-cli/src/commands/hardware/args.rs +++ b/lp-cli/src/commands/hardware/args.rs @@ -22,7 +22,7 @@ pub struct ManifestArgs { #[arg(long)] pub repo: Option, - /// Board manifest directory. Defaults to lp-core/lpc-shared/boards under the repo root. + /// Board manifest directory. Defaults to lp-core/lpc-hardware/boards under the repo root. #[arg(long)] pub boards_dir: Option, @@ -100,7 +100,7 @@ pub struct CalibrateArgs { /// Repository root. Defaults to searching upward from the current directory. #[arg(long)] pub repo: Option, - /// Board manifest directory. Defaults to lp-core/lpc-shared/boards under the repo root. + /// Board manifest directory. Defaults to lp-core/lpc-hardware/boards under the repo root. #[arg(long)] pub boards_dir: Option, /// Firmware response timeout before a pin is treated as crash-suspect. @@ -119,7 +119,7 @@ pub enum HardwareTargetArg { Rv32imacEmu, } -impl From for lpc_shared::hardware::HardwareTarget { +impl From for lpc_hardware::HardwareTarget { fn from(value: HardwareTargetArg) -> Self { match value { HardwareTargetArg::Esp32c6 => Self::Esp32c6, diff --git a/lp-cli/src/commands/hardware/calibrate/app.rs b/lp-cli/src/commands/hardware/calibrate/app.rs index 90bd7b400..93e634915 100644 --- a/lp-cli/src/commands/hardware/calibrate/app.rs +++ b/lp-cli/src/commands/hardware/calibrate/app.rs @@ -1,6 +1,6 @@ use anyhow::{Result, anyhow, bail}; use dialoguer::Input; -use lpc_shared::hardware::{HardwareManifestFile, HardwareTarget}; +use lpc_hardware::{HardwareManifestFile, HardwareTarget}; use std::io::{IsTerminal, stdin}; use std::time::Duration; diff --git a/lp-cli/src/commands/hardware/calibrate/calibration_manifest_update.rs b/lp-cli/src/commands/hardware/calibrate/calibration_manifest_update.rs index 2387654a6..23056262d 100644 --- a/lp-cli/src/commands/hardware/calibrate/calibration_manifest_update.rs +++ b/lp-cli/src/commands/hardware/calibrate/calibration_manifest_update.rs @@ -1,7 +1,6 @@ use anyhow::Result; -use lpc_shared::hardware::{ - HardwareCapability, HardwareManifestFile, hardware_manifest_file::HardwareResourceFile, -}; +use lpc_hardware::manifest::hw_manifest_file::HardwareResourceFile; +use lpc_hardware::{HardwareManifestFile, HwCapability}; const DANGEROUS_REASON: &str = "crashed or timed out during calibration"; @@ -87,10 +86,7 @@ fn find_or_insert_gpio<'a>( manifest.gpio.push(HardwareResourceFile { address, display_label: fallback_label.into(), - capabilities: vec![ - HardwareCapability::GpioOutput, - HardwareCapability::GpioInput, - ], + capabilities: vec![HwCapability::GpioOutput, HwCapability::GpioInput], aliases: vec![format!("GPIO{gpio}"), format!("IO{gpio}")], location: None, reserved_reason: None, @@ -110,7 +106,7 @@ fn ensure_alias(resource: &mut HardwareResourceFile, alias: &str) { #[cfg(test)] mod tests { use super::*; - use lpc_shared::hardware::HardwareTarget; + use lpc_hardware::HardwareTarget; #[test] fn mapping_preserves_previous_display_label_as_alias() { @@ -119,10 +115,7 @@ mod tests { manifest.gpio.push(HardwareResourceFile::new( "/gpio/18", "GPIO18", - [ - HardwareCapability::GpioOutput, - HardwareCapability::GpioInput, - ], + [HwCapability::GpioOutput, HwCapability::GpioInput], )); apply_mapping(&mut manifest, 18, "D6").unwrap(); @@ -152,18 +145,12 @@ mod tests { manifest.gpio.push(HardwareResourceFile::new( "/gpio/0", "D0", - [ - HardwareCapability::GpioOutput, - HardwareCapability::GpioInput, - ], + [HwCapability::GpioOutput, HwCapability::GpioInput], )); manifest.gpio.push(HardwareResourceFile::new( "/gpio/1", "GPIO1", - [ - HardwareCapability::GpioOutput, - HardwareCapability::GpioInput, - ], + [HwCapability::GpioOutput, HwCapability::GpioInput], )); apply_dangerous(&mut manifest, 2).unwrap(); diff --git a/lp-cli/src/commands/hardware/calibrate/model.rs b/lp-cli/src/commands/hardware/calibrate/model.rs index 65659e43f..e33ddfeac 100644 --- a/lp-cli/src/commands/hardware/calibrate/model.rs +++ b/lp-cli/src/commands/hardware/calibrate/model.rs @@ -1,7 +1,5 @@ use anyhow::{Result, bail}; -use lpc_shared::hardware::{ - HardwareBoardLabelFile, HardwareBoardLabelStatus, HardwareManifestFile, -}; +use lpc_hardware::{HardwareBoardLabelFile, HardwareBoardLabelStatus, HardwareManifestFile}; use super::calibration_manifest_update::{ apply_mapping, is_provisional_gpio_label, parse_gpio_address, @@ -246,7 +244,7 @@ fn clear_label_from_gpio_resources( } fn ensure_resource_alias( - resource: &mut lpc_shared::hardware::hardware_manifest_file::HardwareResourceFile, + resource: &mut lpc_hardware::manifest::hw_manifest_file::HardwareResourceFile, alias: &str, ) { if !resource.aliases.iter().any(|existing| existing == alias) { @@ -333,7 +331,7 @@ fn natural_label_key(label: &str) -> (String, u32, String) { #[cfg(test)] mod tests { use super::*; - use lpc_shared::hardware::HardwareTarget; + use lpc_hardware::HardwareTarget; #[test] fn expands_label_ranges() { diff --git a/lp-cli/src/commands/hardware/calibrate/screen_board_select.rs b/lp-cli/src/commands/hardware/calibrate/screen_board_select.rs index 5090c352c..2bc7762ec 100644 --- a/lp-cli/src/commands/hardware/calibrate/screen_board_select.rs +++ b/lp-cli/src/commands/hardware/calibrate/screen_board_select.rs @@ -1,6 +1,6 @@ use anyhow::{Result, bail}; use dialoguer::Select; -use lpc_shared::hardware::HardwareTarget; +use lpc_hardware::HardwareTarget; use std::io::{IsTerminal, stdin}; use crate::commands::hardware::manifest::board_manifest_store::BoardManifestStore; diff --git a/lp-cli/src/commands/hardware/manifest/board_manifest_commands.rs b/lp-cli/src/commands/hardware/manifest/board_manifest_commands.rs index b33e4a6b1..b0d90cca2 100644 --- a/lp-cli/src/commands/hardware/manifest/board_manifest_commands.rs +++ b/lp-cli/src/commands/hardware/manifest/board_manifest_commands.rs @@ -1,6 +1,6 @@ use anyhow::{Result, anyhow, bail}; use dialoguer::{Confirm, Input, Select}; -use lpc_shared::hardware::{HardwareManifestFile, HardwareTarget}; +use lpc_hardware::{HardwareManifestFile, HardwareTarget}; use std::io::IsTerminal; use crate::commands::hardware::args::{ diff --git a/lp-cli/src/commands/hardware/manifest/board_manifest_store.rs b/lp-cli/src/commands/hardware/manifest/board_manifest_store.rs index 32e42b514..2568de5d5 100644 --- a/lp-cli/src/commands/hardware/manifest/board_manifest_store.rs +++ b/lp-cli/src/commands/hardware/manifest/board_manifest_store.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result, anyhow, bail}; -use lpc_shared::hardware::HardwareManifestFile; +use lpc_hardware::HardwareManifestFile; use std::fs; use std::path::{Path, PathBuf}; @@ -12,7 +12,7 @@ impl BoardManifestStore { let repo_root = find_repo_root(repo)?; let boards_dir = match boards_dir { Some(path) => path, - None => repo_root.join("lp-core/lpc-shared/boards"), + None => repo_root.join("lp-core/lpc-hardware/boards"), }; Ok(Self { boards_dir }) } diff --git a/lp-cli/src/debug_ui/node_cards.rs b/lp-cli/src/debug_ui/node_cards.rs index c4dc6a4d5..c520430df 100644 --- a/lp-cli/src/debug_ui/node_cards.rs +++ b/lp-cli/src/debug_ui/node_cards.rs @@ -3,7 +3,7 @@ use eframe::egui; use lpc_model::{NodeId, ResourceRef, SlotData}; use lpc_view::project::ProjectView; -use lpc_wire::{WireEntryState, WireNodeStatus}; +use lpc_wire::{NodeRuntimeStatus, WireEntryState}; use super::format::format_resource_metadata; use super::inspector::InspectorSelection; @@ -663,13 +663,13 @@ fn node_kind_from_path_tag(tag: &str) -> Option<&'static str> { } } -fn status_label(status: &WireNodeStatus) -> &str { +fn status_label(status: &NodeRuntimeStatus) -> &str { match status { - WireNodeStatus::Created => "created", - WireNodeStatus::InitError(_) => "init error", - WireNodeStatus::Ok => "ok", - WireNodeStatus::Warn(_) => "warn", - WireNodeStatus::Error(_) => "error", + NodeRuntimeStatus::Created => "created", + NodeRuntimeStatus::InitError(_) => "init error", + NodeRuntimeStatus::Ok => "ok", + NodeRuntimeStatus::Warn(_) => "warn", + NodeRuntimeStatus::Error(_) => "error", } } @@ -681,12 +681,12 @@ fn state_label(state: &WireEntryState) -> &str { } } -fn node_status_color(status: &WireNodeStatus, state: &WireEntryState) -> egui::Color32 { +fn node_status_color(status: &NodeRuntimeStatus, state: &WireEntryState) -> egui::Color32 { match (status, state) { - (WireNodeStatus::Error(_) | WireNodeStatus::InitError(_), _) + (NodeRuntimeStatus::Error(_) | NodeRuntimeStatus::InitError(_), _) | (_, WireEntryState::Failed { .. }) => egui::Color32::from_rgb(220, 75, 72), - (WireNodeStatus::Warn(_), _) => egui::Color32::from_rgb(214, 159, 43), - (WireNodeStatus::Ok, WireEntryState::Alive) => egui::Color32::from_rgb(76, 174, 114), + (NodeRuntimeStatus::Warn(_), _) => egui::Color32::from_rgb(214, 159, 43), + (NodeRuntimeStatus::Ok, WireEntryState::Alive) => egui::Color32::from_rgb(76, 174, 114), _ => egui::Color32::from_rgb(112, 144, 191), } } diff --git a/lp-cli/src/debug_ui/slot_edit.rs b/lp-cli/src/debug_ui/slot_edit.rs index 2541c4616..75f79f97f 100644 --- a/lp-cli/src/debug_ui/slot_edit.rs +++ b/lp-cli/src/debug_ui/slot_edit.rs @@ -4,8 +4,6 @@ use std::collections::BTreeMap; use eframe::egui; use lpc_model::{LpValue, SlotPath, SlotPolicy, SlotValueShape, ValueEditorHint}; -use lpc_view::SlotMirrorView; -use lpc_wire::{WireSlotMutationId, WireSlotMutationRejection}; /// Stable UI key for a slot mutation target. #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -37,38 +35,28 @@ impl SlotEditIntent { } } -/// Read-only mutation status lookup for value rows. +/// Read-only edit status lookup for value rows. pub(crate) struct SlotEditStatusContext<'a> { - last_mutation_by_slot: &'a BTreeMap, - slots: &'a SlotMirrorView, + errors_by_slot: &'a BTreeMap, } impl<'a> SlotEditStatusContext<'a> { - pub fn new( - last_mutation_by_slot: &'a BTreeMap, - slots: &'a SlotMirrorView, - ) -> Self { - Self { - last_mutation_by_slot, - slots, - } + pub fn new(errors_by_slot: &'a BTreeMap) -> Self { + Self { errors_by_slot } } pub fn status(&self, root: &str, path: &SlotPath) -> SlotEditStatus<'_> { let key = SlotEditKey::new(root, path.clone()); - let Some(id) = self.last_mutation_by_slot.get(&key).copied() else { - return SlotEditStatus::default(); - }; SlotEditStatus { - error: self.slots.error(id), + error: self.errors_by_slot.get(&key).map(String::as_str), } } } -/// Per-row mutation status. +/// Per-row edit status. #[derive(Clone, Copy, Debug, Default)] pub(crate) struct SlotEditStatus<'a> { - pub error: Option<&'a WireSlotMutationRejection>, + pub error: Option<&'a str>, } /// Render a supported editor for one slot value leaf. @@ -111,7 +99,7 @@ pub(crate) fn slot_value_editor_supported(shape: &SlotValueShape, value: &LpValu pub(crate) fn render_slot_edit_status(ui: &mut egui::Ui, status: SlotEditStatus<'_>) { if let Some(error) = status.error { ui.colored_label(egui::Color32::LIGHT_RED, "rejected") - .on_hover_text(format!("{error:?}")); + .on_hover_text(error); } } diff --git a/lp-cli/src/debug_ui/slot_render.rs b/lp-cli/src/debug_ui/slot_render.rs index f0af1c4e7..d0e8bfab4 100644 --- a/lp-cli/src/debug_ui/slot_render.rs +++ b/lp-cli/src/debug_ui/slot_render.rs @@ -412,6 +412,25 @@ fn render_named_slot_shape_row( return; }; + if let SlotShape::Custom { shape, .. } = shape { + render_named_slot_shape_row( + ui, + registry, + root, + path, + policy, + name, + shape, + data, + depth, + id_path, + selection, + status, + edit_intents, + ); + return; + } + match (shape, data) { (SlotShape::Unit { .. }, SlotData::Unit { revision }) => { row(ui, depth, name, "unit", format!("rev {}", revision.0)); diff --git a/lp-cli/src/debug_ui/ui.rs b/lp-cli/src/debug_ui/ui.rs index d7a85e017..6535e8d28 100644 --- a/lp-cli/src/debug_ui/ui.rs +++ b/lp-cli/src/debug_ui/ui.rs @@ -6,22 +6,30 @@ use std::time::{Duration, Instant}; use crate::client::LpClient; use eframe::egui; -use lpc_model::{Revision, SlotShapeId}; +use lpc_model::{ + MutationCmd, MutationCmdBatch, MutationCmdId, MutationOp, NodeId, Revision, SlotEdit, SlotPath, + SlotShapeId, +}; use lpc_view::{ProjectView, apply_project_read_response}; use lpc_wire::{ NodeReadQuery, NodeReadSelection, ProjectProbeRequest, ProjectProbeResult, ProjectReadQuery, ProjectReadRequest, ProjectReadResponse, ProjectReadResult as WireProjectReadResult, ReadLevel, RenderProductProbeRequest, RenderProductProbeResult, ResourcePayloadRead, ResourceReadQuery, RuntimeReadQuery, RuntimeReadResult, ShapeReadQuery, ShapeReadResult, - WireProjectHandle as ProjectHandle, WireSlotMutationId, WireSlotMutationRequest, - WireTextureFormat, + WireOverlayMutationRequest, WireProjectHandle as ProjectHandle, + WireProjectInventoryReadResponse, WireTextureFormat, }; use super::inspector::{InspectorSelection, render_debug_inspector}; use super::node_cards::render_node_workspace; use super::slot_edit::{SlotEditIntent, SlotEditKey, SlotEditStatusContext}; -type ProjectReadResult = Result; +type DebugUiResult = Result; + +enum DebugUiMessage { + ProjectRead(ProjectReadResponse), + Inventory(WireProjectInventoryReadResponse), +} const TARGET_UI_FPS: u64 = 30; const TARGET_UI_FRAME_MS: u64 = 1000 / TARGET_UI_FPS; @@ -35,8 +43,8 @@ pub struct DebugUiState { project_handle: ProjectHandle, async_client: LpClient, runtime_handle: tokio::runtime::Handle, - response_tx: std::sync::mpsc::Sender, - response_rx: std::sync::mpsc::Receiver, + response_tx: std::sync::mpsc::Sender, + response_rx: std::sync::mpsc::Receiver, last_poll: Instant, poll_in_flight: bool, last_error: Option, @@ -45,9 +53,10 @@ pub struct DebugUiState { last_runtime_status: Option, shapes_synced: bool, next_shape_cursor: Option, - next_mutation_id: u64, - queued_mutations: BTreeMap, - last_mutation_by_slot: BTreeMap, + next_overlay_cmd_id: u64, + queued_edits: BTreeMap, + slot_edit_errors: BTreeMap, + project_inventory: Option, } impl DebugUiState { @@ -74,9 +83,10 @@ impl DebugUiState { last_runtime_status: None, shapes_synced: false, next_shape_cursor: None, - next_mutation_id: 1, - queued_mutations: BTreeMap::new(), - last_mutation_by_slot: BTreeMap::new(), + next_overlay_cmd_id: 1, + queued_edits: BTreeMap::new(), + slot_edit_errors: BTreeMap::new(), + project_inventory: None, } } @@ -84,7 +94,7 @@ impl DebugUiState { while let Ok(result) = self.response_rx.try_recv() { self.poll_in_flight = false; match result { - Ok(response) => { + Ok(DebugUiMessage::ProjectRead(response)) => { let paged_shape_sync_in_progress = !self.shapes_synced; if let Some(probe) = response.probes.iter().find_map(render_product_probe) { self.last_render_product_probe = Some(probe.clone()); @@ -108,6 +118,10 @@ impl DebugUiState { } } } + Ok(DebugUiMessage::Inventory(inventory)) => { + self.project_inventory = Some(inventory); + self.last_error = None; + } Err(error) => { self.last_error = Some(error); } @@ -122,29 +136,65 @@ impl DebugUiState { self.last_poll = Instant::now(); self.poll_in_flight = true; - let request = if self.shapes_synced { - let mutations = self.prepare_queued_mutations(); + if self.shapes_synced && !self.queued_edits.is_empty() && self.project_inventory.is_none() { + let client = self.async_client.clone(); + let handle = self.project_handle; + let tx = self.response_tx.clone(); + let repaint = ctx.clone(); + self.runtime_handle.spawn(async move { + let result = client + .project_inventory_read(handle) + .await + .map(DebugUiMessage::Inventory) + .map_err(|error| error.to_string()); + let _ = tx.send(result); + repaint.request_repaint(); + }); + return; + } + + let read_context = if self.shapes_synced { + let mutation = self.prepare_queued_overlay_mutation(); let (since, needs_slot_snapshot, selected_resource, selected_visual_product) = self.next_project_read_context(); - let include_slots = needs_slot_snapshot || !mutations.is_empty(); - debug_ui_project_read( + let include_slots = needs_slot_snapshot || mutation.is_some(); + ( since, include_slots, selected_resource, selected_visual_product, - mutations, + mutation, + ) + } else { + (None, false, None, None, None) + }; + let request = if self.shapes_synced { + debug_ui_project_read( + read_context.0, + read_context.1, + read_context.2, + read_context.3, ) } else { debug_ui_shape_sync_read(self.next_shape_cursor) }; + let mutation = read_context.4; let client = self.async_client.clone(); let handle = self.project_handle; let tx = self.response_tx.clone(); let repaint = ctx.clone(); self.runtime_handle.spawn(async move { + if let Some(mutation) = mutation { + if let Err(error) = client.project_overlay_mutate(handle, mutation).await { + let _ = tx.send(Err(error.to_string())); + repaint.request_repaint(); + return; + } + } let result = client .project_read(handle, request) .await + .map(DebugUiMessage::ProjectRead) .map_err(|error| error.to_string()); let _ = tx.send(result); repaint.request_repaint(); @@ -186,56 +236,103 @@ impl DebugUiState { } for intent in intents { - self.queued_mutations.insert(intent.key(), intent); + self.queued_edits.insert(intent.key(), intent); } } - fn prepare_queued_mutations(&mut self) -> Vec { - if self.queued_mutations.is_empty() { - return Vec::new(); + fn prepare_queued_overlay_mutation(&mut self) -> Option { + if self.queued_edits.is_empty() { + return None; } - let queued = core::mem::take(&mut self.queued_mutations); - let mut requests = Vec::new(); - let mut next_mutation_id = self.next_mutation_id; + let Some(inventory) = &self.project_inventory else { + return None; + }; + + let queued = core::mem::take(&mut self.queued_edits); + let mut commands = Vec::new(); + let mut next_overlay_cmd_id = self.next_overlay_cmd_id; let mut last_error = None; match self.project_view.lock() { - Ok(mut view) => { + Ok(view) => { for (key, intent) in queued { - let id = WireSlotMutationId::new(next_mutation_id); - next_mutation_id = next_mutation_id.saturating_add(1); - match view.slots.prepare_set_value( + if let Err(error) = + view.slots + .validate_set_value(&intent.root, &intent.path, &intent.value) + { + let message = format!("slot edit rejected locally: {error}"); + self.slot_edit_errors.insert(key, message.clone()); + last_error = Some(message); + continue; + } + + let Some((artifact, path)) = overlay_target_for_slot_edit(inventory, &intent) + else { + let message = format!("slot edit target is not an authored node def root"); + self.slot_edit_errors.insert(key, message.clone()); + last_error = Some(message); + continue; + }; + + let id = MutationCmdId::new(next_overlay_cmd_id); + next_overlay_cmd_id = next_overlay_cmd_id.saturating_add(1); + self.slot_edit_errors.remove(&key); + commands.push(MutationCmd { id, - &intent.root, - intent.path.clone(), - intent.value, - ) { - Ok(request) => { - self.last_mutation_by_slot.insert(key, id); - requests.push(request); - } - Err(error) => { - last_error = Some(format!("slot edit rejected locally: {error}")); - } + mutation: MutationOp::PutSlotEdit { + artifact, + edit: SlotEdit::assign_value(path, intent.value), + }, + }); + } + if commands.is_empty() { + if let Some(error) = last_error { + self.last_error = Some(error); } + self.next_overlay_cmd_id = next_overlay_cmd_id; + return None; } } Err(_) => { self.last_error = Some(String::from("Project view locked")); - self.queued_mutations = queued; - return Vec::new(); + self.queued_edits = queued; + return None; } } - self.next_mutation_id = next_mutation_id; + self.next_overlay_cmd_id = next_overlay_cmd_id; if let Some(error) = last_error { self.last_error = Some(error); } - requests + Some(WireOverlayMutationRequest::new(MutationCmdBatch::new( + commands, + ))) } } +fn overlay_target_for_slot_edit( + inventory: &WireProjectInventoryReadResponse, + intent: &SlotEditIntent, +) -> Option<(lpc_model::ArtifactLocation, SlotPath)> { + let node_id = node_id_from_def_root(&intent.root)?; + let node = inventory + .nodes + .iter() + .find(|node| node.runtime_id == Some(node_id))?; + let mut segments = node.def_location.path.segments().to_vec(); + segments.extend(intent.path.segments().iter().cloned()); + Some(( + node.def_location.artifact.clone(), + SlotPath::from_segments(segments), + )) +} + +fn node_id_from_def_root(root: &str) -> Option { + let value = root.strip_prefix("node.")?.strip_suffix(".def")?; + value.parse::().ok().map(NodeId::new) +} + fn render_product_probe(probe: &ProjectProbeResult) -> Option<&RenderProductProbeResult> { match probe { ProjectProbeResult::RenderProduct(probe) => Some(probe), @@ -268,10 +365,7 @@ fn apply_debug_ui_project_read_response( view.slots.apply_registry_page(registry); } } - if response.results.is_empty() - && response.probes.is_empty() - && response.mutations.is_empty() - { + if response.results.is_empty() && response.probes.is_empty() { return Ok(()); } } @@ -296,7 +390,6 @@ fn debug_ui_project_read( include_slots: bool, selected_resource: Option, selected_visual_product: Option, - mutations: Vec, ) -> ProjectReadRequest { let mut queries = Vec::new(); queries.push(ProjectReadQuery::Nodes(NodeReadQuery { @@ -331,7 +424,6 @@ fn debug_ui_project_read( since, queries, probes, - mutations, } } @@ -344,7 +436,6 @@ fn debug_ui_shape_sync_read(after: Option) -> ProjectReadRequest { limit: Some(SHAPE_SYNC_PAGE_LIMIT), })]), probes: Vec::new(), - mutations: Vec::new(), } } @@ -379,7 +470,7 @@ impl eframe::App for DebugUiState { ui.label("Project view locked"); return; }; - let status = SlotEditStatusContext::new(&self.last_mutation_by_slot, &view.slots); + let status = SlotEditStatusContext::new(&self.slot_edit_errors); render_node_workspace( ui, &view, @@ -428,7 +519,6 @@ mod tests { next: None, })], probes: vec![], - mutations: vec![], }; apply_debug_ui_project_read_response(&mut view, response, true).unwrap(); @@ -445,7 +535,6 @@ mod tests { assert_eq!(request.since, None); assert!(request.probes.is_empty()); - assert!(request.mutations.is_empty()); assert_eq!(request.queries.len(), 1); assert_eq!( request.queries[0], diff --git a/lp-cli/src/server/create_server.rs b/lp-cli/src/server/create_server.rs index 6e404dc4e..7a5ec0c3a 100644 --- a/lp-cli/src/server/create_server.rs +++ b/lp-cli/src/server/create_server.rs @@ -1,7 +1,7 @@ use crate::commands::serve::init::{create_filesystem, initialize_server}; use lpa_server::{ButtonService, Graphics, LpGraphics, LpServer, RadioService}; +use lpc_hardware::{HardwareSystem, HwRegistry, default_esp32c6_hardware_manifest}; use lpc_model::AsLpPath; -use lpc_shared::hardware::{HardwareRegistry, HardwareSystem, default_esp32c6_hardware_manifest}; use lpc_shared::output::MemoryOutputProvider; use lpfs::LpFs; use std::cell::RefCell; @@ -46,7 +46,7 @@ pub fn create_server( // Create output provider let output_provider = Rc::new(RefCell::new(MemoryOutputProvider::new_permissive())); let hardware = Rc::new(HardwareSystem::with_virtual_drivers(Rc::new( - HardwareRegistry::new(default_esp32c6_hardware_manifest()), + HwRegistry::new(default_esp32c6_hardware_manifest()), ))); let button_service: Rc = hardware.clone(); let radio_service: Rc = hardware.clone(); diff --git a/lp-cli/tests/file_watch_sync.rs b/lp-cli/tests/file_watch_sync.rs index cdf567842..54ec5fca3 100644 --- a/lp-cli/tests/file_watch_sync.rs +++ b/lp-cli/tests/file_watch_sync.rs @@ -6,7 +6,7 @@ //! - Path formatting in sync_file_change use lpc_model::AsLpPath; -use lpfs::{ChangeType, FsChange, LpFs, LpFsMemory}; +use lpfs::{FsEvent, FsEventKind, LpFs, LpFsMemory}; use std::collections::HashMap; use std::time::Instant; @@ -15,12 +15,12 @@ use lp_cli::commands::dev::fs_loop::add_pending_change; #[test] fn test_add_pending_change() { // Test that add_pending_change correctly adds changes and updates timestamp - let mut pending_changes: HashMap = HashMap::new(); + let mut pending_changes: HashMap = HashMap::new(); let mut last_change_time: Option = None; - let change1 = FsChange { + let change1 = FsEvent { path: lpc_model::LpPathBuf::from("/src/test1.glsl"), - change_type: ChangeType::Create, + kind: FsEventKind::Create, }; add_pending_change(&mut pending_changes, &mut last_change_time, change1.clone()); @@ -30,9 +30,9 @@ fn test_add_pending_change() { assert!(last_change_time.is_some()); // Add another change - let change2 = FsChange { + let change2 = FsEvent { path: lpc_model::LpPathBuf::from("/src/test2.glsl"), - change_type: ChangeType::Modify, + kind: FsEventKind::Modify, }; add_pending_change(&mut pending_changes, &mut last_change_time, change2.clone()); @@ -42,9 +42,9 @@ fn test_add_pending_change() { assert!(pending_changes.contains_key("/src/test2.glsl")); // Update existing change (should deduplicate by path) - let change1_updated = FsChange { + let change1_updated = FsEvent { path: lpc_model::LpPathBuf::from("/src/test1.glsl"), - change_type: ChangeType::Modify, // Changed from Create to Modify + kind: FsEventKind::Modify, // Changed from Create to Modify }; let time_before = last_change_time.unwrap(); @@ -59,8 +59,8 @@ fn test_add_pending_change() { assert_eq!(pending_changes.len(), 2); // The change should be updated (Modify, not Create) assert_eq!( - pending_changes.get("/src/test1.glsl").unwrap().change_type, - ChangeType::Modify + pending_changes.get("/src/test1.glsl").unwrap().kind, + FsEventKind::Modify ); // Timestamp should be updated assert!(last_change_time.unwrap() > time_before); @@ -81,7 +81,7 @@ fn test_file_change_detection() { let changes = fs.get_changes(); assert_eq!(changes.len(), 1); assert_eq!(changes[0].path.as_str(), "/src/test.glsl"); - assert_eq!(changes[0].change_type, ChangeType::Create); + assert_eq!(changes[0].kind, FsEventKind::Create); // Modify the file fs.reset_changes(); @@ -93,7 +93,7 @@ fn test_file_change_detection() { let changes = fs.get_changes(); assert_eq!(changes.len(), 1); - assert_eq!(changes[0].change_type, ChangeType::Modify); + assert_eq!(changes[0].kind, FsEventKind::Modify); // Delete the file fs.reset_changes(); @@ -101,7 +101,7 @@ fn test_file_change_detection() { let changes = fs.get_changes(); assert_eq!(changes.len(), 1); - assert_eq!(changes[0].change_type, ChangeType::Delete); + assert_eq!(changes[0].kind, FsEventKind::Delete); } #[test] @@ -122,7 +122,7 @@ fn test_multiple_file_changes() { // Verify all changes are Create type for change in &changes { - assert_eq!(change.change_type, ChangeType::Create); + assert_eq!(change.kind, FsEventKind::Create); } // Verify paths @@ -164,13 +164,13 @@ fn test_debouncing_logic() { use lp_cli::commands::dev::fs_loop::DEBOUNCE_DURATION; use std::time::Duration; - let mut pending_changes: HashMap = HashMap::new(); + let mut pending_changes: HashMap = HashMap::new(); let mut last_change_time: Option = None; // Add a change - let change = FsChange { + let change = FsEvent { path: lpc_model::LpPathBuf::from("/src/test.glsl"), - change_type: ChangeType::Create, + kind: FsEventKind::Create, }; add_pending_change(&mut pending_changes, &mut last_change_time, change); diff --git a/lp-core/lpc-engine/Cargo.toml b/lp-core/lpc-engine/Cargo.toml index 85f33487b..522a5489c 100644 --- a/lp-core/lpc-engine/Cargo.toml +++ b/lp-core/lpc-engine/Cargo.toml @@ -13,11 +13,13 @@ default = ["std"] panic-recovery = ["dep:unwinding"] naga = ["lp-shader/naga"] std = [ + "lpc-hardware/std", "lpc-shared/std", "lpfs/std", ] [dependencies] +lp-collection = { workspace = true, features = ["serde"] } unwinding = { version = "0.2", optional = true, default-features = false, features = ["panic"] } serde = { workspace = true, features = ["derive"] } hashbrown = { workspace = true } @@ -30,6 +32,8 @@ lps-q32 = { path = "../../lp-shader/lps-q32", default-features = false } log = { workspace = true, default-features = false } lpc-model = { path = "../lpc-model", default-features = false } +lpc-hardware = { path = "../lpc-hardware", default-features = false } +lpc-registry = { path = "../lpc-registry", default-features = false } lpc-wire = { path = "../lpc-wire", default-features = false } lp-perf = { path = "../../lp-base/lp-perf", default-features = false } lpfs = { path = "../../lp-base/lpfs", default-features = false } @@ -51,6 +55,7 @@ lpvm-native = { path = "../../lp-shader/lpvm-native", default-features = false } lpvm-wasm = { path = "../../lp-shader/lpvm-wasm", default-features = false } [dev-dependencies] +lpc-hardware = { path = "../lpc-hardware", default-features = false, features = ["std"] } lpc-shared = { path = "../lpc-shared", default-features = false, features = ["std"] } lpfs = { path = "../../lp-base/lpfs", default-features = false, features = ["std"] } lpc-view = { path = "../lpc-view", default-features = false, features = ["std"] } diff --git a/lp-core/lpc-engine/src/artifact/artifact_entry.rs b/lp-core/lpc-engine/src/artifact/artifact_entry.rs deleted file mode 100644 index e5caf8567..000000000 --- a/lp-core/lpc-engine/src/artifact/artifact_entry.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Single artifact slot in [`super::ArtifactStore`]. - -use lpc_model::Revision; - -use super::{ArtifactError, ArtifactId, ArtifactLocation, ArtifactState}; - -/// One artifact record: runtime id, resolved location, refcount, last successful content frame, and state. -pub struct ArtifactEntry { - pub id: ArtifactId, - pub location: ArtifactLocation, - pub state: ArtifactState, - pub refcount: u32, - pub content_frame: Revision, - /// Secondary slot for errors not represented in [`ArtifactState`] (currently unused; kept for API parity). - pub error: Option, -} diff --git a/lp-core/lpc-engine/src/artifact/artifact_error.rs b/lp-core/lpc-engine/src/artifact/artifact_error.rs deleted file mode 100644 index c7754e8a2..000000000 --- a/lp-core/lpc-engine/src/artifact/artifact_error.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Structured errors for artifact manager operations and loader callbacks. - -use alloc::string::String; - -/// Errors returned by [`super::ArtifactStore`] and loader closures. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ArtifactError { - /// No entry exists for this [`super::ArtifactId`] handle. - UnknownHandle { handle: u32 }, - /// [`super::ArtifactStore::release`] called when refcount is already zero. - InvalidRelease { handle: u32 }, - /// Artifact resolution failed (forwarded into [`super::ArtifactState::ResolutionError`] when stored). - Resolution(String), - /// Load failed (forwarded into [`super::ArtifactState::LoadError`] when stored). - Load(String), - /// Prepare failed (forwarded into [`super::ArtifactState::PrepareError`] when stored). - Prepare(String), -} - -impl ArtifactError { - pub(crate) fn summary_for_state(&self) -> String { - match self { - ArtifactError::UnknownHandle { handle } => { - alloc::format!("unknown artifact handle {handle}") - } - ArtifactError::InvalidRelease { handle } => { - alloc::format!("invalid release for handle {handle}") - } - ArtifactError::Resolution(s) | ArtifactError::Load(s) | ArtifactError::Prepare(s) => { - s.clone() - } - } - } -} diff --git a/lp-core/lpc-engine/src/artifact/artifact_id.rs b/lp-core/lpc-engine/src/artifact/artifact_id.rs deleted file mode 100644 index e2a78b75d..000000000 --- a/lp-core/lpc-engine/src/artifact/artifact_id.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Opaque handle to an artifact entry inside [`super::ArtifactStore`]. - -/// Runtime handle returned by [`super::ArtifactStore::acquire_resolved`]. -/// -/// Dropping a reference does **not** decrement refcount; call [`super::ArtifactStore::release`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ArtifactId { - handle: u32, -} - -impl ArtifactId { - pub(crate) const fn from_raw(handle: u32) -> Self { - Self { handle } - } - - pub fn handle(&self) -> u32 { - self.handle - } -} diff --git a/lp-core/lpc-engine/src/artifact/artifact_location.rs b/lp-core/lpc-engine/src/artifact/artifact_location.rs deleted file mode 100644 index 6609fa4df..000000000 --- a/lp-core/lpc-engine/src/artifact/artifact_location.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Resolved runtime location for loading and caching artifacts. - -use core::cmp::Ordering; - -use alloc::string::String; -use lpc_model::{ArtifactLocator, LpPathBuf}; - -/// Resolved load location used as the artifact manager cache key. -/// -/// `ArtifactLocator` is authored and context-dependent. `ArtifactLocation` -/// is the engine-side resolved address that can be loaded and cached. It is -/// deliberately separate from the authored locator so relative paths, future -/// libraries, and built-ins can all resolve into stable runtime identities. -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub enum ArtifactLocation { - File(LpPathBuf), - InlineNode { owner: LpPathBuf, name: String }, -} - -impl ArtifactLocation { - pub fn file(path: impl Into) -> Self { - Self::File(path.into()) - } - - pub fn inline_node(owner: impl Into, name: impl Into) -> Self { - Self::InlineNode { - owner: owner.into(), - name: name.into(), - } - } - - pub fn try_from_src_spec(spec: &ArtifactLocator) -> Result { - match spec { - ArtifactLocator::Path(path) => Ok(Self::File(path.clone())), - ArtifactLocator::Lib(lib) => Err(super::ArtifactError::Resolution(alloc::format!( - "library artifact references are not supported yet ({lib})" - ))), - } - } -} - -impl Ord for ArtifactLocation { - fn cmp(&self, other: &Self) -> Ordering { - match (self, other) { - (Self::File(a), Self::File(b)) => a.as_str().cmp(b.as_str()), - (Self::File(_), Self::InlineNode { .. }) => Ordering::Less, - (Self::InlineNode { .. }, Self::File(_)) => Ordering::Greater, - ( - Self::InlineNode { - owner: a_owner, - name: a_name, - }, - Self::InlineNode { - owner: b_owner, - name: b_name, - }, - ) => a_owner - .as_str() - .cmp(b_owner.as_str()) - .then_with(|| a_name.cmp(b_name)), - } - } -} - -impl PartialOrd for ArtifactLocation { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use crate::artifact::ArtifactError; - #[test] - fn try_from_src_spec_preserves_file_path_location() { - let spec = ArtifactLocator::path("./fx/../fx/a.effect.toml"); - let location = ArtifactLocation::try_from_src_spec(&spec).unwrap(); - match location { - ArtifactLocation::File(path) => assert_eq!(path.as_str(), "fx/../fx/a.effect.toml"), - ArtifactLocation::InlineNode { .. } => panic!("expected file location"), - } - } - - #[test] - fn try_from_src_spec_rejects_lib_for_now() { - let spec = ArtifactLocator::parse("lib:core/x").unwrap(); - let err = ArtifactLocation::try_from_src_spec(&spec).unwrap_err(); - assert!(matches!(err, ArtifactError::Resolution(s) if s.contains("not supported"))); - } -} diff --git a/lp-core/lpc-engine/src/artifact/artifact_state.rs b/lp-core/lpc-engine/src/artifact/artifact_state.rs deleted file mode 100644 index 73f376499..000000000 --- a/lp-core/lpc-engine/src/artifact/artifact_state.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Lifecycle state for a resolved artifact entry. - -use lpc_model::NodeDef; - -/// State of an artifact entry in the runtime cache. -#[derive(Debug)] -pub enum ArtifactState { - /// Spec is known and refcounted; payload has not been loaded yet. - Resolved, - /// Payload loaded successfully. - Loaded(NodeDef), - /// Payload prepared for use (reserved for future prepare hooks). - Prepared(NodeDef), - /// No active refs; payload retained until eviction or reload. - Idle(NodeDef), - ResolutionError(alloc::string::String), - LoadError(alloc::string::String), - PrepareError(alloc::string::String), -} diff --git a/lp-core/lpc-engine/src/artifact/artifact_store.rs b/lp-core/lpc-engine/src/artifact/artifact_store.rs deleted file mode 100644 index a227777a2..000000000 --- a/lp-core/lpc-engine/src/artifact/artifact_store.rs +++ /dev/null @@ -1,266 +0,0 @@ -//! Maps [`ArtifactLocation`](super::ArtifactLocation) to refcounted runtime entries. - -use alloc::collections::BTreeMap; - -use lpc_model::{NodeDef, Revision}; - -use super::{ArtifactEntry, ArtifactError, ArtifactId, ArtifactLocation, ArtifactState}; - -/// Cache of artifacts keyed by opaque handle and resolved location. -/// -/// When the refcount of an entry in [`ArtifactState::Resolved`] or an error state reaches zero, -/// the entry is **removed** from both maps. Payload-bearing states transition to [`ArtifactState::Idle`] -/// instead so the location continues to resolve to the same handle for future acquires. -pub struct ArtifactStore { - by_handle: BTreeMap, - location_to_handle: BTreeMap, - next_handle: u32, -} - -impl ArtifactStore { - pub fn new() -> Self { - Self { - by_handle: BTreeMap::new(), - location_to_handle: BTreeMap::new(), - next_handle: 1, - } - } - - fn alloc_handle(&mut self) -> u32 { - let h = self.next_handle; - self.next_handle = self.next_handle.wrapping_add(1); - if self.next_handle == 0 { - self.next_handle = 1; - } - h - } - - /// Acquire (or reuse) an entry for `location`, increment refcount, and return its handle. - /// - /// New entries start as [`ArtifactState::Resolved`] with `content_frame = frame`. - pub fn acquire_location(&mut self, location: ArtifactLocation, frame: Revision) -> ArtifactId { - if let Some(&handle) = self.location_to_handle.get(&location) { - if let Some(entry) = self.by_handle.get_mut(&handle) { - entry.refcount += 1; - return ArtifactId::from_raw(handle); - } - self.location_to_handle.remove(&location); - } - let handle = self.alloc_handle(); - self.location_to_handle.insert(location.clone(), handle); - let id = ArtifactId::from_raw(handle); - self.by_handle.insert( - handle, - ArtifactEntry { - id, - location, - state: ArtifactState::Resolved, - refcount: 1, - content_frame: frame, - error: None, - }, - ); - id - } - - /// Run `loader` for this handle and update state / `content_frame` on success. - /// - /// Invokes `loader` regardless of current payload state so callers can reload and bump - /// [`ArtifactEntry::content_frame`]. - pub fn load_with( - &mut self, - r: &ArtifactId, - frame: Revision, - loader: F, - ) -> Result<(), ArtifactError> - where - F: FnOnce(&ArtifactLocation) -> Result, - { - let handle = r.handle(); - let entry = self - .by_handle - .get_mut(&handle) - .ok_or(ArtifactError::UnknownHandle { handle })?; - let location = entry.location.clone(); - match loader(&location) { - Ok(a) => { - entry.state = ArtifactState::Loaded(a); - entry.content_frame = frame; - Ok(()) - } - Err(e) => { - entry.state = match &e { - ArtifactError::Prepare(s) => ArtifactState::PrepareError(s.clone()), - ArtifactError::Resolution(s) => ArtifactState::ResolutionError(s.clone()), - ArtifactError::Load(s) => ArtifactState::LoadError(s.clone()), - ArtifactError::UnknownHandle { .. } | ArtifactError::InvalidRelease { .. } => { - ArtifactState::LoadError(e.summary_for_state()) - } - }; - Err(e) - } - } - } - - /// Decrement refcount. Payload-bearing entries become [`ArtifactState::Idle`] at zero refs; - /// resolved-only and error entries are removed (see struct docs). - pub fn release(&mut self, r: &ArtifactId, _frame: Revision) -> Result<(), ArtifactError> { - let handle = r.handle(); - let entry = self - .by_handle - .get_mut(&handle) - .ok_or(ArtifactError::UnknownHandle { handle })?; - if entry.refcount == 0 { - return Err(ArtifactError::InvalidRelease { handle }); - } - entry.refcount -= 1; - if entry.refcount != 0 { - return Ok(()); - } - let state = core::mem::replace(&mut entry.state, ArtifactState::Resolved); - match state { - ArtifactState::Resolved - | ArtifactState::ResolutionError(_) - | ArtifactState::LoadError(_) - | ArtifactState::PrepareError(_) => { - let location = entry.location.clone(); - self.location_to_handle.remove(&location); - self.by_handle.remove(&handle); - } - ArtifactState::Loaded(a) | ArtifactState::Prepared(a) => { - entry.state = ArtifactState::Idle(a); - } - ArtifactState::Idle(a) => { - entry.state = ArtifactState::Idle(a); - } - } - Ok(()) - } - - pub fn entry(&self, r: &ArtifactId) -> Option<&ArtifactEntry> { - self.by_handle.get(&r.handle()) - } - - pub fn entry_mut(&mut self, r: &ArtifactId) -> Option<&mut ArtifactEntry> { - self.by_handle.get_mut(&r.handle()) - } - - pub fn content_frame(&self, r: &ArtifactId) -> Option { - self.entry(r).map(|e| e.content_frame) - } - - pub fn refcount(&self, r: &ArtifactId) -> Option { - self.entry(r).map(|e| e.refcount) - } -} - -impl Default for ArtifactStore { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloc::string::String; - use lpc_model::{NodeDef, TextureDef}; - - fn location(path: &str) -> ArtifactLocation { - ArtifactLocation::file(path) - } - - #[test] - fn acquire_same_location_reuses_handle_and_increments_refcount() { - let mut m = ArtifactStore::new(); - let l = location("a.lp"); - let r1 = m.acquire_location(l.clone(), Revision::new(1)); - let r2 = m.acquire_location(l, Revision::new(2)); - assert_eq!(r1.handle(), r2.handle()); - assert_eq!(m.refcount(&r1), Some(2)); - } - - #[test] - fn release_decrements_refcount() { - let mut m = ArtifactStore::new(); - let r = m.acquire_location(location("b.lp"), Revision::new(1)); - let h = r.handle(); - let r2 = m.acquire_location(location("b.lp"), Revision::new(1)); - assert_eq!(m.refcount(&r), Some(2)); - m.release(&r2, Revision::new(1)).unwrap(); - assert_eq!(m.refcount(&r), Some(1)); - assert_eq!(m.entry(&r).unwrap().id.handle(), h); - assert_eq!(m.entry(&r).unwrap().location, location("b.lp")); - assert_eq!(m.entry(&ArtifactId::from_raw(h)).unwrap().refcount, 1); - } - - #[test] - fn loaded_moves_to_idle_when_refcount_reaches_zero() { - let mut m = ArtifactStore::new(); - let r = m.acquire_location(location("c.lp"), Revision::new(1)); - m.load_with(&r, Revision::new(5), |_location| Ok(texture_def(42, 24))) - .unwrap(); - assert!(matches!( - m.entry(&r).unwrap().state, - ArtifactState::Loaded(NodeDef::Texture(_)) - )); - m.release(&r, Revision::new(1)).unwrap(); - let e = m.entry(&r).unwrap(); - assert_eq!(e.refcount, 0); - assert!(matches!(&e.state, ArtifactState::Idle(NodeDef::Texture(_)))); - } - - #[test] - fn load_success_bumps_content_frame() { - let mut m = ArtifactStore::new(); - let r = m.acquire_location(location("d.lp"), Revision::new(1)); - m.load_with(&r, Revision::new(10), |_location| Ok(texture_def(1, 1))) - .unwrap(); - assert_eq!(m.content_frame(&r), Some(Revision::new(10))); - m.load_with(&r, Revision::new(99), |_location| Ok(texture_def(2, 2))) - .unwrap(); - assert_eq!(m.content_frame(&r), Some(Revision::new(99))); - if let ArtifactState::Loaded(NodeDef::Texture(v)) = &m.entry(&r).unwrap().state { - assert_eq!(v.width(), 2); - } else { - panic!("expected Loaded texture"); - } - } - - #[test] - fn load_failure_records_load_error() { - let mut m = ArtifactStore::new(); - let r = m.acquire_location(location("e.lp"), Revision::new(1)); - let err = m - .load_with(&r, Revision::new(3), |_location| { - Err(ArtifactError::Load(String::from("boom"))) - }) - .unwrap_err(); - assert_eq!(err, ArtifactError::Load(String::from("boom"))); - let e = m.entry(&r).unwrap(); - assert!(matches!( - &e.state, - ArtifactState::LoadError(msg) if msg == "boom" - )); - } - - #[test] - fn unknown_handle_returns_structured_error() { - let mut m = ArtifactStore::new(); - let bad = ArtifactId::from_raw(999); - assert_eq!( - m.release(&bad, Revision::default()).unwrap_err(), - ArtifactError::UnknownHandle { handle: 999 } - ); - assert_eq!( - m.load_with(&bad, Revision::default(), |_location| Ok(texture_def(0, 0))) - .unwrap_err(), - ArtifactError::UnknownHandle { handle: 999 } - ); - assert!(m.entry(&bad).is_none()); - } - - fn texture_def(width: u32, height: u32) -> NodeDef { - NodeDef::Texture(TextureDef::new(width, height)) - } -} diff --git a/lp-core/lpc-engine/src/artifact/mod.rs b/lp-core/lpc-engine/src/artifact/mod.rs deleted file mode 100644 index 54735cb1e..000000000 --- a/lp-core/lpc-engine/src/artifact/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Runtime artifact refcount cache and load states. - -mod artifact_entry; -mod artifact_error; -mod artifact_id; -mod artifact_location; -mod artifact_state; -mod artifact_store; - -pub use artifact_entry::ArtifactEntry; -pub use artifact_error::ArtifactError; -pub use artifact_id::ArtifactId; -pub use artifact_location::ArtifactLocation; -pub use artifact_state::ArtifactState; -pub use artifact_store::ArtifactStore; diff --git a/lp-core/lpc-engine/src/dataflow/bus/bus.rs b/lp-core/lpc-engine/src/dataflow/bus/bus.rs index 5eff127d7..53957670a 100644 --- a/lp-core/lpc-engine/src/dataflow/bus/bus.rs +++ b/lp-core/lpc-engine/src/dataflow/bus/bus.rs @@ -2,7 +2,7 @@ use crate::dataflow::bus::bus_error::BusError; use crate::dataflow::bus::channel_entry::ChannelEntry; -use alloc::collections::BTreeMap; +use lp_collection::VecMap; use lpc_model::{ChannelName, Kind, NodeId, Revision, SlotPath}; use lps_shared::LpsValueF32; @@ -18,13 +18,13 @@ use lps_shared::LpsValueF32; /// registration are deferred. #[derive(Default)] pub struct Bus { - channels: BTreeMap, + channels: VecMap, } impl Bus { pub fn new() -> Self { Self { - channels: BTreeMap::new(), + channels: VecMap::new(), } } 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 dd0417f2d..07d66f02b 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/query_key.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/query_key.rs @@ -36,16 +36,16 @@ impl QueryKey { #[cfg(test)] mod tests { use super::QueryKey; - use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; + use lp_collection::VecMap; use lpc_model::ChannelName; use lpc_model::NodeId; use lpc_model::SlotPath; #[test] fn query_key_works_as_btree_map_key() { - let mut m = BTreeMap::new(); + let mut m = VecMap::new(); let k1 = QueryKey::Bus(ChannelName(String::from("a"))); let k2 = QueryKey::Bus(ChannelName(String::from("b"))); m.insert(k1.clone(), 1u32); 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 91c979539..3b064a6e1 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/resolve_session.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/resolve_session.rs @@ -1,8 +1,8 @@ //! [`EngineSession`] — per-frame demand resolution and engine-dispatched work. -use alloc::collections::BTreeMap; use alloc::format; use alloc::vec::Vec; +use lp_collection::VecMap; use crate::dataflow::binding::{BindingEntry, BindingRef, BindingSource}; use crate::dataflow::resolver::production::{Production, ProductionSource}; @@ -286,7 +286,7 @@ fn merge_maps_by_key( trace: &ResolveTrace, ) -> Result { let mut keys_revision = Revision::default(); - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); for input in inputs { let SlotData::Map(map) = input.data().clone() else { return Err(SessionResolveError::other(format!( @@ -340,8 +340,8 @@ mod tests { use crate::dataflow::binding::BindingTarget; use crate::dataflow::resolver::resolve_trace::ResolveLogLevel; use crate::dataflow::resolver::resolve_trace::ResolveTraceEvent; - use alloc::collections::BTreeMap; use alloc::string::String; + use lp_collection::VecMap; use lpc_model::Kind; use lpc_model::{ChannelName, LpValue, SlotMapKey, WithRevision}; use lps_shared::LpsValueF32; @@ -759,7 +759,7 @@ mod tests { } } - fn map_data(revision: Revision, pairs: BTreeMap) -> SlotData { + fn map_data(revision: Revision, pairs: VecMap) -> SlotData { SlotData::Map(SlotMapDyn::with_revision( revision, pairs diff --git a/lp-core/lpc-engine/src/engine/engine.rs b/lp-core/lpc-engine/src/engine/engine.rs index 9ad424eb1..467135411 100644 --- a/lp-core/lpc-engine/src/engine/engine.rs +++ b/lp-core/lpc-engine/src/engine/engine.rs @@ -1,40 +1,37 @@ //! [`Engine`] — owns spine state and mediates [`ResolveHost`] production for produced slots. use alloc::boxed::Box; -use alloc::collections::BTreeSet; use alloc::format; use alloc::rc::Rc; -use alloc::string::{String, ToString}; +use alloc::string::ToString; use alloc::sync::Arc; use alloc::vec::Vec; -use hashbrown::HashMap; - +use lp_collection::VecSet; use lpc_model::{ - ControlProduct, NodeDef, NodeId, Revision, SlotAccess, SlotAccessor, SlotData, SlotDirection, - SlotMerge, SlotPath, SlotPathSegment, SlotSemantics, SlotShapeLookup, SlotShapeRegistry, - SlotShapeView, TreePath, WithRevision, advance_revision, current_revision, - lookup_slot_data_and_shape, + ControlProduct, NodeDef, NodeDefLocation, NodeDefState, NodeId, Revision, SlotAccess, + SlotAccessor, SlotData, SlotDirection, SlotMerge, SlotPath, SlotPathSegment, SlotSemantics, + SlotShapeLookup, SlotShapeRegistry, SlotShapeView, TreePath, WithRevision, advance_revision, + current_revision, lookup_slot_data_and_shape, }; +use lpc_registry::ProjectRegistry; use lpc_shared::time::TimeProvider; -use lpc_wire::WireNodeStatus; -use lpfs::FsChange; -use lpfs::lp_path::{LpPath, LpPathBuf}; +use lpc_wire::NodeRuntimeStatus; -use crate::artifact::{ArtifactState, ArtifactStore}; 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, }; use crate::gfx::LpGraphics; -use crate::node::NodeEntry; +use crate::node::RuntimeNodeEntry; use crate::node::catch_node_panic::catch_node_panic; use crate::node::{ ControlRenderContext, ControlRenderServices, NodeCall, NodeCallKey, NodeError, NodeResourceInitContext, NodeRuntime, ProduceResult, RenderContext, TickContext, VisualRenderServices, }; -use crate::node::{NodeEntryState, NodeTree}; +use crate::node::{NodeEntryState, RuntimeNodeTree}; use crate::products::control::{ControlLayout, ControlRenderRequest, ControlRenderTarget}; use crate::products::visual::{ RenderTextureRequest, TextureRenderProduct, VisualProduct, VisualSampleBufferRequest, @@ -42,7 +39,7 @@ use crate::products::visual::{ }; use crate::resource::{RuntimeBufferId, RuntimeBufferStore}; -use super::{ButtonService, EngineError, EngineServices, RadioService}; +use super::{ButtonService, EngineError, EngineServices, ProjectRuntimeIndex, RadioService}; use super::{FrameNum, FrameTime}; /// Conventional demand input used by the M2 engine slice. @@ -56,13 +53,12 @@ pub struct Engine { frame_num: FrameNum, revision: Revision, frame_time: FrameTime, - tree: NodeTree>, + tree: RuntimeNodeTree>, resolver: Resolver, slot_shapes: SlotShapeRegistry, runtime_buffers: RuntimeBufferStore, - artifacts: ArtifactStore, + project_runtime_index: ProjectRuntimeIndex, services: EngineServices, - artifact_nodes: HashMap, demand_roots: Vec, graphics: Option>, } @@ -79,13 +75,12 @@ impl Engine { frame_num: FrameNum::default(), revision, frame_time: FrameTime::zero(), - tree: NodeTree::new(root_path.clone(), revision), + tree: RuntimeNodeTree::new(root_path.clone(), revision), resolver: Resolver::new(), slot_shapes, runtime_buffers: RuntimeBufferStore::new(), - artifacts: ArtifactStore::new(), + project_runtime_index: ProjectRuntimeIndex::new(), services, - artifact_nodes: HashMap::new(), demand_roots: Vec::new(), graphics: None, } @@ -95,10 +90,6 @@ impl Engine { self.revision } - pub(super) fn set_revision(&mut self, revision: Revision) { - self.revision = revision; - } - pub fn frame_num(&self) -> FrameNum { self.frame_num } @@ -107,11 +98,11 @@ impl Engine { self.frame_time } - pub fn tree(&self) -> &NodeTree> { + pub fn tree(&self) -> &RuntimeNodeTree> { &self.tree } - pub fn tree_mut(&mut self) -> &mut NodeTree> { + pub fn tree_mut(&mut self) -> &mut RuntimeNodeTree> { &mut self.tree } @@ -139,12 +130,12 @@ impl Engine { &mut self.runtime_buffers } - pub fn artifacts(&self) -> &ArtifactStore { - &self.artifacts + pub fn project_runtime_index(&self) -> &ProjectRuntimeIndex { + &self.project_runtime_index } - pub fn artifacts_mut(&mut self) -> &mut ArtifactStore { - &mut self.artifacts + pub(crate) fn project_runtime_index_mut(&mut self) -> &mut ProjectRuntimeIndex { + &mut self.project_runtime_index } pub fn services(&self) -> &EngineServices { @@ -155,15 +146,6 @@ impl Engine { &mut self.services } - /// Engine [`NodeId`] for a node artifact path, if loaded. - pub fn artifact_node_id(&self, path: &LpPath) -> Option { - self.artifact_nodes.get(path.as_str()).copied() - } - - pub(crate) fn insert_artifact_node(&mut self, path: LpPathBuf, id: NodeId) { - self.artifact_nodes.insert(String::from(path.as_str()), id); - } - pub fn demand_roots(&self) -> &[NodeId] { &self.demand_roots } @@ -172,6 +154,88 @@ impl Engine { self.demand_roots.push(node); } + pub(crate) fn remove_runtime_subtree( + &mut self, + node: NodeId, + frame: Revision, + ) -> Result<(), EngineError> { + if node == self.tree.root() { + return Err(EngineError::Tree(crate::node::TreeError::RootMutation)); + } + let ids = self.tree.subtree_ids_depth_first(node)?; + for &id in &ids { + self.cleanup_runtime_node(id, frame)?; + self.project_runtime_index.remove_runtime_node(id); + } + self.demand_roots.retain(|root| !ids.contains(root)); + self.tree.remove_subtree(node, frame)?; + self.resolver.clear_frame_cache(); + Ok(()) + } + + pub(crate) fn reattach_runtime_node( + &mut self, + node: NodeId, + runtime: Box, + frame: Revision, + ) -> Result<(), EngineError> { + self.cleanup_runtime_node(node, frame)?; + self.attach_runtime_node(node, runtime, frame)?; + self.resolver.clear_frame_cache(); + Ok(()) + } + + fn cleanup_runtime_node(&mut self, node: NodeId, frame: Revision) -> Result<(), EngineError> { + let sink = self.runtime_output_sink_buffer_id(node); + if let Some(sink) = sink { + self.services.unregister_output_sink(sink); + } + + let state = { + let entry = self + .tree + .get_mut(node) + .ok_or(EngineError::UnknownNode(node))?; + let old_changed_at = entry.state.changed_at(); + core::mem::replace( + &mut entry.state, + WithRevision::new(old_changed_at, NodeEntryState::Pending), + ) + .into_value() + }; + + match state { + NodeEntryState::Alive(mut runtime) => { + let bus = Bus::new(); + let mut ctx = crate::node::DestroyCtx::new(node, frame, &bus); + runtime + .destroy(&mut ctx) + .map_err(|err| EngineError::node(node, err))?; + } + NodeEntryState::Pending | NodeEntryState::Failed { .. } => {} + NodeEntryState::Executing { call } => { + let entry = self + .tree + .get_mut(node) + .ok_or(EngineError::UnknownNode(node))?; + entry.set_state(NodeEntryState::Executing { call: call.clone() }, frame); + return Err(EngineError::Node { + node, + message: format!( + "cannot remove or reattach node while executing {}", + call.call.label() + ), + }); + } + } + + for buffer_id in self.runtime_buffers.remove_owned_by(node) { + self.services.unregister_output_sink(buffer_id); + } + self.demand_roots.retain(|&root| root != node); + Ok(()) + } + pub fn add_binding( &mut self, draft: BindingDraft, @@ -222,12 +286,70 @@ impl Engine { } } - pub fn tick(&mut self, delta_ms: u32) -> Result<(), EngineError> { + pub(crate) fn loaded_node_def_for_entry<'a, N>( + &self, + registry: &'a ProjectRegistry, + entry: &RuntimeNodeEntry, + ) -> Option<&'a NodeDef> { + let location = entry.def_location.as_ref()?; + loaded_registry_def(registry, location).ok() + } + + #[cfg(test)] + pub(crate) fn load_test_node_defs( + &mut self, + registry: &mut ProjectRegistry, + defs: &[(NodeId, NodeDef)], + frame: Revision, + ) -> Result<(), alloc::string::String> { + use alloc::format; + use alloc::string::String; + use lpc_model::{ArtifactLocation, NodeDefLocation}; + use lpc_registry::ParseCtx; + use lpfs::lp_path::AsLpPath; + use lpfs::{LpFs, LpFsMemory}; + + let fs = LpFsMemory::new(); + let mut project = String::from("kind = \"Project\"\n"); + for (index, (_, def)) in defs.iter().enumerate() { + let node_path = format!("/test-node-{index}.toml"); + project.push_str(&format!("\n[nodes.node{index}]\nref = \".{node_path}\"\n")); + let text = def + .write_toml(&self.slot_shapes) + .map_err(|e| e.to_string())?; + fs.write_file(node_path.as_path(), text.as_bytes()) + .map_err(|e| e.to_string())?; + } + fs.write_file("/project.toml".as_path(), project.as_bytes()) + .map_err(|e| e.to_string())?; + + let ctx = ParseCtx { + shapes: &self.slot_shapes, + }; + registry + .load_root(&fs, "/project.toml".as_path(), frame, &ctx) + .map_err(|e| format!("{e:?}"))?; + + for (index, (node_id, _)) in defs.iter().enumerate() { + let location = NodeDefLocation::artifact_root(ArtifactLocation::file(format!( + "/test-node-{index}.toml" + ))); + let entry = self + .tree + .get_mut(*node_id) + .ok_or_else(|| format!("unknown test node {node_id:?}"))?; + entry.def_location = Some(location); + } + + Ok(()) + } + + pub fn tick(&mut self, registry: &ProjectRegistry, delta_ms: u32) -> Result<(), EngineError> { lp_perf::emit_begin!(lp_perf::EVENT_FRAME); let result = (|| { - self.tick_nodes(delta_ms)?; + self.tick_nodes(registry, delta_ms)?; let revision = self.revision; - self.refresh_output_sink_configs(); + self.refresh_output_sink_configs(registry); let buffers = &self.runtime_buffers; self.services .flush_dirty_output_sinks(revision, buffers) @@ -240,13 +362,13 @@ impl Engine { result } - fn refresh_output_sink_configs(&mut self) { + fn refresh_output_sink_configs(&mut self, registry: &ProjectRegistry) { let mut updates = Vec::new(); for entry in self.tree.entries() { let Some(buffer_id) = self.runtime_output_sink_buffer_id(entry.id) else { continue; }; - let Some(NodeDef::Output(def)) = self.loaded_node_def(entry.artifact()) else { + let Some(NodeDef::Output(def)) = self.loaded_node_def_for_entry(registry, entry) else { continue; }; updates.push((buffer_id, def.clone())); @@ -257,7 +379,7 @@ impl Engine { } } - fn tick_nodes(&mut self, delta_ms: u32) -> Result<(), EngineError> { + fn tick_nodes(&mut self, registry: &ProjectRegistry, delta_ms: u32) -> Result<(), EngineError> { self.resolver.clear_frame_cache(); self.frame_num = self.frame_num.next(); self.revision = advance_revision(); @@ -268,14 +390,14 @@ impl Engine { let trace = ResolveTrace::new(ResolveLogLevel::Off); let mut session = EngineSession::new(self.revision, &mut resolver, trace); - let mut producers_ticked = BTreeSet::new(); + let mut producers_ticked = VecSet::new(); let time_s = self.frame_time.total_ms as f32 / 1000.0; let time_provider = self.services.time_provider(); let button_service = self.services.button_service(); let radio_service = self.services.radio_service(); let mut host = EngineResolveHost { tree: &mut self.tree, - artifacts: &self.artifacts, + registry, producers_ticked: &mut producers_ticked, runtime_buffers: &mut self.runtime_buffers, slot_shapes: &self.slot_shapes, @@ -296,28 +418,20 @@ impl Engine { Ok(()) } - /// Accept filesystem changes for direct engine embedders. - /// - /// The server-owned project wrapper currently reloads the project from its - /// filesystem on changes so node definition and shader source updates use - /// the same loader path as initial load. - pub fn handle_fs_changes(&mut self, _changes: &[FsChange]) -> Result<(), EngineError> { - Ok(()) - } - pub(crate) fn render_texture_product( &mut self, + registry: &ProjectRegistry, product: VisualProduct, request: &RenderTextureRequest, ) -> Result { - let mut producers_ticked = BTreeSet::new(); + let mut producers_ticked = VecSet::new(); let time_s = self.frame_time.total_ms as f32 / 1000.0; let time_provider = self.services.time_provider(); let button_service = self.services.button_service(); let radio_service = self.services.radio_service(); let mut host = EngineResolveHost { tree: &mut self.tree, - artifacts: &self.artifacts, + registry, producers_ticked: &mut producers_ticked, runtime_buffers: &mut self.runtime_buffers, slot_shapes: &self.slot_shapes, @@ -333,27 +447,29 @@ impl Engine { #[cfg(test)] pub(crate) fn render_texture_for_test( &mut self, + registry: &ProjectRegistry, product: VisualProduct, request: &RenderTextureRequest, ) -> Result { - self.render_texture_product(product, request) + self.render_texture_product(registry, product, request) } #[cfg(test)] pub(crate) fn render_control_for_test( &mut self, + registry: &ProjectRegistry, product: ControlProduct, request: &ControlRenderRequest, target: ControlRenderTarget<'_>, ) -> Result { - let mut producers_ticked = BTreeSet::new(); + let mut producers_ticked = VecSet::new(); let time_s = self.frame_time.total_ms as f32 / 1000.0; let time_provider = self.services.time_provider(); let button_service = self.services.button_service(); let radio_service = self.services.radio_service(); let mut host = EngineResolveHost { tree: &mut self.tree, - artifacts: &self.artifacts, + registry, producers_ticked: &mut producers_ticked, runtime_buffers: &mut self.runtime_buffers, slot_shapes: &self.slot_shapes, @@ -369,9 +485,9 @@ impl Engine { /// Host adapter with borrows disjoint from the [`Resolver`] handed to [`EngineSession`]. struct EngineResolveHost<'a> { - tree: &'a mut NodeTree>, - artifacts: &'a ArtifactStore, - producers_ticked: &'a mut BTreeSet, + tree: &'a mut RuntimeNodeTree>, + registry: &'a ProjectRegistry, + producers_ticked: &'a mut VecSet, runtime_buffers: &'a mut RuntimeBufferStore, slot_shapes: &'a SlotShapeRegistry, graphics: Option>, @@ -419,19 +535,19 @@ impl EngineResolveHost<'_> { node: NodeId, slot: &SlotPath, ) -> Result { - let entry = + let _entry = self.tree .get(node) .ok_or_else(|| SessionResolveError::UnresolvedConsumedSlot { node, slot: slot.clone(), })?; - let product = self - .read_authored_def_product(&entry.def_handle, slot) - .map_err(|_| SessionResolveError::UnresolvedConsumedSlot { + let product = self.read_authored_def_product(node, slot).map_err(|_| { + SessionResolveError::UnresolvedConsumedSlot { node, slot: slot.clone(), - })?; + } + })?; Ok(Production::new(product, ProductionSource::Default)) } @@ -441,7 +557,7 @@ impl EngineResolveHost<'_> { node: NodeId, accessor: &SlotAccessor, ) -> Result { - let entry = + let _entry = self.tree .get(node) .ok_or_else(|| SessionResolveError::UnresolvedConsumedSlot { @@ -449,7 +565,7 @@ impl EngineResolveHost<'_> { slot: accessor.path().clone(), })?; let product = self - .read_authored_def_product_by_accessor(&entry.def_handle, accessor) + .read_authored_def_product_by_accessor(node, accessor) .map_err(|_| SessionResolveError::UnresolvedConsumedSlot { node, slot: accessor.path().clone(), @@ -469,16 +585,10 @@ impl EngineResolveHost<'_> { let revision = session.revision(); let restore_frame = session.revision(); - let (artifact_id, content_frame, mut node_runtime) = { + let mut node_runtime = { let entry = self.tree.get_mut(node_id).ok_or_else(|| { SessionResolveError::other(format!("produce: unknown node {node_id:?}")) })?; - let artifact_id = entry.artifact(); - let content_frame = self - .artifacts - .content_frame(&artifact_id) - .unwrap_or_default(); - let old_changed_at = entry.state.changed_at(); let executing = NodeEntryState::Executing { call: NodeCallKey::new(node_id, NodeCall::ProduceSlot { slot: slot.clone() }), @@ -506,7 +616,7 @@ impl EngineResolveHost<'_> { ))); } }; - (artifact_id, content_frame, node_runtime) + node_runtime }; let gfx = self.graphics.clone(); @@ -524,8 +634,6 @@ impl EngineResolveHost<'_> { let mut tick_ctx = TickContext::with_engine_services( node_id, revision, - artifact_id, - content_frame, resolver_dyn, slot_shapes, gfx, @@ -540,11 +648,12 @@ impl EngineResolveHost<'_> { let entry = self.tree.get_mut(node_id).ok_or_else(|| { SessionResolveError::other(format!("produce: unknown node {node_id:?}")) })?; + let runtime_status = runtime_status_or_ok(&*node_runtime); entry.set_state(NodeEntryState::Alive(node_runtime), restore_frame); match produce_result { Ok(ProduceResult::Produced) => { - set_entry_status_if_changed(entry, WireNodeStatus::Ok, revision); + set_entry_status_if_changed(entry, runtime_status, revision); self.producers_ticked.insert(node_id); Ok(()) } @@ -555,7 +664,7 @@ impl EngineResolveHost<'_> { let message = e.to_string(); set_entry_status_if_changed( entry, - WireNodeStatus::Error(message.clone()), + NodeRuntimeStatus::Error(message.clone()), revision, ); Err(SessionResolveError::other(format!( @@ -620,15 +729,13 @@ impl ResolveHost for EngineResolveHost<'_> { } fn merge_policy_for_consumed_slot(&self, node: NodeId, slot: &SlotPath) -> SlotMerge { - let Some(entry) = self.tree.get(node) else { + let Some(_entry) = self.tree.get(node) else { return SlotMerge::Latest; }; - if let Ok(Some(policy)) = - self.read_shader_consumed_slot_merge_policy(&entry.def_handle, slot) - { + if let Ok(Some(policy)) = self.read_shader_consumed_slot_merge_policy(node, slot) { return policy; } - self.read_authored_def_slot_semantics(&entry.def_handle, slot) + self.read_authored_def_slot_semantics(node, slot) .ok() .filter(|semantics| semantics.direction == SlotDirection::Consumed) .map_or(SlotMerge::Latest, |semantics| semantics.merge) @@ -678,32 +785,10 @@ impl EngineResolveHost<'_> { fn read_authored_def_product( &self, - handle: &crate::node::NodeDefHandle, + node: NodeId, slot: &SlotPath, ) -> Result { - if !handle.is_artifact_root() { - return Err(SessionResolveError::other(format!( - "non-root node def handles are not supported yet: {}", - handle.path() - ))); - } - let entry = self.artifacts.entry(&handle.artifact()).ok_or_else(|| { - SessionResolveError::other(format!( - "node def artifact {:?} is not loaded", - handle.artifact() - )) - })?; - let def = match &entry.state { - ArtifactState::Loaded(def) - | ArtifactState::Prepared(def) - | ArtifactState::Idle(def) => def, - other => { - return Err(SessionResolveError::other(format!( - "node def artifact {:?} has no loaded payload: {other:?}", - handle.artifact() - ))); - } - }; + let def = self.loaded_node_def(node)?; let (data, shape) = lookup_slot_data_and_shape(def, self.slot_shapes, slot) .map_err(|e| SessionResolveError::other(format!("authored def lookup: {e}")))?; Ok(lpc_wire::snapshot_slot_shape(shape, data, self.slot_shapes)) @@ -711,32 +796,10 @@ impl EngineResolveHost<'_> { fn read_authored_def_product_by_accessor( &self, - handle: &crate::node::NodeDefHandle, + node: NodeId, accessor: &SlotAccessor, ) -> Result { - if !handle.is_artifact_root() { - return Err(SessionResolveError::other(format!( - "non-root node def handles are not supported yet: {}", - handle.path() - ))); - } - let entry = self.artifacts.entry(&handle.artifact()).ok_or_else(|| { - SessionResolveError::other(format!( - "node def artifact {:?} is not loaded", - handle.artifact() - )) - })?; - let def = match &entry.state { - ArtifactState::Loaded(def) - | ArtifactState::Prepared(def) - | ArtifactState::Idle(def) => def, - other => { - return Err(SessionResolveError::other(format!( - "node def artifact {:?} has no loaded payload: {other:?}", - handle.artifact() - ))); - } - }; + let def = self.loaded_node_def(node)?; let data = accessor .access(def, self.slot_shapes) .map_err(|e| SessionResolveError::other(format!("authored def accessor: {e}")))?; @@ -747,7 +810,7 @@ impl EngineResolveHost<'_> { fn read_shader_consumed_slot_merge_policy( &self, - handle: &crate::node::NodeDefHandle, + node: NodeId, slot: &SlotPath, ) -> Result, SessionResolveError> { let Some(SlotPathSegment::Field(name)) = slot.segments().first() else { @@ -756,7 +819,7 @@ impl EngineResolveHost<'_> { if slot.segments().len() != 1 { return Ok(None); } - let def = self.loaded_node_def(handle)?; + let def = self.loaded_node_def(node)?; let shader_slot = match def { NodeDef::Shader(config) => config.consumed_slots.entries.get(name.as_str()), NodeDef::ComputeShader(config) => config.consumed_slots.entries.get(name.as_str()), @@ -770,63 +833,25 @@ impl EngineResolveHost<'_> { fn read_authored_def_slot_semantics( &self, - handle: &crate::node::NodeDefHandle, + node: NodeId, slot: &SlotPath, ) -> Result { - if !handle.is_artifact_root() { - return Err(SessionResolveError::other(format!( - "non-root node def handles are not supported yet: {}", - handle.path() - ))); - } - let entry = self.artifacts.entry(&handle.artifact()).ok_or_else(|| { - SessionResolveError::other(format!( - "node def artifact {:?} is not loaded", - handle.artifact() - )) - })?; - let def = match &entry.state { - ArtifactState::Loaded(def) - | ArtifactState::Prepared(def) - | ArtifactState::Idle(def) => def, - other => { - return Err(SessionResolveError::other(format!( - "node def artifact {:?} has no loaded payload: {other:?}", - handle.artifact() - ))); - } - }; + let def = self.loaded_node_def(node)?; let shape = self.slot_shapes.get_shape(def.shape_id()).ok_or_else(|| { SessionResolveError::other(format!("missing node def shape {}", def.shape_id())) })?; slot_path_semantics(shape, self.slot_shapes, slot) } - fn loaded_node_def( - &self, - handle: &crate::node::NodeDefHandle, - ) -> Result<&NodeDef, SessionResolveError> { - if !handle.is_artifact_root() { - return Err(SessionResolveError::other(format!( - "non-root node def handles are not supported yet: {}", - handle.path() - ))); - } - let entry = self.artifacts.entry(&handle.artifact()).ok_or_else(|| { - SessionResolveError::other(format!( - "node def artifact {:?} is not loaded", - handle.artifact() - )) + fn loaded_node_def(&self, node: NodeId) -> Result<&NodeDef, SessionResolveError> { + let entry = self + .tree + .get(node) + .ok_or_else(|| SessionResolveError::other(format!("unknown node {node:?}")))?; + let location = entry.def_location.as_ref().ok_or_else(|| { + SessionResolveError::other(format!("node {node:?} has no project definition location")) })?; - match &entry.state { - ArtifactState::Loaded(def) - | ArtifactState::Prepared(def) - | ArtifactState::Idle(def) => Ok(def), - other => Err(SessionResolveError::other(format!( - "node def artifact {:?} has no loaded payload: {other:?}", - handle.artifact() - ))), - } + loaded_registry_def(self.registry, location) } fn render_node_texture( @@ -896,18 +921,19 @@ impl EngineResolveHost<'_> { let entry = self.tree.get_mut(node_id).ok_or_else(|| { SessionResolveError::other(format!("render: unknown node {node_id:?}")) })?; + let runtime_status = runtime_status_or_ok(&*node_runtime); entry.set_state(NodeEntryState::Alive(node_runtime), revision); match result { Ok(product) => { - set_entry_status_if_changed(entry, WireNodeStatus::Ok, revision); + set_entry_status_if_changed(entry, runtime_status, revision); Ok(product) } Err(e) => { let message = e.to_string(); set_entry_status_if_changed( entry, - WireNodeStatus::Error(message.clone()), + NodeRuntimeStatus::Error(message.clone()), revision, ); Err(SessionResolveError::other(format!("render: {message}"))) @@ -983,18 +1009,19 @@ impl EngineResolveHost<'_> { let entry = self.tree.get_mut(node_id).ok_or_else(|| { SessionResolveError::other(format!("render: unknown node {node_id:?}")) })?; + let runtime_status = runtime_status_or_ok(&*node_runtime); entry.set_state(NodeEntryState::Alive(node_runtime), revision); match result { Ok(()) => { - set_entry_status_if_changed(entry, WireNodeStatus::Ok, revision); + set_entry_status_if_changed(entry, runtime_status, revision); Ok(()) } Err(e) => { let message = e.to_string(); set_entry_status_if_changed( entry, - WireNodeStatus::Error(message.clone()), + NodeRuntimeStatus::Error(message.clone()), revision, ); Err(SessionResolveError::other(format!("render: {message}"))) @@ -1070,18 +1097,19 @@ impl EngineResolveHost<'_> { let entry = self.tree.get_mut(node_id).ok_or_else(|| { SessionResolveError::other(format!("sample visual: unknown node {node_id:?}")) })?; + let runtime_status = runtime_status_or_ok(&*node_runtime); entry.set_state(NodeEntryState::Alive(node_runtime), revision); match result { Ok(()) => { - set_entry_status_if_changed(entry, WireNodeStatus::Ok, revision); + set_entry_status_if_changed(entry, runtime_status, revision); Ok(()) } Err(e) => { let message = e.to_string(); set_entry_status_if_changed( entry, - WireNodeStatus::Error(message.clone()), + NodeRuntimeStatus::Error(message.clone()), revision, ); Err(SessionResolveError::other(format!( @@ -1158,18 +1186,19 @@ impl EngineResolveHost<'_> { let entry = self.tree.get_mut(node_id).ok_or_else(|| { SessionResolveError::other(format!("control render: unknown node {node_id:?}")) })?; + let runtime_status = runtime_status_or_ok(&*node_runtime); entry.set_state(NodeEntryState::Alive(node_runtime), revision); match result { Ok(layout) => { - set_entry_status_if_changed(entry, WireNodeStatus::Ok, revision); + set_entry_status_if_changed(entry, runtime_status, revision); Ok(layout) } Err(e) => { let message = e.to_string(); set_entry_status_if_changed( entry, - WireNodeStatus::Error(message.clone()), + NodeRuntimeStatus::Error(message.clone()), revision, ); Err(SessionResolveError::other(format!( @@ -1318,7 +1347,7 @@ impl VisualRenderServices for EngineResolveHost<'_> { } fn restore_node_after_failed_render( - tree: &mut NodeTree>, + tree: &mut RuntimeNodeTree>, node_id: NodeId, node_runtime: Box, revision: Revision, @@ -1331,8 +1360,8 @@ fn restore_node_after_failed_render( } fn set_entry_status_if_changed( - entry: &mut NodeEntry, - status: WireNodeStatus, + entry: &mut RuntimeNodeEntry, + status: NodeRuntimeStatus, revision: Revision, ) { if entry.status.value() != &status { @@ -1340,8 +1369,12 @@ fn set_entry_status_if_changed( } } +fn runtime_status_or_ok(node: &dyn NodeRuntime) -> NodeRuntimeStatus { + node.runtime_status().unwrap_or(NodeRuntimeStatus::Ok) +} + fn restore_node_after_failed_render_unit( - tree: &mut NodeTree>, + tree: &mut RuntimeNodeTree>, node_id: NodeId, node_runtime: Box, revision: Revision, @@ -1354,7 +1387,7 @@ fn restore_node_after_failed_render_unit( } fn restore_node_after_failed_control( - tree: &mut NodeTree>, + tree: &mut RuntimeNodeTree>, node_id: NodeId, node_runtime: Box, revision: Revision, @@ -1373,16 +1406,11 @@ fn consume_tree_node( ) -> Result<(), EngineError> { let revision = session.revision(); let restore_frame = session.revision(); - let (artifact_id, content_frame, mut node_runtime) = { + let mut node_runtime = { let entry = host .tree .get_mut(node_id) .ok_or(EngineError::UnknownNode(node_id))?; - let artifact_id = entry.artifact(); - let content_frame = host - .artifacts - .content_frame(&artifact_id) - .unwrap_or_default(); let old_changed_at = entry.state.changed_at(); let executing = NodeEntryState::Executing { @@ -1409,7 +1437,7 @@ fn consume_tree_node( return Err(EngineError::NotAlive(node_id)); } }; - (artifact_id, content_frame, node_runtime) + node_runtime }; let gfx = host.graphics.clone(); @@ -1427,8 +1455,6 @@ fn consume_tree_node( let mut tick_ctx = TickContext::with_engine_services( node_id, revision, - artifact_id, - content_frame, resolver_dyn, slot_shapes, gfx, @@ -1444,17 +1470,18 @@ fn consume_tree_node( .tree .get_mut(node_id) .ok_or(EngineError::UnknownNode(node_id))?; + let runtime_status = runtime_status_or_ok(&*node_runtime); entry.set_state(NodeEntryState::Alive(node_runtime), restore_frame); match consume_result { Ok(()) => { - set_entry_status_if_changed(entry, WireNodeStatus::Ok, revision); + set_entry_status_if_changed(entry, runtime_status, revision); host.producers_ticked.insert(node_id); Ok(()) } Err(e) => { let message = e.to_string(); - set_entry_status_if_changed(entry, WireNodeStatus::Error(message.clone()), revision); + set_entry_status_if_changed(entry, NodeRuntimeStatus::Error(message.clone()), revision); Err(EngineError::Node { node: node_id, message, @@ -1463,9 +1490,25 @@ fn consume_tree_node( } } +fn loaded_registry_def<'a>( + registry: &'a ProjectRegistry, + location: &NodeDefLocation, +) -> Result<&'a NodeDef, SessionResolveError> { + let entry = registry.def(location).ok_or_else(|| { + SessionResolveError::other(format!("node definition {location:?} is not in inventory")) + })?; + match &entry.state { + NodeDefState::Loaded(def) => Ok(def), + other => Err(SessionResolveError::other(format!( + "node definition {location:?} has no loaded payload: {other:?}" + ))), + } +} + #[cfg(test)] pub(crate) fn resolve_with_engine_host( eng: &mut Engine, + registry: &ProjectRegistry, key: QueryKey, log_level: ResolveLogLevel, ) -> Result<(Production, ResolveTrace), SessionResolveError> { @@ -1473,14 +1516,14 @@ pub(crate) fn resolve_with_engine_host( let mut resolver_tmp = core::mem::replace(&mut eng.resolver, Resolver::new()); resolver_tmp.clear_frame_cache(); let mut session = EngineSession::new(fid, &mut resolver_tmp, ResolveTrace::new(log_level)); - let mut producers_ticked = BTreeSet::new(); + let mut producers_ticked = VecSet::new(); let time_s = eng.frame_time.total_ms as f32 / 1000.0; let time_provider = eng.services.time_provider(); let button_service = eng.services.button_service(); let radio_service = eng.services.radio_service(); let mut host = EngineResolveHost { tree: &mut eng.tree, - artifacts: &eng.artifacts, + registry, producers_ticked: &mut producers_ticked, runtime_buffers: &mut eng.runtime_buffers, slot_shapes: &eng.slot_shapes, @@ -1500,6 +1543,7 @@ pub(crate) fn resolve_with_engine_host( #[cfg(test)] pub(super) fn resolve_twice_same_frame_with_engine_host( eng: &mut Engine, + registry: &ProjectRegistry, key: QueryKey, ) -> Result<(Production, Production), SessionResolveError> { let fid = eng.revision; @@ -1510,14 +1554,14 @@ pub(super) fn resolve_twice_same_frame_with_engine_host( &mut resolver_tmp, ResolveTrace::new(ResolveLogLevel::Off), ); - let mut producers_ticked = BTreeSet::new(); + let mut producers_ticked = VecSet::new(); let time_s = eng.frame_time.total_ms as f32 / 1000.0; let time_provider = eng.services.time_provider(); let button_service = eng.services.button_service(); let radio_service = eng.services.radio_service(); let mut host = EngineResolveHost { tree: &mut eng.tree, - artifacts: &eng.artifacts, + registry, producers_ticked: &mut producers_ticked, runtime_buffers: &mut eng.runtime_buffers, slot_shapes: &eng.slot_shapes, @@ -1563,14 +1607,15 @@ mod tests { #[test] fn tick_advances_frame_num_revision_and_accumulates_frame_time() { let mut eng = Engine::new(TreePath::parse("/show.t").expect("path")); + let registry = ProjectRegistry::new(); let initial_revision = eng.revision(); - eng.tick(10).expect("tick"); + eng.tick(®istry, 10).expect("tick"); assert_eq!(eng.frame_num(), FrameNum::new(1)); assert!(eng.revision() > initial_revision); assert_eq!(eng.frame_time().delta_ms, 10); assert_eq!(eng.frame_time().total_ms, 10); let first_tick_revision = eng.revision(); - eng.tick(5).expect("tick"); + eng.tick(®istry, 5).expect("tick"); assert_eq!(eng.frame_num(), FrameNum::new(2)); assert!(eng.revision() > first_tick_revision); assert_eq!(eng.frame_time().total_ms, 15); @@ -1579,8 +1624,9 @@ mod tests { #[test] fn tick_error_sets_node_status_and_restores_runtime() { let mut eng = Engine::new(TreePath::parse("/show.t").expect("path")); + let registry = ProjectRegistry::new(); let root = eng.tree().root(); - let (cfg, artifact) = test_placeholder_spine(); + let cfg = test_placeholder_spine(); let node = eng .tree_mut() .add_child( @@ -1591,7 +1637,6 @@ mod tests { source: WireSlotIndex(0), }, cfg, - artifact, Revision::new(1), ) .expect("add node"); @@ -1615,14 +1660,14 @@ mod tests { .expect("bind demand input"); eng.add_demand_root(node); - let err = eng.tick(10).expect_err("tick should fail"); + let err = eng.tick(®istry, 10).expect_err("tick should fail"); assert!(err.to_string().contains("intentional tick failure")); let entry = eng.tree().get(node).expect("entry"); assert!(matches!(entry.state.value(), NodeEntryState::Alive(_))); assert!(matches!( entry.status.value(), - WireNodeStatus::Error(message) if message == "intentional tick failure" + NodeRuntimeStatus::Error(message) if message == "intentional tick failure" )); } @@ -1639,7 +1684,7 @@ mod tests { .demand_root("output") .build(); - h.engine.tick(1).expect("tick"); + h.tick(1).expect("tick"); assert_eq!(h.fixture_f32("fixture"), Some(0.75)); assert_eq!(h.output_f32("output"), Some(0.75)); @@ -1655,7 +1700,7 @@ mod tests { .bind_demand_input("fixture", bus("video")) .demand_root("fixture") .build(); - h.engine.tick(1).expect("tick"); + h.tick(1).expect("tick"); assert!( !h.engine.resolver().cache().is_empty(), "resolver cache should hold demand-driven values after tick" @@ -1691,8 +1736,9 @@ mod tests { slot: out, }; - let (first, second) = super::resolve_twice_same_frame_with_engine_host(&mut h.engine, key) - .expect("resolve pair"); + let (first, second) = + super::resolve_twice_same_frame_with_engine_host(&mut h.engine, &h.registry, key) + .expect("resolve pair"); assert!( first .as_value() diff --git a/lp-core/lpc-engine/src/engine/engine_services.rs b/lp-core/lpc-engine/src/engine/engine_services.rs index c5527caa3..3c9cb33d5 100644 --- a/lp-core/lpc-engine/src/engine/engine_services.rs +++ b/lp-core/lpc-engine/src/engine/engine_services.rs @@ -10,12 +10,12 @@ use alloc::vec::Vec; use core::fmt; use hashbrown::HashMap; -use lpc_model::nodes::output::{OutputDef, OutputDriverOptionsConfig}; -use lpc_model::{HardwareEndpointSpec, Revision, TreePath}; -use lpc_shared::error::OutputError; -use lpc_shared::hardware::{ +use lpc_hardware::OutputError; +use lpc_hardware::{ ButtonConfig, ButtonInput, HardwareEndpointError, HardwareSystem, RadioConfig, RadioDevice, }; +use lpc_model::nodes::output::{OutputDef, OutputDriverOptionsConfig}; +use lpc_model::{HwEndpointSpec, Revision, TreePath}; use lpc_shared::output::{OutputChannelHandle, OutputDriverOptions, OutputFormat, OutputProvider}; use lpc_shared::time::TimeProvider; @@ -24,7 +24,7 @@ use crate::resource::{RuntimeBufferId, RuntimeBufferStore}; /// Per-sink channel state for [`EngineServices`] output flushing. #[derive(Debug)] struct OutputSinkBinding { - endpoint: HardwareEndpointSpec, + endpoint: HwEndpointSpec, display_options: Option, channel_handle: Option, last_byte_count: Option, @@ -67,7 +67,7 @@ pub struct EngineServices { pub trait ButtonService { fn open_button_by_spec( &self, - spec: &HardwareEndpointSpec, + spec: &HwEndpointSpec, config: ButtonConfig, ) -> Result, HardwareEndpointError>; } @@ -75,7 +75,7 @@ pub trait ButtonService { impl ButtonService for HardwareSystem { fn open_button_by_spec( &self, - spec: &HardwareEndpointSpec, + spec: &HwEndpointSpec, config: ButtonConfig, ) -> Result, HardwareEndpointError> { HardwareSystem::open_button_by_spec(self, spec, config) @@ -86,7 +86,7 @@ impl ButtonService for HardwareSystem { pub trait RadioService { fn open_radio_by_spec( &self, - spec: &HardwareEndpointSpec, + spec: &HwEndpointSpec, config: RadioConfig, ) -> Result, HardwareEndpointError>; } @@ -94,7 +94,7 @@ pub trait RadioService { impl RadioService for HardwareSystem { fn open_radio_by_spec( &self, - spec: &HardwareEndpointSpec, + spec: &HwEndpointSpec, config: RadioConfig, ) -> Result, HardwareEndpointError> { HardwareSystem::open_radio_by_spec(self, spec, config) @@ -194,6 +194,12 @@ impl EngineServices { self.output_sinks.insert(buffer_id, existing); } + pub fn unregister_output_sink(&mut self, buffer_id: RuntimeBufferId) { + if let Some(mut existing) = self.output_sinks.remove(&buffer_id) { + self.close_output_sink(&mut existing); + } + } + /// Flush sinks whose backing buffer [`WithRevision::revision`] equals `revision`. /// /// Temporarily removes the boxed [`OutputProvider`] from `self` so sinks can be mutated without @@ -244,7 +250,7 @@ impl Drop for EngineServices { } } -fn endpoint_from_output_config(config: &OutputDef) -> HardwareEndpointSpec { +fn endpoint_from_output_config(config: &OutputDef) -> HwEndpointSpec { config.endpoint().clone() } @@ -362,9 +368,9 @@ mod tests { use alloc::string::ToString; use alloc::vec; + use lpc_hardware::OutputError; use lpc_model::nodes::output::{OutputDef, OutputDriverOptionsConfig}; - use lpc_model::{HardwareEndpointSpec, OptionSlot, Revision, TreePath, WithRevision}; - use lpc_shared::error::OutputError; + use lpc_model::{HwEndpointSpec, OptionSlot, Revision, TreePath, WithRevision}; use lpc_shared::output::{ MemoryOutputProvider, OutputChannelHandle, OutputDriverOptions, OutputFormat, OutputProvider, @@ -443,6 +449,28 @@ mod tests { assert_eq!(provider.open_channel_count(), 1); } + #[test] + fn unregister_output_sink_closes_open_channel() { + let provider = Rc::new(MemoryOutputProvider::new()); + let mut services = EngineServices::new(TreePath::parse("/p.show").expect("tree path")); + services.set_output_provider(Some(Box::new(SharedMemoryOutputProvider(Rc::clone( + &provider, + ))))); + + let mut buffers = RuntimeBufferStore::new(); + let buffer_id = output_buffer(&mut buffers, Revision::new(1)); + let endpoint = endpoint("ws281x:rmt:D10"); + services.register_output_sink(buffer_id, &OutputDef::new(endpoint.clone())); + services + .flush_dirty_output_sinks(Revision::new(1), &buffers) + .expect("initial flush"); + assert!(provider.is_endpoint_open(&endpoint)); + + services.unregister_output_sink(buffer_id); + + assert!(!provider.is_endpoint_open(&endpoint)); + } + #[test] fn engine_services_duplicate_output_pin_reports_hardware_conflict() { let provider = Rc::new(MemoryOutputProvider::new()); @@ -500,8 +528,8 @@ mod tests { assert_ne!(provider.is_pin_open(18), provider.is_pin_open(19)); } - fn endpoint(spec: &'static str) -> HardwareEndpointSpec { - HardwareEndpointSpec::from_static(spec) + fn endpoint(spec: &'static str) -> HwEndpointSpec { + HwEndpointSpec::from_static(spec) } fn output_buffer(store: &mut RuntimeBufferStore, revision: Revision) -> RuntimeBufferId { @@ -516,7 +544,7 @@ mod tests { impl OutputProvider for SharedMemoryOutputProvider { fn open( &self, - endpoint: &HardwareEndpointSpec, + endpoint: &HwEndpointSpec, byte_count: u32, format: OutputFormat, options: Option, diff --git a/lp-core/lpc-engine/src/engine/error.rs b/lp-core/lpc-engine/src/engine/error.rs index 87318683a..1f445ec7c 100644 --- a/lp-core/lpc-engine/src/engine/error.rs +++ b/lp-core/lpc-engine/src/engine/error.rs @@ -85,24 +85,24 @@ extern crate std; #[cfg(feature = "std")] impl std::error::Error for Error {} -impl From for Error { - fn from(err: lpc_shared::OutputError) -> Self { +impl From for Error { + fn from(err: lpc_hardware::OutputError) -> Self { match err { - lpc_shared::OutputError::PinAlreadyOpen { pin } => Error::Other { + lpc_hardware::OutputError::PinAlreadyOpen { pin } => Error::Other { message: format!("Pin {pin} is already open"), }, - lpc_shared::OutputError::Hardware { error } => Error::InvalidConfig { + lpc_hardware::OutputError::Hardware { error } => Error::InvalidConfig { node_path: String::from("output"), reason: error.to_string(), }, - lpc_shared::OutputError::InvalidHandle { handle } => Error::Other { + lpc_hardware::OutputError::InvalidHandle { handle } => Error::Other { message: format!("Invalid handle: {handle}"), }, - lpc_shared::OutputError::InvalidConfig { reason } => Error::InvalidConfig { + lpc_hardware::OutputError::InvalidConfig { reason } => Error::InvalidConfig { node_path: String::from("output"), reason, }, - lpc_shared::OutputError::DataLengthMismatch { expected, actual } => { + lpc_hardware::OutputError::DataLengthMismatch { expected, actual } => { Error::InvalidConfig { node_path: String::from("output"), reason: format!( @@ -110,7 +110,7 @@ impl From for Error { ), } } - lpc_shared::OutputError::Other { message } => Error::Other { message }, + lpc_hardware::OutputError::Other { message } => Error::Other { message }, } } } diff --git a/lp-core/lpc-engine/src/engine/loaded_project_runtime.rs b/lp-core/lpc-engine/src/engine/loaded_project_runtime.rs new file mode 100644 index 000000000..7a77b53a4 --- /dev/null +++ b/lp-core/lpc-engine/src/engine/loaded_project_runtime.rs @@ -0,0 +1,108 @@ +//! Loaded project runtime helper returned by [`super::ProjectLoader`]. + +use core::ops::{Deref, DerefMut}; + +use lpc_registry::ProjectRegistry; +use lpc_wire::json::json_write::JsonWrite; +use lpc_wire::json::json_writer::JsonWriterError; +use lpc_wire::{ProjectReadRequest, ProjectReadResponse}; + +use super::{Engine, EngineError}; + +/// A loaded runtime projection paired with the registry state it was built from. +/// +/// `Engine` is intentionally only the runtime projection. This helper exists +/// for direct embedders and tests that load a project through `lpc-engine` +/// without the higher-level server `Project` wrapper. +pub struct LoadedProjectRuntime { + engine: Engine, + registry: ProjectRegistry, +} + +impl LoadedProjectRuntime { + pub(crate) fn new(engine: Engine, registry: ProjectRegistry) -> Self { + Self { engine, registry } + } + + pub fn engine(&self) -> &Engine { + &self.engine + } + + pub fn engine_mut(&mut self) -> &mut Engine { + &mut self.engine + } + + pub fn registry(&self) -> &ProjectRegistry { + &self.registry + } + + pub fn registry_mut(&mut self) -> &mut ProjectRegistry { + &mut self.registry + } + + pub fn into_parts(self) -> (Engine, ProjectRegistry) { + (self.engine, self.registry) + } + + pub fn tick(&mut self, delta_ms: u32) -> Result<(), EngineError> { + self.engine.tick(&self.registry, delta_ms) + } + + pub fn read_project(&mut self, request: ProjectReadRequest) -> ProjectReadResponse { + self.engine.read_project(&self.registry, request) + } + + pub fn write_project_read_json( + &mut self, + request: ProjectReadRequest, + out: W, + ) -> Result> + where + W: JsonWrite, + { + self.engine + .write_project_read_json(&self.registry, request, out) + } + + #[cfg(test)] + pub(crate) fn resolve_with_engine_host( + &mut self, + key: crate::dataflow::resolver::QueryKey, + log_level: crate::dataflow::resolver::ResolveLogLevel, + ) -> Result< + ( + crate::dataflow::resolver::Production, + crate::dataflow::resolver::ResolveTrace, + ), + crate::dataflow::resolver::SessionResolveError, + > { + super::resolve_with_engine_host(&mut self.engine, &self.registry, key, log_level) + } + + #[cfg(test)] + pub(crate) fn render_texture_for_test( + &mut self, + product: crate::products::visual::VisualProduct, + request: &crate::products::visual::RenderTextureRequest, + ) -> Result< + crate::products::visual::TextureRenderProduct, + crate::dataflow::resolver::SessionResolveError, + > { + self.engine + .render_texture_for_test(&self.registry, product, request) + } +} + +impl Deref for LoadedProjectRuntime { + type Target = Engine; + + fn deref(&self) -> &Self::Target { + &self.engine + } +} + +impl DerefMut for LoadedProjectRuntime { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.engine + } +} diff --git a/lp-core/lpc-engine/src/engine/mod.rs b/lp-core/lpc-engine/src/engine/mod.rs index eab0ed80a..2fb0ca3e1 100644 --- a/lp-core/lpc-engine/src/engine/mod.rs +++ b/lp-core/lpc-engine/src/engine/mod.rs @@ -1,4 +1,4 @@ -//! Core runtime owner: [`Engine`] drives frame state, tree, bindings, resolver, and artifacts. +//! Core runtime owner: [`Engine`] drives frame state, tree, bindings, and resolver. mod engine; mod engine_error; @@ -6,9 +6,11 @@ mod engine_services; pub mod error; mod frame_num; mod frame_time; +mod loaded_project_runtime; pub mod memory_pressure; #[cfg(test)] mod output_flush_tests; +mod project_apply; mod project_loader; mod project_read; mod project_read_nodes; @@ -17,7 +19,7 @@ mod project_read_resources; mod project_read_runtime; mod project_read_shapes; mod project_read_stream; -mod slot_mutation; +mod project_runtime_index; #[cfg(test)] pub(crate) mod test_support; @@ -28,7 +30,11 @@ pub use engine_error::EngineError; pub use engine_services::{ButtonService, EngineServices, OutputFlushError, RadioService}; pub use frame_num::FrameNum; pub use frame_time::FrameTime; +pub use loaded_project_runtime::LoadedProjectRuntime; +pub use project_apply::RuntimeApplyResult; pub use project_loader::{ProjectLoadError, ProjectLoader}; +pub use project_read_stream::EngineProjectReadSource; +pub use project_runtime_index::ProjectRuntimeIndex; #[cfg(test)] pub(crate) use engine::resolve_with_engine_host; 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 f56793293..34e88af9b 100644 --- a/lp-core/lpc-engine/src/engine/output_flush_tests.rs +++ b/lp-core/lpc-engine/src/engine/output_flush_tests.rs @@ -21,9 +21,10 @@ use crate::resource::RuntimeBufferId; use lpc_model::nodes::fixture::{ColorOrder, MappingConfig, PathSpec, RingOrder}; use lpc_model::nodes::output::OutputDef; use lpc_model::{ - Dim2u, HardwareEndpointSpec, Kind, LpValue, Revision, ShaderState, SlotAccess, SlotPath, + Dim2u, HwEndpointSpec, Kind, LpValue, Revision, ShaderState, SlotAccess, SlotPath, SlotShapeRegistry, SlotShapeRegistryError, ToLpValue, TreePath, }; +use lpc_registry::ProjectRegistry; use lpc_shared::output::{ MemoryOutputProvider, OutputChannelHandle, OutputDriverOptions, OutputFormat, OutputProvider, }; @@ -37,11 +38,11 @@ struct RcMemoryOutput(Rc); impl OutputProvider for RcMemoryOutput { fn open( &self, - endpoint: &HardwareEndpointSpec, + endpoint: &HwEndpointSpec, byte_count: u32, format: OutputFormat, options: Option, - ) -> Result { + ) -> Result { self.0.open(endpoint, byte_count, format, options) } @@ -49,17 +50,17 @@ impl OutputProvider for RcMemoryOutput { &self, handle: OutputChannelHandle, data: &[u16], - ) -> Result<(), lpc_shared::error::OutputError> { + ) -> Result<(), lpc_hardware::OutputError> { self.0.write(handle, data) } - fn close(&self, handle: OutputChannelHandle) -> Result<(), lpc_shared::error::OutputError> { + fn close(&self, handle: OutputChannelHandle) -> Result<(), lpc_hardware::OutputError> { self.0.close(handle) } } -fn endpoint(spec: &'static str) -> HardwareEndpointSpec { - HardwareEndpointSpec::from_static(spec) +fn endpoint(spec: &'static str) -> HwEndpointSpec { + HwEndpointSpec::from_static(spec) } struct CountingGraphics { @@ -276,10 +277,9 @@ fn attach_output_demand_root( rt: &mut Engine, root: lpc_model::NodeId, spine: lpc_model::NodeInvocation, - artifact: crate::artifact::ArtifactId, frame: Revision, name: &str, - endpoint: HardwareEndpointSpec, + endpoint: HwEndpointSpec, ) -> (lpc_model::NodeId, RuntimeBufferId) { let out_id = rt .tree_mut() @@ -291,7 +291,6 @@ fn attach_output_demand_root( source: WireSlotIndex(0), }, spine.clone(), - artifact, frame, ) .unwrap(); @@ -325,10 +324,9 @@ fn attach_idle_output_sink( rt: &mut Engine, root: lpc_model::NodeId, spine: lpc_model::NodeInvocation, - artifact: crate::artifact::ArtifactId, frame: Revision, name: &str, - endpoint: HardwareEndpointSpec, + endpoint: HwEndpointSpec, ) -> (lpc_model::NodeId, RuntimeBufferId) { let out_id = rt .tree_mut() @@ -340,7 +338,6 @@ fn attach_idle_output_sink( source: WireSlotIndex(0), }, spine.clone(), - artifact, frame, ) .unwrap(); @@ -389,13 +386,14 @@ fn engine_output_sink_flush_writes_expected_rgb_via_memory_provider() { let mut services = EngineServices::new(path.clone()); services.set_output_provider(Some(Box::new(RcMemoryOutput(Rc::clone(&mem))))); let mut rt = Engine::with_services(path, services); + let registry = ProjectRegistry::new(); let graphics = Arc::new(CountingGraphics::new()); rt.set_graphics(Some(graphics.clone())); let ticks = Arc::new(AtomicU32::new(0)); let frame = Revision::new(1); let root = rt.tree().root(); - let (spine, artifact) = test_placeholder_spine(); + let spine = test_placeholder_spine(); let sh_id = rt .tree_mut() @@ -407,7 +405,6 @@ fn engine_output_sink_flush_writes_expected_rgb_via_memory_provider() { source: WireSlotIndex(0), }, spine.clone(), - artifact, frame, ) .unwrap(); @@ -447,7 +444,6 @@ fn engine_output_sink_flush_writes_expected_rgb_via_memory_provider() { source: WireSlotIndex(0), }, spine.clone(), - artifact, frame, ) .unwrap(); @@ -482,19 +478,12 @@ fn engine_output_sink_flush_writes_expected_rgb_via_memory_provider() { ) .unwrap(); - let (out_id, _sink) = attach_output_demand_root( - &mut rt, - root, - spine.clone(), - artifact, - frame, - "out", - endpoint.clone(), - ); + let (out_id, _sink) = + attach_output_demand_root(&mut rt, root, spine.clone(), frame, "out", endpoint.clone()); bind_output_to_fixture(&mut rt, out_id, fix_id, frame); - rt.tick(10).expect("tick"); - rt.tick(10) + rt.tick(®istry, 10).expect("tick"); + rt.tick(®istry, 10) .expect("second tick reuses fixture render target"); let handle = mem @@ -527,12 +516,13 @@ fn engine_output_idle_registered_sink_skips_second_pin() { let mut services = EngineServices::new(path.clone()); services.set_output_provider(Some(Box::new(RcMemoryOutput(Rc::clone(&mem))))); let mut rt = Engine::with_services(path, services); + let registry = ProjectRegistry::new(); rt.set_graphics(Some(Arc::new(crate::Graphics::new()))); let ticks = Arc::new(AtomicU32::new(0)); let frame = Revision::new(1); let root = rt.tree().root(); - let (spine, artifact) = test_placeholder_spine(); + let spine = test_placeholder_spine(); let sh_id = rt .tree_mut() @@ -544,7 +534,6 @@ fn engine_output_idle_registered_sink_skips_second_pin() { source: WireSlotIndex(0), }, spine.clone(), - artifact, frame, ) .unwrap(); @@ -584,7 +573,6 @@ fn engine_output_idle_registered_sink_skips_second_pin() { source: WireSlotIndex(0), }, spine.clone(), - artifact, frame, ) .unwrap(); @@ -623,7 +611,6 @@ fn engine_output_idle_registered_sink_skips_second_pin() { &mut rt, root, spine.clone(), - artifact, frame, "out_written", endpoint_written.clone(), @@ -633,13 +620,12 @@ fn engine_output_idle_registered_sink_skips_second_pin() { &mut rt, root, spine.clone(), - artifact, frame, "out_idle", endpoint_idle.clone(), ); - rt.tick(10).expect("tick"); + rt.tick(®istry, 10).expect("tick"); assert!( mem.is_endpoint_open(&endpoint_written), @@ -658,12 +644,13 @@ fn output_demand_marks_output_buffer_dirty_same_frame_before_flush() { let mut services = EngineServices::new(path.clone()); services.set_output_provider(Some(Box::new(RcMemoryOutput(Rc::clone(&mem))))); let mut rt = Engine::with_services(path, services); + let registry = ProjectRegistry::new(); rt.set_graphics(Some(Arc::new(crate::Graphics::new()))); let ticks = Arc::new(AtomicU32::new(0)); let frame = Revision::new(1); let root = rt.tree().root(); - let (spine, artifact) = test_placeholder_spine(); + let spine = test_placeholder_spine(); let sh_id = rt .tree_mut() @@ -675,7 +662,6 @@ fn output_demand_marks_output_buffer_dirty_same_frame_before_flush() { source: WireSlotIndex(0), }, spine.clone(), - artifact, frame, ) .unwrap(); @@ -715,7 +701,6 @@ fn output_demand_marks_output_buffer_dirty_same_frame_before_flush() { source: WireSlotIndex(0), }, spine.clone(), - artifact, frame, ) .unwrap(); @@ -751,18 +736,11 @@ fn output_demand_marks_output_buffer_dirty_same_frame_before_flush() { .unwrap(); let endpoint = endpoint("ws281x:rmt:D10"); - let (out_id, sink) = attach_output_demand_root( - &mut rt, - root, - spine.clone(), - artifact, - frame, - "out", - endpoint.clone(), - ); + let (out_id, sink) = + attach_output_demand_root(&mut rt, root, spine.clone(), frame, "out", endpoint.clone()); bind_output_to_fixture(&mut rt, out_id, fix_id, frame); - rt.tick(10).expect("tick"); + rt.tick(®istry, 10).expect("tick"); let ver_frame = rt.runtime_buffers().get(sink).expect("sink").changed_at(); assert_eq!( diff --git a/lp-core/lpc-engine/src/engine/project_apply.rs b/lp-core/lpc-engine/src/engine/project_apply.rs new file mode 100644 index 000000000..f4724e285 --- /dev/null +++ b/lp-core/lpc-engine/src/engine/project_apply.rs @@ -0,0 +1,338 @@ +//! Incremental runtime projection from registry project changes. + +use alloc::format; +use alloc::string::ToString; +use alloc::vec::Vec; +use lp_collection::VecSet; + +use lpc_model::{ + AssetChangeKind, AssetLocation, NodeDefChangeKind, NodeId, NodeKind, NodeRuntimeStatus, + NodeUseChangeKind, NodeUseLocation, ProjectChangeSummary, +}; +use lpc_registry::ProjectRegistry; +use lpfs::LpFs; + +use crate::node::{AssetRefreshContext, AssetRefreshResult, NodeEntryState}; +use crate::nodes::CorePlaceholderNode; + +use super::{Engine, ProjectLoadError, ProjectLoader}; + +/// Summary of runtime lifecycle work performed for one project apply. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RuntimeApplyResult { + /// Runtime subtrees removed by node-use location. + pub removed_nodes: Vec, + /// Runtime nodes added by node-use location. + pub added_nodes: Vec, + /// Existing use locations rebuilt by remove/reproject. + pub reattached_nodes: Vec, + /// Effective assets that refreshed at least one existing runtime node. + pub refreshed_assets: Vec, + /// Existing runtime node uses refreshed from changed effective assets. + pub refreshed_nodes: Vec, + /// Node uses that could not be applied. + pub failed_nodes: Vec, +} + +impl RuntimeApplyResult { + pub fn is_empty(&self) -> bool { + self.removed_nodes.is_empty() + && self.added_nodes.is_empty() + && self.reattached_nodes.is_empty() + && self.refreshed_assets.is_empty() + && self.refreshed_nodes.is_empty() + && self.failed_nodes.is_empty() + } +} + +impl Engine { + /// Apply registry changes to the current runtime projection. + /// + /// This is intentionally a lifecycle/topology operation. Same-kind + /// definition body changes and asset body changes are value changes owned by + /// runtime nodes through resolver/revision-aware reads. + pub fn apply_project_changes( + &mut self, + fs: &dyn LpFs, + registry: &mut ProjectRegistry, + changes: &ProjectChangeSummary, + ) -> Result { + if changes.is_empty() { + self.resolver_mut().clear_frame_cache(); + self.project_runtime_index_mut() + .rebuild_asset_consumers(®istry.inventory().tree); + return Ok(RuntimeApplyResult::default()); + } + + let frame = lpc_model::current_revision(); + let mut remove_roots = VecSet::new(); + let mut add_targets = VecSet::new(); + let mut reattach_roots = VecSet::new(); + + for removed in &changes.uses.removed { + if let Some(parent) = playlist_parent_for_changed_child(registry, removed) { + reattach_roots.insert(parent); + } else { + remove_roots.insert(removed.clone()); + } + } + + for added in &changes.uses.added { + if let Some(parent) = playlist_parent_for_changed_child(registry, added) { + reattach_roots.insert(parent); + } else { + add_targets.insert(added.clone()); + } + } + + for changed in &changes.uses.changed { + match changed.kind { + NodeUseChangeKind::DefinitionChanged { .. } + | NodeUseChangeKind::ParentChanged + | NodeUseChangeKind::OriginChanged => { + if let Some(parent) = + playlist_parent_for_changed_child(registry, &changed.location) + { + reattach_roots.insert(parent); + } else { + reattach_roots.insert(changed.location.clone()); + } + } + } + } + + for changed in &changes.defs.changed { + match changed.kind { + NodeDefChangeKind::KindChanged { .. } + | NodeDefChangeKind::EnteredError + | NodeDefChangeKind::LeftError => { + for node_id in self + .project_runtime_index() + .runtime_nodes_for_def(&changed.location) + { + if let Some(use_location) = + self.project_runtime_index().use_location(*node_id) + { + reattach_roots.insert(use_location.clone()); + } + } + } + NodeDefChangeKind::Body => {} + } + } + + for root in &reattach_roots { + remove_roots.insert(root.clone()); + add_subtree_targets(registry, root, &mut add_targets); + } + + let mut result = RuntimeApplyResult::default(); + let mut removals = remove_roots.into_iter().collect::>(); + removals.sort_by(|a, b| { + b.segments + .len() + .cmp(&a.segments.len()) + .then_with(|| b.cmp(a)) + }); + for location in removals { + if location.is_root() { + if reattach_roots.contains(&location) { + self.reattach_runtime_node( + self.tree().root(), + alloc::boxed::Box::new(CorePlaceholderNode::new_leaf(NodeKind::Project)), + frame, + ) + .map_err(|e| { + ProjectLoadError::InvalidProjectReference { + path: format_node_use(&location), + reason: format!("reattach runtime root: {e}"), + } + })?; + result.reattached_nodes.push(location); + } + continue; + } + let Some(node_id) = self.project_runtime_index().node_id(&location) else { + continue; + }; + self.remove_runtime_subtree(node_id, frame).map_err(|e| { + ProjectLoadError::InvalidProjectReference { + path: format_node_use(&location), + reason: format!("remove runtime subtree: {e}"), + } + })?; + if reattach_roots.contains(&location) { + result.reattached_nodes.push(location); + } else { + result.removed_nodes.push(location); + } + } + + if !add_targets.is_empty() { + let projected_nodes = ProjectLoader::ensure_runtime_spine(registry, self, frame)?; + ProjectLoader::attach_selected_projected_nodes( + fs, + registry, + self, + &projected_nodes, + &add_targets, + frame, + )?; + for location in add_targets { + if reattach_roots.contains(&location) { + if !result.reattached_nodes.contains(&location) { + result.reattached_nodes.push(location); + } + } else { + result.added_nodes.push(location); + } + } + } + + for location in changed_effective_assets(changes) { + let node_ids = self + .project_runtime_index() + .runtime_nodes_for_asset(location) + .to_vec(); + if node_ids.is_empty() { + continue; + } + let refreshed_nodes = + self.refresh_project_asset_consumers(fs, registry, location, node_ids, frame)?; + if !refreshed_nodes.is_empty() { + result.refreshed_assets.push(location.clone()); + result.refreshed_nodes.extend(refreshed_nodes); + } + } + + self.project_runtime_index_mut() + .rebuild_asset_consumers(®istry.inventory().tree); + self.resolver_mut().clear_frame_cache(); + Ok(result) + } + + fn refresh_project_asset_consumers( + &mut self, + fs: &dyn LpFs, + registry: &mut ProjectRegistry, + location: &AssetLocation, + node_ids: Vec, + frame: lpc_model::Revision, + ) -> Result, ProjectLoadError> { + let mut targets = Vec::new(); + for node_id in node_ids { + let Some(use_location) = self.project_runtime_index().use_location(node_id) else { + continue; + }; + targets.push((node_id, use_location.clone())); + } + + let slot_shapes = self.slot_shapes().clone(); + let mut refreshed = Vec::new(); + for (node_id, use_location) in targets { + let entry = self.tree_mut().get_mut(node_id).ok_or( + ProjectLoadError::InvalidProjectReference { + path: format_node_use(&use_location), + reason: format!("asset consumer runtime node {node_id:?} is missing"), + }, + )?; + let NodeEntryState::Alive(runtime) = entry.state.get_mut() else { + continue; + }; + let mut ctx = AssetRefreshContext::new(fs, registry, &slot_shapes, frame); + let refresh_result = runtime.refresh_asset(location, &mut ctx).map_err(|e| { + ProjectLoadError::InvalidProjectReference { + path: format_node_use(&use_location), + reason: format!("refresh asset {location:?}: {e}"), + } + })?; + let runtime_status = runtime.runtime_status().unwrap_or(NodeRuntimeStatus::Ok); + match refresh_result { + AssetRefreshResult::Unused | AssetRefreshResult::Unchanged => {} + AssetRefreshResult::Refreshed => { + entry.state.mark_updated(frame); + set_entry_status_if_changed(entry, runtime_status, frame); + refreshed.push(use_location); + } + } + } + + Ok(refreshed) + } +} + +fn set_entry_status_if_changed( + entry: &mut crate::node::RuntimeNodeEntry, + status: NodeRuntimeStatus, + revision: lpc_model::Revision, +) { + if entry.status.value() != &status { + entry.set_status(status, revision); + } +} + +fn changed_effective_assets( + changes: &ProjectChangeSummary, +) -> impl Iterator { + changes + .assets + .changed + .iter() + .filter_map(|change| match change.kind { + AssetChangeKind::Body | AssetChangeKind::EnteredError | AssetChangeKind::LeftError => { + Some(&change.location) + } + }) +} + +fn playlist_parent_for_changed_child( + registry: &ProjectRegistry, + location: &NodeUseLocation, +) -> Option { + let parent = parent_location(location)?; + (node_kind_for_use(registry, &parent) == Some(NodeKind::Playlist)).then_some(parent) +} + +fn parent_location(location: &NodeUseLocation) -> Option { + let mut parent = location.clone(); + parent.segments.pop()?; + Some(parent) +} + +fn node_kind_for_use(registry: &ProjectRegistry, location: &NodeUseLocation) -> Option { + let node = registry.inventory().tree.nodes.get(location)?; + registry.def(&node.def_location)?.state.kind() +} + +fn add_subtree_targets( + registry: &ProjectRegistry, + root: &NodeUseLocation, + targets: &mut VecSet, +) { + for location in registry.inventory().tree.nodes.keys() { + if is_same_or_descendant(root, location) { + targets.insert(location.clone()); + } + } +} + +fn is_same_or_descendant(root: &NodeUseLocation, candidate: &NodeUseLocation) -> bool { + candidate.segments.len() >= root.segments.len() + && candidate + .segments + .iter() + .zip(root.segments.iter()) + .all(|(candidate, root)| candidate == root) +} + +fn format_node_use(location: &NodeUseLocation) -> alloc::string::String { + if location.is_root() { + return alloc::string::String::from(""); + } + location + .segments + .iter() + .map(|segment| segment.slot.to_string()) + .collect::>() + .join("/") +} diff --git a/lp-core/lpc-engine/src/engine/project_loader.rs b/lp-core/lpc-engine/src/engine/project_loader.rs index 83c9a6d56..d3ae55b3e 100644 --- a/lp-core/lpc-engine/src/engine/project_loader.rs +++ b/lp-core/lpc-engine/src/engine/project_loader.rs @@ -4,22 +4,23 @@ use alloc::boxed::Box; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; +use lp_collection::VecSet; use lpc_model::LpType; -use lpc_model::generate_compute_shader_header; -use lpc_model::nodes::project::project_def::ProjectDef; -use lpc_model::{ArtifactLocator, ArtifactReadRoot, NodeDefRef, NodeInvocation, NodeKind}; +use lpc_model::{ArtifactSpec, NodeInvocation, NodeKind}; +use lpc_model::{AssetContentType, AssetLocation, NodeDefLocation, NodeDefState}; use lpc_model::{ BindingDefs, BindingRef as AuthoredBindingRef, ChannelName, FixtureDef, FluidDef, Kind, - LpValue, MappingConfig, NodeDef, NodeId, NodeName, PlaylistDef, PlaylistEntry, Revision, - ShaderDef, ShaderSlotKind, ShaderSource, SlotPath, SlotShapeRegistry, + LpValue, MappingConfig, NodeDef, NodeId, NodeName, PlaylistDef, ProjectNodeOrigin, + ProjectNodePlacement, Revision, ShaderDef, ShaderSlotKind, SlotPath, }; -use lpc_wire::{WireChildKind, WireSlotIndex}; +use lpc_registry::{AssetText, ParseCtx, ProjectRegistry}; +use lpc_wire::{NodeRuntimeStatus, WireChildKind, WireSlotIndex}; +use lpfs::LpFs; use lpfs::lp_path::{LpPath, LpPathBuf}; -use crate::artifact::{ArtifactLocation, ArtifactState}; use crate::dataflow::binding::{BindingDraft, BindingPriority, BindingSource, BindingTarget}; -use crate::node::{NodeDefHandle, TreeError}; +use crate::node::{NodeEntryState, TreeError}; use crate::nodes::fixture::mapping::resolve_svg_path_mapping; use crate::nodes::{ ButtonNode, ClockNode, ComputeShaderNode, ControlRadioNode, CorePlaceholderNode, FixtureNode, @@ -27,7 +28,7 @@ use crate::nodes::{ playlist_output_path, }; -use super::{Engine, EngineServices}; +use super::{Engine, EngineServices, LoadedProjectRuntime}; /// Errors loading an authored project into [`Engine`]. #[derive(Debug)] @@ -35,7 +36,7 @@ pub enum ProjectLoadError { Io { path: String, details: String }, ProjectToml { file: String, error: String }, UnknownKind { path: String, suffix: String }, - InvalidSourcePath { path: String, reason: String }, + InvalidProjectReference { path: String, reason: String }, TomlParse { path: String, error: String }, InvalidNodeName { path: String, reason: String }, Tree(TreeError), @@ -47,8 +48,8 @@ impl core::fmt::Display for ProjectLoadError { Self::Io { path, details } => write!(f, "io error at {path}: {details}"), Self::ProjectToml { file, error } => write!(f, "parse {file}: {error}"), Self::UnknownKind { path, suffix } => write!(f, "{path}: unknown node kind `{suffix}`"), - Self::InvalidSourcePath { path, reason } => { - write!(f, "source path {path}: {reason}") + Self::InvalidProjectReference { path, reason } => { + write!(f, "project reference {path}: {reason}") } Self::TomlParse { path, error } => write!(f, "{path}: TOML parse failed: {error}"), Self::InvalidNodeName { path, reason } => write!(f, "{path}: invalid name: {reason}"), @@ -59,24 +60,26 @@ impl core::fmt::Display for ProjectLoadError { impl core::error::Error for ProjectLoadError {} -struct LoadedNode { - name: NodeName, - parent: Option, - artifact_path: LpPathBuf, - source_base_path: LpPathBuf, - id: NodeId, - kind: NodeKind, - provides_default_time_bus: bool, - ownership: LoadedNodeOwnership, +#[derive(Clone)] +pub(super) struct ProjectedNode { + pub(super) name: NodeName, + pub(super) parent: Option, + pub(super) def_location: NodeDefLocation, + pub(super) use_location: lpc_model::NodeUseLocation, + pub(super) id: NodeId, + pub(super) kind: NodeKind, + pub(super) provides_default_time_bus: bool, + pub(super) ownership: ProjectedNodeOwnership, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum LoadedNodeOwnership { +pub(super) enum ProjectedNodeOwnership { + Root, ProjectChild, PlaylistEntry { playlist: NodeId, entry: u32 }, } -impl LoadedNodeOwnership { +impl ProjectedNodeOwnership { fn suppress_visual_default_output(self) -> bool { matches!(self, Self::PlaylistEntry { .. }) } @@ -86,212 +89,297 @@ impl LoadedNodeOwnership { pub struct ProjectLoader; impl ProjectLoader { - pub fn load_from_root(root: &R, services: EngineServices) -> Result - where - R: ArtifactReadRoot + ?Sized, - R::Err: core::fmt::Debug, - { - Self::load_project_artifact(root, services, ArtifactLocator::path("/project.toml")) + pub fn load_from_root( + root: &dyn LpFs, + services: EngineServices, + ) -> Result { + Self::load_project_artifact(root, services, ArtifactSpec::path("/project.toml")) } - pub fn load_project_artifact( - root: &R, + pub fn load_project_artifact( + root: &dyn LpFs, services: EngineServices, - project_locator: ArtifactLocator, - ) -> Result - where - R: ArtifactReadRoot + ?Sized, - R::Err: core::fmt::Debug, - { - let project_path = resolve_project_locator(&project_locator)?; + project_specifier: ArtifactSpec, + ) -> Result { + let project_path = resolve_project_specifier(&project_specifier)?; let project_root = services.project_root().clone(); let mut runtime = Engine::with_services(project_root.clone(), services); - let project_def = load_project_def(root, &project_path, runtime.slot_shapes())?; + let mut registry = ProjectRegistry::new(); let frame = Revision::new(1); - let root_id = runtime.tree().root(); - let project_artifact = runtime - .artifacts_mut() - .acquire_location(ArtifactLocation::file(project_path.clone()), frame); - runtime - .artifacts_mut() - .load_with(&project_artifact, frame, |_location| { - Ok(NodeDef::Project(project_def.clone())) - }) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: project_path.as_str().to_string(), - reason: format!("load project artifact payload: {e:?}"), + let shapes = runtime.slot_shapes().clone(); + let ctx = ParseCtx { shapes: &shapes }; + + let load_result = registry + .load_root(root, project_path.as_path(), frame, &ctx) + .map_err(|e| ProjectLoadError::ProjectToml { + file: project_path.as_str().to_string(), + error: format!("{e:?}"), })?; - let project_invocation = NodeInvocation::new(project_locator); + Self::validate_loaded_root(®istry, &load_result.root, project_path.as_path())?; + + let projected_nodes = + Self::build_runtime_spine(®istry, &mut runtime, project_specifier.clone(), frame)?; + Self::attach_projected_nodes(root, &mut registry, &mut runtime, &projected_nodes, frame)?; + Ok(LoadedProjectRuntime::new(runtime, registry)) + } + + fn validate_loaded_root( + registry: &ProjectRegistry, + root: &NodeDefLocation, + path: &LpPath, + ) -> Result<(), ProjectLoadError> { + let entry = registry + .def(root) + .ok_or_else(|| ProjectLoadError::ProjectToml { + file: path.as_str().to_string(), + error: String::from("registry did not load the project root"), + })?; + + match &entry.state { + NodeDefState::Loaded(NodeDef::Project(_)) => Ok(()), + NodeDefState::Loaded(other) => Err(ProjectLoadError::ProjectToml { + file: path.as_str().to_string(), + error: format!("root artifact must be Project, got {:?}", other.kind()), + }), + state => Err(project_load_error_for_root_state(path, state)), + } + } + + fn build_runtime_spine( + registry: &ProjectRegistry, + runtime: &mut Engine, + project_specifier: ArtifactSpec, + frame: Revision, + ) -> Result, ProjectLoadError> { + let projected_nodes = Self::ensure_runtime_spine(registry, runtime, frame)?; + + let root = runtime.tree().root(); { - let root_entry = runtime - .tree_mut() - .get_mut(root_id) - .ok_or(ProjectLoadError::Tree(TreeError::UnknownNode(root_id)))?; - root_entry.config = project_invocation; - root_entry.def_handle = NodeDefHandle::artifact_root(project_artifact); + let entry = runtime + .tree() + .get(root) + .ok_or(ProjectLoadError::Tree(TreeError::UnknownNode(root)))?; + if entry.def_location.is_none() { + return Err(ProjectLoadError::InvalidProjectReference { + path: artifact_specifier_label(&project_specifier), + reason: String::from("registry did not project a root node"), + }); + } } runtime .attach_runtime_node( - root_id, + root, Box::new(CorePlaceholderNode::new_leaf(NodeKind::Project)), frame, ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: project_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: artifact_specifier_label(&project_specifier), reason: format!("attach project runtime: {e}"), })?; - let mut loaded_nodes = Vec::new(); - for (name, invocation) in project_def.nodes.entries { - let node_name = - NodeName::parse(&name).map_err(|e| ProjectLoadError::InvalidNodeName { - path: project_path.as_str().to_string(), - reason: format!("{e}"), - })?; - Self::load_child_invocation( - root, - &mut runtime, - &mut loaded_nodes, - root_id, - node_name, - invocation, - &project_path, - LoadedNodeOwnership::ProjectChild, - frame, - )?; - } - - Self::attach_loaded_nodes(root, &mut runtime, &loaded_nodes, frame)?; - - Ok(runtime) + Ok(projected_nodes) } - #[allow( - clippy::too_many_arguments, - reason = "recursive project loading carries the active tree, artifact, and ownership context" - )] - fn load_child_invocation( - root: &R, + pub(super) fn ensure_runtime_spine( + registry: &ProjectRegistry, runtime: &mut Engine, - loaded_nodes: &mut Vec, - parent_id: NodeId, - node_name: NodeName, - invocation: NodeInvocation, - containing_file: &LpPathBuf, - ownership: LoadedNodeOwnership, frame: Revision, - ) -> Result - where - R: ArtifactReadRoot + ?Sized, - R::Err: core::fmt::Debug, - { - let (artifact_path, source_base_path, config, artifact_id) = match &invocation.def { - NodeDefRef::Path(artifact_locator) => { - let artifact_path = - resolve_child_artifact_locator(containing_file, artifact_locator)?; - let config = load_node_def(root, artifact_path.as_path(), runtime.slot_shapes())?; - let artifact_id = runtime - .artifacts_mut() - .acquire_location(ArtifactLocation::file(artifact_path.clone()), frame); - (artifact_path.clone(), artifact_path, config, artifact_id) - } - NodeDefRef::Inline(def) => { - let artifact_path = inline_node_artifact_path(containing_file, &node_name); - let config = (**def).clone(); - let artifact_id = runtime.artifacts_mut().acquire_location( - ArtifactLocation::inline_node(containing_file.clone(), node_name.as_str()), - frame, - ); - (artifact_path, containing_file.clone(), config, artifact_id) - } - }; - runtime - .artifacts_mut() - .load_with(&artifact_id, frame, |_location| Ok(config.clone())) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: artifact_path.as_str().to_string(), - reason: format!("load node artifact payload: {e:?}"), - })?; - let ty = node_kind_name(&config, artifact_path.as_path())?; - let kind = config.kind(); - let provides_default_time_bus = node_provides_default_time_bus(&config); - let leaf_id = runtime - .tree_mut() - .add_child( - parent_id, - node_name.clone(), - ty, - WireChildKind::Input { - source: WireSlotIndex(0), - }, - invocation, - artifact_id, - frame, - ) - .map_err(ProjectLoadError::Tree)?; - - runtime.insert_artifact_node(artifact_path.clone(), leaf_id); - loaded_nodes.push(LoadedNode { - name: node_name, - parent: Some(parent_id), - artifact_path: artifact_path.clone(), - source_base_path: source_base_path.clone(), - id: leaf_id, - kind, - provides_default_time_bus, - ownership, + ) -> Result, ProjectLoadError> { + let mut project_nodes = registry + .inventory() + .tree + .nodes + .values() + .cloned() + .collect::>(); + project_nodes.sort_by(|a, b| { + a.key + .segments + .len() + .cmp(&b.key.segments.len()) + .then_with(|| a.key.cmp(&b.key)) }); - if let NodeDef::Playlist(config) = &config { - for (entry_index, entry) in &config.entries.entries { - let child_name = playlist_entry_child_name(&artifact_path, *entry_index, entry)?; - Self::load_child_invocation( - root, - runtime, - loaded_nodes, - leaf_id, - child_name, - entry.node.clone(), - &source_base_path, - LoadedNodeOwnership::PlaylistEntry { - playlist: leaf_id, - entry: *entry_index, - }, - frame, + let mut projected_nodes = Vec::new(); + for project_node in project_nodes { + let def_entry = registry.def(&project_node.def_location).ok_or_else(|| { + ProjectLoadError::InvalidProjectReference { + path: def_location_label(&project_node.def_location), + reason: String::from("project tree references missing definition entry"), + } + })?; + let kind = def_entry.state.kind().unwrap_or(NodeKind::Project); + let provides_default_time_bus = def_entry + .state + .loaded_def() + .is_some_and(node_provides_default_time_bus); + let state_error = def_entry + .state + .is_error() + .then(|| node_def_state_message(&project_node.def_location, &def_entry.state)); + + let existing_node_id = runtime.project_runtime_index().node_id(&project_node.key); + let (node_id, name, parent, ownership, inserted) = if project_node.key.is_root() { + let root_id = runtime.tree().root(); + let root_entry = runtime + .tree_mut() + .get_mut(root_id) + .ok_or(ProjectLoadError::Tree(TreeError::UnknownNode(root_id)))?; + root_entry.set_project_identity( + project_node.key.clone(), + project_node.def_location.clone(), + ); + ( + root_id, + NodeName::parse("project").map_err(|e| ProjectLoadError::InvalidNodeName { + path: def_location_label(&project_node.def_location), + reason: e.to_string(), + })?, + None, + ProjectedNodeOwnership::Root, + existing_node_id.is_none(), + ) + } else { + let parent_key = project_node.parent.as_ref().ok_or_else(|| { + ProjectLoadError::InvalidProjectReference { + path: def_location_label(&project_node.def_location), + reason: String::from("non-root project node has no parent"), + } + })?; + let parent = runtime + .project_runtime_index() + .node_id(parent_key) + .ok_or_else(|| ProjectLoadError::InvalidProjectReference { + path: def_location_label(&project_node.def_location), + reason: String::from("project node parent was not projected"), + })?; + let (name, ownership) = projected_node_name_and_ownership( + &project_node.origin, + parent, + &project_node.def_location, )?; + if let Some(node_id) = existing_node_id { + (node_id, name, Some(parent), ownership, false) + } else { + let ty = match def_entry.state.loaded_def() { + Some(def) => node_kind_name(def, &project_node.def_location)?, + None => NodeName::parse("node").map_err(|e| { + ProjectLoadError::InvalidNodeName { + path: def_location_label(&project_node.def_location), + reason: e.to_string(), + } + })?, + }; + let node_id = runtime + .tree_mut() + .add_child( + parent, + name.clone(), + ty, + WireChildKind::Input { + source: WireSlotIndex(0), + }, + project_node_invocation(&project_node.origin), + frame, + ) + .map_err(ProjectLoadError::Tree)?; + runtime + .tree_mut() + .get_mut(node_id) + .expect("add_child inserted the node") + .set_project_identity( + project_node.key.clone(), + project_node.def_location.clone(), + ); + (node_id, name, Some(parent), ownership, true) + } + }; + + if inserted { + runtime.project_runtime_index_mut().insert_node( + project_node.key.clone(), + node_id, + project_node.def_location.clone(), + ); + } + if let Some(message) = state_error { + mark_node_load_error(runtime, node_id, frame, message); } + + projected_nodes.push(ProjectedNode { + name, + parent, + def_location: project_node.def_location, + use_location: project_node.key, + id: node_id, + kind, + provides_default_time_bus, + ownership, + }); } + runtime + .project_runtime_index_mut() + .rebuild_asset_consumers(®istry.inventory().tree); - Ok(leaf_id) + Ok(projected_nodes) } - fn attach_loaded_nodes( - root: &R, + pub(super) fn attach_projected_nodes( + fs: &dyn LpFs, + registry: &mut ProjectRegistry, runtime: &mut Engine, - loaded_nodes: &[LoadedNode], + projected_nodes: &[ProjectedNode], frame: Revision, - ) -> Result<(), ProjectLoadError> - where - R: ArtifactReadRoot + ?Sized, - R::Err: core::fmt::Debug, - { - for node in loaded_nodes { + ) -> Result<(), ProjectLoadError> { + Self::attach_projected_nodes_filtered(fs, registry, runtime, projected_nodes, None, frame) + } + + pub(super) fn attach_selected_projected_nodes( + fs: &dyn LpFs, + registry: &mut ProjectRegistry, + runtime: &mut Engine, + projected_nodes: &[ProjectedNode], + targets: &VecSet, + frame: Revision, + ) -> Result<(), ProjectLoadError> { + Self::attach_projected_nodes_filtered( + fs, + registry, + runtime, + projected_nodes, + Some(targets), + frame, + ) + } + + fn attach_projected_nodes_filtered( + fs: &dyn LpFs, + registry: &mut ProjectRegistry, + runtime: &mut Engine, + projected_nodes: &[ProjectedNode], + targets: Option<&VecSet>, + frame: Revision, + ) -> Result<(), ProjectLoadError> { + for node in projected_nodes { + if !should_attach_projected_node(node, targets) { + continue; + } if node.kind != NodeKind::Clock { continue; } - let NodeDef::Clock(config) = loaded_node_config(runtime, node)?.clone() else { + let NodeDef::Clock(config) = projected_node_config(registry, node)?.clone() else { continue; }; runtime .attach_runtime_node(node.id, Box::new(ClockNode::new(node.id)), frame) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: format!("attach clock runtime: {e}"), })?; register_target_binding( runtime, - loaded_nodes, + projected_nodes, node, "seconds", &config.bindings, @@ -299,7 +387,7 @@ impl ProjectLoader { )?; register_target_binding( runtime, - loaded_nodes, + projected_nodes, node, "delta_seconds", &config.bindings, @@ -308,40 +396,68 @@ impl ProjectLoader { register_clock_default_time_binding(runtime, node, &config.bindings, frame)?; } - for node in loaded_nodes { + for node in projected_nodes { + if !should_attach_projected_node(node, targets) { + continue; + } if node.kind != NodeKind::Button { continue; } - let NodeDef::Button(config) = loaded_node_config(runtime, node)?.clone() else { + let NodeDef::Button(config) = projected_node_config(registry, node)?.clone() else { continue; }; runtime .attach_runtime_node(node.id, Box::new(ButtonNode::new()), frame) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: format!("attach button runtime: {e}"), })?; - register_target_binding(runtime, loaded_nodes, node, "down", &config.bindings, frame)?; - register_target_binding(runtime, loaded_nodes, node, "held", &config.bindings, frame)?; - register_target_binding(runtime, loaded_nodes, node, "up", &config.bindings, frame)?; + register_target_binding( + runtime, + projected_nodes, + node, + "down", + &config.bindings, + frame, + )?; + register_target_binding( + runtime, + projected_nodes, + node, + "held", + &config.bindings, + frame, + )?; + register_target_binding( + runtime, + projected_nodes, + node, + "up", + &config.bindings, + frame, + )?; } - for node in loaded_nodes { + for node in projected_nodes { + if !should_attach_projected_node(node, targets) { + continue; + } if node.kind != NodeKind::ControlRadio { continue; } - let NodeDef::ControlRadio(config) = loaded_node_config(runtime, node)?.clone() else { + let NodeDef::ControlRadio(config) = projected_node_config(registry, node)?.clone() + else { continue; }; runtime .attach_runtime_node(node.id, Box::new(ControlRadioNode::new()), frame) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: format!("attach control radio runtime: {e}"), })?; register_optional_source_binding( runtime, - loaded_nodes, + projected_nodes, node, "input", &config.bindings, @@ -349,7 +465,7 @@ impl ProjectLoader { )?; register_target_binding( runtime, - loaded_nodes, + projected_nodes, node, "output", &config.bindings, @@ -358,35 +474,41 @@ impl ProjectLoader { runtime.add_demand_root(node.id); } - for node in loaded_nodes { + for node in projected_nodes { + if !should_attach_projected_node(node, targets) { + continue; + } if node.kind != NodeKind::Texture { continue; } runtime .attach_runtime_node(node.id, Box::new(TextureNode::new(node.id)), frame) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: format!("attach texture runtime: {e}"), })?; } - for node in loaded_nodes { + for node in projected_nodes { + if !should_attach_projected_node(node, targets) { + continue; + } if node.kind != NodeKind::Output { continue; } - let NodeDef::Output(config) = loaded_node_config(runtime, node)?.clone() else { + let NodeDef::Output(config) = projected_node_config(registry, node)?.clone() else { continue; }; runtime .attach_runtime_node(node.id, Box::new(OutputNode::new()), frame) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: format!("attach output runtime: {e}"), })?; let sink_id = runtime .runtime_output_sink_buffer_id(node.id) - .ok_or_else(|| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), + .ok_or_else(|| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: String::from("output runtime node produced no sink buffer"), })?; runtime @@ -406,13 +528,13 @@ impl ProjectLoader { }, frame, ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: format!("bind output demand slot: {e}"), })?; register_source_binding( runtime, - loaded_nodes, + projected_nodes, node, "input", &config.bindings, @@ -421,15 +543,23 @@ impl ProjectLoader { runtime.add_demand_root(node.id); } - for node in loaded_nodes { + for node in projected_nodes { + if !should_attach_projected_node(node, targets) { + continue; + } if node.kind != NodeKind::Shader { continue; } - let NodeDef::Shader(config) = loaded_node_config(runtime, node)?.clone() else { + let NodeDef::Shader(config) = projected_node_config(registry, node)?.clone() else { continue; }; - let glsl_source = - read_shader_source(root, &node.source_base_path, config.shader_source())?; + let glsl_source = materialize_node_text_asset( + fs, + registry, + node, + AssetContentType::ShaderSource, + "shader source", + )?; let bindings = config.bindings.clone(); let consumed_slot_names = config .consumed_slots @@ -444,16 +574,16 @@ impl ProjectLoader { Box::new(ShaderNode::new(node.id, config, glsl_source)), frame, ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: format!("attach shader runtime: {e}"), })?; - register_target_binding(runtime, loaded_nodes, node, "output", &bindings, frame)?; + register_target_binding(runtime, projected_nodes, node, "output", &bindings, frame)?; register_visual_default_output_binding(runtime, node, &bindings, frame)?; for name in consumed_slot_names { register_optional_source_binding( runtime, - loaded_nodes, + projected_nodes, node, name.as_str(), &bindings, @@ -465,22 +595,24 @@ impl ProjectLoader { } } - for node in loaded_nodes { + for node in projected_nodes { + if !should_attach_projected_node(node, targets) { + continue; + } if node.kind != NodeKind::ComputeShader { continue; } - let NodeDef::ComputeShader(config) = loaded_node_config(runtime, node)?.clone() else { + let NodeDef::ComputeShader(config) = projected_node_config(registry, node)?.clone() + else { continue; }; - let source = read_shader_source(root, &node.source_base_path, config.shader_source())?; - let header = - generate_compute_shader_header(&config, runtime.slot_shapes()).map_err(|e| { - ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), - reason: format!("generate compute shader header: {e}"), - } - })?; - let glsl_source = format!("{header}\n{source}"); + let source = materialize_node_text_asset( + fs, + registry, + node, + AssetContentType::ComputeShaderSource, + "compute shader source", + )?; let bindings = config.bindings.clone(); let consumed_slot_names = config .consumed_slots @@ -497,18 +629,32 @@ impl ProjectLoader { runtime .attach_runtime_node( node.id, - Box::new(ComputeShaderNode::new(node.id, config, glsl_source, frame)), + Box::new( + ComputeShaderNode::from_asset_text( + node.id, + config, + source, + runtime.slot_shapes(), + frame, + ) + .map_err(|e| { + ProjectLoadError::InvalidProjectReference { + path: node_label(node), + reason: format!("generate compute shader header: {e}"), + } + })?, + ), frame, ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: format!("attach compute shader runtime: {e}"), })?; for name in consumed_slot_names { register_optional_source_binding( runtime, - loaded_nodes, + projected_nodes, node, name.as_str(), &bindings, @@ -518,7 +664,7 @@ impl ProjectLoader { for name in produced_slot_names { register_target_binding( runtime, - loaded_nodes, + projected_nodes, node, name.as_str(), &bindings, @@ -527,31 +673,34 @@ impl ProjectLoader { } } - for node in loaded_nodes { + for node in projected_nodes { + if !should_attach_projected_node(node, targets) { + continue; + } if node.kind != NodeKind::Fluid { continue; } - let NodeDef::Fluid(config) = loaded_node_config(runtime, node)?.clone() else { + let NodeDef::Fluid(config) = projected_node_config(registry, node)?.clone() else { continue; }; runtime .attach_runtime_node(node.id, Box::new(FluidNode::new(node.id)), frame) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: format!("attach fluid runtime: {e}"), })?; register_optional_source_binding( runtime, - loaded_nodes, + projected_nodes, node, "time", &config.bindings, frame, )?; - register_fluid_default_time_binding(runtime, loaded_nodes, node, &config, frame)?; + register_fluid_default_time_binding(runtime, projected_nodes, node, &config, frame)?; register_optional_source_binding( runtime, - loaded_nodes, + projected_nodes, node, "emitters", &config.bindings, @@ -559,7 +708,7 @@ impl ProjectLoader { )?; register_target_binding( runtime, - loaded_nodes, + projected_nodes, node, "output", &config.bindings, @@ -568,7 +717,10 @@ impl ProjectLoader { register_visual_default_output_binding(runtime, node, &config.bindings, frame)?; } - for node in loaded_nodes { + for node in projected_nodes { + if !should_attach_projected_node(node, targets) { + continue; + } if node.kind != NodeKind::Playlist { continue; } @@ -580,20 +732,20 @@ impl ProjectLoader { output_target, entry_trigger_sources, ) = { - let NodeDef::Playlist(config) = loaded_node_config(runtime, node)? else { + let NodeDef::Playlist(config) = projected_node_config(registry, node)? else { continue; }; ( *config.idle_entry.value(), config.default_fade.value().0, - playlist_runtime_entries(loaded_nodes, node.id, config), + playlist_runtime_entries(projected_nodes, node.id, config), binding_source(&config.bindings, "time") - .map(|source| binding_source_endpoint(loaded_nodes, node, source)) + .map(|source| binding_source_endpoint(projected_nodes, node, source)) .transpose()?, binding_target(&config.bindings, "output") - .map(|target| binding_target_endpoint(loaded_nodes, node, target)) + .map(|target| binding_target_endpoint(projected_nodes, node, target)) .transpose()?, - playlist_entry_trigger_sources(loaded_nodes, node, config)?, + playlist_entry_trigger_sources(projected_nodes, node, config)?, ) }; if let Some(source) = time_source { @@ -621,8 +773,8 @@ impl ProjectLoader { }, frame, ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: format!("register output target binding: {e}"), })?; } @@ -631,8 +783,8 @@ impl ProjectLoader { } for (entry_index, source) in entry_trigger_sources { let target_slot = SlotPath::parse(&format!("entries[{entry_index}].trigger")) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: format!("invalid playlist entry trigger path: {e}"), })?; register_source_binding_at_path( @@ -655,38 +807,49 @@ impl ProjectLoader { )), frame, ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: format!("attach playlist placeholder runtime: {e}"), })?; } - for node in loaded_nodes { + for node in projected_nodes { + if !should_attach_projected_node(node, targets) { + continue; + } if node.kind != NodeKind::Fixture { continue; } - let NodeDef::Fixture(config) = loaded_node_config(runtime, node)?.clone() else { + let NodeDef::Fixture(config) = projected_node_config(registry, node)?.clone() else { continue; }; - let mapping = resolve_fixture_mapping(root, &node.source_base_path, &config)?; - runtime - .attach_runtime_node( - node.id, - Box::new(FixtureNode::new( - node.id, - mapping, - *config.sampling.value(), - frame, - )), - frame, - ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), - reason: format!("attach fixture runtime: {e}"), - })?; + match resolve_fixture_mapping(fs, registry, node, &config) { + Ok(mapping) => { + runtime + .attach_runtime_node( + node.id, + Box::new(FixtureNode::new( + node.id, + mapping, + *config.sampling.value(), + frame, + )), + frame, + ) + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), + reason: format!("attach fixture runtime: {e}"), + })?; + mark_node_status(runtime, node.id, frame, NodeRuntimeStatus::Ok); + } + Err(error) => { + let message = error.to_string(); + mark_node_load_error(runtime, node.id, frame, message); + } + } register_source_binding( runtime, - loaded_nodes, + projected_nodes, node, "input", &config.bindings, @@ -694,7 +857,7 @@ impl ProjectLoader { )?; register_target_binding( runtime, - loaded_nodes, + projected_nodes, node, "output", &config.bindings, @@ -706,156 +869,200 @@ impl ProjectLoader { } } -fn load_project_def( - root: &R, - path: &LpPathBuf, - registry: &SlotShapeRegistry, -) -> Result -where - R: ArtifactReadRoot + ?Sized, - R::Err: core::fmt::Debug, -{ - let text = read_utf8_file(root, path.as_path())?; - match NodeDef::read_toml(registry, &text) { - Ok(NodeDef::Project(def)) => Ok(def), - Ok(other) => Err(ProjectLoadError::UnknownKind { - path: path.as_str().to_string(), - suffix: other.kind_name().to_string(), - }), - Err(lpc_model::NodeDefParseError::UnknownKind { kind }) => { - Err(ProjectLoadError::UnknownKind { +fn should_attach_projected_node( + node: &ProjectedNode, + targets: Option<&VecSet>, +) -> bool { + targets.is_none_or(|targets| targets.contains(&node.use_location)) +} + +fn mark_node_load_error(runtime: &mut Engine, node_id: NodeId, frame: Revision, message: String) { + if let Some(entry) = runtime.tree_mut().get_mut(node_id) { + entry.set_status(NodeRuntimeStatus::Error(message.clone()), frame); + entry.set_state(NodeEntryState::Failed { reason: message }, frame); + } +} + +fn project_load_error_for_root_state(path: &LpPath, state: &NodeDefState) -> ProjectLoadError { + match state { + NodeDefState::NotFound | NodeDefState::Deleted | NodeDefState::ReadError { .. } => { + ProjectLoadError::Io { path: path.as_str().to_string(), - suffix: kind, - }) + details: node_def_state_message( + &NodeDefLocation::artifact_root(lpc_model::ArtifactLocation::file( + path.as_str(), + )), + state, + ), + } + } + NodeDefState::ParseError(lpc_model::NodeDefParseError::UnknownKind { kind }) => { + ProjectLoadError::UnknownKind { + path: path.as_str().to_string(), + suffix: kind.clone(), + } } - Err(lpc_model::NodeDefParseError::Toml { error }) => Err(ProjectLoadError::ProjectToml { + NodeDefState::ParseError(err) => ProjectLoadError::ProjectToml { file: path.as_str().to_string(), - error, - }), + error: err.to_string(), + }, + NodeDefState::ValidationError(err) => ProjectLoadError::ProjectToml { + file: path.as_str().to_string(), + error: err.message.clone(), + }, + NodeDefState::Loaded(_) => ProjectLoadError::ProjectToml { + file: path.as_str().to_string(), + error: String::from("root artifact is not a Project"), + }, } } -fn load_node_def( - root: &R, - path: &LpPath, - registry: &SlotShapeRegistry, -) -> Result -where - R: ArtifactReadRoot + ?Sized, - R::Err: core::fmt::Debug, -{ - let text = read_utf8_file(root, path)?; - match NodeDef::read_toml(registry, &text) { - Ok(NodeDef::Project(_)) => Err(ProjectLoadError::UnknownKind { - path: path.as_str().to_string(), - suffix: "project".to_string(), - }), - Ok(def) => Ok(def), - Err(lpc_model::NodeDefParseError::UnknownKind { kind }) => { - Err(ProjectLoadError::UnknownKind { - path: path.as_str().to_string(), - suffix: kind, - }) +fn node_def_state_message(location: &NodeDefLocation, state: &NodeDefState) -> String { + match state { + NodeDefState::Loaded(_) => String::from("loaded"), + NodeDefState::NotFound => format!("definition not found: {}", def_location_label(location)), + NodeDefState::Deleted => format!("definition deleted: {}", def_location_label(location)), + NodeDefState::ReadError { message } => { + format!( + "definition read error at {}: {message}", + def_location_label(location) + ) + } + NodeDefState::ParseError(err) => { + format!( + "definition parse error at {}: {err}", + def_location_label(location) + ) + } + NodeDefState::ValidationError(err) => { + format!( + "definition validation error at {}: {}", + def_location_label(location), + err.message + ) } - Err(lpc_model::NodeDefParseError::Toml { error }) => Err(ProjectLoadError::TomlParse { - path: path.as_str().to_string(), - error, - }), } } -fn resolve_project_locator(locator: &ArtifactLocator) -> Result { - resolve_path_locator_from_dir(LpPath::new("/"), locator) +fn mark_node_status( + runtime: &mut Engine, + node_id: NodeId, + frame: Revision, + status: NodeRuntimeStatus, +) { + if let Some(entry) = runtime.tree_mut().get_mut(node_id) { + entry.set_status(status, frame); + } } -fn resolve_child_artifact_locator( - containing_file: &LpPathBuf, - locator: &ArtifactLocator, -) -> Result { - let parent = containing_file - .as_path() - .parent() - .unwrap_or(LpPath::new("/")); - resolve_path_locator_from_dir(parent, locator) +fn projected_node_name_and_ownership( + origin: &ProjectNodeOrigin, + parent: NodeId, + def_location: &NodeDefLocation, +) -> Result<(NodeName, ProjectedNodeOwnership), ProjectLoadError> { + match origin { + ProjectNodeOrigin::Root => Ok(( + NodeName::parse("project").map_err(|e| ProjectLoadError::InvalidNodeName { + path: def_location_label(def_location), + reason: e.to_string(), + })?, + ProjectedNodeOwnership::Root, + )), + ProjectNodeOrigin::Invocation { role, .. } => match role { + ProjectNodePlacement::ProjectChild { name } => Ok(( + NodeName::parse(name).map_err(|e| ProjectLoadError::InvalidNodeName { + path: def_location_label(def_location), + reason: e.to_string(), + })?, + ProjectedNodeOwnership::ProjectChild, + )), + ProjectNodePlacement::PlaylistEntry { entry, name } => { + let fallback = format!("entry_{entry}"); + Ok(( + NodeName::parse(name.as_deref().unwrap_or(&fallback)).map_err(|e| { + ProjectLoadError::InvalidNodeName { + path: def_location_label(def_location), + reason: e.to_string(), + } + })?, + ProjectedNodeOwnership::PlaylistEntry { + playlist: parent, + entry: *entry, + }, + )) + } + }, + } +} + +fn project_node_invocation(origin: &ProjectNodeOrigin) -> NodeInvocation { + match origin { + ProjectNodeOrigin::Root => NodeInvocation::Unset, + ProjectNodeOrigin::Invocation { invocation, .. } => invocation.clone(), + } +} + +fn node_label(node: &ProjectedNode) -> String { + def_location_label(&node.def_location) +} + +fn def_location_label(location: &NodeDefLocation) -> String { + if location.path.is_root() { + location.artifact.file_path().as_str().to_string() + } else { + format!( + "{}#{}", + location.artifact.file_path().as_str(), + location.path + ) + } +} + +fn artifact_specifier_label(specifier: &ArtifactSpec) -> String { + match specifier { + ArtifactSpec::Path(path) => path.as_str().to_string(), + ArtifactSpec::Lib(lib) => lib.to_string(), + } } -fn resolve_path_locator_from_dir( +fn resolve_project_specifier(specifier: &ArtifactSpec) -> Result { + resolve_path_specifier_from_dir(LpPath::new("/"), specifier) +} + +fn resolve_path_specifier_from_dir( base_dir: &LpPath, - locator: &ArtifactLocator, + specifier: &ArtifactSpec, ) -> Result { - match locator { - ArtifactLocator::Path(path) => { + match specifier { + ArtifactSpec::Path(path) => { if path.is_absolute() { Ok(path.clone()) } else { base_dir .to_path_buf() .join_relative(path.as_str()) - .ok_or_else(|| ProjectLoadError::InvalidSourcePath { + .ok_or_else(|| ProjectLoadError::InvalidProjectReference { path: path.as_str().to_string(), reason: format!("path cannot be resolved relative to {base_dir:?}"), }) } } - ArtifactLocator::Lib(lib) => Err(ProjectLoadError::InvalidSourcePath { + ArtifactSpec::Lib(lib) => Err(ProjectLoadError::InvalidProjectReference { path: lib.to_string(), - reason: String::from("library artifact locators are not supported for nodes yet"), + reason: String::from("library artifact specifiers are not supported for nodes yet"), }), } } -fn resolve_path_relative_to_file( - containing_file: &LpPathBuf, - path: &LpPathBuf, -) -> Result { - let parent = containing_file - .as_path() - .parent() - .unwrap_or(LpPath::new("/")); - parent - .to_path_buf() - .join_relative(path.as_str()) - .ok_or_else(|| ProjectLoadError::InvalidSourcePath { - path: path.as_str().to_string(), - reason: format!( - "path cannot be resolved relative to {}", - containing_file.as_str() - ), - }) -} - -fn inline_node_artifact_path(project_path: &LpPathBuf, node_name: &NodeName) -> LpPathBuf { - LpPathBuf::from(format!( - "{}#nodes.{}", - project_path.as_str(), - node_name.as_str() - )) -} - -fn playlist_entry_child_name( - playlist_path: &LpPathBuf, - entry_index: u32, - entry: &PlaylistEntry, -) -> Result { - let name = entry.name.data.as_ref().map_or_else( - || format!("entry_{entry_index}"), - |name| name.value().clone(), - ); - NodeName::parse(&name).map_err(|e| ProjectLoadError::InvalidNodeName { - path: playlist_path.as_str().to_string(), - reason: format!("entry {entry_index}: {e}"), - }) -} - fn playlist_runtime_entries( - loaded_nodes: &[LoadedNode], + projected_nodes: &[ProjectedNode], playlist: NodeId, config: &PlaylistDef, ) -> Vec { - loaded_nodes + projected_nodes .iter() .filter_map(|node| match node.ownership { - LoadedNodeOwnership::PlaylistEntry { + ProjectedNodeOwnership::PlaylistEntry { playlist: owner, entry, } if owner == playlist => Some(PlaylistRuntimeEntry { @@ -881,8 +1088,8 @@ fn playlist_runtime_entries( } fn playlist_entry_trigger_sources( - loaded_nodes: &[LoadedNode], - current: &LoadedNode, + projected_nodes: &[ProjectedNode], + current: &ProjectedNode, config: &PlaylistDef, ) -> Result, ProjectLoadError> { let mut sources = Vec::new(); @@ -892,56 +1099,37 @@ fn playlist_entry_trigger_sources( }; sources.push(( *entry_index, - binding_source_endpoint(loaded_nodes, current, source)?, + binding_source_endpoint(projected_nodes, current, source)?, )); } Ok(sources) } -fn read_shader_source( - root: &R, - containing_file: &LpPathBuf, - source: &ShaderSource, -) -> Result -where - R: ArtifactReadRoot + ?Sized, - R::Err: core::fmt::Debug, -{ - match source { - ShaderSource::Path(path) => { - let shader_path = - resolve_path_relative_to_file(containing_file, &path.value().as_path_buf())?; - read_utf8_file(root, shader_path.as_path()) - } - ShaderSource::Glsl(source) => Ok(source.value().clone()), - } -} - -fn resolve_fixture_mapping( - root: &R, - containing_file: &LpPathBuf, +fn resolve_fixture_mapping( + fs: &dyn LpFs, + registry: &mut ProjectRegistry, + node: &ProjectedNode, config: &FixtureDef, -) -> Result -where - R: ArtifactReadRoot + ?Sized, - R::Err: core::fmt::Debug, -{ +) -> Result { match config.mapping.value() { MappingConfig::SvgPath { - source, - sample_diameter, + sample_diameter, .. } => { - let svg_path = - resolve_path_relative_to_file(containing_file, &source.value().as_path_buf())?; - let svg = read_utf8_file(root, svg_path.as_path())?; + let svg = materialize_node_text_asset( + fs, + registry, + node, + AssetContentType::FixtureSvg, + "fixture SVG", + )?; resolve_svg_path_mapping( - &svg, + &svg.text, config.render_width(), config.render_height(), sample_diameter.value().0, ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: svg_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), reason: format!("resolve svg fixture mapping: {e}"), }) } @@ -949,7 +1137,10 @@ where } } -fn node_kind_name(config: &NodeDef, path: &LpPath) -> Result { +fn node_kind_name( + config: &NodeDef, + location: &NodeDefLocation, +) -> Result { let name = match config { NodeDef::ComputeShader(_) => "compute_shader", NodeDef::ControlRadio(_) => "control_radio", @@ -957,33 +1148,76 @@ fn node_kind_name(config: &NodeDef, path: &LpPath) -> Result config.kind_name(), }; NodeName::parse(name).map_err(|e| ProjectLoadError::InvalidNodeName { - path: path.as_str().to_string(), + path: def_location_label(location), reason: format!("{e}"), }) } -fn loaded_node_config<'a>( - runtime: &'a Engine, - node: &LoadedNode, +fn projected_node_config<'a>( + registry: &'a ProjectRegistry, + node: &ProjectedNode, ) -> Result<&'a NodeDef, ProjectLoadError> { - let artifact = runtime - .tree() - .get(node.id) - .ok_or(ProjectLoadError::Tree(TreeError::UnknownNode(node.id)))? - .artifact(); - let entry = runtime.artifacts().entry(&artifact).ok_or_else(|| { - ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), - reason: format!("missing artifact payload for node {:?}", node.id), + let entry = registry.def(&node.def_location).ok_or_else(|| { + ProjectLoadError::InvalidProjectReference { + path: node_label(node), + reason: format!("missing definition payload for node {:?}", node.id), } })?; match &entry.state { - ArtifactState::Loaded(def) | ArtifactState::Prepared(def) | ArtifactState::Idle(def) => { - Ok(def) + NodeDefState::Loaded(def) => Ok(def), + other => Err(ProjectLoadError::InvalidProjectReference { + path: node_label(node), + reason: format!("definition payload is not loaded: {other:?}"), + }), + } +} + +fn materialize_node_text_asset( + fs: &dyn LpFs, + registry: &mut ProjectRegistry, + node: &ProjectedNode, + content_type: AssetContentType, + label: &str, +) -> Result { + let source = asset_for_node_content_type(registry, node, content_type)?; + registry.materialize_asset_text(fs, &source).map_err(|e| { + ProjectLoadError::InvalidProjectReference { + path: node_label(node), + reason: format!("materialize {label}: {e:?}"), + } + }) +} + +fn asset_for_node_content_type( + registry: &ProjectRegistry, + node: &ProjectedNode, + content_type: AssetContentType, +) -> Result { + let mut matches = Vec::new(); + for (source, consumers) in ®istry.inventory().tree.asset_consumers { + if !consumers + .iter() + .any(|consumer| consumer == &node.use_location) + { + continue; } - other => Err(ProjectLoadError::InvalidSourcePath { - path: node.artifact_path.as_str().to_string(), - reason: format!("artifact payload is not loaded: {other:?}"), + let Some(entry) = registry.asset(source) else { + continue; + }; + if entry.content_type == content_type { + matches.push(source.clone()); + } + } + + match matches.len() { + 1 => Ok(matches.remove(0)), + 0 => Err(ProjectLoadError::InvalidProjectReference { + path: node_label(node), + reason: format!("node has no referenced {content_type:?} asset"), + }), + _ => Err(ProjectLoadError::InvalidProjectReference { + path: node_label(node), + reason: format!("node has multiple referenced {content_type:?} assets"), }), } } @@ -998,29 +1232,32 @@ fn node_provides_default_time_bus(config: &NodeDef) -> bool { } fn resolve_node_loc<'a>( - loaded_nodes: &'a [LoadedNode], - current: &'a LoadedNode, + projected_nodes: &'a [ProjectedNode], + current: &'a ProjectedNode, loc: &lpc_model::RelativeNodeRef, expected: &str, -) -> Result<&'a LoadedNode, ProjectLoadError> { - resolve_relative_node_ref(loaded_nodes, current, loc).ok_or_else(|| { - ProjectLoadError::InvalidSourcePath { - path: current.artifact_path.as_str().to_string(), +) -> Result<&'a ProjectedNode, ProjectLoadError> { + resolve_relative_node_ref(projected_nodes, current, loc).ok_or_else(|| { + ProjectLoadError::InvalidProjectReference { + path: node_label(current), reason: format!("unknown {expected} node ref `{loc}`"), } }) } fn resolve_relative_node_ref<'a>( - loaded_nodes: &'a [LoadedNode], - current: &'a LoadedNode, + projected_nodes: &'a [ProjectedNode], + current: &'a ProjectedNode, parsed: &lpc_model::RelativeNodeRef, -) -> Option<&'a LoadedNode> { +) -> Option<&'a ProjectedNode> { let mut node = Some(current); let mut virtual_parent = None; for _ in 0..parsed.parent_hops() { let parent = node?.parent?; - if let Some(parent_node) = loaded_nodes.iter().find(|candidate| candidate.id == parent) { + if let Some(parent_node) = projected_nodes + .iter() + .find(|candidate| candidate.id == parent) + { node = Some(parent_node); virtual_parent = None; } else { @@ -1030,7 +1267,7 @@ fn resolve_relative_node_ref<'a>( } for segment in parsed.segments() { let parent = node.map(|node| node.id).or(virtual_parent)?; - node = loaded_nodes + node = projected_nodes .iter() .find(|candidate| candidate.parent == Some(parent) && &candidate.name == segment); virtual_parent = None; @@ -1061,21 +1298,22 @@ fn binding_target<'a>(bindings: &'a BindingDefs, slot: &str) -> Option<&'a Autho fn register_source_binding( engine: &mut Engine, - loaded_nodes: &[LoadedNode], - current: &LoadedNode, + projected_nodes: &[ProjectedNode], + current: &ProjectedNode, slot_name: &str, bindings: &BindingDefs, frame: Revision, ) -> Result<(), ProjectLoadError> { - let source = - binding_source(bindings, slot_name).ok_or_else(|| ProjectLoadError::InvalidSourcePath { - path: current.artifact_path.as_str().to_string(), + let source = binding_source(bindings, slot_name).ok_or_else(|| { + ProjectLoadError::InvalidProjectReference { + path: node_label(current), reason: format!("{slot_name} source binding is missing"), - })?; - let source = binding_source_endpoint(loaded_nodes, current, source)?; + } + })?; + let source = binding_source_endpoint(projected_nodes, current, source)?; let target_slot = - SlotPath::parse(slot_name).map_err(|e| ProjectLoadError::InvalidSourcePath { - path: current.artifact_path.as_str().to_string(), + SlotPath::parse(slot_name).map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(current), reason: format!("invalid target slot `{slot_name}`: {e}"), })?; register_source_binding_at_path(engine, current, slot_name, source, target_slot, frame) @@ -1083,7 +1321,7 @@ fn register_source_binding( fn register_source_binding_at_path( engine: &mut Engine, - current: &LoadedNode, + current: &ProjectedNode, binding_slot_name: &str, source: BindingSource, target_slot: SlotPath, @@ -1103,8 +1341,8 @@ fn register_source_binding_at_path( }, frame, ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: current.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(current), reason: format!("register {binding_slot_name} source binding: {e}"), })?; Ok(()) @@ -1112,8 +1350,8 @@ fn register_source_binding_at_path( fn register_optional_source_binding( engine: &mut Engine, - loaded_nodes: &[LoadedNode], - current: &LoadedNode, + projected_nodes: &[ProjectedNode], + current: &ProjectedNode, slot_name: &str, bindings: &BindingDefs, frame: Revision, @@ -1121,13 +1359,13 @@ fn register_optional_source_binding( if binding_source(bindings, slot_name).is_none() { return Ok(()); } - register_source_binding(engine, loaded_nodes, current, slot_name, bindings, frame) + register_source_binding(engine, projected_nodes, current, slot_name, bindings, frame) } fn register_target_binding( engine: &mut Engine, - loaded_nodes: &[LoadedNode], - current: &LoadedNode, + projected_nodes: &[ProjectedNode], + current: &ProjectedNode, slot_name: &str, bindings: &BindingDefs, frame: Revision, @@ -1135,10 +1373,10 @@ fn register_target_binding( let Some(target) = binding_target(bindings, slot_name) else { return Ok(()); }; - let target = binding_target_endpoint(loaded_nodes, current, target)?; + let target = binding_target_endpoint(projected_nodes, current, target)?; let source_slot = - SlotPath::parse(slot_name).map_err(|e| ProjectLoadError::InvalidSourcePath { - path: current.artifact_path.as_str().to_string(), + SlotPath::parse(slot_name).map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(current), reason: format!("invalid source slot `{slot_name}`: {e}"), })?; engine @@ -1155,8 +1393,8 @@ fn register_target_binding( }, frame, ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: current.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(current), reason: format!("register {slot_name} target binding: {e}"), })?; Ok(()) @@ -1164,7 +1402,7 @@ fn register_target_binding( fn register_visual_default_output_binding( engine: &mut Engine, - current: &LoadedNode, + current: &ProjectedNode, bindings: &BindingDefs, frame: Revision, ) -> Result<(), ProjectLoadError> { @@ -1178,7 +1416,7 @@ fn register_visual_default_output_binding( fn add_visual_default_output_binding( engine: &mut Engine, - current: &LoadedNode, + current: &ProjectedNode, frame: Revision, ) -> Result<(), ProjectLoadError> { engine @@ -1195,8 +1433,8 @@ fn add_visual_default_output_binding( }, frame, ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: current.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(current), reason: format!("register visual default output binding: {e}"), })?; Ok(()) @@ -1211,7 +1449,7 @@ fn binding_kind_for_slot(slot_name: &str) -> Kind { fn register_clock_default_time_binding( engine: &mut Engine, - current: &LoadedNode, + current: &ProjectedNode, bindings: &BindingDefs, frame: Revision, ) -> Result<(), ProjectLoadError> { @@ -1232,8 +1470,8 @@ fn register_clock_default_time_binding( }, frame, ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: current.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(current), reason: format!("register clock default time binding: {e}"), })?; Ok(()) @@ -1252,7 +1490,7 @@ fn shader_needs_default_time_binding(config: &ShaderDef) -> bool { fn add_visual_default_time_binding( engine: &mut Engine, - current: &LoadedNode, + current: &ProjectedNode, frame: Revision, ) -> Result<(), ProjectLoadError> { engine @@ -1269,8 +1507,8 @@ fn add_visual_default_time_binding( }, frame, ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: current.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(current), reason: format!("register visual shader default time binding: {e}"), })?; Ok(()) @@ -1278,12 +1516,13 @@ fn add_visual_default_time_binding( fn register_fluid_default_time_binding( engine: &mut Engine, - loaded_nodes: &[LoadedNode], - current: &LoadedNode, + projected_nodes: &[ProjectedNode], + current: &ProjectedNode, config: &FluidDef, frame: Revision, ) -> Result<(), ProjectLoadError> { - if binding_source(&config.bindings, "time").is_some() || !has_default_time_bus(loaded_nodes) { + if binding_source(&config.bindings, "time").is_some() || !has_default_time_bus(projected_nodes) + { return Ok(()); } engine @@ -1300,15 +1539,15 @@ fn register_fluid_default_time_binding( }, frame, ) - .map_err(|e| ProjectLoadError::InvalidSourcePath { - path: current.artifact_path.as_str().to_string(), + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(current), reason: format!("register fluid default time binding: {e}"), })?; Ok(()) } -fn has_default_time_bus(loaded_nodes: &[LoadedNode]) -> bool { - loaded_nodes +fn has_default_time_bus(projected_nodes: &[ProjectedNode]) -> bool { + projected_nodes .iter() .any(|node| node.provides_default_time_bus) } @@ -1318,33 +1557,34 @@ fn is_time_seconds_bus_target(target: &AuthoredBindingRef) -> bool { } fn binding_source_endpoint( - loaded_nodes: &[LoadedNode], - current: &LoadedNode, + projected_nodes: &[ProjectedNode], + current: &ProjectedNode, endpoint: AuthoredBindingSource<'_>, ) -> Result { match endpoint { AuthoredBindingSource::Value(value) => Ok(BindingSource::Literal(value.clone())), AuthoredBindingSource::Ref(binding_ref) => { - binding_ref_source(loaded_nodes, current, binding_ref) + binding_ref_source(projected_nodes, current, binding_ref) } } } fn binding_ref_source( - loaded_nodes: &[LoadedNode], - current: &LoadedNode, + projected_nodes: &[ProjectedNode], + current: &ProjectedNode, binding_ref: &AuthoredBindingRef, ) -> Result { match binding_ref { - AuthoredBindingRef::Unset => Err(ProjectLoadError::InvalidSourcePath { - path: current.artifact_path.as_str().to_string(), + AuthoredBindingRef::Unset => Err(ProjectLoadError::InvalidProjectReference { + path: node_label(current), reason: String::from("binding source cannot be unset"), }), AuthoredBindingRef::Bus(bus) => Ok(BindingSource::BusChannel(ChannelName( bus.slot().to_string(), ))), AuthoredBindingRef::Node(node_slot) => { - let node = resolve_node_loc(loaded_nodes, current, node_slot.node(), "binding source")?; + let node = + resolve_node_loc(projected_nodes, current, node_slot.node(), "binding source")?; Ok(BindingSource::ProducedSlot { node: node.id, slot: node_slot.slot().clone(), @@ -1354,20 +1594,21 @@ fn binding_ref_source( } fn binding_target_endpoint( - loaded_nodes: &[LoadedNode], - current: &LoadedNode, + projected_nodes: &[ProjectedNode], + current: &ProjectedNode, endpoint: &AuthoredBindingRef, ) -> Result { match endpoint { - AuthoredBindingRef::Unset => Err(ProjectLoadError::InvalidSourcePath { - path: current.artifact_path.as_str().to_string(), + AuthoredBindingRef::Unset => Err(ProjectLoadError::InvalidProjectReference { + path: node_label(current), reason: String::from("binding target cannot be unset"), }), AuthoredBindingRef::Bus(bus) => Ok(BindingTarget::BusChannel(ChannelName( bus.slot().to_string(), ))), AuthoredBindingRef::Node(node_slot) => { - let node = resolve_node_loc(loaded_nodes, current, node_slot.node(), "binding target")?; + let node = + resolve_node_loc(projected_nodes, current, node_slot.node(), "binding target")?; Ok(BindingTarget::ConsumedSlot { node: node.id, slot: node_slot.slot().clone(), @@ -1376,21 +1617,6 @@ fn binding_target_endpoint( } } -fn read_utf8_file(root: &R, path: &LpPath) -> Result -where - R: ArtifactReadRoot + ?Sized, - R::Err: core::fmt::Debug, -{ - let data = root.read_file(path).map_err(|e| ProjectLoadError::Io { - path: path.as_str().to_string(), - details: format!("{e:?}"), - })?; - String::from_utf8(data).map_err(|e| ProjectLoadError::InvalidSourcePath { - path: path.as_str().to_string(), - reason: format!("shader source is not UTF-8: {e}"), - }) -} - #[cfg(test)] mod tests { extern crate std; @@ -1399,11 +1625,13 @@ mod tests { use alloc::rc::Rc; use alloc::sync::Arc; - use lpc_model::{NodeName, ProductRef, SlotData, SlotMapKey, TreePath}; - use lpc_shared::hardware::{ - HardwareAddress, HardwareRegistry, HardwareSystem, VirtualButtonDriver, VirtualRadioDriver, + use lpc_hardware::{ + HardwareSystem, HwAddress, HwRegistry, VirtualButtonDriver, VirtualRadioDriver, default_esp32c6_hardware_manifest, }; + use lpc_model::{ + ArtifactLocation, NodeDefLocation, NodeName, ProductRef, SlotData, SlotMapKey, TreePath, + }; use lpc_shared::time::TimeProvider; use lpc_wire::{ ProjectProbeRequest, ProjectProbeResult, ProjectReadRequest, ProjectReadResult, @@ -1416,9 +1644,17 @@ mod tests { use super::*; use crate::dataflow::binding::{BindingPriority, BindingSource, BindingTarget}; use crate::dataflow::resolver::{Production, QueryKey, ResolveLogLevel}; - use crate::engine::{ButtonService, RadioService, resolve_with_engine_host}; + use crate::engine::{ButtonService, RadioService}; use crate::products::visual::RenderTextureRequest; + fn node_for_def_path(rt: &Engine, path: &str) -> Option { + let location = NodeDefLocation::artifact_root(ArtifactLocation::file(path)); + rt.project_runtime_index() + .runtime_nodes_for_def(&location) + .first() + .copied() + } + fn flat_project() -> LpFsMemory { let fs = LpFsMemory::new(); write_flat_basic_files(&fs); @@ -1433,7 +1669,7 @@ mod tests { kind = "Project" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" "#, ) .expect("project.toml"); @@ -1475,7 +1711,7 @@ sample_diameter = 2.0 let services = EngineServices::new(TreePath::parse("/svg_fixture.show").expect("path")); let rt = ProjectLoader::load_from_root(&fs, services).expect("load svg fixture project"); - assert!(rt.artifact_node_id(LpPath::new("/fixture.toml")).is_some()); + assert!(node_for_def_path(&rt, "/fixture.toml").is_some()); } #[test] @@ -1490,14 +1726,8 @@ sample_diameter = 2.0 ); let services = EngineServices::new(TreePath::parse("/svg_fixture.show").expect("path")); - let err = match ProjectLoader::load_from_root(&fs, services) { - Ok(_) => panic!("expected ungrouped mapping text error"), - Err(err) => err, - }; - assert!( - err.to_string().contains("not inside a valid group"), - "{err}" - ); + let rt = ProjectLoader::load_from_root(&fs, services).expect("load with bad fixture"); + assert_fixture_node_error(&rt, "not inside a valid group"); } #[test] @@ -1511,14 +1741,25 @@ sample_diameter = 2.0 ); let services = EngineServices::new(TreePath::parse("/svg_fixture.show").expect("path")); - let err = match ProjectLoader::load_from_root(&fs, services) { - Ok(_) => panic!("expected curve command error"), - Err(err) => err, - }; - assert!( - err.to_string().contains("unsupported SVG path command"), - "{err}" - ); + let rt = ProjectLoader::load_from_root(&fs, services).expect("load with bad fixture"); + assert_fixture_node_error(&rt, "unsupported SVG path command"); + } + + fn assert_fixture_node_error(rt: &Engine, expected: &str) { + assert_node_for_def_error(rt, "/fixture.toml", expected); + } + + fn assert_node_for_def_error(rt: &Engine, path: &str, expected: &str) { + let node = node_for_def_path(rt, path).expect("runtime node"); + let entry = rt.tree().get(node).expect("runtime entry"); + assert!(matches!( + entry.status.value(), + NodeRuntimeStatus::Error(message) if message.contains(expected) + )); + assert!(matches!( + entry.state.value(), + NodeEntryState::Failed { reason } if reason.contains(expected) + )); } fn playlist_project_fs() -> LpFsMemory { @@ -1529,7 +1770,7 @@ sample_diameter = 2.0 kind = "Project" [nodes.playlist] -def = { path = "./playlist.toml" } +ref = "./playlist.toml" "#, ) .expect("project.toml"); @@ -1541,12 +1782,12 @@ default_fade = 0.35 [entries.1] name = "idle" -node = { def = { path = "./idle.toml" } } +node = { ref = "./idle.toml" } [entries.2] name = "active" duration = 4.0 -node = { def = { path = "./active.toml" } } +node = { ref = "./active.toml" } [entries.2.bindings.trigger] source = "bus#trigger" @@ -1598,13 +1839,13 @@ default = 0.0 kind = "Project" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.button] -def = { path = "./button.toml" } +ref = "./button.toml" [nodes.playlist] -def = { path = "./playlist.toml" } +ref = "./playlist.toml" "#, ) .expect("project.toml"); @@ -1633,12 +1874,12 @@ source = "bus#time.seconds" [entries.1] name = "idle" -node = { def = { path = "./idle.toml" } } +node = { ref = "./idle.toml" } [entries.2] name = "active" duration = 4.0 -node = { def = { path = "./active.toml" } } +node = { ref = "./active.toml" } [entries.2.bindings.trigger] source = "bus#trigger" @@ -1705,10 +1946,7 @@ source = "bus#trigger" .lookup_sibling(root, NodeName::parse("fixture").unwrap()) .expect("fixture id"); - assert_eq!( - rt.artifact_node_id(LpPath::new("/texture.toml")), - Some(tex_id) - ); + assert_eq!(node_for_def_path(&rt, "/texture.toml"), Some(tex_id)); for id in [tex_id, sh_id, out_id, fix_id] { let entry = rt.tree().get(id).expect("entry"); @@ -1755,10 +1993,10 @@ source = "bus#trigger" kind = "Project" [nodes.clock] -def = { path = "./clock.toml" } +ref = "./clock.toml" [nodes.shader] -def = { path = "./shader.toml" } +ref = "./shader.toml" "#, ) .expect("project.toml"); @@ -1809,54 +2047,54 @@ default = 0.0 .expect("shader node"); rt.tick(1000).expect("first tick"); - let first = resolve_with_engine_host( - &mut rt, - QueryKey::Bus(ChannelName(String::from("time.seconds"))), - ResolveLogLevel::Off, - ) - .expect("resolve time bus") - .0; + let first = rt + .resolve_with_engine_host( + QueryKey::Bus(ChannelName(String::from("time.seconds"))), + ResolveLogLevel::Off, + ) + .expect("resolve time bus") + .0; assert_eq!( *first.value_leaf().expect("time value").value(), LpValue::F32(0.0) ); - let shader_first = resolve_with_engine_host( - &mut rt, - QueryKey::ConsumedSlot { - node: shader, - slot: SlotPath::parse("time").expect("time slot"), - }, - ResolveLogLevel::Off, - ) - .expect("resolve visual shader time") - .0; + let shader_first = rt + .resolve_with_engine_host( + QueryKey::ConsumedSlot { + node: shader, + slot: SlotPath::parse("time").expect("time slot"), + }, + ResolveLogLevel::Off, + ) + .expect("resolve visual shader time") + .0; assert_eq!( *shader_first.value_leaf().expect("time value").value(), LpValue::F32(0.0) ); rt.tick(1000).expect("second tick"); - let second = resolve_with_engine_host( - &mut rt, - QueryKey::Bus(ChannelName(String::from("time.seconds"))), - ResolveLogLevel::Off, - ) - .expect("resolve time bus") - .0; + let second = rt + .resolve_with_engine_host( + QueryKey::Bus(ChannelName(String::from("time.seconds"))), + ResolveLogLevel::Off, + ) + .expect("resolve time bus") + .0; assert_eq!( *second.value_leaf().expect("time value").value(), LpValue::F32(1.0) ); - let shader_second = resolve_with_engine_host( - &mut rt, - QueryKey::ConsumedSlot { - node: shader, - slot: SlotPath::parse("time").expect("time slot"), - }, - ResolveLogLevel::Off, - ) - .expect("resolve visual shader time") - .0; + let shader_second = rt + .resolve_with_engine_host( + QueryKey::ConsumedSlot { + node: shader, + slot: SlotPath::parse("time").expect("time slot"), + }, + ResolveLogLevel::Off, + ) + .expect("resolve visual shader time") + .0; assert_eq!( *shader_second.value_leaf().expect("time value").value(), LpValue::F32(1.0) @@ -1888,16 +2126,16 @@ source = { glsl = "vec4 render(vec2 pos) { return vec4(1.0, 0.0, 0.0, 1.0); }" } .expect("shader node"); rt.tick(16).expect("tick"); - let production = resolve_with_engine_host( - &mut rt, - QueryKey::ProducedSlot { - node: shader, - slot: crate::nodes::shader_output_path(), - }, - ResolveLogLevel::Off, - ) - .expect("resolve shader output") - .0; + let production = rt + .resolve_with_engine_host( + QueryKey::ProducedSlot { + node: shader, + slot: crate::nodes::shader_output_path(), + }, + ResolveLogLevel::Off, + ) + .expect("resolve shader output") + .0; let LpValue::Product(ProductRef::Visual(product)) = production.value_leaf().expect("visual product").value() else { @@ -1934,7 +2172,7 @@ source = { glsl = "vec4 render(vec2 pos) { return vec4(1.0, 0.0, 0.0, 1.0); }" } kind = "Project" [nodes.shader] -def = { path = "./shader.toml" } +ref = "./shader.toml" "#, ) .expect("project.toml"); @@ -1954,9 +2192,7 @@ source = { path = "shader.glsl" } let services = EngineServices::new(TreePath::parse("/default_visual.show").expect("path")); let rt = ProjectLoader::load_from_root(&fs, services).expect("load"); - let shader = rt - .artifact_node_id(LpPath::new("/shader.toml")) - .expect("shader node"); + let shader = node_for_def_path(&rt, "/shader.toml").expect("shader node"); assert!(rt.tree().bindings().any(|binding| { matches!( @@ -2011,12 +2247,8 @@ order = "inner_first" let services = EngineServices::new(TreePath::parse("/sibling_ref.show").expect("path")); let rt = ProjectLoader::load_from_root(&fs, services).expect("load"); - let texture = rt - .artifact_node_id(LpPath::new("/texture.toml")) - .expect("texture node"); - let fixture = rt - .artifact_node_id(LpPath::new("/fixture.toml")) - .expect("fixture node"); + let texture = node_for_def_path(&rt, "/texture.toml").expect("texture node"); + let fixture = node_for_def_path(&rt, "/fixture.toml").expect("fixture node"); assert!(rt.tree().bindings().any(|binding| { matches!( @@ -2102,7 +2334,7 @@ order = "inner_first" #[test] fn playlist_entry_trigger_restarts_active_entry_and_returns_idle() { let fs = button_playlist_project_fs(); - let registry = Rc::new(HardwareRegistry::new(default_esp32c6_hardware_manifest())); + let registry = Rc::new(HwRegistry::new(default_esp32c6_hardware_manifest())); let driver = VirtualButtonDriver::new(Rc::clone(®istry)); let control = driver.clone(); let mut hardware = HardwareSystem::new(registry); @@ -2123,7 +2355,7 @@ order = "inner_first" -1.0 ); - control.set_pressed(HardwareAddress::gpio(20), true); + control.set_pressed(HwAddress::gpio(20), true); assert_eq!(resolve_playlist_u32(&mut rt, playlist, "active_entry"), 1); assert_eq!(resolve_playlist_u32(&mut rt, playlist, "active_entry"), 2); assert_eq!(resolve_playlist_f32(&mut rt, playlist, "entry_time"), 0.0); @@ -2137,10 +2369,10 @@ order = "inner_first" assert!(resolve_playlist_f32(&mut rt, playlist, "entry_time") >= 1.0); assert!(resolve_playlist_f32(&mut rt, playlist, "entry_progress") >= 0.25); - control.set_pressed(HardwareAddress::gpio(20), false); + control.set_pressed(HwAddress::gpio(20), false); let _ = resolve_playlist_u32(&mut rt, playlist, "active_entry"); let _ = resolve_playlist_u32(&mut rt, playlist, "active_entry"); - control.set_pressed(HardwareAddress::gpio(20), true); + control.set_pressed(HwAddress::gpio(20), true); let _ = resolve_playlist_u32(&mut rt, playlist, "active_entry"); let _ = resolve_playlist_u32(&mut rt, playlist, "active_entry"); assert_eq!(resolve_playlist_u32(&mut rt, playlist, "active_entry"), 2); @@ -2159,7 +2391,7 @@ order = "inner_first" } #[test] - fn malformed_node_toml_returns_error() { + fn malformed_child_node_toml_projects_error_node() { let fs = LpFsMemory::new(); fs.write_file( "/project.toml".as_path(), @@ -2167,7 +2399,7 @@ order = "inner_first" kind = "Project" [nodes.broken] -def = { path = "./broken.toml" } +ref = "./broken.toml" "#, ) .expect("project.toml"); @@ -2176,14 +2408,9 @@ def = { path = "./broken.toml" } 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::TomlParse { .. }), - "expected TomlParse, got {err:?}" - ); + let rt = ProjectLoader::load_from_root(&fs, services).expect("load project"); + + assert_node_for_def_error(&rt, "/broken.toml", "parse error"); } #[test] @@ -2202,7 +2429,7 @@ def = { path = "./broken.toml" } } #[test] - fn unknown_child_kind_returns_toml_parse_error() { + fn unknown_child_kind_projects_error_node() { let fs = LpFsMemory::new(); fs.write_file( "/project.toml".as_path(), @@ -2210,7 +2437,7 @@ def = { path = "./broken.toml" } kind = "Project" [nodes.weird] -def = { path = "./weird.toml" } +ref = "./weird.toml" "#, ) .expect("project.toml"); @@ -2219,14 +2446,9 @@ def = { path = "./weird.toml" } 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::UnknownKind { .. }), - "expected UnknownKind, got {err:?}" - ); + let rt = ProjectLoader::load_from_root(&fs, services).expect("load project"); + + assert_node_for_def_error(&rt, "/weird.toml", "unknown node kind"); } #[test] @@ -2275,7 +2497,7 @@ order = "inner_first" assert!( matches!( err, - ProjectLoadError::InvalidSourcePath { ref reason, .. } + ProjectLoadError::InvalidProjectReference { ref reason, .. } if reason.contains("unknown binding source node ref `..missing`") ), "expected missing binding source ref, got {err:?}" @@ -2283,7 +2505,7 @@ order = "inner_first" } #[test] - fn slash_node_ref_is_rejected_during_parse() { + fn slash_node_ref_projects_error_node() { let fs = flat_project(); fs.write_file( "/fixture.toml".as_path(), @@ -2321,18 +2543,9 @@ order = "inner_first" 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::TomlParse { ref error, .. } - if error.contains("node locations use dot syntax") - ), - "expected invalid slash node ref parse error, got {err:?}" - ); + let rt = ProjectLoader::load_from_root(&fs, services).expect("load project"); + + assert_node_for_def_error(&rt, "/fixture.toml", "node locations use dot syntax"); } #[test] @@ -2344,7 +2557,7 @@ order = "inner_first" kind = "Project" [nodes.compute] -def = { path = "./compute.toml" } +ref = "./compute.toml" "#, ) .expect("project.toml"); @@ -2375,20 +2588,18 @@ value = "f32" let services = EngineServices::new(root_path); let mut rt = ProjectLoader::load_from_root(&fs, services).expect("load"); rt.set_graphics(Some(Arc::new(crate::Graphics::new()))); - let node = rt - .artifact_node_id(LpPath::new("/compute.toml")) - .expect("compute node"); + let node = node_for_def_path(&rt, "/compute.toml").expect("compute node"); - let production = resolve_with_engine_host( - &mut rt, - QueryKey::ProducedSlot { - node, - slot: SlotPath::parse("phase").expect("phase"), - }, - ResolveLogLevel::Off, - ) - .expect("resolve phase") - .0; + let production = rt + .resolve_with_engine_host( + QueryKey::ProducedSlot { + node, + slot: SlotPath::parse("phase").expect("phase"), + }, + ResolveLogLevel::Off, + ) + .expect("resolve phase") + .0; assert_eq!( *production.value_leaf().expect("value").value(), @@ -2426,30 +2637,30 @@ value = "f32" assert!(rt.tree().get(id).expect("entry").state.value().is_alive()); } - let (emitters, _) = resolve_with_engine_host( - &mut rt, - QueryKey::ProducedSlot { - node: compute, - slot: SlotPath::parse("emitters").expect("emitters"), - }, - ResolveLogLevel::Off, - ) - .expect("compute emitters"); + let (emitters, _) = rt + .resolve_with_engine_host( + QueryKey::ProducedSlot { + node: compute, + slot: SlotPath::parse("emitters").expect("emitters"), + }, + ResolveLogLevel::Off, + ) + .expect("compute emitters"); let SlotData::Map(map) = emitters.data() else { panic!("compute emitters should be a map"); }; assert!(!map.entries.is_empty()); rt.tick(16).expect("tick fluid graph"); - let (fluid_output, _) = resolve_with_engine_host( - &mut rt, - QueryKey::ProducedSlot { - node: fluid, - slot: SlotPath::parse("output").expect("output"), - }, - ResolveLogLevel::Off, - ) - .expect("fluid output"); + let (fluid_output, _) = rt + .resolve_with_engine_host( + QueryKey::ProducedSlot { + node: fluid, + slot: SlotPath::parse("output").expect("output"), + }, + ResolveLogLevel::Off, + ) + .expect("fluid output"); let LpValue::Product(ProductRef::Visual(product)) = fluid_output.value_leaf().expect("visual product").value() else { @@ -2486,7 +2697,6 @@ value = "f32" format: WireTextureFormat::Srgb8, }, )], - mutations: alloc::vec::Vec::new(), }); let Some(ProjectProbeResult::RenderProduct(RenderProductProbeResult::Texture { format, @@ -2524,6 +2734,13 @@ value = "f32" ); } + // Previously quarantined as a "render/JIT data race" because it rendered black + // under heavy parallel load (`just ci`). Root cause: "black" is a *swallowed + // shader-compile failure* — `ShaderNode::ensure_compiled` fills the target with + // zeros and returns Ok when compilation fails (shader_node.rs), and a compile + // panic is caught and funneled to the same fallback. The brightness assertion + // below now surfaces any such compile/runtime error instead of an opaque black + // frame, so a future flake reports the real cranelift/frontend message. #[test] fn events_example_merges_bus_maps_into_visual_shader() { let fs = examples_events_fs(); @@ -2539,26 +2756,26 @@ value = "f32" .expect("shader node"); rt.tick(16).expect("tick trigger graph"); - let (shader_output, _) = resolve_with_engine_host( - &mut rt, - QueryKey::ProducedSlot { - node: shader, - slot: SlotPath::parse("output").expect("output"), - }, - ResolveLogLevel::Off, - ) - .expect("shader output"); + let (shader_output, _) = rt + .resolve_with_engine_host( + QueryKey::ProducedSlot { + node: shader, + slot: SlotPath::parse("output").expect("output"), + }, + ResolveLogLevel::Off, + ) + .expect("shader output"); let LpValue::Product(ProductRef::Visual(product)) = shader_output.value_leaf().expect("visual product").value() else { panic!("shader output should be a visual product"); }; let first = render_test_texture_bytes(&mut rt, *product); - assert_bright_event_pixels(&first); + assert_bright_event_pixels(&mut rt, &first); rt.tick(500).expect("advance trigger graph"); let second = render_test_texture_bytes(&mut rt, *product); - assert_bright_event_pixels(&second); + assert_bright_event_pixels(&mut rt, &second); assert_ne!( first, second, "event example should blink as scheduled events fire and clear" @@ -2569,7 +2786,7 @@ value = "f32" fn button_playlist_example_renders_idle_and_active_after_press() { let fs = examples_button_playlist_fs(); let fs: &dyn LpFs = &fs; - let registry = Rc::new(HardwareRegistry::new(default_esp32c6_hardware_manifest())); + let registry = Rc::new(HwRegistry::new(default_esp32c6_hardware_manifest())); let driver = VirtualButtonDriver::new(Rc::clone(®istry)); let control = driver.clone(); let mut hardware = HardwareSystem::new(registry); @@ -2602,7 +2819,7 @@ value = "f32" let idle = render_test_texture_bytes(&mut rt, idle_product); assert_nonzero_rgb(&idle, "idle playlist visual"); - control.set_pressed(HardwareAddress::gpio(20), true); + control.set_pressed(HwAddress::gpio(20), true); tick_with_test_time(&mut rt, &time, 16, "press candidate"); tick_with_test_time(&mut rt, &time, 30, "press stable"); assert_eq!(resolve_playlist_u32(&mut rt, playlist, "active_entry"), 2); @@ -2620,10 +2837,10 @@ value = "f32" assert!(resolve_playlist_f32(&mut rt, playlist, "entry_time") >= 1.0); assert!(resolve_playlist_f32(&mut rt, playlist, "entry_progress") >= 0.25); - control.set_pressed(HardwareAddress::gpio(20), false); + control.set_pressed(HwAddress::gpio(20), false); tick_with_test_time(&mut rt, &time, 16, "release candidate"); tick_with_test_time(&mut rt, &time, 30, "release stable"); - control.set_pressed(HardwareAddress::gpio(20), true); + control.set_pressed(HwAddress::gpio(20), true); tick_with_test_time(&mut rt, &time, 16, "second press candidate"); tick_with_test_time(&mut rt, &time, 30, "second press stable"); assert_eq!(resolve_playlist_u32(&mut rt, playlist, "active_entry"), 2); @@ -2694,7 +2911,7 @@ value = "f32" fn button_sign_example_ticks_without_radio_trigger_cycle() { let fs = examples_button_sign_fs(); let fs: &dyn LpFs = &fs; - let registry = Rc::new(HardwareRegistry::new(default_esp32c6_hardware_manifest())); + let registry = Rc::new(HwRegistry::new(default_esp32c6_hardware_manifest())); let hardware = Rc::new(HardwareSystem::with_virtual_drivers(registry)); let button_service: Rc = hardware.clone(); let radio_service: Rc = hardware.clone(); @@ -2712,7 +2929,7 @@ value = "f32" fn fyeah_sign_example_ticks_without_radio_trigger_cycle() { let fs = examples_fyeah_sign_fs(); let fs: &dyn LpFs = &fs; - let registry = Rc::new(HardwareRegistry::new(default_esp32c6_hardware_manifest())); + let registry = Rc::new(HwRegistry::new(default_esp32c6_hardware_manifest())); let hardware = Rc::new(HardwareSystem::with_virtual_drivers(registry)); let button_service: Rc = hardware.clone(); let radio_service: Rc = hardware.clone(); @@ -2730,7 +2947,7 @@ value = "f32" fn fyeah_button_example_ticks_without_radio_trigger_cycle() { let fs = examples_fyeah_button_fs(); let fs: &dyn LpFs = &fs; - let registry = Rc::new(HardwareRegistry::new(default_esp32c6_hardware_manifest())); + let registry = Rc::new(HwRegistry::new(default_esp32c6_hardware_manifest())); let hardware = Rc::new(HardwareSystem::with_virtual_drivers(registry)); let button_service: Rc = hardware.clone(); let radio_service: Rc = hardware.clone(); @@ -2755,7 +2972,7 @@ value = "f32" kind = "Project" [nodes.button] -def = { path = "./button.toml" } +ref = "./button.toml" "#, ) .expect("project"); @@ -2769,7 +2986,7 @@ stable_ms = 1 ) .expect("button"); - let registry = Rc::new(HardwareRegistry::new(default_esp32c6_hardware_manifest())); + let registry = Rc::new(HwRegistry::new(default_esp32c6_hardware_manifest())); let driver = VirtualButtonDriver::new(Rc::clone(®istry)); let control = driver.clone(); let mut hardware = HardwareSystem::new(registry); @@ -2786,7 +3003,7 @@ stable_ms = 1 .lookup_sibling(root, NodeName::parse("button").unwrap()) .expect("button node"); - control.set_pressed(HardwareAddress::gpio(20), true); + control.set_pressed(HwAddress::gpio(20), true); let held = resolve_button_map(&mut rt, button, "held"); assert!(!held.entries.contains_key(&SlotMapKey::U32(1))); @@ -2794,7 +3011,7 @@ stable_ms = 1 let held = resolve_button_map(&mut rt, button, "held"); assert!(held.entries.contains_key(&SlotMapKey::U32(1))); - control.set_pressed(HardwareAddress::gpio(20), false); + control.set_pressed(HwAddress::gpio(20), false); rt.tick(1).expect("release candidate frame"); assert!( resolve_button_map(&mut rt, button, "held") @@ -2818,10 +3035,10 @@ stable_ms = 1 kind = "Project" [nodes.button] -def = { path = "./button.toml" } +ref = "./button.toml" [nodes.radio] -def = { path = "./radio.toml" } +ref = "./radio.toml" "#, ) .expect("project"); @@ -2854,7 +3071,7 @@ target = "bus#trigger" ) .expect("radio"); - let registry = Rc::new(HardwareRegistry::new(default_esp32c6_hardware_manifest())); + let registry = Rc::new(HwRegistry::new(default_esp32c6_hardware_manifest())); let button_driver = VirtualButtonDriver::new(Rc::clone(®istry)); let button_control = button_driver.clone(); let radio_driver = VirtualRadioDriver::new(Rc::clone(®istry), 0); @@ -2876,7 +3093,7 @@ target = "bus#trigger" .lookup_sibling(root, NodeName::parse("radio").unwrap()) .expect("radio node"); - button_control.set_pressed(HardwareAddress::gpio(20), true); + button_control.set_pressed(HwAddress::gpio(20), true); let first = resolve_node_map(&mut rt, radio, "output", "radio output"); assert!(first.entries.is_empty()); @@ -2889,12 +3106,15 @@ target = "bus#trigger" assert_eq!(sent.len(), 1); assert_eq!( sent[0].kind(), - lpc_shared::hardware::RadioMessageKind::ControlMessage + lpc_hardware::RadioMessageKind::ControlMessage ); assert_eq!(sent[0].payload(), &[1, 0, 0, 0, 1, 0, 0, 0]); } - fn render_test_texture_bytes(rt: &mut Engine, product: lpc_model::VisualProduct) -> Vec { + fn render_test_texture_bytes( + rt: &mut LoadedProjectRuntime, + product: lpc_model::VisualProduct, + ) -> Vec { rt.render_texture_for_test( product, &RenderTextureRequest { @@ -2919,7 +3139,7 @@ target = "bus#trigger" ); } - fn assert_bright_event_pixels(bytes: &[u8]) { + fn assert_bright_event_pixels(rt: &mut LoadedProjectRuntime, bytes: &[u8]) { let max_rgb = bytes .chunks_exact(8) .flat_map(|px| { @@ -2932,31 +3152,57 @@ target = "bus#trigger" .max() .unwrap_or(0); - assert!( - max_rgb > 10_000, - "trigger event circles should render bright RGB pixels" - ); + if max_rgb <= 10_000 { + // A black/dim frame here means a shader compile failed and was swallowed + // into a zero-filled fallback render. Surface the real error(s) so the + // failure is diagnosable instead of an opaque "not bright" assertion. + let errors = collect_node_compile_errors(rt); + panic!( + "trigger event circles should render bright RGB pixels, but max_rgb={max_rgb} \ + (a shader likely failed to compile and rendered a black fallback). \ + Node compile/runtime errors: {errors:?}" + ); + } } - fn resolve_button_map(rt: &mut Engine, button: NodeId, slot: &str) -> lpc_model::SlotMapDyn { + /// Collect compile/runtime errors the engine otherwise hides behind a black + /// fallback render. Compute shaders (`event_a`/`event_b`) compile during tick, + /// but the visual shader compiles lazily at render time, so refresh node + /// statuses with a zero-delta tick before reading them. + fn collect_node_compile_errors(rt: &mut LoadedProjectRuntime) -> Vec { + let _ = rt.tick(0); + rt.tree() + .entries() + .filter_map(|entry| match entry.status.value() { + NodeRuntimeStatus::Error(message) => Some(format!("{:?}: {message}", entry.path)), + _ => None, + }) + .collect() + } + + fn resolve_button_map( + rt: &mut LoadedProjectRuntime, + button: NodeId, + slot: &str, + ) -> lpc_model::SlotMapDyn { resolve_node_map(rt, button, slot, "button slot") } fn resolve_node_map( - rt: &mut Engine, + rt: &mut LoadedProjectRuntime, node: NodeId, slot: &str, label: &str, ) -> lpc_model::SlotMapDyn { - let (production, _) = resolve_with_engine_host( - rt, - QueryKey::ProducedSlot { - node, - slot: SlotPath::parse(slot).expect("map slot"), - }, - ResolveLogLevel::Off, - ) - .expect("map production"); + let (production, _) = rt + .resolve_with_engine_host( + QueryKey::ProducedSlot { + node, + slot: SlotPath::parse(slot).expect("map slot"), + }, + ResolveLogLevel::Off, + ) + .expect("map production"); let SlotData::Map(map) = production.data().clone() else { panic!("{label} should be a map"); }; @@ -2964,7 +3210,7 @@ target = "bus#trigger" } fn resolve_visual_product( - rt: &mut Engine, + rt: &mut LoadedProjectRuntime, node: NodeId, slot: &str, ) -> lpc_model::VisualProduct { @@ -2977,7 +3223,7 @@ target = "bus#trigger" *product } - fn resolve_playlist_u32(rt: &mut Engine, playlist: NodeId, slot: &str) -> u32 { + fn resolve_playlist_u32(rt: &mut LoadedProjectRuntime, playlist: NodeId, slot: &str) -> u32 { let production = resolve_playlist_slot(rt, playlist, slot); let LpValue::U32(value) = production.value_leaf().expect("playlist value").value() else { panic!("playlist slot {slot} should be u32"); @@ -2985,7 +3231,7 @@ target = "bus#trigger" *value } - fn resolve_playlist_f32(rt: &mut Engine, playlist: NodeId, slot: &str) -> f32 { + fn resolve_playlist_f32(rt: &mut LoadedProjectRuntime, playlist: NodeId, slot: &str) -> f32 { let production = resolve_playlist_slot(rt, playlist, slot); let LpValue::F32(value) = production.value_leaf().expect("playlist value").value() else { panic!("playlist slot {slot} should be f32"); @@ -2993,9 +3239,12 @@ target = "bus#trigger" *value } - fn resolve_playlist_slot(rt: &mut Engine, playlist: NodeId, slot: &str) -> Production { - resolve_with_engine_host( - rt, + fn resolve_playlist_slot( + rt: &mut LoadedProjectRuntime, + playlist: NodeId, + slot: &str, + ) -> Production { + rt.resolve_with_engine_host( QueryKey::ProducedSlot { node: playlist, slot: SlotPath::parse(slot).expect("playlist slot"), @@ -3028,7 +3277,12 @@ target = "bus#trigger" } } - fn tick_with_test_time(rt: &mut Engine, time: &TestTimeProvider, delta_ms: u32, label: &str) { + fn tick_with_test_time( + rt: &mut LoadedProjectRuntime, + time: &TestTimeProvider, + delta_ms: u32, + label: &str, + ) { time.advance(u64::from(delta_ms)); rt.tick(delta_ms) .unwrap_or_else(|err| panic!("{label}: {err}")); @@ -3042,16 +3296,16 @@ kind = "Project" name = "basic" [nodes.output] -def = { path = "./output.toml" } +ref = "./output.toml" [nodes.texture] -def = { path = "./texture.toml" } +ref = "./texture.toml" [nodes.shader] -def = { path = "./shader.toml" } +ref = "./shader.toml" [nodes.fixture] -def = { path = "./fixture.toml" } +ref = "./fixture.toml" "#, ) .expect("project.toml"); diff --git a/lp-core/lpc-engine/src/engine/project_read.rs b/lp-core/lpc-engine/src/engine/project_read.rs index 1f32618cc..0f089a8a7 100644 --- a/lp-core/lpc-engine/src/engine/project_read.rs +++ b/lp-core/lpc-engine/src/engine/project_read.rs @@ -1,5 +1,6 @@ //! Stateless project read builder for [`Engine`]. +use lpc_registry::ProjectRegistry; use lpc_wire::{ ProjectProbeRequest, ProjectProbeResult, ProjectReadQuery, ProjectReadRequest, ProjectReadResponse, ProjectReadResult, @@ -9,8 +10,11 @@ use super::Engine; impl Engine { /// Answer one stateless project read request from the current engine state. - pub fn read_project(&mut self, request: ProjectReadRequest) -> ProjectReadResponse { - let mutations = self.mutate_project_slots(request.mutations); + pub fn read_project( + &mut self, + registry: &ProjectRegistry, + request: ProjectReadRequest, + ) -> ProjectReadResponse { let revision = self.revision(); let results = request .queries @@ -19,9 +23,9 @@ impl Engine { ProjectReadQuery::Shapes(query) => { ProjectReadResult::Shapes(self.read_project_shapes(query)) } - ProjectReadQuery::Nodes(query) => { - ProjectReadResult::Nodes(self.read_project_nodes(request.since, query)) - } + ProjectReadQuery::Nodes(query) => ProjectReadResult::Nodes( + self.read_project_nodes(registry, request.since, query), + ), ProjectReadQuery::Resources(query) => { ProjectReadResult::Resources(self.read_project_resources(query)) } @@ -35,7 +39,7 @@ impl Engine { .into_iter() .map(|probe| match probe { ProjectProbeRequest::RenderProduct(request) => ProjectProbeResult::RenderProduct( - self.read_project_render_product_probe(request), + self.read_project_render_product_probe(registry, request), ), ProjectProbeRequest::ExplainSlot(request) => { ProjectProbeResult::ExplainSlot(self.read_project_explain_slot_probe(request)) @@ -47,7 +51,6 @@ impl Engine { revision, results, probes, - mutations, } } } @@ -64,12 +67,13 @@ mod tests { #[test] fn default_debug_read_returns_shapes_nodes_and_resource_summaries() { let mut engine = Engine::new(TreePath::parse("/basic.project").unwrap()); + let registry = lpc_registry::ProjectRegistry::new(); engine.runtime_buffers_mut().insert(WithRevision::new( Revision::new(1), RuntimeBuffer::output_channels_u16(3, alloc::vec![0, 1, 2, 3, 4, 5]), )); - let response = engine.read_project(ProjectReadRequest::default_debug(None)); + let response = engine.read_project(®istry, ProjectReadRequest::default_debug(None)); assert_eq!(response.results.len(), 4); assert!(matches!(response.results[0], ProjectReadResult::Shapes(_))); @@ -85,6 +89,7 @@ mod tests { #[test] fn default_debug_shape_read_is_complete_without_limit() { let mut engine = Engine::new(TreePath::parse("/basic.project").unwrap()); + let registry = lpc_registry::ProjectRegistry::new(); let dynamic_ids = (0..70) .map(|index| SlotShapeId::new(0x7000_0000 + index)) .collect::>(); @@ -95,7 +100,7 @@ mod tests { .expect("dynamic test shape"); } - let response = engine.read_project(ProjectReadRequest::default_debug(None)); + let response = engine.read_project(®istry, ProjectReadRequest::default_debug(None)); let ProjectReadResult::Shapes(shapes) = &response.results[0] else { panic!("first result should be shapes"); @@ -117,7 +122,7 @@ mod tests { let response = h .engine - .read_project(ProjectReadRequest::default_debug(None)); + .read_project(&h.registry, ProjectReadRequest::default_debug(None)); let ProjectReadResult::Nodes(nodes) = &response.results[1] else { panic!("second result should be nodes"); @@ -135,13 +140,14 @@ mod tests { #[test] fn resource_summary_reports_owning_node() { let mut engine = Engine::new(TreePath::parse("/basic.project").unwrap()); + let registry = lpc_registry::ProjectRegistry::new(); let owner = lpc_model::NodeId::new(7); let buffer_id = engine.runtime_buffers_mut().insert_owned( owner, WithRevision::new(Revision::new(1), RuntimeBuffer::raw(alloc::vec![1])), ); - let response = engine.read_project(ProjectReadRequest::default_debug(None)); + let response = engine.read_project(®istry, ProjectReadRequest::default_debug(None)); let ProjectReadResult::Resources(resources) = &response.results[2] else { panic!("third result should be resources"); @@ -159,6 +165,7 @@ mod tests { #[test] fn resource_payload_read_all_includes_buffer_bytes() { let mut engine = Engine::new(TreePath::parse("/basic.project").unwrap()); + let registry = lpc_registry::ProjectRegistry::new(); engine.runtime_buffers_mut().insert(WithRevision::new( Revision::new(1), RuntimeBuffer::raw(alloc::vec![1, 2, 3]), @@ -169,7 +176,7 @@ mod tests { level: lpc_wire::ReadLevel::Detail, payloads: ResourcePayloadRead::All, }); - let response = engine.read_project(request); + let response = engine.read_project(®istry, request); let ProjectReadResult::Resources(resources) = &response.results[2] else { panic!("third result should be resources"); diff --git a/lp-core/lpc-engine/src/engine/project_read_nodes.rs b/lp-core/lpc-engine/src/engine/project_read_nodes.rs index 087eb7adc..8cff70b12 100644 --- a/lp-core/lpc-engine/src/engine/project_read_nodes.rs +++ b/lp-core/lpc-engine/src/engine/project_read_nodes.rs @@ -5,12 +5,12 @@ use alloc::string::String; use alloc::vec::Vec; use lpc_model::{NodeId, SlotAccess}; +use lpc_registry::ProjectRegistry; use lpc_wire::{ NodeReadQuery, NodeReadResult, ReadLevel, WireSlotRootSnapshot, WireSlotRootsSnapshot, wire_slot_data_from_slot_access, }; -use crate::artifact::ArtifactState; use crate::node::{NodeEntryState, tree_deltas_since}; use super::Engine; @@ -18,6 +18,7 @@ use super::Engine; impl Engine { pub(super) fn read_project_nodes( &self, + registry: &ProjectRegistry, since: Option, query: NodeReadQuery, ) -> NodeReadResult { @@ -28,7 +29,7 @@ impl Engine { } }; let slots = if query.include_slots && query.level == ReadLevel::Detail { - Some(self.snapshot_node_slots()) + Some(self.snapshot_node_slots(registry)) } else { None }; @@ -40,11 +41,11 @@ impl Engine { } } - fn snapshot_node_slots(&self) -> WireSlotRootsSnapshot { + fn snapshot_node_slots(&self, registry: &ProjectRegistry) -> WireSlotRootsSnapshot { let mut roots = Vec::new(); for entry in self.tree().entries() { - if let Some(def) = self.loaded_node_def(entry.artifact()) { + if let Some(def) = self.loaded_node_def_for_entry(registry, entry) { roots.push(WireSlotRootSnapshot { name: node_def_root_name(entry.id), shape: def.shape_id(), @@ -73,22 +74,6 @@ impl Engine { WireSlotRootsSnapshot { roots } } - - pub(super) fn loaded_node_def( - &self, - artifact: crate::artifact::ArtifactId, - ) -> Option<&lpc_model::NodeDef> { - let entry = self.artifacts().entry(&artifact)?; - match &entry.state { - ArtifactState::Loaded(def) - | ArtifactState::Prepared(def) - | ArtifactState::Idle(def) => Some(def), - ArtifactState::Resolved - | ArtifactState::ResolutionError(_) - | ArtifactState::LoadError(_) - | ArtifactState::PrepareError(_) => None, - } - } } pub(super) fn node_def_root_name(id: NodeId) -> String { 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 5dba82b61..3d77c36a9 100644 --- a/lp-core/lpc-engine/src/engine/project_read_probes.rs +++ b/lp-core/lpc-engine/src/engine/project_read_probes.rs @@ -2,6 +2,7 @@ use alloc::format; +use lpc_registry::ProjectRegistry; use lpc_wire::{ ExplainSlotProbeRequest, ExplainSlotProbeResult, RenderProductProbeRequest, RenderProductProbeResult, SlotExplanation, @@ -15,6 +16,7 @@ use super::Engine; impl Engine { pub(super) fn read_project_render_product_probe( &mut self, + registry: &ProjectRegistry, request: RenderProductProbeRequest, ) -> RenderProductProbeResult { let texture_request = RenderTextureRequest { @@ -25,7 +27,7 @@ impl Engine { }; let revision = self.revision(); let product = request.product; - match self.render_texture_product(product, &texture_request) { + match self.render_texture_product(registry, product, &texture_request) { Ok(texture) => { let Some(bytes) = texture.try_raw_bytes() else { return RenderProductProbeResult::Error { 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 81c1c9dda..1f0bc8787 100644 --- a/lp-core/lpc-engine/src/engine/project_read_stream.rs +++ b/lp-core/lpc-engine/src/engine/project_read_stream.rs @@ -5,13 +5,13 @@ use lpc_model::{ slot_codec::{SlotWriteError, SlotWriter}, slot_sync_codec::write_slot_snapshot_value, }; +use lpc_registry::ProjectRegistry; use lpc_wire::json::json_write::JsonWrite; use lpc_wire::json::json_writer::{JsonValue, JsonWriter, JsonWriterError}; use lpc_wire::{ NodeReadQuery, ProjectProbeRequest, ProjectProbeResult, ProjectReadQuery, ProjectReadRequest, ProjectReadResult, RuntimeReadQuery, ServerRuntimeStatus, ShapeReadQuery, - WireSlotMutationRequest, WireSlotMutationResponse, write_project_read_result_json, - write_slot_shape_registry_snapshot_json, + write_project_read_result_json, write_slot_shape_registry_snapshot_json, }; use crate::node::{NodeEntryState, tree_deltas_since}; @@ -29,26 +29,53 @@ impl Engine { /// runtime-buffer payload fields. pub fn write_project_read_json( &mut self, + registry: &ProjectRegistry, request: ProjectReadRequest, out: W, ) -> Result> where W: JsonWrite, { - lpc_shared::transport::ProjectReadJsonSource::write_project_read_json(self, request, out) + let mut source = EngineProjectReadSource::new(self, registry); + lpc_shared::transport::ProjectReadJsonSource::write_project_read_json( + &mut source, + request, + out, + ) } } -impl lpc_shared::transport::ProjectReadJsonSource for Engine { - fn project_read_revision(&self) -> lpc_model::Revision { - self.revision() +pub struct EngineProjectReadSource<'a> { + engine: &'a mut Engine, + registry: &'a ProjectRegistry, + server_status: Option, +} + +impl<'a> EngineProjectReadSource<'a> { + pub fn new(engine: &'a mut Engine, registry: &'a ProjectRegistry) -> Self { + Self { + engine, + registry, + server_status: None, + } } - fn apply_project_mutations( - &mut self, - mutations: alloc::vec::Vec, - ) -> alloc::vec::Vec { - self.mutate_project_slots(mutations) + pub fn with_server_status( + engine: &'a mut Engine, + registry: &'a ProjectRegistry, + server_status: Option, + ) -> Self { + Self { + engine, + registry, + server_status, + } + } +} + +impl lpc_shared::transport::ProjectReadJsonSource for EngineProjectReadSource<'_> { + fn project_read_revision(&self) -> lpc_model::Revision { + self.engine.revision() } fn write_project_read_result_json( @@ -62,20 +89,29 @@ impl lpc_shared::transport::ProjectReadJsonSource for Engine { { match query { ProjectReadQuery::Shapes(query) => { - return self.write_project_shape_read_result_json(query, out); + return self.engine.write_project_shape_read_result_json(query, out); } ProjectReadQuery::Nodes(query) => { - return self.write_project_node_read_result_json(since, query, out); + return self.engine.write_project_node_read_result_json( + self.registry, + since, + query, + out, + ); } ProjectReadQuery::Runtime(query) => { - return self.write_project_runtime_read_result_json(query, None, out); + return self.engine.write_project_runtime_read_result_json( + query, + self.server_status.clone(), + out, + ); } ProjectReadQuery::Resources(_) => {} } let result = match query { ProjectReadQuery::Resources(query) => { - ProjectReadResult::Resources(self.read_project_resources(query)) + ProjectReadResult::Resources(self.engine.read_project_resources(query)) } ProjectReadQuery::Shapes(_) | ProjectReadQuery::Nodes(_) @@ -97,12 +133,13 @@ impl lpc_shared::transport::ProjectReadJsonSource for Engine { W: JsonWrite, { let result = match probe { - ProjectProbeRequest::RenderProduct(request) => { - ProjectProbeResult::RenderProduct(self.read_project_render_product_probe(request)) - } - ProjectProbeRequest::ExplainSlot(request) => { - ProjectProbeResult::ExplainSlot(self.read_project_explain_slot_probe(request)) - } + ProjectProbeRequest::RenderProduct(request) => ProjectProbeResult::RenderProduct( + self.engine + .read_project_render_product_probe(self.registry, request), + ), + ProjectProbeRequest::ExplainSlot(request) => ProjectProbeResult::ExplainSlot( + self.engine.read_project_explain_slot_probe(request), + ), }; let mut writer = JsonWriter::new(out); writer.serde(&result)?; @@ -159,6 +196,7 @@ impl Engine { fn write_project_node_read_result_json( &mut self, + registry: &ProjectRegistry, since: Option, query: NodeReadQuery, out: W, @@ -181,7 +219,7 @@ impl Engine { let mut slots = nodes.prop("slots")?.object()?; let mut roots = slots.prop("roots")?.array()?; for entry in self.tree().entries() { - if let Some(def) = self.loaded_node_def(entry.artifact()) { + if let Some(def) = self.loaded_node_def_for_entry(registry, entry) { let mut root = roots.item()?.object()?; root.prop("name")?.string(&node_def_root_name(entry.id))?; root.prop("shape")?.serde(&def.shape_id())?; @@ -255,7 +293,7 @@ mod tests { let mut h = EngineTestBuilder::new().output_node("output").build(); let request = ProjectReadRequest::default_debug(None); - assert_streams_to_full_response(&mut h.engine, request); + assert_streams_to_full_response(&mut h.engine, &h.registry, request); } #[test] @@ -265,13 +303,14 @@ mod tests { Revision::new(1), RuntimeBuffer::raw(vec![1, 2, 3, 253, 254, 255]), )); + let registry = ProjectRegistry::new(); let mut request = ProjectReadRequest::default_debug(None); request.queries[2] = ProjectReadQuery::Resources(ResourceReadQuery { level: lpc_wire::ReadLevel::Detail, payloads: ResourcePayloadRead::All, }); - assert_streams_to_full_response(&mut engine, request); + assert_streams_to_full_response(&mut engine, ®istry, request); } #[test] @@ -281,7 +320,7 @@ mod tests { .build(); let request = ProjectReadRequest::default_debug(None); - assert_detailed_slot_roots_read_through_sync_codec(&mut h.engine, request); + assert_detailed_slot_roots_read_through_sync_codec(&mut h.engine, &h.registry, request); } #[test] @@ -292,7 +331,7 @@ mod tests { let request = ProjectReadRequest::default_debug(None); let streamed = h .engine - .write_project_read_json(request, Vec::new()) + .write_project_read_json(&h.registry, request, Vec::new()) .expect("stream project read"); let decoded: ProjectReadResponse = lpc_wire::json::from_slice(&streamed).expect("decode streamed project read"); @@ -316,9 +355,11 @@ mod tests { Revision::new(1), RuntimeBuffer::raw(vec![1, 2, 3, 253, 254, 255]), )); + let registry = ProjectRegistry::new(); let out = engine .write_project_read_json( + ®istry, ProjectReadRequest::default_debug(None), ChunkCountingWrite::new(16), ) @@ -329,10 +370,14 @@ mod tests { assert!(out.chunk_count() > 1); } - fn assert_streams_to_full_response(engine: &mut Engine, request: ProjectReadRequest) { - let full = engine.read_project(request.clone()); + fn assert_streams_to_full_response( + engine: &mut Engine, + registry: &ProjectRegistry, + request: ProjectReadRequest, + ) { + let full = engine.read_project(registry, request.clone()); let streamed = engine - .write_project_read_json(request, Vec::new()) + .write_project_read_json(registry, request, Vec::new()) .expect("stream project read"); let decoded: ProjectReadResponse = lpc_wire::json::from_slice(&streamed).expect("decode streamed project read"); @@ -354,10 +399,11 @@ mod tests { fn assert_detailed_slot_roots_read_through_sync_codec( engine: &mut Engine, + registry: &ProjectRegistry, request: ProjectReadRequest, ) { let streamed = engine - .write_project_read_json(request, Vec::new()) + .write_project_read_json(registry, request, Vec::new()) .expect("stream project read"); let decoded: ProjectReadResponse = lpc_wire::json::from_slice(&streamed).expect("decode project read"); diff --git a/lp-core/lpc-engine/src/engine/project_runtime_index.rs b/lp-core/lpc-engine/src/engine/project_runtime_index.rs new file mode 100644 index 000000000..6e0046071 --- /dev/null +++ b/lp-core/lpc-engine/src/engine/project_runtime_index.rs @@ -0,0 +1,185 @@ +//! Projection index between project node uses and runtime node ids. + +use alloc::vec::Vec; +use lp_collection::VecMap; + +use lpc_model::{AssetLocation, NodeDefLocation, NodeId, NodeUseLocation, ProjectTree}; + +/// Engine-local lookup table for the current registry-to-runtime projection. +/// +/// `ProjectRegistry` owns project identity and effective inventory. The engine +/// owns compact runtime [`NodeId`] handles. This index connects those layers +/// without making either identity pretend to be the other. +#[derive(Debug, Default)] +pub struct ProjectRuntimeIndex { + node_to_runtime: VecMap, + runtime_to_node: VecMap, + def_to_runtime: VecMap>, + asset_to_runtime: VecMap>, +} + +impl ProjectRuntimeIndex { + pub fn new() -> Self { + Self::default() + } + + pub fn insert_node( + &mut self, + use_location: NodeUseLocation, + node_id: NodeId, + def_location: NodeDefLocation, + ) { + self.node_to_runtime.insert(use_location.clone(), node_id); + self.runtime_to_node.insert(node_id, use_location); + self.def_to_runtime + .entry(def_location) + .or_default() + .push(node_id); + } + + pub fn add_asset_consumer(&mut self, source: AssetLocation, node_id: NodeId) { + self.asset_to_runtime + .entry(source) + .or_default() + .push(node_id); + } + + pub fn rebuild_asset_consumers(&mut self, tree: &ProjectTree) { + self.asset_to_runtime.clear(); + for (source, consumers) in &tree.asset_consumers { + for consumer in consumers { + if let Some(node_id) = self.node_id(consumer) { + self.add_asset_consumer(source.clone(), node_id); + } + } + } + } + + pub fn remove_runtime_node(&mut self, node_id: NodeId) { + if let Some(use_location) = self.runtime_to_node.remove(&node_id) { + self.node_to_runtime.remove(&use_location); + } + remove_node_from_index(&mut self.def_to_runtime, node_id); + remove_node_from_index(&mut self.asset_to_runtime, node_id); + } + + pub fn node_id(&self, use_location: &NodeUseLocation) -> Option { + self.node_to_runtime.get(use_location).copied() + } + + pub fn use_location(&self, node_id: NodeId) -> Option<&NodeUseLocation> { + self.runtime_to_node.get(&node_id) + } + + pub fn runtime_nodes_for_def(&self, location: &NodeDefLocation) -> &[NodeId] { + self.def_to_runtime + .get(location) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + pub fn runtime_nodes_for_asset(&self, source: &AssetLocation) -> &[NodeId] { + self.asset_to_runtime + .get(source) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + pub fn clear(&mut self) { + self.node_to_runtime.clear(); + self.runtime_to_node.clear(); + self.def_to_runtime.clear(); + self.asset_to_runtime.clear(); + } +} + +fn remove_node_from_index(index: &mut VecMap>, node_id: NodeId) { + index.retain(|_, nodes| { + nodes.retain(|&candidate| candidate != node_id); + !nodes.is_empty() + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use lpc_model::{ArtifactLocation, SlotPath}; + + fn def(path: &str) -> NodeDefLocation { + NodeDefLocation::artifact_root(ArtifactLocation::file(path)) + } + + #[test] + fn indexes_nodes_in_both_directions() { + let mut index = ProjectRuntimeIndex::new(); + let use_location = NodeUseLocation::root().child(SlotPath::parse("nodes[shader]").unwrap()); + let node_id = NodeId::new(7); + + index.insert_node(use_location.clone(), node_id, def("/shader.toml")); + + assert_eq!(index.node_id(&use_location), Some(node_id)); + assert_eq!(index.use_location(node_id), Some(&use_location)); + } + + #[test] + fn definition_and_asset_indexes_allow_multiple_runtime_nodes() { + let mut index = ProjectRuntimeIndex::new(); + let def_location = def("/shared.toml"); + let asset = AssetLocation::artifact(ArtifactLocation::file("/shader.glsl")); + + index.insert_node( + NodeUseLocation::root(), + NodeId::new(1), + def_location.clone(), + ); + index.insert_node( + NodeUseLocation::root().child(SlotPath::parse("nodes[copy]").unwrap()), + NodeId::new(2), + def_location.clone(), + ); + index.add_asset_consumer(asset.clone(), NodeId::new(1)); + index.add_asset_consumer(asset.clone(), NodeId::new(2)); + + assert_eq!( + index.runtime_nodes_for_def(&def_location), + &[NodeId::new(1), NodeId::new(2)] + ); + assert_eq!( + index.runtime_nodes_for_asset(&asset), + &[NodeId::new(1), NodeId::new(2)] + ); + } + + #[test] + fn clear_empties_all_indexes() { + let mut index = ProjectRuntimeIndex::new(); + let use_location = NodeUseLocation::root(); + let def_location = def("/project.toml"); + let asset = AssetLocation::artifact(ArtifactLocation::file("/shader.glsl")); + + index.insert_node(use_location.clone(), NodeId::new(0), def_location.clone()); + index.add_asset_consumer(asset.clone(), NodeId::new(0)); + index.clear(); + + assert_eq!(index.node_id(&use_location), None); + assert!(index.runtime_nodes_for_def(&def_location).is_empty()); + assert!(index.runtime_nodes_for_asset(&asset).is_empty()); + } + + #[test] + fn remove_runtime_node_prunes_all_indexes() { + let mut index = ProjectRuntimeIndex::new(); + let use_location = NodeUseLocation::root(); + let def_location = def("/project.toml"); + let asset = AssetLocation::artifact(ArtifactLocation::file("/shader.glsl")); + let node = NodeId::new(3); + + index.insert_node(use_location.clone(), node, def_location.clone()); + index.add_asset_consumer(asset.clone(), node); + index.remove_runtime_node(node); + + assert_eq!(index.node_id(&use_location), None); + assert!(index.runtime_nodes_for_def(&def_location).is_empty()); + assert!(index.runtime_nodes_for_asset(&asset).is_empty()); + } +} diff --git a/lp-core/lpc-engine/src/engine/slot_mutation.rs b/lp-core/lpc-engine/src/engine/slot_mutation.rs deleted file mode 100644 index 8200cb8af..000000000 --- a/lp-core/lpc-engine/src/engine/slot_mutation.rs +++ /dev/null @@ -1,662 +0,0 @@ -//! Project slot mutation handling. - -use alloc::string::ToString; -use alloc::vec::Vec; -use lpc_model::{ - LpType, LpValue, NodeDef, NodeId, SlotAccess, SlotDataAccess, SlotDataAccessMut, SlotPath, - SlotPathSegment, SlotPolicy, SlotShapeLookup, SlotShapeRegistry, SlotShapeView, - lookup_slot_data_mut, -}; -use lpc_wire::{ - WireSlotMutationOp, WireSlotMutationRejection, WireSlotMutationRequest, - WireSlotMutationResponse, WireSlotMutationResult, -}; - -use crate::artifact::ArtifactState; - -use super::Engine; - -impl Engine { - pub fn mutate_project_slots( - &mut self, - requests: Vec, - ) -> Vec { - requests - .into_iter() - .map(|request| { - let id = request.id; - let result = self.mutate_project_slot(request); - WireSlotMutationResponse { id, result } - }) - .collect() - } - - fn mutate_project_slot(&mut self, request: WireSlotMutationRequest) -> WireSlotMutationResult { - match self.try_mutate_project_slot(request) { - Ok(()) => WireSlotMutationResult::Accepted, - Err(rejection) => WireSlotMutationResult::Rejected(rejection), - } - } - - fn try_mutate_project_slot( - &mut self, - request: WireSlotMutationRequest, - ) -> Result<(), WireSlotMutationRejection> { - let node_id = match parse_node_root(&request.root) { - Some(ParsedNodeRoot::Def(node_id)) => node_id, - Some(ParsedNodeRoot::State) => { - return Err(WireSlotMutationRejection::UnsupportedTarget); - } - None => return Err(WireSlotMutationRejection::UnknownRoot), - }; - let artifact = self - .tree() - .get(node_id) - .ok_or(WireSlotMutationRejection::UnknownRoot)? - .artifact(); - - let target_info = { - let def = self - .loaded_node_def(artifact) - .ok_or(WireSlotMutationRejection::UnknownRoot)?; - mutation_target_info(def, self.slot_shapes(), &request.path)? - }; - - if !target_info.writable { - return Err(WireSlotMutationRejection::UnsupportedTarget); - } - - let WireSlotMutationOp::SetValue(value) = request.op; - if !lp_value_matches_type(&value, &target_info.ty) { - return Err(WireSlotMutationRejection::WrongType); - } - - let registry = self.slot_shapes().clone(); - let revision = lpc_model::advance_revision(); - self.set_revision(revision); - let def = self - .loaded_node_def_mut(artifact) - .ok_or(WireSlotMutationRejection::UnknownRoot)?; - let SlotDataAccessMut::Value(slot) = - lookup_slot_data_mut(def, ®istry, &request.path).map_err(map_lookup_error)? - else { - return Err(WireSlotMutationRejection::UnsupportedTarget); - }; - slot.set_lp_value(revision, value) - .map_err(|_| WireSlotMutationRejection::WrongType)?; - - Ok(()) - } - - fn loaded_node_def_mut( - &mut self, - artifact: crate::artifact::ArtifactId, - ) -> Option<&mut NodeDef> { - let revision = self.revision(); - let entry = self.artifacts_mut().entry_mut(&artifact)?; - entry.content_frame = revision; - match &mut entry.state { - ArtifactState::Loaded(def) - | ArtifactState::Prepared(def) - | ArtifactState::Idle(def) => Some(def), - ArtifactState::Resolved - | ArtifactState::ResolutionError(_) - | ArtifactState::LoadError(_) - | ArtifactState::PrepareError(_) => None, - } - } -} - -struct MutationTargetInfo { - ty: LpType, - writable: bool, -} - -enum ParsedNodeRoot { - Def(NodeId), - State, -} - -fn parse_node_root(root: &str) -> Option { - let inner = root.strip_prefix("node.")?; - if let Some(inner) = inner.strip_suffix(".def") { - return inner - .parse::() - .ok() - .map(NodeId::new) - .map(ParsedNodeRoot::Def); - } - if let Some(inner) = inner.strip_suffix(".state") { - return inner.parse::().ok().map(|_| ParsedNodeRoot::State); - } - None -} - -fn mutation_target_info( - def: &NodeDef, - registry: &SlotShapeRegistry, - path: &SlotPath, -) -> Result { - let shape_id = def.shape_id(); - let shape = SlotShapeLookup::get_shape(registry, shape_id) - .ok_or(WireSlotMutationRejection::UnknownRoot)?; - let target = resolve_mutation_target_info( - def.data(), - shape, - registry, - path.segments(), - SlotPolicy::default(), - )?; - Ok(MutationTargetInfo { - ty: target.ty, - writable: target.writable, - }) -} - -struct ResolvedMutationTargetInfo { - ty: LpType, - writable: bool, -} - -fn resolve_mutation_target_info( - data: SlotDataAccess<'_>, - shape: SlotShapeView<'_>, - registry: &SlotShapeRegistry, - segments: &[SlotPathSegment], - inherited_policy: SlotPolicy, -) -> Result { - let shape = resolve_shape_ref(shape, registry)?; - - let Some((head, tail)) = segments.split_first() else { - return match (shape.value_shape(), data) { - (Some(shape), SlotDataAccess::Value(_value)) => Ok(ResolvedMutationTargetInfo { - ty: shape.ty_owned(), - writable: inherited_policy.writable, - }), - _ => Err(WireSlotMutationRejection::UnsupportedTarget), - }; - }; - - match (data, head) { - (SlotDataAccess::Record(record), SlotPathSegment::Field(name)) => { - let (index, field) = shape - .record_field_by_name(name) - .ok_or(WireSlotMutationRejection::UnknownPath)?; - let field_data = record - .field(index) - .ok_or(WireSlotMutationRejection::UnknownPath)?; - resolve_mutation_target_info(field_data, field.shape(), registry, tail, field.policy()) - } - (SlotDataAccess::Map(map), SlotPathSegment::Key(key)) => { - let value_shape = shape - .map_value() - .ok_or(WireSlotMutationRejection::UnknownPath)?; - let field_data = map.get(key).ok_or(WireSlotMutationRejection::UnknownPath)?; - resolve_mutation_target_info(field_data, value_shape, registry, tail, inherited_policy) - } - (SlotDataAccess::Option(option), SlotPathSegment::Field(name)) - if name.as_str() == "some" => - { - let some_shape = shape - .option_some() - .ok_or(WireSlotMutationRejection::UnknownPath)?; - let field_data = option - .data() - .ok_or(WireSlotMutationRejection::UnknownPath)?; - resolve_mutation_target_info(field_data, some_shape, registry, tail, inherited_policy) - } - (SlotDataAccess::Enum(en), SlotPathSegment::Field(name)) => { - if en.variant() != name.as_str() { - return Err(WireSlotMutationRejection::UnknownPath); - } - let variant = shape - .enum_variant_by_name(name) - .ok_or(WireSlotMutationRejection::UnknownPath)?; - resolve_mutation_target_info( - en.data(), - variant.shape(), - registry, - tail, - inherited_policy, - ) - } - _ => Err(WireSlotMutationRejection::UnknownPath), - } -} - -fn resolve_shape_ref<'a>( - mut shape: SlotShapeView<'a>, - registry: &'a SlotShapeRegistry, -) -> Result, WireSlotMutationRejection> { - while let Some(id) = shape.ref_id() { - shape = SlotShapeLookup::get_shape(registry, id) - .ok_or(WireSlotMutationRejection::UnknownPath)?; - } - Ok(shape) -} - -fn map_lookup_error(error: lpc_model::SlotLookupError) -> WireSlotMutationRejection { - let message = error.to_string(); - if message.contains("unknown") || message.contains("no field") || message.contains("no key") { - WireSlotMutationRejection::UnknownPath - } else { - WireSlotMutationRejection::UnsupportedTarget - } -} - -fn lp_value_matches_type(value: &LpValue, ty: &LpType) -> bool { - match (value, ty) { - (LpValue::String(_), LpType::String) - | (LpValue::I32(_), LpType::I32) - | (LpValue::U32(_), LpType::U32) - | (LpValue::F32(_), LpType::F32) - | (LpValue::Bool(_), LpType::Bool) - | (LpValue::Vec2(_), LpType::Vec2) - | (LpValue::Vec3(_), LpType::Vec3) - | (LpValue::Vec4(_), LpType::Vec4) - | (LpValue::IVec2(_), LpType::IVec2) - | (LpValue::IVec3(_), LpType::IVec3) - | (LpValue::IVec4(_), LpType::IVec4) - | (LpValue::UVec2(_), LpType::UVec2) - | (LpValue::UVec3(_), LpType::UVec3) - | (LpValue::UVec4(_), LpType::UVec4) - | (LpValue::BVec2(_), LpType::BVec2) - | (LpValue::BVec3(_), LpType::BVec3) - | (LpValue::BVec4(_), LpType::BVec4) - | (LpValue::Mat2x2(_), LpType::Mat2x2) - | (LpValue::Mat3x3(_), LpType::Mat3x3) - | (LpValue::Mat4x4(_), LpType::Mat4x4) - | (LpValue::Resource(_), LpType::Resource) - | (LpValue::Product(_), LpType::Product(_)) => true, - ( - LpValue::Struct { fields, .. }, - LpType::Struct { - fields: expected, .. - }, - ) => fields.len() == expected.len(), - (LpValue::Array(values), LpType::Array(item_ty, len)) => { - values.len() == *len - && values - .iter() - .all(|value| lp_value_matches_type(value, item_ty)) - } - (LpValue::Array(values), LpType::List(item_ty)) => values - .iter() - .all(|value| lp_value_matches_type(value, item_ty)), - _ => false, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloc::string::{String, ToString}; - use lpc_model::{ - AsLpPath, ControlExtent, ControlProduct, FixtureDiagnosticMode, NodeName, Revision, - ToLpValue, TreePath, - }; - use lpc_wire::WireSlotMutationId; - use lpfs::{LpFs, LpFsMemory}; - - use crate::engine::{EngineServices, ProjectLoader}; - use crate::products::control::{ - ControlRenderRequest, ControlRenderTarget, ControlSampleFormat, - }; - - #[test] - fn accepted_clock_mutation_changes_loaded_def() { - let fs = clock_project(); - let services = EngineServices::new(TreePath::parse("/clock.show").unwrap()); - let mut engine = ProjectLoader::load_from_root(&fs, services).unwrap(); - let clock = node_id(&engine, "clock"); - let root = alloc::format!("node.{}.def", clock.0); - let request = mutation_request(&engine, &root, "controls.running", LpValue::Bool(false)); - - let responses = engine.mutate_project_slots(Vec::from([request])); - - assert!(matches!( - responses[0].result, - WireSlotMutationResult::Accepted - )); - let def = engine - .loaded_node_def(engine.tree().get(clock).unwrap().artifact()) - .unwrap(); - let NodeDef::Clock(def) = def else { - panic!("clock def"); - }; - assert!(!*def.controls.running.value()); - } - - #[test] - fn accepted_output_mutation_changes_loaded_def() { - let fs = output_project(); - let services = EngineServices::new(TreePath::parse("/output.show").unwrap()); - let mut engine = ProjectLoader::load_from_root(&fs, services).unwrap(); - let output = node_id(&engine, "output"); - let root = alloc::format!("node.{}.def", output.0); - let request = mutation_request( - &engine, - &root, - "options.some.brightness", - LpValue::F32(0.75), - ); - - let responses = engine.mutate_project_slots(Vec::from([request])); - - assert!(matches!( - responses[0].result, - WireSlotMutationResult::Accepted - )); - let def = engine - .loaded_node_def(engine.tree().get(output).unwrap().artifact()) - .unwrap(); - let NodeDef::Output(def) = def else { - panic!("output def"); - }; - let options = def.options().expect("output options"); - assert!((options.brightness.value().0 - 0.75).abs() < 0.001); - } - - #[test] - fn accepted_fixture_diagnostic_mutation_changes_loaded_def() { - let fs = fixture_project(); - let services = EngineServices::new(TreePath::parse("/fixture.show").unwrap()); - let mut engine = ProjectLoader::load_from_root(&fs, services).unwrap(); - let fixture = node_id(&engine, "fixture"); - let root = alloc::format!("node.{}.def", fixture.0); - let request = mutation_request( - &engine, - &root, - "diagnostic_mode", - FixtureDiagnosticMode::LedIndex.to_lp_value(), - ); - - let responses = engine.mutate_project_slots(Vec::from([request])); - - assert!(matches!( - responses[0].result, - WireSlotMutationResult::Accepted - )); - let def = engine - .loaded_node_def(engine.tree().get(fixture).unwrap().artifact()) - .unwrap(); - let NodeDef::Fixture(def) = def else { - panic!("fixture def"); - }; - assert_eq!( - *def.diagnostic_mode.value(), - FixtureDiagnosticMode::LedIndex - ); - } - - #[test] - fn mutated_fixture_diagnostic_mode_affects_rendered_control_output() { - let fs = fixture_project(); - let services = EngineServices::new(TreePath::parse("/fixture.show").unwrap()); - let mut engine = ProjectLoader::load_from_root(&fs, services).unwrap(); - let fixture = node_id(&engine, "fixture"); - let root = alloc::format!("node.{}.def", fixture.0); - let request = mutation_request( - &engine, - &root, - "diagnostic_mode", - FixtureDiagnosticMode::LedIndex.to_lp_value(), - ); - - let responses = engine.mutate_project_slots(Vec::from([request])); - - assert!(matches!( - responses[0].result, - WireSlotMutationResult::Accepted - )); - engine.add_demand_root(fixture); - engine.tick(10).unwrap(); - - let extent = ControlExtent::new(1, 6); - let request = ControlRenderRequest::unorm16(extent); - let mut samples = alloc::vec![0u16; extent.sample_count() as usize]; - let target = ControlRenderTarget::new(extent, ControlSampleFormat::Unorm16, &mut samples); - engine - .render_control_for_test(ControlProduct::new(fixture, 0, extent), &request, target) - .expect("control render"); - - assert_eq!(samples, alloc::vec![65535, 0, 0, 0, 65535, 0]); - } - - #[test] - fn stale_mutation_versions_are_accepted() { - let fs = clock_project(); - let services = EngineServices::new(TreePath::parse("/clock.show").unwrap()); - let mut engine = ProjectLoader::load_from_root(&fs, services).unwrap(); - let clock = node_id(&engine, "clock"); - let root = alloc::format!("node.{}.def", clock.0); - let mut request = mutation_request(&engine, &root, "controls.rate", LpValue::F32(2.0)); - request.expected_shape_version = Revision::new(999); - request.expected_data_version = Revision::new(999); - - let responses = engine.mutate_project_slots(Vec::from([request])); - - assert!(matches!( - responses[0].result, - WireSlotMutationResult::Accepted - )); - let def = engine - .loaded_node_def(engine.tree().get(clock).unwrap().artifact()) - .unwrap(); - let NodeDef::Clock(def) = def else { - panic!("clock def"); - }; - assert!((*def.controls.rate.value() - 2.0).abs() < 0.001); - } - - #[test] - fn wrong_type_mutation_is_rejected() { - let fs = output_project(); - let services = EngineServices::new(TreePath::parse("/output.show").unwrap()); - let mut engine = ProjectLoader::load_from_root(&fs, services).unwrap(); - let output = node_id(&engine, "output"); - let root = alloc::format!("node.{}.def", output.0); - let request = mutation_request(&engine, &root, "endpoint", LpValue::Bool(false)); - - let responses = engine.mutate_project_slots(Vec::from([request])); - - assert!(matches!( - responses[0].result, - WireSlotMutationResult::Rejected(WireSlotMutationRejection::WrongType) - )); - } - - #[test] - fn accepted_binding_mutation_changes_loaded_def() { - let fs = output_project(); - let services = EngineServices::new(TreePath::parse("/output.show").unwrap()); - let mut engine = ProjectLoader::load_from_root(&fs, services).unwrap(); - let output = node_id(&engine, "output"); - let root = alloc::format!("node.{}.def", output.0); - let request = mutation_request( - &engine, - &root, - "bindings[input].source.some", - LpValue::String(String::from("bus#control.next")), - ); - - let responses = engine.mutate_project_slots(Vec::from([request])); - - assert!(matches!( - responses[0].result, - WireSlotMutationResult::Accepted - )); - let def = engine - .loaded_node_def(engine.tree().get(output).unwrap().artifact()) - .unwrap(); - let NodeDef::Output(def) = def else { - panic!("output def"); - }; - let binding = def.bindings.entries().get("input").expect("input binding"); - assert_eq!( - binding - .source - .data - .as_ref() - .map(|source| source.value().to_string()), - Some(String::from("bus#control.next")) - ); - assert!(binding.target.is_none()); - } - - #[test] - fn state_root_mutation_is_rejected() { - let fs = clock_project(); - let services = EngineServices::new(TreePath::parse("/clock.show").unwrap()); - let mut engine = ProjectLoader::load_from_root(&fs, services).unwrap(); - let clock = node_id(&engine, "clock"); - let root = alloc::format!("node.{}.state", clock.0); - let request = WireSlotMutationRequest { - id: WireSlotMutationId::new(1), - root, - path: SlotPath::root(), - expected_shape_version: Revision::default(), - expected_data_version: Revision::default(), - op: WireSlotMutationOp::SetValue(LpValue::F32(0.0)), - }; - - let responses = engine.mutate_project_slots(Vec::from([request])); - - assert!(matches!( - responses[0].result, - WireSlotMutationResult::Rejected(WireSlotMutationRejection::UnsupportedTarget) - )); - } - - fn mutation_request( - engine: &Engine, - root: &str, - path: &str, - value: LpValue, - ) -> WireSlotMutationRequest { - let ParsedNodeRoot::Def(node_id) = parse_node_root(root).expect("def root") else { - panic!("expected def root"); - }; - let def = engine - .loaded_node_def(engine.tree().get(node_id).unwrap().artifact()) - .unwrap(); - let path = SlotPath::parse(path).unwrap(); - mutation_target_info(def, engine.slot_shapes(), &path).unwrap(); - WireSlotMutationRequest { - id: WireSlotMutationId::new(1), - root: String::from(root), - path, - expected_shape_version: Revision::default(), - expected_data_version: Revision::default(), - op: WireSlotMutationOp::SetValue(value), - } - } - - fn clock_project() -> LpFsMemory { - let fs = LpFsMemory::new(); - fs.write_file( - "/project.toml".as_path(), - br#" -kind = "Project" - -[nodes.clock] -def = { path = "./clock.toml" } -"#, - ) - .unwrap(); - fs.write_file( - "/clock.toml".as_path(), - br#"kind = "Clock" -"#, - ) - .unwrap(); - fs - } - - fn output_project() -> LpFsMemory { - let fs = LpFsMemory::new(); - fs.write_file( - "/project.toml".as_path(), - br#" -kind = "Project" - -[nodes.output] -def = { path = "./output.toml" } -"#, - ) - .unwrap(); - fs.write_file( - "/output.toml".as_path(), - br#" -kind = "Output" -endpoint = "ws281x:rmt:D10" - -[bindings.input] -source = "bus#control.out" - -[options] -brightness = 0.25 -"#, - ) - .unwrap(); - fs - } - - fn fixture_project() -> LpFsMemory { - let fs = LpFsMemory::new(); - fs.write_file( - "/project.toml".as_path(), - br#" -kind = "Project" - -[nodes.fixture] -def = { path = "./fixture.toml" } -"#, - ) - .unwrap(); - fs.write_file( - "/fixture.toml".as_path(), - br#" -kind = "Fixture" -color_order = "rgb" -brightness = 255 -gamma_correction = false -transform = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] - -[bindings.input] -source = "bus#visual.out" - -[bindings.output] -target = "bus#control.out" - -[mapping] -kind = "PathPoints" -sample_diameter = 2.0 - -[mapping.paths.0] -kind = "RingArray" -center = [0.5, 0.5] -diameter = 1.0 -start_ring_inclusive = 0 -end_ring_exclusive = 1 -offset_angle = 0.0 -order = "inner_first" - -[mapping.paths.0.ring_lamp_counts] -0 = 2 -"#, - ) - .unwrap(); - fs - } - - fn node_id(engine: &Engine, name: &str) -> NodeId { - engine - .tree() - .lookup_sibling( - engine.tree().root(), - NodeName::parse(name).expect("node name"), - ) - .expect("node") - } -} diff --git a/lp-core/lpc-engine/src/engine/test_support.rs b/lp-core/lpc-engine/src/engine/test_support.rs index fcb7aa38f..9bef57ad0 100644 --- a/lp-core/lpc-engine/src/engine/test_support.rs +++ b/lp-core/lpc-engine/src/engine/test_support.rs @@ -1,14 +1,15 @@ use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::String; use alloc::sync::Arc; use core::sync::atomic::{AtomicU32, Ordering}; +use lp_collection::VecMap; use lpc_model::{ ChannelName, Kind, MapSlot, NodeId, NodeName, Revision, SlotAccess, SlotMapKey, SlotPath, SlotPathSegment, SlotShapeRegistry, SlotShapeRegistryError, Slotted, TreePath, ValueSlot, }; +use lpc_registry::ProjectRegistry; use lpc_wire::{WireChildKind, WireSlotIndex}; use lps_shared::LpsValueF32; @@ -30,18 +31,20 @@ use super::resolve_with_engine_host; pub(crate) struct EngineTestBuilder { engine: Engine, - labels: BTreeMap, - shader_ticks: BTreeMap>, - fixture_records: BTreeMap, - output_records: BTreeMap, + registry: ProjectRegistry, + labels: VecMap, + shader_ticks: VecMap>, + fixture_records: VecMap, + output_records: VecMap, } pub(crate) struct EngineTestHarness { pub(crate) engine: Engine, - labels: BTreeMap, - shader_ticks: BTreeMap>, - fixture_records: BTreeMap, - output_records: BTreeMap, + pub(crate) registry: ProjectRegistry, + labels: VecMap, + shader_ticks: VecMap>, + fixture_records: VecMap, + output_records: VecMap, } pub(crate) struct OutputSpec { @@ -65,10 +68,11 @@ impl EngineTestBuilder { pub(crate) fn new() -> Self { Self { engine: Engine::new(TreePath::parse("/show.test").expect("test root path")), - labels: BTreeMap::new(), - shader_ticks: BTreeMap::new(), - fixture_records: BTreeMap::new(), - output_records: BTreeMap::new(), + registry: ProjectRegistry::new(), + labels: VecMap::new(), + shader_ticks: VecMap::new(), + fixture_records: VecMap::new(), + output_records: VecMap::new(), } } @@ -157,6 +161,7 @@ impl EngineTestBuilder { pub(crate) fn build(self) -> EngineTestHarness { EngineTestHarness { engine: self.engine, + registry: self.registry, labels: self.labels, shader_ticks: self.shader_ticks, fixture_records: self.fixture_records, @@ -166,7 +171,7 @@ impl EngineTestBuilder { fn attach_node(&mut self, label: &str, ty: &str, node: Box) -> NodeId { let root = self.engine.tree().root(); - let (cfg, artifact) = test_placeholder_spine(); + let cfg = test_placeholder_spine(); let node_id = self .engine .tree_mut() @@ -178,7 +183,6 @@ impl EngineTestBuilder { source: WireSlotIndex(0), }, cfg, - artifact, Revision::new(1), ) .expect("add test node"); @@ -252,14 +256,29 @@ impl EngineTestHarness { } pub(crate) fn resolve(&mut self, query: QueryKey) -> Result { - resolve_with_engine_host(&mut self.engine, query, ResolveLogLevel::Off).map(|(pv, _)| pv) + resolve_with_engine_host( + &mut self.engine, + &self.registry, + query, + ResolveLogLevel::Off, + ) + .map(|(pv, _)| pv) } pub(crate) fn resolve_with_trace( &mut self, query: QueryKey, ) -> Result<(Production, ResolveTrace), SessionResolveError> { - resolve_with_engine_host(&mut self.engine, query, ResolveLogLevel::Basic) + resolve_with_engine_host( + &mut self.engine, + &self.registry, + query, + ResolveLogLevel::Basic, + ) + } + + pub(crate) fn tick(&mut self, delta_ms: u32) -> Result<(), super::EngineError> { + self.engine.tick(&self.registry, delta_ms) } } @@ -273,7 +292,7 @@ impl OutputSpec { } impl TestBindingSource { - fn into_binding_source(self, labels: &BTreeMap) -> BindingSource { + fn into_binding_source(self, labels: &VecMap) -> BindingSource { match self { Self::Literal(value) => { BindingSource::Literal(lpc_model::LpValue::F32(f32_value(value))) @@ -286,7 +305,7 @@ impl TestBindingSource { } } - fn owner(&self, labels: &BTreeMap) -> NodeId { + fn owner(&self, labels: &VecMap) -> NodeId { match self { Self::ProducedSlot { label, .. } => *labels.get(label).expect("produced slot label"), Self::Literal(_) | Self::Bus(_) => NodeId::new(0), @@ -374,7 +393,7 @@ pub(crate) struct DummyShaderState { impl DummyShaderNode { fn new(slot: SlotPath, value: LpsValueF32, tick_count: Arc) -> Self { - let mut outputs = BTreeMap::new(); + let mut outputs = VecMap::new(); outputs.insert(output_key(&slot), ValueSlot::new(f32_value(value))); Self { state: DummyShaderState { diff --git a/lp-core/lpc-engine/src/gfx/compute_desc.rs b/lp-core/lpc-engine/src/gfx/compute_desc.rs index 989945c38..7c2fa9603 100644 --- a/lp-core/lpc-engine/src/gfx/compute_desc.rs +++ b/lp-core/lpc-engine/src/gfx/compute_desc.rs @@ -151,8 +151,8 @@ fn ensure_u32_map_key(slot: &ShaderSlotDef) -> Result<(), ComputeDescError> { mod tests { use super::*; use alloc::boxed::Box; - use alloc::collections::BTreeMap; use alloc::format; + use lp_collection::VecMap; use lpc_model::{ BindingDefs, CONTROL_MESSAGE_SHAPE_NAME, MapSlot, ShaderSlotMappingDef, ValueSlot, @@ -166,13 +166,13 @@ mod tests { fn compute_def_header_and_runtime_descriptor_execute() { let registry = SlotShapeRegistry::default(); - let mut consumed = BTreeMap::new(); + let mut consumed = VecMap::new(); consumed.insert( String::from("time"), ShaderSlotDef::value_f32("Time", "Seconds", 0.0, None), ); - let mut produced = BTreeMap::new(); + let mut produced = VecMap::new(); produced.insert( String::from("emitters"), ShaderSlotDef::map_u32_native( @@ -182,7 +182,7 @@ mod tests { ); let def = ComputeShaderDef { - source: lpc_model::EnumSlot::new(lpc_model::ShaderSource::path("emitters.glsl")), + source: lpc_model::AssetSlot::path("emitters.glsl"), bindings: BindingDefs::default(), glsl_opts: lpc_model::GlslOpts::default(), consumed_slots: MapSlot::new(consumed), @@ -236,7 +236,7 @@ void tick() {{ fn compute_desc_accepts_consumed_sentinel_maps() { let registry = SlotShapeRegistry::default(); - let mut consumed = BTreeMap::new(); + let mut consumed = VecMap::new(); consumed.insert( String::from("events"), ShaderSlotDef::map_u32_native( @@ -245,7 +245,7 @@ void tick() {{ ), ); - let mut produced = BTreeMap::new(); + let mut produced = VecMap::new(); produced.insert( String::from("phase"), ShaderSlotDef { @@ -262,7 +262,7 @@ void tick() {{ ); let def = ComputeShaderDef { - source: lpc_model::EnumSlot::new(lpc_model::ShaderSource::path("events.glsl")), + source: lpc_model::AssetSlot::path("events.glsl"), bindings: BindingDefs::default(), glsl_opts: lpc_model::GlslOpts::default(), consumed_slots: MapSlot::new(consumed), diff --git a/lp-core/lpc-engine/src/lib.rs b/lp-core/lpc-engine/src/lib.rs index 14df3af4a..cbb54f82d 100644 --- a/lp-core/lpc-engine/src/lib.rs +++ b/lp-core/lpc-engine/src/lib.rs @@ -11,7 +11,6 @@ extern crate alloc; -pub mod artifact; pub mod dataflow; pub mod engine; pub mod gfx; @@ -24,7 +23,7 @@ pub mod resources; pub use engine::error::Error; pub use engine::{ - ButtonService, Engine, EngineError, EngineServices, FrameNum, FrameTime, OutputFlushError, - ProjectLoadError, ProjectLoader, RadioService, + ButtonService, Engine, EngineError, EngineProjectReadSource, EngineServices, FrameNum, + FrameTime, OutputFlushError, ProjectLoadError, ProjectLoader, RadioService, RuntimeApplyResult, }; pub use gfx::{Graphics, LpGraphics, LpShader, ShaderCompileOptions}; diff --git a/lp-core/lpc-engine/src/node/contexts.rs b/lp-core/lpc-engine/src/node/contexts.rs index 0971c58de..de54a0dae 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::artifact::ArtifactId; use crate::dataflow::bus::Bus; use crate::dataflow::resolver::{ Production, ProductionSource, QueryKey, ResolveError, TickResolver, @@ -22,10 +21,12 @@ use crate::products::visual::{ }; use crate::resource::{RuntimeBuffer, RuntimeBufferId, RuntimeBufferStore}; use lpc_model::{ - FromLpValue, NodeId, Revision, SlotAccess, SlotAccessor, SlotPath, SlotShapeRegistry, - WithRevision, bus::ChannelName, lookup_slot_data_and_shape, + AssetLocation, FromLpValue, NodeId, Revision, SlotAccess, SlotAccessor, SlotPath, + SlotShapeRegistry, WithRevision, bus::ChannelName, 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; @@ -54,14 +55,70 @@ impl<'a> NodeResourceInitContext<'a> { } } +/// Context for [`super::NodeRuntime::refresh_asset`]. +pub struct AssetRefreshContext<'a> { + fs: &'a dyn LpFs, + registry: &'a mut ProjectRegistry, + slot_shapes: &'a SlotShapeRegistry, + revision: Revision, +} + +impl<'a> AssetRefreshContext<'a> { + pub fn new( + fs: &'a dyn LpFs, + registry: &'a mut ProjectRegistry, + slot_shapes: &'a SlotShapeRegistry, + revision: Revision, + ) -> Self { + Self { + fs, + registry, + slot_shapes, + revision, + } + } + + pub fn fs(&self) -> &dyn LpFs { + self.fs + } + + pub fn registry(&mut self) -> &mut ProjectRegistry { + self.registry + } + + pub fn slot_shapes(&self) -> &SlotShapeRegistry { + self.slot_shapes + } + + pub fn revision(&self) -> Revision { + self.revision + } + + pub fn read_asset_bytes_if_changed( + &mut self, + location: &AssetLocation, + since: Revision, + ) -> Result, AssetReadError> { + self.registry + .read_asset_bytes_if_changed(self.fs, location, since) + } + + pub fn read_asset_text_if_changed( + &mut self, + location: &AssetLocation, + since: Revision, + ) -> Result, AssetReadError> { + self.registry + .read_asset_text_if_changed(self.fs, location, since) + } +} + /// Context for [`super::NodeRuntime::produce`] and [`super::NodeRuntime::consume`]. /// /// Demand-style reads go through [`TickResolver`] (typically [`crate::dataflow::resolver::SessionHostResolver`]). pub struct TickContext<'r> { node_id: NodeId, revision: Revision, - artifact_ref: ArtifactId, - artifact_content_frame: Revision, resolver: &'r mut dyn TickResolver, slot_shapes: &'r SlotShapeRegistry, graphics: Option>, @@ -75,30 +132,16 @@ impl<'r> TickContext<'r> { pub fn new( node_id: NodeId, frame_id: Revision, - artifact_ref: ArtifactId, - artifact_content_frame: Revision, resolver: &'r mut dyn TickResolver, slot_shapes: &'r SlotShapeRegistry, ) -> Self { - Self::with_render_services( - node_id, - frame_id, - artifact_ref, - artifact_content_frame, - resolver, - slot_shapes, - None, - None, - 0.0, - ) + Self::with_render_services(node_id, frame_id, resolver, slot_shapes, None, None, 0.0) } /// [`TickContext`] with graphics and frame time. pub fn with_render_services( node_id: NodeId, frame_id: Revision, - artifact_ref: ArtifactId, - artifact_content_frame: Revision, resolver: &'r mut dyn TickResolver, slot_shapes: &'r SlotShapeRegistry, graphics: Option>, @@ -108,8 +151,6 @@ impl<'r> TickContext<'r> { Self::with_engine_services( node_id, frame_id, - artifact_ref, - artifact_content_frame, resolver, slot_shapes, graphics, @@ -124,8 +165,6 @@ impl<'r> TickContext<'r> { pub fn with_engine_services( node_id: NodeId, frame_id: Revision, - artifact_ref: ArtifactId, - artifact_content_frame: Revision, resolver: &'r mut dyn TickResolver, slot_shapes: &'r SlotShapeRegistry, graphics: Option>, @@ -137,8 +176,6 @@ impl<'r> TickContext<'r> { Self { node_id, revision: frame_id, - artifact_ref, - artifact_content_frame, resolver, slot_shapes, graphics, @@ -237,18 +274,6 @@ impl<'r> TickContext<'r> { self.slot_shapes } - pub fn artifact_ref(&self) -> ArtifactId { - self.artifact_ref - } - - pub fn artifact_content_frame(&self) -> Revision { - self.artifact_content_frame - } - - pub fn artifact_changed_since(&self, since: Revision) -> bool { - self.artifact_content_frame.0 > since.0 - } - /// Monotonic shader time in seconds for the current engine frame. pub fn time_seconds(&self) -> f32 { self.frame_time_seconds @@ -742,7 +767,6 @@ mod tests { let mut session = session_bundle(&mut resolver, frame); let mut host = PanicProduceHost::default(); let slot_shapes = SlotShapeRegistry::default(); - let artifact_ref = ArtifactId::from_raw(1); let mut bridge = SessionHostResolver { session: &mut session, @@ -751,16 +775,12 @@ mod tests { let ctx = TickContext::new( NodeId::new(7), Revision::new(3), - artifact_ref, - Revision::new(5), &mut bridge as &mut dyn TickResolver, &slot_shapes, ); assert_eq!(ctx.node_id(), NodeId::new(7)); assert_eq!(ctx.revision(), Revision::new(3)); - assert_eq!(ctx.artifact_ref(), artifact_ref); - assert_eq!(ctx.artifact_content_frame(), Revision::new(5)); } #[test] @@ -790,8 +810,6 @@ mod tests { let mut ctx = TickContext::new( NodeId::new(1), frame, - ArtifactId::from_raw(1), - Revision::new(1), &mut bridge as &mut dyn TickResolver, &slot_shapes, ); @@ -832,8 +850,6 @@ mod tests { let mut ctx = TickContext::new( node, frame, - ArtifactId::from_raw(1), - Revision::new(1), &mut bridge as &mut dyn TickResolver, &slot_shapes, ); @@ -863,8 +879,6 @@ mod tests { let mut ctx = TickContext::new( node, frame, - ArtifactId::from_raw(1), - Revision::new(1), &mut bridge as &mut dyn TickResolver, &slot_shapes, ); @@ -885,32 +899,6 @@ mod tests { ); } - #[test] - fn tick_context_artifact_changed_since_compares_content_frame() { - let mut resolver = Resolver::new(); - let frame = Revision::new(10); - let mut session = session_bundle(&mut resolver, frame); - let mut host = PanicProduceHost::default(); - let slot_shapes = SlotShapeRegistry::default(); - - let mut bridge = SessionHostResolver { - session: &mut session, - host: &mut host, - }; - let ctx = TickContext::new( - NodeId::new(1), - frame, - ArtifactId::from_raw(1), - Revision::new(5), - &mut bridge as &mut dyn TickResolver, - &slot_shapes, - ); - - assert!(ctx.artifact_changed_since(Revision::new(4))); - assert!(!ctx.artifact_changed_since(Revision::new(5))); - assert!(!ctx.artifact_changed_since(Revision::new(6))); - } - struct FixtureProduceHost { node: NodeId, out_path: SlotPath, @@ -1008,8 +996,6 @@ mod tests { let mut ctx = TickContext::new( NodeId::new(2), frame, - ArtifactId::from_raw(1), - Revision::new(1), &mut bridge as &mut dyn TickResolver, &slot_shapes, ); @@ -1048,8 +1034,6 @@ mod tests { let mut ctx = TickContext::new( node_id, frame, - ArtifactId::from_raw(1), - Revision::new(1), &mut bridge as &mut dyn TickResolver, &slot_shapes, ); diff --git a/lp-core/lpc-engine/src/node/mod.rs b/lp-core/lpc-engine/src/node/mod.rs index 33616575b..2f91d15e7 100644 --- a/lp-core/lpc-engine/src/node/mod.rs +++ b/lp-core/lpc-engine/src/node/mod.rs @@ -6,7 +6,6 @@ mod contexts; mod control_node; mod node_binding_index; mod node_call; -mod node_def_handle; pub mod node_entry; pub mod node_entry_state; mod node_error; @@ -19,26 +18,22 @@ pub mod tree_error; pub use crate::engine::memory_pressure::PressureLevel; pub use contexts::{ - ControlRenderContext, ControlRenderServices, DestroyCtx, MemPressureCtx, + AssetRefreshContext, ControlRenderContext, ControlRenderServices, DestroyCtx, MemPressureCtx, NodeResourceInitContext, RenderContext, TickContext, VisualRenderServices, }; pub use control_node::ControlNode; pub use node_call::{NodeCall, NodeCallKey}; -pub use node_def_handle::NodeDefHandle; -pub use node_entry::NodeEntry; +pub use node_entry::RuntimeNodeEntry; pub use node_entry_state::NodeEntryState; pub use node_error::NodeError; -pub use node_runtime::{NodeRuntime, ProduceResult}; -pub use node_tree::NodeTree; +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 sync::tree_deltas_since; pub use tree_error::TreeError; #[cfg(test)] -pub(crate) fn test_placeholder_spine() -> (lpc_model::NodeInvocation, crate::artifact::ArtifactId) { - ( - lpc_model::NodeInvocation::new(lpc_model::ArtifactLocator::path("__test__.vis")), - crate::artifact::ArtifactId::from_raw(0), - ) +pub(crate) fn test_placeholder_spine() -> lpc_model::NodeInvocation { + lpc_model::NodeInvocation::new(lpc_model::ArtifactSpec::path("__test__.vis")) } diff --git a/lp-core/lpc-engine/src/node/node_binding_index.rs b/lp-core/lpc-engine/src/node/node_binding_index.rs index c5e3746a7..ecd9b5ca3 100644 --- a/lp-core/lpc-engine/src/node/node_binding_index.rs +++ b/lp-core/lpc-engine/src/node/node_binding_index.rs @@ -1,7 +1,7 @@ //! Derived indexes over bindings stored on node entries. -use alloc::collections::BTreeMap; use alloc::vec::Vec; +use lp_collection::VecMap; use lpc_model::{ChannelName, Kind, NodeId, SlotPath}; @@ -9,17 +9,19 @@ use crate::dataflow::binding::{ BindingEntry, BindingError, BindingRef, BindingTarget, channels_touched, }; -use super::NodeEntry; +use super::RuntimeNodeEntry; #[derive(Clone, Debug, Default)] pub(super) struct NodeBindingIndex { - consumed_targets: BTreeMap<(NodeId, SlotPath), Vec>, - bus_targets: BTreeMap>, - channel_kinds: BTreeMap, + consumed_targets: VecMap<(NodeId, SlotPath), Vec>, + bus_targets: VecMap>, + channel_kinds: VecMap, } impl NodeBindingIndex { - pub(super) fn rebuild(entries: &[Option>]) -> Result { + pub(super) fn rebuild( + entries: &[Option>], + ) -> Result { let mut index = Self::default(); for entry in entries.iter().filter_map(|entry| entry.as_ref()) { @@ -85,7 +87,7 @@ impl NodeBindingIndex { } pub(super) fn binding_by_ref( - entries: &[Option>], + entries: &[Option>], binding_ref: BindingRef, ) -> Option<&BindingEntry> { entries diff --git a/lp-core/lpc-engine/src/node/node_def_handle.rs b/lp-core/lpc-engine/src/node/node_def_handle.rs deleted file mode 100644 index 8d4d5d77c..000000000 --- a/lp-core/lpc-engine/src/node/node_def_handle.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Runtime handle to a node's authored definition. - -use lpc_model::SlotPath; - -use crate::artifact::ArtifactId; - -/// Address of an authored node definition inside the artifact store. -/// -/// The current loader stores every node definition at the root of its artifact, -/// so handles are `artifact + SlotPath::root()`. Non-root paths are reserved for -/// future inline node definitions nested inside another artifact. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct NodeDefHandle { - artifact: ArtifactId, - path: SlotPath, -} - -impl NodeDefHandle { - /// Handle for a node definition that is the artifact root. - pub fn artifact_root(artifact: ArtifactId) -> Self { - Self { - artifact, - path: SlotPath::root(), - } - } - - pub fn new(artifact: ArtifactId, path: SlotPath) -> Self { - Self { artifact, path } - } - - pub fn artifact(&self) -> ArtifactId { - self.artifact - } - - pub fn path(&self) -> &SlotPath { - &self.path - } - - pub fn is_artifact_root(&self) -> bool { - self.path.is_root() - } -} diff --git a/lp-core/lpc-engine/src/node/node_entry.rs b/lp-core/lpc-engine/src/node/node_entry.rs index 2a7de23b1..69b58ed1e 100644 --- a/lp-core/lpc-engine/src/node/node_entry.rs +++ b/lp-core/lpc-engine/src/node/node_entry.rs @@ -3,15 +3,12 @@ //! See `docs/roadmaps/2026-04-28-node-runtime/design/01-tree.md` §NodeEntry. use alloc::vec::Vec; -use lpc_model::{ArtifactLocator, NodeId, NodeInvocation, Revision, TreePath, WithRevision}; -use lpc_wire::{WireChildKind, WireNodeStatus}; +use lpc_model::{NodeDefLocation, NodeId, NodeUseLocation, Revision, TreePath, WithRevision}; +use lpc_wire::{NodeRuntimeStatus, WireChildKind}; -use crate::artifact::ArtifactId; use crate::dataflow::binding::BindingSet; use crate::node::node_entry_state::NodeEntryState; -use super::NodeDefHandle; - /// Server-side metadata for a node instance. /// /// Generic over `N` — the payload type in `EntryState::Alive(N)`. In M3 this @@ -19,36 +16,29 @@ use super::NodeDefHandle; /// `Box`. /// #[derive(Debug)] -pub struct NodeEntry { +pub struct RuntimeNodeEntry { pub id: NodeId, pub path: TreePath, pub parent: Option, pub child_kind: Option, // None for root; immutable for entry's lifetime pub children: WithRevision>, // ordered - pub status: WithRevision, + pub status: WithRevision, pub state: WithRevision>, pub bindings: WithRevision, pub created_at: Revision, - /// Authored per-instance config (artifact spec + overrides). - pub config: NodeInvocation, + /// Stable project-side identity for this runtime node, when projected from a project. + pub project_use: Option, - /// Runtime handle to this node's authored definition. - pub def_handle: NodeDefHandle, + /// Effective authored definition backing this runtime node. + pub def_location: Option, } -impl NodeEntry { - /// Placeholder artifact path for [`Self::new`] (tests and roots without a real spec yet). - /// - /// Spine placeholder artifact path: empty authored `""` normalizes to `/` (`lpc_model::LpPathBuf`). - pub(crate) const PLACEHOLDER_ARTIFACT_PATH: &'static str = "/"; - +impl RuntimeNodeEntry { /// Create a new entry. Sets `created_at`, `changed_at`, and /// `children_changed_at` to `revision`. - /// - /// Fills spine fields with placeholders: root-normalized artifact path (`/`), handle `0`. pub fn new( id: NodeId, path: TreePath, @@ -56,25 +46,17 @@ impl NodeEntry { child_kind: Option, revision: Revision, ) -> Self { - Self::new_spine( - id, - path, - parent, - child_kind, - NodeInvocation::new(ArtifactLocator::path(Self::PLACEHOLDER_ARTIFACT_PATH)), - NodeDefHandle::artifact_root(ArtifactId::from_raw(0)), - revision, - ) + Self::new_spine(id, path, parent, child_kind, None, None, revision) } - /// Create a new entry with explicit source config and artifact handle. + /// Create a new entry with explicit project identity. pub fn new_spine( id: NodeId, path: TreePath, parent: Option, child_kind: Option, - config: NodeInvocation, - def_handle: NodeDefHandle, + project_use: Option, + def_location: Option, revision: Revision, ) -> Self { Self { @@ -83,21 +65,26 @@ impl NodeEntry { parent, child_kind, children: WithRevision::new(revision, Vec::new()), - status: WithRevision::new(revision, WireNodeStatus::Created), + status: WithRevision::new(revision, NodeRuntimeStatus::Created), state: WithRevision::new(revision, NodeEntryState::Pending), bindings: WithRevision::new(revision, BindingSet::new()), created_at: revision, - config, - def_handle, + project_use, + def_location, } } - pub fn artifact(&self) -> ArtifactId { - self.def_handle.artifact() + pub fn set_project_identity( + &mut self, + project_use: NodeUseLocation, + def_location: NodeDefLocation, + ) { + self.project_use = Some(project_use); + self.def_location = Some(def_location); } /// Set status and bump `changed_at`. - pub fn set_status(&mut self, status: WireNodeStatus, revision: Revision) { + pub fn set_status(&mut self, status: NodeRuntimeStatus, revision: Revision) { self.status.set(revision, status); } @@ -126,16 +113,16 @@ impl NodeEntry { #[cfg(test)] mod tests { - use super::NodeEntry; - use crate::node::NodeDefHandle; - use lpc_model::{ArtifactLocator, NodeInvocation}; - use lpc_model::{NodeId, Revision, TreePath}; - use lpc_wire::{WireChildKind, WireNodeStatus, WireSlotIndex}; + use super::RuntimeNodeEntry; + use lpc_model::{ + ArtifactLocation, NodeDefLocation, NodeId, NodeUseLocation, Revision, TreePath, + }; + use lpc_wire::{NodeRuntimeStatus, WireChildKind, WireSlotIndex}; #[test] fn node_entry_new_sets_all_frame_counters() { let frame = Revision::new(5); - let entry: NodeEntry<()> = NodeEntry::new( + let entry: RuntimeNodeEntry<()> = RuntimeNodeEntry::new( NodeId::new(1), TreePath::parse("/main.show").unwrap(), None, @@ -145,22 +132,22 @@ mod tests { assert_eq!(entry.created_at.0, 5); assert_eq!(entry.changed_at().0, 5); assert_eq!(entry.children_changed_at().0, 5); - assert_eq!(*entry.status.value(), WireNodeStatus::Created); + assert_eq!(*entry.status.value(), NodeRuntimeStatus::Created); assert!(entry.state.value().is_pending()); } #[test] fn node_entry_set_status_bumps_change_frame() { let frame = Revision::new(5); - let mut entry: NodeEntry<()> = NodeEntry::new( + let mut entry: RuntimeNodeEntry<()> = RuntimeNodeEntry::new( NodeId::new(1), TreePath::parse("/main.show").unwrap(), None, None, frame, ); - entry.set_status(WireNodeStatus::Ok, Revision::new(10)); - assert_eq!(*entry.status.value(), WireNodeStatus::Ok); + entry.set_status(NodeRuntimeStatus::Ok, Revision::new(10)); + assert_eq!(*entry.status.value(), NodeRuntimeStatus::Ok); assert_eq!(entry.changed_at().0, 10); // created_frame and children_ver unchanged assert_eq!(entry.created_at.0, 5); @@ -170,7 +157,7 @@ mod tests { #[test] fn node_entry_is_dirty_since() { let frame = Revision::new(5); - let entry: NodeEntry<()> = NodeEntry::new( + let entry: RuntimeNodeEntry<()> = RuntimeNodeEntry::new( NodeId::new(1), TreePath::parse("/main.show").unwrap(), None, @@ -186,7 +173,7 @@ mod tests { fn node_entry_child_kind_is_immutable_conceptually() { // Verify we can set it at construction; it's not changed after let frame = Revision::new(1); - let entry: NodeEntry<()> = NodeEntry::new( + let entry: RuntimeNodeEntry<()> = RuntimeNodeEntry::new( NodeId::new(2), TreePath::parse("/main.show/child.vis").unwrap(), Some(NodeId::new(1)), @@ -203,22 +190,20 @@ mod tests { } #[test] - fn node_entry_new_spine_stores_config_and_def_handle() { + fn node_entry_new_spine_stores_project_identity() { let frame = Revision::new(1); - let config = NodeInvocation::new(ArtifactLocator::path("./fluid.vis")); - let artifact = crate::artifact::ArtifactId::from_raw(7); - let def_handle = NodeDefHandle::artifact_root(artifact); - let entry: NodeEntry<()> = NodeEntry::new_spine( + let project_use = NodeUseLocation::root(); + let def_location = NodeDefLocation::artifact_root(ArtifactLocation::file("/project.toml")); + let entry: RuntimeNodeEntry<()> = RuntimeNodeEntry::new_spine( NodeId::new(1), TreePath::parse("/main.show").unwrap(), None, None, - config.clone(), - def_handle.clone(), + Some(project_use.clone()), + Some(def_location.clone()), frame, ); - assert_eq!(entry.config, config); - assert_eq!(entry.def_handle, def_handle); - assert_eq!(entry.artifact(), artifact); + assert_eq!(entry.project_use, Some(project_use)); + assert_eq!(entry.def_location, Some(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 7532d3866..c0e463f9a 100644 --- a/lp-core/lpc-engine/src/node/node_runtime.rs +++ b/lp-core/lpc-engine/src/node/node_runtime.rs @@ -1,9 +1,14 @@ //! Engine spine [`NodeRuntime`] trait: produce, consume, destroy, memory pressure, and runtime state. use crate::resource::RuntimeBufferId; -use lpc_model::{SlotAccess, SlotPath, SlotShapeRegistry, SlotShapeRegistryError}; - -use super::contexts::{DestroyCtx, MemPressureCtx, NodeResourceInitContext, TickContext}; +use lpc_model::{ + AssetLocation, NodeRuntimeStatus, SlotAccess, SlotPath, SlotShapeRegistry, + SlotShapeRegistryError, +}; + +use super::contexts::{ + AssetRefreshContext, DestroyCtx, MemPressureCtx, NodeResourceInitContext, TickContext, +}; use super::node_error::NodeError; use super::{ControlNode, RenderNode}; use crate::engine::memory_pressure::PressureLevel; @@ -15,6 +20,17 @@ pub enum ProduceResult { Unsupported, } +/// Result of asking a runtime node to refresh an asset it may consume. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum AssetRefreshResult { + /// The node does not consume this asset. + Unused, + /// The node consumes the asset, but the effective asset body did not change. + Unchanged, + /// The node refreshed internal state from the new effective asset body. + Refreshed, +} + /// Runtime node instance for the demand-driven engine spine. pub trait NodeRuntime { /// Allocate [`RuntimeBufferId`] slots owned by this node before first use. @@ -45,6 +61,18 @@ pub trait NodeRuntime { Ok(()) } + /// Refresh a referenced asset after the project registry reports an effective asset change. + /// + /// Nodes that compile or cache asset bodies should compare the incoming asset's revision to + /// the revision they last consumed and invalidate only their own cached runtime state. + fn refresh_asset( + &mut self, + _location: &AssetLocation, + _ctx: &mut AssetRefreshContext<'_>, + ) -> Result { + Ok(AssetRefreshResult::Unused) + } + fn destroy(&mut self, ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError>; fn handle_memory_pressure( @@ -53,6 +81,15 @@ pub trait NodeRuntime { ctx: &mut MemPressureCtx<'_>, ) -> Result<(), NodeError>; + /// Current runtime health, when the node has a more specific status than "ok". + /// + /// Returning `None` lets the engine report [`NodeRuntimeStatus::Ok`] after a successful + /// runtime operation. Nodes with cached/degraded internal state can return an error or + /// warning while still rendering fallback output or otherwise keeping the runtime alive. + fn runtime_status(&self) -> Option { + None + } + /// Node-owned runtime state exposed as a slot root. /// /// Nodes without public runtime state return `None`; they do not publish a @@ -90,12 +127,11 @@ mod tests { use super::*; use alloc::boxed::Box; - use crate::artifact::ArtifactId; use crate::dataflow::resolver::{ ResolveHost, ResolveSession, ResolveTrace, Resolver, SessionHostResolver, TickResolver, resolve_trace::ResolveLogLevel, }; - use lpc_model::{NodeId, Revision, SlotShapeRegistry}; + use lpc_model::{AssetLocation, NodeId, Revision, SlotShapeRegistry}; struct EmptyResolveHost; @@ -161,8 +197,6 @@ mod tests { let mut tick = TickContext::new( NodeId::new(0), frame, - ArtifactId::from_raw(1), - Revision::new(0), &mut bridge as &mut dyn TickResolver, &slot_shapes, ); @@ -174,4 +208,22 @@ mod tests { ProduceResult::Unsupported ); } + + #[test] + fn default_asset_refresh_is_unused() { + let mut node = DummyNode::new(); + let fs = lpfs::LpFsMemory::new(); + let mut registry = lpc_registry::ProjectRegistry::new(); + let slot_shapes = SlotShapeRegistry::default(); + let mut ctx = AssetRefreshContext::new(&fs, &mut registry, &slot_shapes, Revision::new(1)); + + assert_eq!( + node.refresh_asset( + &AssetLocation::artifact(lpc_model::ArtifactLocation::file("/shader.glsl")), + &mut ctx, + ) + .expect("refresh"), + AssetRefreshResult::Unused + ); + } } diff --git a/lp-core/lpc-engine/src/node/node_tree.rs b/lp-core/lpc-engine/src/node/node_tree.rs index 8bb3c03de..e0cfd4633 100644 --- a/lp-core/lpc-engine/src/node/node_tree.rs +++ b/lp-core/lpc-engine/src/node/node_tree.rs @@ -2,18 +2,17 @@ //! //! See `docs/roadmaps/2026-04-28-node-runtime/design/01-tree.md` §NodeTree. -use alloc::collections::BTreeMap; use alloc::vec::Vec; +use lp_collection::VecMap; use lpc_model::{ ChannelName, NodeId, NodeInvocation, NodeName, NodePathSegment, Revision, SlotPath, TreePath, }; use lpc_wire::WireChildKind; -use crate::artifact::ArtifactId; use crate::dataflow::binding::{BindingDraft, BindingEntry, BindingError, BindingRef}; use crate::node::node_binding_index::{NodeBindingIndex, binding_by_ref}; -use crate::node::{NodeDefHandle, NodeEntry, TreeError}; +use crate::node::{RuntimeNodeEntry, TreeError}; /// The node tree container. /// @@ -21,31 +20,31 @@ use crate::node::{NodeDefHandle, NodeEntry, TreeError}; /// is `()` (no Node trait yet). When the Node trait lands, this becomes /// `Box`. #[derive(Debug)] -pub struct NodeTree { - nodes: Vec>>, - by_path: BTreeMap, - by_sibling: BTreeMap<(NodeId, NodeName), NodeId>, +pub struct RuntimeNodeTree { + nodes: Vec>>, + by_path: VecMap, + by_sibling: VecMap<(NodeId, NodeName), NodeId>, binding_index: NodeBindingIndex, next_id: u32, root: NodeId, } -impl NodeTree { +impl RuntimeNodeTree { /// Create a new tree with a root node at the given path and frame. pub fn new(root_path: TreePath, frame: Revision) -> Self { let root_id = NodeId::new(0); - let root_entry = NodeEntry::new(root_id, root_path.clone(), None, None, frame); + let root_entry = RuntimeNodeEntry::new(root_id, root_path.clone(), None, None, frame); let mut nodes = Vec::new(); nodes.push(Some(root_entry)); - let mut by_path = BTreeMap::new(); + let mut by_path = VecMap::new(); by_path.insert(root_path, root_id); Self { nodes, by_path, - by_sibling: BTreeMap::new(), + by_sibling: VecMap::new(), binding_index: NodeBindingIndex::default(), next_id: 1, root: root_id, @@ -58,12 +57,12 @@ impl NodeTree { } /// Get a reference to an entry by id. - pub fn get(&self, id: NodeId) -> Option<&NodeEntry> { + pub fn get(&self, id: NodeId) -> Option<&RuntimeNodeEntry> { self.nodes.get(id.0 as usize).and_then(|opt| opt.as_ref()) } /// Get a mutable reference to an entry by id. - pub fn get_mut(&mut self, id: NodeId) -> Option<&mut NodeEntry> { + pub fn get_mut(&mut self, id: NodeId) -> Option<&mut RuntimeNodeEntry> { self.nodes .get_mut(id.0 as usize) .and_then(|opt| opt.as_mut()) @@ -80,12 +79,12 @@ impl NodeTree { } /// Iterate over all live entries (skips tombstones). - pub fn entries(&self) -> impl Iterator> { + pub fn entries(&self) -> impl Iterator> { self.nodes.iter().filter_map(|opt| opt.as_ref()) } /// Iterate over all live entries mutably (skips tombstones). - pub fn entries_mut(&mut self) -> impl Iterator> { + pub fn entries_mut(&mut self) -> impl Iterator> { self.nodes.iter_mut().filter_map(|opt| opt.as_mut()) } @@ -98,8 +97,7 @@ impl NodeTree { name: NodeName, ty: NodeName, child_kind: WireChildKind, - config: NodeInvocation, - artifact: ArtifactId, + _config: NodeInvocation, frame: Revision, ) -> Result { // Validate parent exists and is in the tree @@ -127,13 +125,13 @@ impl NodeTree { self.next_id += 1; // Create entry - let child_entry = NodeEntry::new_spine( + let child_entry = RuntimeNodeEntry::new_spine( child_id, child_path.clone(), Some(parent), Some(child_kind), - config, - NodeDefHandle::artifact_root(artifact), + None, + None, frame, ); @@ -210,6 +208,16 @@ impl NodeTree { Ok(()) } + pub(crate) fn subtree_ids_depth_first(&self, id: NodeId) -> Result, TreeError> { + let entry = self.get(id).ok_or(TreeError::UnknownNode(id))?; + let mut ids = Vec::new(); + for &child in entry.children.value() { + ids.extend(self.subtree_ids_depth_first(child)?); + } + ids.push(id); + Ok(ids) + } + /// Add one runtime binding to its owning node and update derived indexes. pub fn add_binding( &mut self, @@ -335,27 +343,26 @@ impl NodeTree { #[cfg(test)] mod tests { - use super::NodeTree; - use crate::artifact::ArtifactId; + use super::RuntimeNodeTree; use crate::dataflow::binding::{BindingDraft, BindingPriority, BindingSource, BindingTarget}; use crate::node::test_placeholder_spine; use alloc::string::String; use alloc::vec::Vec; - use lpc_model::{ArtifactLocator, NodeInvocation}; + use lpc_model::{ArtifactSpec, NodeInvocation}; use lpc_model::{ChannelName, Kind, LpValue, NodeId, NodeName, Revision, SlotPath, TreePath}; use lpc_wire::{WireChildKind, WireSlotIndex}; - fn make_tree() -> NodeTree<()> { - NodeTree::new(TreePath::parse("/root.show").unwrap(), Revision::new(0)) + fn make_tree() -> RuntimeNodeTree<()> { + RuntimeNodeTree::new(TreePath::parse("/root.show").unwrap(), Revision::new(0)) } - fn spine_placeholder() -> (NodeInvocation, ArtifactId) { + fn spine_placeholder() -> NodeInvocation { test_placeholder_spine() } - fn add_test_child(tree: &mut NodeTree<()>, name: &str) -> NodeId { + fn add_test_child(tree: &mut RuntimeNodeTree<()>, name: &str) -> NodeId { let root = tree.root(); - let (cfg, art) = spine_placeholder(); + let cfg = spine_placeholder(); tree.add_child( root, NodeName::parse(name).unwrap(), @@ -364,18 +371,16 @@ mod tests { source: WireSlotIndex(0), }, cfg, - art, Revision::new(1), ) .unwrap() } #[test] - fn tree_add_child_stores_config_and_artifact() { + fn tree_add_child_stores_child_metadata() { let mut tree = make_tree(); let root = tree.root(); - let cfg = NodeInvocation::new(ArtifactLocator::path("child.lp")); - let art = ArtifactId::from_raw(9); + let cfg = NodeInvocation::new(ArtifactSpec::path("child.lp")); let child = tree .add_child( root, @@ -385,13 +390,15 @@ mod tests { source: WireSlotIndex(0), }, cfg.clone(), - art, Revision::new(1), ) .unwrap(); let entry = tree.get(child).unwrap(); - assert_eq!(entry.config, cfg); - assert_eq!(entry.artifact(), art); + assert_eq!(entry.parent, Some(root)); + assert_eq!( + entry.path, + TreePath::parse("/root.show/n.vis").expect("child path") + ); } #[test] @@ -408,7 +415,7 @@ mod tests { fn tree_add_child_increases_len() { let mut tree = make_tree(); let root = tree.root(); - let (cfg, art) = spine_placeholder(); + let cfg = spine_placeholder(); let child = tree .add_child( root, @@ -418,7 +425,6 @@ mod tests { source: WireSlotIndex(0), }, cfg, - art, Revision::new(1), ) .unwrap(); @@ -435,7 +441,7 @@ mod tests { let mut tree = make_tree(); let root = tree.root(); let frame = Revision::new(5); - let (cfg, art) = spine_placeholder(); + let cfg = spine_placeholder(); tree.add_child( root, NodeName::parse("a").unwrap(), @@ -444,7 +450,6 @@ mod tests { name: NodeName::parse("a").unwrap(), }, cfg, - art, frame, ) .unwrap(); @@ -526,26 +531,24 @@ mod tests { let name = NodeName::parse("foo").unwrap(); let ty = NodeName::parse("vis").unwrap(); - let (cfg1, art1) = spine_placeholder(); + let cfg1 = spine_placeholder(); tree.add_child( root, name.clone(), ty.clone(), WireChildKind::Sidecar { name: name.clone() }, cfg1, - art1, Revision::new(1), ) .unwrap(); - let (cfg2, art2) = spine_placeholder(); + let cfg2 = spine_placeholder(); let result = tree.add_child( root, name.clone(), ty, WireChildKind::Sidecar { name: name.clone() }, cfg2, - art2, Revision::new(2), ); assert!(result.is_err()); @@ -555,7 +558,7 @@ mod tests { fn tree_lookup_path_finds_entry() { let mut tree = make_tree(); let root = tree.root(); - let (cfg, art) = spine_placeholder(); + let cfg = spine_placeholder(); let child = tree .add_child( root, @@ -565,7 +568,6 @@ mod tests { source: WireSlotIndex(0), }, cfg, - art, Revision::new(1), ) .unwrap(); @@ -579,7 +581,7 @@ mod tests { let mut tree = make_tree(); let root = tree.root(); let name = NodeName::parse("lfo").unwrap(); - let (cfg, art) = spine_placeholder(); + let cfg = spine_placeholder(); let child = tree .add_child( root, @@ -587,7 +589,6 @@ mod tests { NodeName::parse("mod").unwrap(), WireChildKind::Sidecar { name: name.clone() }, cfg, - art, Revision::new(1), ) .unwrap(); @@ -600,7 +601,7 @@ mod tests { fn tree_remove_subtree_tombstones_entry() { let mut tree = make_tree(); let root = tree.root(); - let (cfg, art) = spine_placeholder(); + let cfg = spine_placeholder(); let child = tree .add_child( root, @@ -610,7 +611,6 @@ mod tests { source: WireSlotIndex(0), }, cfg, - art, Revision::new(1), ) .unwrap(); @@ -624,7 +624,7 @@ mod tests { fn tree_remove_subtree_bumps_parent_children_ver() { let mut tree = make_tree(); let root = tree.root(); - let (cfg, art) = spine_placeholder(); + let cfg = spine_placeholder(); let child = tree .add_child( root, @@ -634,7 +634,6 @@ mod tests { source: WireSlotIndex(0), }, cfg, - art, Revision::new(1), ) .unwrap(); @@ -658,7 +657,7 @@ mod tests { let root = tree.root(); // Create grandchild -> child -> root chain - let (cfg_p, art_p) = spine_placeholder(); + let cfg_p = spine_placeholder(); let child = tree .add_child( root, @@ -668,12 +667,11 @@ mod tests { name: NodeName::parse("parent").unwrap(), }, cfg_p, - art_p, Revision::new(1), ) .unwrap(); - let (cfg_g, art_g) = spine_placeholder(); + let cfg_g = spine_placeholder(); let grandchild = tree .add_child( child, @@ -683,7 +681,6 @@ mod tests { source: WireSlotIndex(0), }, cfg_g, - art_g, Revision::new(2), ) .unwrap(); @@ -703,7 +700,7 @@ mod tests { let mut tree = make_tree(); let root = tree.root(); - let (cfg_a, art_a) = spine_placeholder(); + let cfg_a = spine_placeholder(); let a = tree .add_child( root, @@ -713,11 +710,10 @@ mod tests { source: WireSlotIndex(0), }, cfg_a, - art_a, Revision::new(1), ) .unwrap(); - let (cfg_b, art_b) = spine_placeholder(); + let cfg_b = spine_placeholder(); let b = tree .add_child( root, @@ -727,7 +723,6 @@ mod tests { source: WireSlotIndex(1), }, cfg_b, - art_b, Revision::new(2), ) .unwrap(); @@ -746,7 +741,7 @@ mod tests { let mut tree = make_tree(); let root = tree.root(); - let (cfg_a, art_a) = spine_placeholder(); + let cfg_a = spine_placeholder(); let a = tree .add_child( root, @@ -756,7 +751,6 @@ mod tests { source: WireSlotIndex(0), }, cfg_a, - art_a, Revision::new(1), ) .unwrap(); @@ -764,7 +758,7 @@ mod tests { tree.remove_subtree(a, Revision::new(2)).unwrap(); - let (cfg_b, art_b) = spine_placeholder(); + let cfg_b = spine_placeholder(); let b = tree .add_child( root, @@ -774,7 +768,6 @@ mod tests { source: WireSlotIndex(0), }, cfg_b, - art_b, Revision::new(3), ) .unwrap(); diff --git a/lp-core/lpc-engine/src/node/sync.rs b/lp-core/lpc-engine/src/node/sync.rs index d4841d295..8a368ffe6 100644 --- a/lp-core/lpc-engine/src/node/sync.rs +++ b/lp-core/lpc-engine/src/node/sync.rs @@ -6,7 +6,7 @@ use alloc::vec::Vec; use lpc_model::Revision; use lpc_wire::WireTreeDelta; -use crate::node::{NodeEntry, NodeTree}; +use crate::node::{RuntimeNodeEntry, RuntimeNodeTree}; /// Generate tree deltas since a given frame. /// @@ -19,11 +19,11 @@ use crate::node::{NodeEntry, NodeTree}; /// the initial sync to work correctly even though root is created at frame 0. /// /// `Created` deltas are emitted in parent-before-child order (depth-first pre-order). -pub fn tree_deltas_since(tree: &NodeTree, since: Revision) -> Vec { +pub fn tree_deltas_since(tree: &RuntimeNodeTree, since: Revision) -> Vec { let mut deltas = Vec::new(); // First pass: collect all live entries - let entries: Vec<&NodeEntry> = tree.entries().collect(); + let entries: Vec<&RuntimeNodeEntry> = tree.entries().collect(); // Pass 1: Created entries // If since == 0, return all entries. Otherwise, return entries with created_frame > since. @@ -31,7 +31,7 @@ pub fn tree_deltas_since(tree: &NodeTree, since: Revision) -> Vec = deltas + let created_ids: lp_collection::VecSet = deltas .iter() .filter_map(|d| { if let WireTreeDelta::Created { id, .. } = d { @@ -73,7 +73,7 @@ pub fn tree_deltas_since(tree: &NodeTree, since: Revision) -> Vec since` are included. fn collect_created_deltas( - tree: &NodeTree, + tree: &RuntimeNodeTree, id: lpc_model::NodeId, since: Revision, deltas: &mut Vec, @@ -106,20 +106,19 @@ fn collect_created_deltas( #[cfg(test)] mod tests { use super::tree_deltas_since; - use crate::artifact::ArtifactId; use crate::node::test_placeholder_spine; - use crate::node::{NodeEntryState, NodeTree}; + use crate::node::{NodeEntryState, RuntimeNodeTree}; use alloc::vec; use alloc::vec::Vec; use lpc_model::NodeInvocation; use lpc_model::{NodeId, NodeName, Revision, TreePath}; use lpc_wire::{WireChildKind, WireEntryState, WireSlotIndex, WireTreeDelta}; - fn make_tree() -> NodeTree<()> { - NodeTree::new(TreePath::parse("/root.show").unwrap(), Revision::new(0)) + fn make_tree() -> RuntimeNodeTree<()> { + RuntimeNodeTree::new(TreePath::parse("/root.show").unwrap(), Revision::new(0)) } - fn spine_placeholder() -> (NodeInvocation, ArtifactId) { + fn spine_placeholder() -> NodeInvocation { test_placeholder_spine() } @@ -129,7 +128,7 @@ mod tests { let root = tree.root(); // Add some children - let (cfg_a, art_a) = spine_placeholder(); + let cfg_a = spine_placeholder(); let a = tree .add_child( root, @@ -139,11 +138,10 @@ mod tests { source: WireSlotIndex(0), }, cfg_a, - art_a, Revision::new(1), ) .unwrap(); - let (cfg_b, art_b) = spine_placeholder(); + let cfg_b = spine_placeholder(); let b = tree .add_child( root, @@ -153,7 +151,6 @@ mod tests { source: WireSlotIndex(1), }, cfg_b, - art_b, Revision::new(2), ) .unwrap(); @@ -188,7 +185,7 @@ mod tests { let mut tree = make_tree(); let root = tree.root(); - let (cfg, art) = spine_placeholder(); + let cfg = spine_placeholder(); tree.add_child( root, NodeName::parse("a").unwrap(), @@ -197,7 +194,6 @@ mod tests { source: WireSlotIndex(0), }, cfg, - art, Revision::new(1), ) .unwrap(); @@ -216,7 +212,7 @@ mod tests { let mut tree = make_tree(); let root = tree.root(); - let (cfg, art) = spine_placeholder(); + let cfg = spine_placeholder(); let a = tree .add_child( root, @@ -226,7 +222,6 @@ mod tests { source: WireSlotIndex(0), }, cfg, - art, Revision::new(1), ) .unwrap(); @@ -234,7 +229,7 @@ mod tests { // Change status at frame 5 tree.get_mut(a) .unwrap() - .set_status(lpc_wire::WireNodeStatus::Ok, Revision::new(5)); + .set_status(lpc_wire::NodeRuntimeStatus::Ok, Revision::new(5)); let deltas = tree_deltas_since(&tree, Revision::new(0)); @@ -263,7 +258,7 @@ mod tests { assert_eq!(changed.len(), 1); if let WireTreeDelta::EntryChanged { id, status, .. } = changed[0] { assert_eq!(*id, a); - assert!(matches!(status, lpc_wire::WireNodeStatus::Ok)); + assert!(matches!(status, lpc_wire::NodeRuntimeStatus::Ok)); } } @@ -273,7 +268,7 @@ mod tests { let root = tree.root(); // Add child at frame 5 - let (cfg, art) = spine_placeholder(); + let cfg = spine_placeholder(); let a = tree .add_child( root, @@ -283,7 +278,6 @@ mod tests { source: WireSlotIndex(0), }, cfg, - art, Revision::new(5), ) .unwrap(); @@ -311,7 +305,7 @@ mod tests { let root = tree.root(); // Create nested structure: root -> parent -> child - let (cfg_p, art_p) = spine_placeholder(); + let cfg_p = spine_placeholder(); let parent = tree .add_child( root, @@ -321,11 +315,10 @@ mod tests { name: NodeName::parse("parent").unwrap(), }, cfg_p, - art_p, Revision::new(1), ) .unwrap(); - let (cfg_c, art_c) = spine_placeholder(); + let cfg_c = spine_placeholder(); let child = tree .add_child( parent, @@ -335,7 +328,6 @@ mod tests { source: WireSlotIndex(0), }, cfg_c, - art_c, Revision::new(2), ) .unwrap(); @@ -363,7 +355,7 @@ mod tests { let mut tree = make_tree(); let root = tree.root(); - let (cfg, art) = spine_placeholder(); + let cfg = spine_placeholder(); let a = tree .add_child( root, @@ -373,7 +365,6 @@ mod tests { source: WireSlotIndex(0), }, cfg, - art, Revision::new(1), ) .unwrap(); @@ -407,13 +398,14 @@ mod tests { /// Full round-trip test: server tree → deltas → client mirror #[test] fn tree_round_trip_server_to_client() { + use lp_collection::VecSet; use lpc_view::{NodeTreeView, apply_tree_deltas}; // Build server tree let mut server_tree = make_tree(); let root = server_tree.root(); - let (cfg_a, art_a) = spine_placeholder(); + let cfg_a = spine_placeholder(); let a = server_tree .add_child( root, @@ -423,11 +415,10 @@ mod tests { source: WireSlotIndex(0), }, cfg_a, - art_a, Revision::new(1), ) .unwrap(); - let (cfg_b, art_b) = spine_placeholder(); + let cfg_b = spine_placeholder(); let b = server_tree .add_child( root, @@ -437,7 +428,6 @@ mod tests { source: WireSlotIndex(1), }, cfg_b, - art_b, Revision::new(2), ) .unwrap(); @@ -479,7 +469,7 @@ mod tests { { let b_entry = server_tree.get_mut(b).unwrap(); b_entry.set_state(NodeEntryState::Alive(()), Revision::new(5)); - b_entry.set_status(lpc_wire::WireNodeStatus::Ok, Revision::new(5)); + b_entry.set_status(lpc_wire::NodeRuntimeStatus::Ok, Revision::new(5)); } // Get deltas since frame 2 (after b was created) @@ -492,7 +482,7 @@ mod tests { assert!(client_tree.get(a).is_none()); // a removed assert!(client_tree.get(b).is_some()); // b still there let client_b = client_tree.get(b).unwrap(); - assert!(matches!(client_b.status, lpc_wire::WireNodeStatus::Ok)); + assert!(matches!(client_b.status, lpc_wire::NodeRuntimeStatus::Ok)); assert!(matches!(client_b.state, WireEntryState::Alive)); // Verify root's children list updated 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 85956e7cc..865c331f8 100644 --- a/lp-core/lpc-engine/src/nodes/button/button_node.rs +++ b/lp-core/lpc-engine/src/nodes/button/button_node.rs @@ -1,14 +1,14 @@ //! Runtime hardware button node: polls a debounced input and produces control maps. use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::format; +use lp_collection::VecMap; +use lpc_hardware::{ButtonConfig, ButtonEventKind, ButtonInput}; use lpc_model::{ - ButtonDefView, ButtonState, ControlMessage, HardwareEndpointSpec, MapSlot, Revision, - SlotAccess, SlotPath, SlotShapeRegistry, SlotShapeRegistryError, + ButtonDefView, ButtonState, ControlMessage, HwEndpointSpec, MapSlot, Revision, SlotAccess, + SlotPath, SlotShapeRegistry, SlotShapeRegistryError, }; -use lpc_shared::hardware::{ButtonConfig, ButtonEventKind, ButtonInput}; use crate::node::{ DestroyCtx, MemPressureCtx, NodeError, NodeRuntime, PressureLevel, ProduceResult, @@ -84,14 +84,14 @@ impl ButtonNode { #[derive(Clone, Debug, PartialEq, Eq)] struct ButtonRuntimeConfig { - endpoint: HardwareEndpointSpec, + endpoint: HwEndpointSpec, id: u32, stable_ms: u64, } #[derive(Clone, Debug, PartialEq, Eq)] struct OpenedButton { - endpoint: HardwareEndpointSpec, + endpoint: HwEndpointSpec, stable_ms: u64, } @@ -167,7 +167,7 @@ impl NodeRuntime for ButtonNode { } fn one_message_map(revision: Revision, id: u32, seq: u32) -> MapSlot { - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); entries.insert(id, ControlMessage::new(id, seq)); MapSlot::with_version(revision, entries) } 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 7a17d08db..db670fca4 100644 --- a/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs +++ b/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs @@ -1143,6 +1143,7 @@ mod tests { use lpc_model::nodes::fixture::{PathSpec, RingOrder}; use lpc_model::{Dim2u, Kind, LpValue, ToLpValue, TreePath}; + use lpc_registry::ProjectRegistry; use lpc_wire::{WireChildKind, WireSlotIndex}; use crate::dataflow::binding::{BindingDraft, BindingPriority, BindingSource, BindingTarget}; @@ -1401,9 +1402,10 @@ mod tests { #[test] fn fixture_diagnostic_led_index_bypasses_visual_input_and_marks_count_groups() { let mut engine = Engine::new(TreePath::parse("/show.t").unwrap()); + let registry = ProjectRegistry::new(); let frame = Revision::new(1); let root = engine.tree().root(); - let (spine, artifact) = test_placeholder_spine(); + let spine = test_placeholder_spine(); let mapping = MappingConfig::path_points_vec( vec![PathSpec::ring_array_counts( [0.5, 0.5], @@ -1427,7 +1429,6 @@ mod tests { source: WireSlotIndex(0), }, spine, - artifact, frame, ) .unwrap(); @@ -1454,14 +1455,19 @@ mod tests { ); engine.add_demand_root(fix_id); - engine.tick(10).unwrap(); + engine.tick(®istry, 10).unwrap(); let extent = ControlExtent::new(1, 36); let request = ControlRenderRequest::unorm16(extent); let mut samples = vec![0u16; extent.sample_count() as usize]; let target = ControlRenderTarget::new(extent, ControlSampleFormat::Unorm16, &mut samples); let layout = engine - .render_control_for_test(ControlProduct::new(fix_id, 0, extent), &request, target) + .render_control_for_test( + ®istry, + ControlProduct::new(fix_id, 0, extent), + &request, + target, + ) .expect("control render"); assert_eq!( @@ -1488,9 +1494,10 @@ mod tests { #[test] fn fixture_diagnostic_path_colors_marks_authored_path_boundaries() { let mut engine = Engine::new(TreePath::parse("/show.t").unwrap()); + let registry = ProjectRegistry::new(); let frame = Revision::new(1); let root = engine.tree().root(); - let (spine, artifact) = test_placeholder_spine(); + let spine = test_placeholder_spine(); let mapping = MappingConfig::path_points_vec( vec![ PathSpec::point_list(0, [[0.0, 0.0], [0.25, 0.0]]), @@ -1509,7 +1516,6 @@ mod tests { source: WireSlotIndex(0), }, spine, - artifact, frame, ) .unwrap(); @@ -1536,14 +1542,19 @@ mod tests { ); engine.add_demand_root(fix_id); - engine.tick(10).unwrap(); + engine.tick(®istry, 10).unwrap(); let extent = ControlExtent::new(1, 15); let request = ControlRenderRequest::unorm16(extent); let mut samples = vec![0u16; extent.sample_count() as usize]; let target = ControlRenderTarget::new(extent, ControlSampleFormat::Unorm16, &mut samples); let layout = engine - .render_control_for_test(ControlProduct::new(fix_id, 0, extent), &request, target) + .render_control_for_test( + ®istry, + ControlProduct::new(fix_id, 0, extent), + &request, + target, + ) .expect("control render"); assert_eq!( @@ -1563,9 +1574,10 @@ mod tests { #[test] fn fixture_diagnostic_groups_10_renders_rgb_color_order_bands() { let mut engine = Engine::new(TreePath::parse("/show.t").unwrap()); + let registry = ProjectRegistry::new(); let frame = Revision::new(1); let root = engine.tree().root(); - let (spine, artifact) = test_placeholder_spine(); + let spine = test_placeholder_spine(); let mapping = MappingConfig::path_points_vec( vec![PathSpec::ring_array_counts( [0.5, 0.5], @@ -1589,7 +1601,6 @@ mod tests { source: WireSlotIndex(0), }, spine, - artifact, frame, ) .unwrap(); @@ -1616,14 +1627,19 @@ mod tests { ); engine.add_demand_root(fix_id); - engine.tick(10).unwrap(); + engine.tick(®istry, 10).unwrap(); let extent = ControlExtent::new(1, 90); let request = ControlRenderRequest::unorm16(extent); let mut samples = vec![0u16; extent.sample_count() as usize]; let target = ControlRenderTarget::new(extent, ControlSampleFormat::Unorm16, &mut samples); let layout = engine - .render_control_for_test(ControlProduct::new(fix_id, 0, extent), &request, target) + .render_control_for_test( + ®istry, + ControlProduct::new(fix_id, 0, extent), + &request, + target, + ) .expect("control render"); for rgb in samples[0..30].chunks_exact(3) { @@ -1643,9 +1659,10 @@ mod tests { fn fixture_demand_resolve_and_tick_share_one_shader_producer_tick_via_resolver_cache() { let ticks = Arc::new(AtomicU32::new(0)); let mut engine = Engine::new(TreePath::parse("/show.t").unwrap()); + let registry = ProjectRegistry::new(); let frame = Revision::new(1); let root = engine.tree().root(); - let (spine, artifact) = test_placeholder_spine(); + let spine = test_placeholder_spine(); let tex_id = engine .tree_mut() @@ -1657,7 +1674,6 @@ mod tests { source: WireSlotIndex(0), }, spine.clone(), - artifact, frame, ) .unwrap(); @@ -1676,7 +1692,6 @@ mod tests { source: WireSlotIndex(0), }, spine.clone(), - artifact, frame, ) .unwrap(); @@ -1717,7 +1732,6 @@ mod tests { source: WireSlotIndex(0), }, spine, - artifact, frame, ) .unwrap(); @@ -1771,7 +1785,7 @@ mod tests { .unwrap(); engine.add_demand_root(fix_id); - engine.tick(10).unwrap(); + engine.tick(®istry, 10).unwrap(); assert_eq!(ticks.load(Ordering::Relaxed), 1); } @@ -1779,10 +1793,11 @@ mod tests { fn fixture_direct_sampling_writes_expected_u16_rgb_for_solid_red_product() { let ticks = Arc::new(AtomicU32::new(0)); let mut engine = Engine::new(TreePath::parse("/show.t").unwrap()); + let registry = ProjectRegistry::new(); engine.set_graphics(Some(Arc::new(crate::Graphics::new()))); let frame = Revision::new(1); let root = engine.tree().root(); - let (spine, artifact) = test_placeholder_spine(); + let spine = test_placeholder_spine(); let tex_id = engine .tree_mut() @@ -1794,7 +1809,6 @@ mod tests { source: WireSlotIndex(0), }, spine.clone(), - artifact, frame, ) .unwrap(); @@ -1813,7 +1827,6 @@ mod tests { source: WireSlotIndex(0), }, spine.clone(), - artifact, frame, ) .unwrap(); @@ -1854,7 +1867,6 @@ mod tests { source: WireSlotIndex(0), }, spine, - artifact, frame, ) .unwrap(); @@ -1908,14 +1920,19 @@ mod tests { .unwrap(); engine.add_demand_root(fix_id); - engine.tick(10).unwrap(); + engine.tick(®istry, 10).unwrap(); let extent = ControlExtent::new(1, 3); let request = ControlRenderRequest::unorm16(extent); let mut samples = vec![0u16; extent.sample_count() as usize]; let target = ControlRenderTarget::new(extent, ControlSampleFormat::Unorm16, &mut samples); let layout = engine - .render_control_for_test(ControlProduct::new(fix_id, 0, extent), &request, target) + .render_control_for_test( + ®istry, + ControlProduct::new(fix_id, 0, extent), + &request, + target, + ) .expect("control render"); assert_eq!(samples, vec![65535u16, 0, 0]); @@ -1926,10 +1943,11 @@ mod tests { #[test] fn fixture_direct_sampling_sends_pixel_space_points_and_output_size() { let mut engine = Engine::new(TreePath::parse("/show.t").unwrap()); + let registry = ProjectRegistry::new(); engine.set_graphics(Some(Arc::new(crate::Graphics::new()))); let frame = Revision::new(1); let root = engine.tree().root(); - let (spine, artifact) = test_placeholder_spine(); + let spine = test_placeholder_spine(); let sh_id = engine .tree_mut() @@ -1941,7 +1959,6 @@ mod tests { source: WireSlotIndex(0), }, spine.clone(), - artifact, frame, ) .unwrap(); @@ -1984,7 +2001,6 @@ mod tests { source: WireSlotIndex(0), }, spine, - artifact, frame, ) .unwrap(); @@ -2037,14 +2053,19 @@ mod tests { .unwrap(); engine.add_demand_root(fix_id); - engine.tick(10).unwrap(); + engine.tick(®istry, 10).unwrap(); let extent = ControlExtent::new(1, 6); let request = ControlRenderRequest::unorm16(extent); let mut samples = vec![0u16; extent.sample_count() as usize]; let target = ControlRenderTarget::new(extent, ControlSampleFormat::Unorm16, &mut samples); engine - .render_control_for_test(ControlProduct::new(fix_id, 0, extent), &request, target) + .render_control_for_test( + ®istry, + ControlProduct::new(fix_id, 0, extent), + &request, + target, + ) .expect("control render"); assert_eq!(samples, vec![1000u16, 2000, 3000, 4000, 5000, 6000]); diff --git a/lp-core/lpc-engine/src/nodes/fixture/mapping/svg_path/mod.rs b/lp-core/lpc-engine/src/nodes/fixture/mapping/svg_path/mod.rs index 53fd911fe..bee0d3861 100644 --- a/lp-core/lpc-engine/src/nodes/fixture/mapping/svg_path/mod.rs +++ b/lp-core/lpc-engine/src/nodes/fixture/mapping/svg_path/mod.rs @@ -6,7 +6,7 @@ mod svg_path_fit; mod svg_path_group; mod svg_path_parser; -use alloc::collections::BTreeMap; +use lp_collection::VecMap; use lpc_model::nodes::fixture::{MappingConfig, PathSpec}; use lpc_model::{EnumSlot, MapSlot}; @@ -26,7 +26,7 @@ pub fn resolve_svg_path_mapping( let mut parsed = parse_svg_path_groups(svg)?; parsed.groups.sort_by_key(|group| group.path_index); - let mut paths = BTreeMap::new(); + let mut paths = VecMap::new(); let mut first_channel = 0u32; let mut previous_path_index = None; for group in &parsed.groups { 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 968cacfbb..d7333b360 100644 --- a/lp-core/lpc-engine/src/nodes/fluid/fluid_node.rs +++ b/lp-core/lpc-engine/src/nodes/fluid/fluid_node.rs @@ -284,21 +284,20 @@ pub fn fluid_output_path() -> SlotPath { #[cfg(test)] mod tests { use super::*; - use alloc::collections::BTreeMap; use alloc::sync::Arc; + use lp_collection::VecMap; use lpc_model::{ LpValue, NodeName, ProductRef, Revision, SlotMapDyn, ToLpValue, TreePath, WithRevision, }; - use lpc_wire::{WireSlotMutationId, WireSlotMutationOp, WireSlotMutationRequest}; use lpfs::lp_path::AsLpPath; use lpfs::{LpFs, LpFsMemory}; use crate::dataflow::resolver::ResolveLogLevel; - use crate::engine::{EngineServices, ProjectLoader, resolve_with_engine_host}; + use crate::engine::{EngineServices, ProjectLoader}; #[test] fn emitters_from_slot_data_reads_value_map() { - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); entries.insert( SlotMapKey::U32(4), SlotData::Value(WithRevision::new( @@ -330,7 +329,7 @@ mod tests { kind = "Project" [nodes.fluid] -def = { path = "./fluid.toml" } +ref = "./fluid.toml" "#, ) .expect("project"); @@ -365,15 +364,15 @@ intensity = 2.0 .expect("fluid node"); engine.tick(16).expect("tick fluid"); - let (production, _) = resolve_with_engine_host( - &mut engine, - QueryKey::ProducedSlot { - node: fluid, - slot: fluid_output_path(), - }, - ResolveLogLevel::Off, - ) - .expect("resolve fluid output"); + let (production, _) = engine + .resolve_with_engine_host( + QueryKey::ProducedSlot { + node: fluid, + slot: fluid_output_path(), + }, + ResolveLogLevel::Off, + ) + .expect("resolve fluid output"); let LpValue::Product(ProductRef::Visual(product)) = production.value_leaf().expect("value").value() else { @@ -401,69 +400,6 @@ intensity = 2.0 ); } - #[test] - fn fluid_node_steps_from_clock_time() { - let (mut engine, clock, fluid) = load_fluid_clock_project("/fluid_clock.test", 1.0, 0.25); - - engine.tick(1000).expect("first tick"); - let first = render_fluid_bytes(&mut engine, fluid); - - let responses = engine.mutate_project_slots(vec![WireSlotMutationRequest { - id: WireSlotMutationId::new(1), - root: alloc::format!("node.{}.def", clock.0), - path: SlotPath::parse("controls.running").unwrap(), - expected_shape_version: Revision::default(), - expected_data_version: Revision::default(), - op: WireSlotMutationOp::SetValue(LpValue::Bool(false)), - }]); - assert!(matches!( - responses[0].result, - lpc_wire::WireSlotMutationResult::Accepted - )); - - engine.tick(1000).expect("second tick"); - let second = render_fluid_bytes(&mut engine, fluid); - - assert_eq!(first, second, "paused clock should pause fluid stepping"); - } - - #[test] - fn fluid_node_reanchors_after_clock_scrubs_backward() { - let (mut engine, clock, fluid) = - load_fluid_clock_project("/fluid_clock_scrub.test", 1.0, 0.25); - - engine.tick(1000).expect("first tick"); - engine.tick(1000).expect("second tick"); - let before_scrub = render_fluid_bytes(&mut engine, fluid); - - let responses = engine.mutate_project_slots(vec![WireSlotMutationRequest { - id: WireSlotMutationId::new(1), - root: alloc::format!("node.{}.def", clock.0), - path: SlotPath::parse("controls.scrub_offset_seconds").unwrap(), - expected_shape_version: Revision::default(), - expected_data_version: Revision::default(), - op: WireSlotMutationOp::SetValue(LpValue::F32(-2.0)), - }]); - assert!(matches!( - responses[0].result, - lpc_wire::WireSlotMutationResult::Accepted - )); - - engine.tick(1000).expect("backward scrub tick"); - let after_scrub = render_fluid_bytes(&mut engine, fluid); - assert_eq!( - before_scrub, after_scrub, - "backward scrub should reanchor without advancing the fluid" - ); - - engine.tick(1000).expect("post scrub tick"); - let after_resume = render_fluid_bytes(&mut engine, fluid); - assert_ne!( - after_scrub, after_resume, - "fluid should resume once clock time advances from the reanchored point" - ); - } - #[test] fn fluid_node_consumes_compute_emitter_map_through_bus() { let fs = LpFsMemory::new(); @@ -473,10 +409,10 @@ intensity = 2.0 kind = "Project" [nodes.compute] -def = { path = "./compute.toml" } +ref = "./compute.toml" [nodes.fluid] -def = { path = "./fluid.toml" } +ref = "./fluid.toml" "#, ) .expect("project"); @@ -543,15 +479,15 @@ source = "bus#fluid.emitters" .expect("fluid node"); engine.tick(16).expect("tick fluid graph"); - let (production, _) = resolve_with_engine_host( - &mut engine, - QueryKey::ProducedSlot { - node: fluid, - slot: fluid_output_path(), - }, - ResolveLogLevel::Off, - ) - .expect("resolve fluid output"); + let (production, _) = engine + .resolve_with_engine_host( + QueryKey::ProducedSlot { + node: fluid, + slot: fluid_output_path(), + }, + ResolveLogLevel::Off, + ) + .expect("resolve fluid output"); let LpValue::Product(ProductRef::Visual(product)) = production.value_leaf().expect("value").value() else { @@ -578,93 +514,4 @@ source = "bus#fluid.emitters" .any(|px| u16::from_le_bytes([px[2], px[3]]) > 0) ); } - - fn render_fluid_bytes(engine: &mut crate::engine::Engine, fluid: NodeId) -> Vec { - let (production, _) = resolve_with_engine_host( - engine, - QueryKey::ProducedSlot { - node: fluid, - slot: fluid_output_path(), - }, - ResolveLogLevel::Off, - ) - .expect("resolve fluid output"); - let LpValue::Product(ProductRef::Visual(product)) = - production.value_leaf().expect("value").value() - else { - panic!("visual product"); - }; - engine - .render_texture_for_test( - *product, - &RenderTextureRequest { - width: 8, - height: 8, - format: TextureStorageFormat::Rgba16Unorm, - time_seconds: 0.0, - }, - ) - .expect("render fluid texture") - .try_raw_bytes() - .expect("bytes") - .to_vec() - } - - fn load_fluid_clock_project( - root_path: &str, - emitter_velocity: f32, - emitter_intensity: f32, - ) -> (crate::engine::Engine, NodeId, NodeId) { - let fs = LpFsMemory::new(); - fs.write_file( - "/project.toml".as_path(), - br#" -kind = "Project" - -[nodes.clock] -def = { path = "./clock.toml" } - -[nodes.fluid] -def = { path = "./fluid.toml" } -"#, - ) - .expect("project"); - fs.write_file("/clock.toml".as_path(), br#"kind = "Clock""#) - .expect("clock"); - - let fluid_toml = alloc::format!( - r#" -kind = "Fluid" -size = {{ width = 8, height = 8 }} -solver_iterations = 1 -step_hz = 1.0 -fade_speed = 0.0 -viscosity = 0.00003 - -[emitters.1] -id = 1 -pos = [0.5, 0.5] -dir = [1.0, 0.0] -radius = 0.2 -color = [1.0, 0.0, 0.0] -velocity = {emitter_velocity:.6} -intensity = {emitter_intensity:.6} -"# - ); - fs.write_file("/fluid.toml".as_path(), fluid_toml.as_bytes()) - .expect("fluid"); - - let services = EngineServices::new(TreePath::parse(root_path).unwrap()); - let engine = ProjectLoader::load_from_root(&fs, services).expect("load"); - let root = engine.tree().root(); - let clock = engine - .tree() - .lookup_sibling(root, NodeName::parse("clock").unwrap()) - .expect("clock node"); - let fluid = engine - .tree() - .lookup_sibling(root, NodeName::parse("fluid").unwrap()) - .expect("fluid node"); - (engine, clock, fluid) - } } diff --git a/lp-core/lpc-engine/src/nodes/placeholder/mod.rs b/lp-core/lpc-engine/src/nodes/placeholder/mod.rs index 710143e09..ba9f1e371 100644 --- a/lp-core/lpc-engine/src/nodes/placeholder/mod.rs +++ b/lp-core/lpc-engine/src/nodes/placeholder/mod.rs @@ -1,12 +1,17 @@ -//! Minimal [`crate::node::NodeRuntime`] stubs for M4 source loading before real core nodes land. +//! Minimal [`crate::node::NodeRuntime`] for projected nodes without behavior. use lpc_model::NodeKind; use crate::node::{DestroyCtx, MemPressureCtx, NodeError, NodeRuntime, PressureLevel}; -/// Placeholder runtime node used while wiring source load into the core tree. +/// Runtime placeholder for synthetic projection nodes and load-error entries. +/// +/// Project projection sometimes needs a runtime tree entry before there is a +/// concrete behavior to attach, or for a node whose definition is currently in +/// an error state. This node keeps those entries addressable without producing +/// values of its own. pub struct CorePlaceholderNode { - /// `None` for synthetic spine folders (`*.folder` segments). + /// `None` for synthetic spine folders. pub kind: Option, } 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 8e8dfab4f..3a9242700 100644 --- a/lp-core/lpc-engine/src/nodes/playlist/playlist_node.rs +++ b/lp-core/lpc-engine/src/nodes/playlist/playlist_node.rs @@ -1,8 +1,8 @@ //! Runtime playlist node: selects and blends owned visual child entries. -use alloc::collections::BTreeMap; use alloc::format; use alloc::vec::Vec; +use lp_collection::VecMap; use lpc_model::{ ControlMessage, FromLpValue, NodeId, PlaylistState, SlotAccess, SlotData, SlotPath, @@ -40,7 +40,7 @@ pub struct PlaylistNode { switch_time: f32, transition_start_time: f32, transition_duration: f32, - last_seen_triggers: BTreeMap<(u32, u32), u32>, + last_seen_triggers: VecMap<(u32, u32), u32>, } impl PlaylistNode { @@ -67,7 +67,7 @@ impl PlaylistNode { switch_time: 0.0, transition_start_time: 0.0, transition_duration: 0.0, - last_seen_triggers: BTreeMap::new(), + last_seen_triggers: VecMap::new(), } } @@ -388,7 +388,7 @@ pub fn playlist_output_path() -> SlotPath { fn detect_triggered_entry( ctx: &mut TickContext<'_>, entries: &[PlaylistRuntimeEntry], - last_seen: &mut BTreeMap<(u32, u32), u32>, + last_seen: &mut VecMap<(u32, u32), u32>, ) -> Result, NodeError> { let mut triggered = None; for entry in entries { 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 62ec0b4fa..fb270d96d 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 @@ -1,16 +1,14 @@ //! Runtime control radio node: mirrors control events between a graph bus and radio channel. use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::format; use alloc::vec::Vec; +use lp_collection::VecMap; +use lpc_hardware::{RadioChannelId, RadioConfig, RadioDevice, RadioMessage, RadioMessageKind}; use lpc_model::{ - ControlMessage, ControlRadioDefView, ControlRadioState, FromLpValue, HardwareEndpointSpec, - MapSlot, SlotAccess, SlotData, SlotPath, SlotShapeRegistry, SlotShapeRegistryError, -}; -use lpc_shared::hardware::{ - RadioChannelId, RadioConfig, RadioDevice, RadioMessage, RadioMessageKind, + ControlMessage, ControlRadioDefView, ControlRadioState, FromLpValue, HwEndpointSpec, MapSlot, + SlotAccess, SlotData, SlotPath, SlotShapeRegistry, SlotShapeRegistryError, }; use crate::dataflow::resolver::QueryKey; @@ -112,7 +110,7 @@ impl ControlRadioNode { &mut self, ctx: &mut TickContext<'_>, repeat_count: u32, - accepted: &mut BTreeMap, + accepted: &mut VecMap, ) -> Result<(), NodeError> { for message in resolve_input_messages(ctx)? { let key = ControlMessageKey::from(message); @@ -157,7 +155,7 @@ impl ControlRadioNode { fn receive_remote( &mut self, channel: RadioChannelId, - accepted: &mut BTreeMap, + accepted: &mut VecMap, ) -> Result<(), NodeError> { self.receive_buffer.clear(); self.device @@ -186,17 +184,17 @@ impl ControlRadioNode { fn publish_output( &mut self, ctx: &mut TickContext<'_>, - accepted: BTreeMap, + accepted: VecMap, ) -> Result<(), NodeError> { self.state.output = MapSlot::with_version(ctx.revision(), accepted); ctx.publish_runtime_slot(&self.state, control_radio_output_path()) } - fn current_frame_output(&self, revision: lpc_model::Revision) -> BTreeMap { + fn current_frame_output(&self, revision: lpc_model::Revision) -> VecMap { if self.state.output.keys_revision == revision { self.state.output.entries.clone() } else { - BTreeMap::new() + VecMap::new() } } @@ -207,7 +205,7 @@ impl ControlRadioNode { #[derive(Clone, Debug, PartialEq, Eq)] struct ControlRadioRuntimeConfig { - endpoint: HardwareEndpointSpec, + endpoint: HwEndpointSpec, channel: RadioChannelId, repeat_count: u32, wifi_channel: Option, @@ -215,7 +213,7 @@ struct ControlRadioRuntimeConfig { #[derive(Clone, Debug, PartialEq, Eq)] struct OpenedRadio { - endpoint: HardwareEndpointSpec, + endpoint: HwEndpointSpec, channel: RadioChannelId, wifi_channel: Option, } diff --git a/lp-core/lpc-engine/src/nodes/shader/compute_materialize.rs b/lp-core/lpc-engine/src/nodes/shader/compute_materialize.rs index 068043c36..b48cdfffa 100644 --- a/lp-core/lpc-engine/src/nodes/shader/compute_materialize.rs +++ b/lp-core/lpc-engine/src/nodes/shader/compute_materialize.rs @@ -1,8 +1,8 @@ //! Materialize compute shader ABI outputs into slot data. -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::String; +use lp_collection::VecMap; use lpc_model::{ Revision, ShaderMapKeyDef, ShaderSlotDef, ShaderSlotKind, ShaderSlotMappingKind, SlotData, @@ -95,7 +95,7 @@ fn materialize_map_slot( ))); }; - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); for item in items.iter() { let key = extract_key(slot_name, key_field, key_def.value(), item)?; if key == SlotMapKey::U32(empty_key) { 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 524eb5454..778da35f3 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 @@ -6,16 +6,19 @@ use alloc::string::String; use alloc::vec::Vec; use lpc_model::{ - AddSubMode, ComputeShaderDef, DivMode, MulMode, NodeId, SlotAccess, SlotPath, - SlotShapeRegistry, SlotShapeRegistryError, + AddSubMode, AssetLocation, ComputeShaderDef, DivMode, MulMode, NodeId, NodeRuntimeStatus, + Revision, ShaderHeaderGenError, SlotAccess, SlotPath, SlotShapeRegistry, + SlotShapeRegistryError, generate_compute_shader_header, }; +use lpc_registry::AssetText; use lps_shared::LpsValueF32; use crate::dataflow::resolver::QueryKey; use crate::gfx::{LpComputeShader, ShaderCompileOptions, compute_desc_from_model_def}; use crate::node::catch_node_panic::catch_panic; use crate::node::{ - DestroyCtx, MemPressureCtx, NodeError, NodeRuntime, PressureLevel, ProduceResult, TickContext, + AssetRefreshContext, AssetRefreshResult, DestroyCtx, MemPressureCtx, NodeError, NodeRuntime, + PressureLevel, ProduceResult, TickContext, }; use super::compute_materialize::materialize_produced_slot; @@ -30,6 +33,7 @@ use super::shader_node::{ pub struct ComputeShaderNode { node_id: NodeId, def: ComputeShaderDef, + source_asset: Option, glsl_source: String, shader: Option>, compilation_error: Option, @@ -47,6 +51,7 @@ impl ComputeShaderNode { Self { node_id, def, + source_asset: None, glsl_source, shader: None, compilation_error: None, @@ -54,16 +59,47 @@ impl ComputeShaderNode { } } + pub fn from_asset_text( + node_id: NodeId, + def: ComputeShaderDef, + source: AssetText, + slot_shapes: &SlotShapeRegistry, + revision: Revision, + ) -> Result { + let glsl_source = compute_glsl_source(&def, &source.text, slot_shapes)?; + let mut node = Self::new(node_id, def, glsl_source, revision); + node.source_asset = Some(RuntimeSourceAsset { + location: source.location, + revision: source.revision, + }); + Ok(node) + } + pub fn compilation_error(&self) -> Option<&str> { self.compilation_error.as_deref() } - fn ensure_compiled(&mut self, ctx: &TickContext<'_>) -> Result<(), NodeError> { + fn refresh_source_asset( + &mut self, + source: AssetText, + slot_shapes: &SlotShapeRegistry, + ) -> Result<(), ShaderHeaderGenError> { + self.glsl_source = compute_glsl_source(&self.def, &source.text, slot_shapes)?; + self.source_asset = Some(RuntimeSourceAsset { + location: source.location, + revision: source.revision, + }); + self.shader = None; + self.compilation_error = None; + Ok(()) + } + + fn ensure_compiled(&mut self, ctx: &TickContext<'_>) -> Result { if self.shader.is_some() { - return Ok(()); + return Ok(true); } - if let Some(error) = &self.compilation_error { - return Err(NodeError::msg(format!("compute shader compile: {error}"))); + if self.compilation_error.is_some() { + return Ok(false); } let graphics = ctx @@ -74,13 +110,23 @@ impl ComputeShaderNode { max_errors: Some(20), ..Default::default() }; - let desc = compute_desc_from_model_def( + let desc = match compute_desc_from_model_def( self.glsl_source.as_str(), &self.def, ctx.slot_shapes(), compile_opts.to_compiler_config(), - ) - .map_err(|e| NodeError::msg(format!("compute descriptor: {e}")))?; + ) { + Ok(desc) => desc, + Err(error) => { + let error = format!("compute descriptor: {error}"); + self.compilation_error = Some(error.clone()); + log::warn!( + "[compute-shader-node] descriptor generation failed (node={:?}): {error}", + self.node_id + ); + return Ok(false); + } + }; log::info!( "[compute-shader-node] compilation starting (node={:?}, {} bytes)", @@ -106,10 +152,10 @@ impl ComputeShaderNode { self.node_id, format_compile_stats(compile_elapsed_ms, stats) ); - Ok(()) + Ok(true) } Err(error) => { - self.compilation_error = Some(error.clone()); + self.compilation_error = Some(format!("compute shader compile: {error}")); self.shader = None; if let Some(compile_elapsed_ms) = compile_elapsed_ms { log::warn!( @@ -123,7 +169,7 @@ impl ComputeShaderNode { self.node_id ); } - Err(NodeError::msg(format!("compute shader compile: {error}"))) + Ok(false) } } } @@ -208,7 +254,9 @@ impl NodeRuntime for ComputeShaderNode { .iter() .map(|(name, value)| (name.as_str(), value.clone())) .collect(); - self.ensure_compiled(ctx)?; + if !self.ensure_compiled(ctx)? { + return Ok(ProduceResult::Produced); + } let shader = self .shader .as_mut() @@ -220,6 +268,36 @@ impl NodeRuntime for ComputeShaderNode { Ok(ProduceResult::Produced) } + fn refresh_asset( + &mut self, + location: &AssetLocation, + ctx: &mut AssetRefreshContext<'_>, + ) -> Result { + let Some(source_asset) = &self.source_asset else { + return Ok(AssetRefreshResult::Unused); + }; + if location != &source_asset.location { + return Ok(AssetRefreshResult::Unused); + } + + let source = match ctx.read_asset_text_if_changed(location, source_asset.revision) { + Ok(Some(source)) => source, + Ok(None) => return Ok(AssetRefreshResult::Unchanged), + Err(err) => { + self.shader = None; + self.compilation_error = Some(format!("read compute shader source: {err:?}")); + return Ok(AssetRefreshResult::Refreshed); + } + }; + + let slot_shapes = ctx.slot_shapes(); + if let Err(err) = self.refresh_source_asset(source, slot_shapes) { + self.shader = None; + self.compilation_error = Some(format!("generate compute shader header: {err}")); + } + Ok(AssetRefreshResult::Refreshed) + } + fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { Ok(()) } @@ -232,6 +310,12 @@ impl NodeRuntime for ComputeShaderNode { Ok(()) } + fn runtime_status(&self) -> Option { + self.compilation_error + .as_ref() + .map(|error| NodeRuntimeStatus::Error(error.clone())) + } + fn runtime_state_slots(&self) -> Option<&dyn SlotAccess> { Some(&self.state) } @@ -247,6 +331,21 @@ impl NodeRuntime for ComputeShaderNode { } } +#[derive(Clone, Debug, PartialEq, Eq)] +struct RuntimeSourceAsset { + location: AssetLocation, + revision: Revision, +} + +fn compute_glsl_source( + def: &ComputeShaderDef, + source: &str, + slot_shapes: &SlotShapeRegistry, +) -> Result { + let header = generate_compute_shader_header(def, slot_shapes)?; + Ok(format!("{header}\n{source}")) +} + fn resolve_or_default_input( ctx: &mut TickContext<'_>, name: &str, @@ -273,27 +372,27 @@ fn resolve_or_default_input( #[cfg(all(test, not(any(target_arch = "riscv32", target_arch = "wasm32"))))] mod tests { use super::*; - use alloc::collections::BTreeMap; use alloc::string::String; use alloc::sync::Arc; + use lp_collection::VecMap; use lpc_model::{ - ArtifactLocator, BindingDefs, EnumSlot, LpValue, MapSlot, NodeDef, NodeInvocation, - ShaderSource, SlotDataAccess, TreePath, ValueSlot, generate_compute_shader_header, - lookup_slot_data, + ArtifactSpec, AssetSlot, BindingDefs, LpValue, MapSlot, NodeDef, NodeInvocation, + SlotDataAccess, TreePath, ValueSlot, generate_compute_shader_header, lookup_slot_data, }; + use lpc_registry::ProjectRegistry; use lpc_wire::{WireChildKind, WireSlotIndex}; - use crate::artifact::ArtifactLocation; use crate::dataflow::resolver::{QueryKey, ResolveLogLevel}; use crate::engine::{Engine, resolve_with_engine_host}; use crate::node::NodeEntryState; #[test] fn compute_node_executes_and_publishes_dynamic_state() { - let (mut engine, node_id) = build_compute_engine(); + let (mut engine, registry, node_id) = build_compute_engine(); let phase = resolve_with_engine_host( &mut engine, + ®istry, QueryKey::ProducedSlot { node: node_id, slot: SlotPath::parse("phase").expect("phase path"), @@ -334,11 +433,12 @@ mod tests { )); } - fn build_compute_engine() -> (Engine, NodeId) { - let registry = lpc_model::SlotShapeRegistry::default(); + fn build_compute_engine() -> (Engine, ProjectRegistry, NodeId) { + let shapes = lpc_model::SlotShapeRegistry::default(); + let mut registry = ProjectRegistry::new(); let def = compute_def(); - let header = generate_compute_shader_header(&def, ®istry).expect("header"); + let header = generate_compute_shader_header(&def, &shapes).expect("header"); let glsl = format!( r#"{header} void tick() {{ @@ -357,15 +457,6 @@ void tick() {{ let mut engine = Engine::new(TreePath::parse("/compute.show").expect("path")); engine.set_graphics(Some(Arc::new(crate::Graphics::new()))); let frame = lpc_model::Revision::new(1); - let artifact = engine - .artifacts_mut() - .acquire_location(ArtifactLocation::file("compute.toml"), frame); - engine - .artifacts_mut() - .load_with(&artifact, frame, |_| { - Ok(NodeDef::ComputeShader(def.clone())) - }) - .expect("artifact"); let root = engine.tree().root(); let node_id = engine .tree_mut() @@ -376,11 +467,17 @@ void tick() {{ WireChildKind::Input { source: WireSlotIndex(0), }, - NodeInvocation::new(ArtifactLocator::path("compute.toml")), - artifact, + NodeInvocation::new(ArtifactSpec::path("compute.toml")), frame, ) .expect("node"); + engine + .load_test_node_defs( + &mut registry, + &[(node_id, NodeDef::ComputeShader(def.clone()))], + frame, + ) + .expect("load test defs"); engine .attach_runtime_node( node_id, @@ -388,17 +485,17 @@ void tick() {{ frame, ) .expect("attach"); - (engine, node_id) + (engine, registry, node_id) } fn compute_def() -> ComputeShaderDef { - let mut consumed = BTreeMap::new(); + let mut consumed = VecMap::new(); consumed.insert( String::from("time"), lpc_model::ShaderSlotDef::value_f32("Time", "Seconds", 0.25, None), ); - let mut produced = BTreeMap::new(); + let mut produced = VecMap::new(); produced.insert( String::from("phase"), lpc_model::ShaderSlotDef { @@ -422,7 +519,7 @@ void tick() {{ ); ComputeShaderDef { - source: EnumSlot::new(ShaderSource::path("emitters.glsl")), + source: AssetSlot::path("emitters.glsl"), bindings: BindingDefs::default(), glsl_opts: lpc_model::GlslOpts::default(), consumed_slots: MapSlot::new(consumed), diff --git a/lp-core/lpc-engine/src/nodes/shader/compute_shader_state.rs b/lp-core/lpc-engine/src/nodes/shader/compute_shader_state.rs index 614dae690..14a9cf401 100644 --- a/lp-core/lpc-engine/src/nodes/shader/compute_shader_state.rs +++ b/lp-core/lpc-engine/src/nodes/shader/compute_shader_state.rs @@ -1,9 +1,9 @@ //! Dynamic runtime state root for one compute shader node. -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; +use lp_collection::VecMap; use lpc_model::{ ComputeShaderDef, LpType, OrderedF32, Revision, ShaderMapKeyDef, ShaderSlotDef, ShaderSlotKind, @@ -231,7 +231,7 @@ fn empty_data_for_slot(slot: &ShaderSlotDef, revision: Revision) -> SlotData { )), ShaderSlotKind::Map => SlotData::Map(lpc_model::SlotMapDyn::with_revision( revision, - BTreeMap::new(), + VecMap::new(), )), } } diff --git a/lp-core/lpc-engine/src/nodes/shader/mod.rs b/lp-core/lpc-engine/src/nodes/shader/mod.rs index fc918eea1..61610ce69 100644 --- a/lp-core/lpc-engine/src/nodes/shader/mod.rs +++ b/lp-core/lpc-engine/src/nodes/shader/mod.rs @@ -3,3 +3,4 @@ pub mod compute_shader_node; pub mod compute_shader_state; pub mod shader_input_materialize; pub mod shader_node; +// TODO-Refactor: Move the two shader nodes into their own directories & rename: compute_shader, visual_shader diff --git a/lp-core/lpc-engine/src/nodes/shader/shader_input_materialize.rs b/lp-core/lpc-engine/src/nodes/shader/shader_input_materialize.rs index dea2c5ffb..09e6f5f56 100644 --- a/lp-core/lpc-engine/src/nodes/shader/shader_input_materialize.rs +++ b/lp-core/lpc-engine/src/nodes/shader/shader_input_materialize.rs @@ -1,9 +1,9 @@ //! Materialize resolved model slot data into shader ABI input values. -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use lpc_model::{ LpType, LpValue, ShaderMapKeyDef, ShaderSlotDef, ShaderSlotKind, ShaderSlotMappingKind, @@ -143,14 +143,14 @@ fn materialize_map_input( set_u32_field(slot_name, item, key_field, empty_key)?; } - let entries: BTreeMap = match data { + let entries: VecMap = match data { Some(SlotData::Map(map)) => map.entries.clone(), Some(_) => { return Err(ShaderInputMaterializeError::ExpectedMap(String::from( slot_name, ))); } - None => BTreeMap::new(), + None => VecMap::new(), }; if entries.len() > len_usize { return Err(ShaderInputMaterializeError::TooManyEntries { @@ -295,7 +295,7 @@ fn validate_u32_field( #[cfg(test)] mod tests { use super::*; - use alloc::collections::BTreeMap; + use lp_collection::VecMap; use lpc_model::{ CONTROL_MESSAGE_SHAPE_NAME, ControlMessage, Revision, ShaderSlotMappingDef, SlotMapDyn, ToLpValue, WithRevision, @@ -310,7 +310,7 @@ mod tests { ); let data = SlotData::Map(SlotMapDyn::with_revision( Revision::new(4), - BTreeMap::from([( + VecMap::from([( SlotMapKey::U32(7), SlotData::Value(WithRevision::new( Revision::new(4), @@ -358,7 +358,7 @@ mod tests { ); let data = SlotData::Map(SlotMapDyn::with_revision( Revision::new(4), - BTreeMap::from([( + VecMap::from([( SlotMapKey::U32(7), SlotData::Value(WithRevision::new( Revision::new(4), 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 77fec1f3a..76248b325 100644 --- a/lp-core/lpc-engine/src/nodes/shader/shader_node.rs +++ b/lp-core/lpc-engine/src/nodes/shader/shader_node.rs @@ -6,11 +6,13 @@ use alloc::string::{String, ToString}; use alloc::vec::Vec; use lpc_model::{ - AddSubMode, DivMode, GlslOpts, MapSlot, MulMode, NodeId, ShaderMapKeyDef, ShaderSlotDef, - ShaderSlotKind, ShaderSlotMappingKind, ShaderState, ShaderValueShapeRef, SlotAccess, SlotPath, - SlotShapeRegistry, SlotShapeRegistryError, StaticSlotShape, ValueSlot, + AddSubMode, AssetLocation, DivMode, GlslOpts, MapSlot, MulMode, NodeId, NodeRuntimeStatus, + Revision, ShaderMapKeyDef, ShaderSlotDef, ShaderSlotKind, ShaderSlotMappingKind, ShaderState, + ShaderValueShapeRef, SlotAccess, SlotPath, SlotShapeRegistry, SlotShapeRegistryError, + StaticSlotShape, ValueSlot, }; use lpc_model::{ShaderDef, SlotAccessor}; +use lpc_registry::AssetText; use lps_shared::LpsValueF32; use lps_shared::TextureBuffer; @@ -19,8 +21,8 @@ use crate::gfx::uniforms::{VisualUniform, build_uniforms}; use crate::gfx::{LpShader, ShaderCompileOptions, ShaderCompileStats}; use crate::node::catch_node_panic::catch_panic; use crate::node::{ - DestroyCtx, MemPressureCtx, NodeError, NodeRuntime, PressureLevel, ProduceResult, - RenderContext, RenderNode, RuntimeStateShape, TickContext, + AssetRefreshContext, AssetRefreshResult, DestroyCtx, MemPressureCtx, NodeError, NodeRuntime, + PressureLevel, ProduceResult, RenderContext, RenderNode, RuntimeStateShape, TickContext, }; use crate::products::visual::{RenderTextureRequest, TextureRenderProduct, VisualProduct}; use crate::products::visual::{VisualSampleBufferRequest, VisualSampleTarget}; @@ -32,6 +34,8 @@ const SHADER_COMPILE_MAX_ERRORS: usize = 20; /// Shader producer wired to the core engine. pub struct ShaderNode { node_id: NodeId, + source_location: AssetLocation, + source_revision: Revision, glsl_source: String, consumed_slots: MapSlot, glsl_opts: GlslOpts, @@ -43,11 +47,13 @@ pub struct ShaderNode { } impl ShaderNode { - pub fn new(node_id: NodeId, def: ShaderDef, glsl_source: String) -> Self { + pub fn new(node_id: NodeId, def: ShaderDef, source: AssetText) -> Self { let visual_uniforms = default_uniforms(&def.consumed_slots); Self { node_id, - glsl_source, + source_location: source.location, + source_revision: source.revision, + glsl_source: source.text, consumed_slots: def.consumed_slots, glsl_opts: def.glsl_opts, visual_uniforms, @@ -70,12 +76,19 @@ impl ShaderNode { self.compilation_error.as_deref() } - fn ensure_compiled(&mut self, ctx: &RenderContext<'_>) -> Result<(), NodeError> { + fn refresh_source(&mut self, source: AssetText) { + self.source_revision = source.revision; + self.glsl_source = source.text; + self.shader = None; + self.compilation_error = None; + } + + fn ensure_compiled(&mut self, ctx: &RenderContext<'_>) -> Result { if self.shader.is_some() { - return Ok(()); + return Ok(true); } - if let Some(error) = &self.compilation_error { - return Err(NodeError::msg(format!("shader compile: {error}"))); + if self.compilation_error.is_some() { + return Ok(false); } let graphics = ctx @@ -113,10 +126,10 @@ impl ShaderNode { self.node_id, format_compile_stats(compile_elapsed_ms, stats) ); - Ok(()) + Ok(true) } Err(error) => { - self.compilation_error = Some(error.clone()); + self.compilation_error = Some(format!("shader compile: {error}")); self.shader = None; if let Some(compile_elapsed_ms) = compile_elapsed_ms { log::warn!( @@ -130,7 +143,7 @@ impl ShaderNode { self.node_id ); } - Err(NodeError::msg(format!("shader compile: {error}"))) + Ok(false) } } } @@ -202,6 +215,29 @@ impl NodeRuntime for ShaderNode { Ok(ProduceResult::Produced) } + fn refresh_asset( + &mut self, + location: &AssetLocation, + ctx: &mut AssetRefreshContext<'_>, + ) -> Result { + if location != &self.source_location { + return Ok(AssetRefreshResult::Unused); + } + + let source = match ctx.read_asset_text_if_changed(location, self.source_revision) { + Ok(Some(source)) => source, + Ok(None) => return Ok(AssetRefreshResult::Unchanged), + Err(err) => { + self.shader = None; + self.compilation_error = Some(format!("read shader source: {err:?}")); + return Ok(AssetRefreshResult::Refreshed); + } + }; + + self.refresh_source(source); + Ok(AssetRefreshResult::Refreshed) + } + fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { Ok(()) } @@ -214,6 +250,12 @@ impl NodeRuntime for ShaderNode { Ok(()) } + fn runtime_status(&self) -> Option { + self.compilation_error + .as_ref() + .map(|error| NodeRuntimeStatus::Error(error.clone())) + } + fn runtime_state_slots(&self) -> Option<&dyn SlotAccess> { Some(&self.state) } @@ -493,7 +535,17 @@ impl RenderNode for ShaderNode { ))); } - self.ensure_compiled(ctx)?; + if !self.ensure_compiled(ctx)? { + log::warn!( + "[shader-node] rendering black fallback texture (node={:?}): {}", + self.node_id, + self.compilation_error + .as_deref() + .unwrap_or("shader not compiled") + ); + target.data_mut().fill(0); + return Ok(()); + } let uniforms = build_uniforms(request.width, request.height, &self.visual_uniforms); let shader = self .shader @@ -523,7 +575,17 @@ impl RenderNode for ShaderNode { ))); } - self.ensure_compiled(ctx)?; + if !self.ensure_compiled(ctx)? { + log::warn!( + "[shader-node] sampling black fallback (node={:?}): {}", + self.node_id, + self.compilation_error + .as_deref() + .unwrap_or("shader not compiled") + ); + target.samples.data_mut().fill(0); + return Ok(()); + } let uniforms = build_uniforms( request.output_width, request.output_height, @@ -616,14 +678,13 @@ pub(super) fn map_model_q32_options(opts: &GlslOpts) -> lps_q32::q32_options::Q3 #[cfg(test)] mod tests { - use alloc::collections::BTreeMap; use alloc::string::String; use alloc::sync::Arc; use alloc::vec; use core::sync::atomic::{AtomicU32, Ordering}; + use lp_collection::VecMap; use super::*; - use crate::artifact::ArtifactLocation; use crate::dataflow::resolver::QueryKey; use crate::dataflow::resolver::ResolveLogLevel; use crate::engine::Engine; @@ -636,15 +697,16 @@ mod tests { VisualSampleTarget, texel_center_to_uv_q16, }; use lpc_model::{ - ArtifactLocator, MapSlot, NodeDef, NodeInvocation, Revision, SlotDataAccess, - StaticSlotShape, TextureDef, TreePath, + ArtifactLocation, ArtifactSpec, AssetContentType, MapSlot, NodeDef, NodeInvocation, + NodeRuntimeStatus, Revision, SlotDataAccess, StaticSlotShape, TextureDef, TreePath, }; + use lpc_registry::{AssetText, ProjectRegistry}; use lpc_wire::{WireChildKind, WireSlotIndex}; const DEMO_GLSL: &str = "layout(binding = 0) uniform vec2 outputSize; layout(binding = 1) uniform float time; vec4 render(vec2 pos) { return vec4(mod(time, 1.0), 0.0, 0.0, 1.0); }"; fn shader_def_with_time() -> ShaderDef { - let mut consumed_slots = BTreeMap::new(); + let mut consumed_slots = VecMap::new(); consumed_slots.insert( String::from("time"), ShaderSlotDef::value_f32("Time", "Seconds", 0.5, None), @@ -655,31 +717,25 @@ mod tests { } } - fn build_texture_and_shader_engine() -> (Engine, NodeId, NodeId, VisualProduct) { + fn shader_asset_text(source: impl Into, revision: Revision) -> AssetText { + AssetText { + location: AssetLocation::artifact(ArtifactLocation::file("/shader.glsl")), + content_type: AssetContentType::ShaderSource, + revision, + text: source.into(), + diagnostic_name: String::from("/shader.glsl"), + } + } + + fn build_texture_and_shader_engine() -> (Engine, ProjectRegistry, NodeId, NodeId, VisualProduct) + { let mut engine = Engine::new(TreePath::parse("/show.t").expect("path")); + let mut registry = ProjectRegistry::new(); engine.set_graphics(Some(Arc::new(crate::Graphics::new()))); let frame = Revision::new(1); let root = engine.tree().root(); - let tex_invocation = NodeInvocation::new(ArtifactLocator::path("tex.toml")); - let tex_artifact = engine - .artifacts_mut() - .acquire_location(ArtifactLocation::file("tex.toml"), frame); - engine - .artifacts_mut() - .load_with(&tex_artifact, frame, |_| { - Ok(NodeDef::Texture(TextureDef::new(8, 8))) - }) - .expect("load texture artifact"); - let shader_invocation = NodeInvocation::new(ArtifactLocator::path("shader.toml")); - let shader_artifact = engine - .artifacts_mut() - .acquire_location(ArtifactLocation::file("shader.toml"), frame); - engine - .artifacts_mut() - .load_with(&shader_artifact, frame, |_| { - Ok(NodeDef::Shader(shader_def_with_time())) - }) - .expect("load shader artifact"); + let tex_invocation = NodeInvocation::new(ArtifactSpec::path("tex.toml")); + let shader_invocation = NodeInvocation::new(ArtifactSpec::path("shader.toml")); let tex_id = engine .tree_mut() @@ -691,7 +747,6 @@ mod tests { source: WireSlotIndex(0), }, tex_invocation, - tex_artifact, frame, ) .expect("texture"); @@ -711,24 +766,38 @@ mod tests { source: WireSlotIndex(0), }, shader_invocation, - shader_artifact, frame, ) .expect("shader"); - let sh = ShaderNode::new(sh_id, shader_def_with_time(), String::from(DEMO_GLSL)); + let shader_def = shader_def_with_time(); + engine + .load_test_node_defs( + &mut registry, + &[ + (tex_id, NodeDef::Texture(TextureDef::new(8, 8))), + (sh_id, NodeDef::Shader(shader_def.clone())), + ], + frame, + ) + .expect("load test defs"); + let sh = ShaderNode::new(sh_id, shader_def, shader_asset_text(DEMO_GLSL, frame)); engine .attach_runtime_node(sh_id, Box::new(sh), frame) .expect("attach shader"); let rid = VisualProduct::new(sh_id, 0); - (engine, tex_id, sh_id, rid) + (engine, registry, tex_id, sh_id, rid) } #[test] fn shader_render_output_is_on_runtime_state_slot_root() { - let node = ShaderNode::new(NodeId::new(1), ShaderDef::default(), String::new()); + let node = ShaderNode::new( + NodeId::new(1), + ShaderDef::default(), + shader_asset_text("", Revision::new(1)), + ); let state = node.runtime_state_slots().expect("shader state slots"); assert_eq!(state.shape_id(), ShaderState::SHAPE_ID); @@ -747,14 +816,14 @@ mod tests { #[test] fn shader_core_produces_visual_product_value() { - let (mut engine, _tex_id, sh_id, rid) = build_texture_and_shader_engine(); - engine.tick(1000).expect("tick"); + let (mut engine, registry, _tex_id, sh_id, rid) = build_texture_and_shader_engine(); + engine.tick(®istry, 1000).expect("tick"); let q = QueryKey::ProducedSlot { node: sh_id, slot: shader_output_path(), }; - let prod = resolve_with_engine_host(&mut engine, q, ResolveLogLevel::Off) + let prod = resolve_with_engine_host(&mut engine, ®istry, q, ResolveLogLevel::Off) .expect("resolve") .0; let got_id = match prod.value_leaf().expect("value").get() { @@ -766,17 +835,18 @@ mod tests { #[test] fn shader_core_visual_product_is_sampleable_red_channel() { - let (mut engine, _tex_id, sh_id, rid) = build_texture_and_shader_engine(); - engine.tick(500).expect("tick"); + let (mut engine, registry, _tex_id, sh_id, rid) = build_texture_and_shader_engine(); + engine.tick(®istry, 500).expect("tick"); let q = QueryKey::ProducedSlot { node: sh_id, slot: shader_output_path(), }; - resolve_with_engine_host(&mut engine, q, ResolveLogLevel::Off).expect("resolve"); + resolve_with_engine_host(&mut engine, ®istry, q, ResolveLogLevel::Off).expect("resolve"); let texture = engine .render_texture_for_test( + ®istry, rid, &crate::products::visual::RenderTextureRequest { width: 8, @@ -805,7 +875,11 @@ mod tests { "layout(binding = 0) uniform vec2 outputSize;\n\ vec4 render(vec2 pos) { return vec4(pos.x / outputSize.x, pos.y / outputSize.y, 0.0, 1.0); }", ); - let mut node = ShaderNode::new(NodeId::new(1), ShaderDef::default(), source); + let mut node = ShaderNode::new( + NodeId::new(1), + ShaderDef::default(), + shader_asset_text(source, Revision::new(1)), + ); let mut ctx = crate::node::RenderContext::new( NodeId::new(1), Revision::new(1), @@ -847,7 +921,11 @@ mod tests { "layout(binding = 0) uniform vec2 outputSize;\n\ vec4 render(vec2 pos) { return vec4(pos.x / outputSize.x, pos.y / outputSize.y, 0.0, 1.0); }", ); - let mut node = ShaderNode::new(NodeId::new(1), ShaderDef::default(), source); + let mut node = ShaderNode::new( + NodeId::new(1), + ShaderDef::default(), + shader_asset_text(source, Revision::new(1)), + ); let mut ctx = crate::node::RenderContext::new( NodeId::new(1), Revision::new(1), @@ -911,14 +989,15 @@ mod tests { #[test] fn shader_compile_cache_survives_unchanged_config_across_frames() { - let (mut engine, _tex_id, sh_id, rid) = build_texture_and_shader_engine(); + let (mut engine, registry, _tex_id, sh_id, rid) = build_texture_and_shader_engine(); let graphics = Arc::new(CountingGraphics::new()); engine.set_graphics(Some(graphics.clone())); for time_ms in [500, 600, 700] { - engine.tick(time_ms).expect("tick"); + engine.tick(®istry, time_ms).expect("tick"); resolve_with_engine_host( &mut engine, + ®istry, QueryKey::ProducedSlot { node: sh_id, slot: shader_output_path(), @@ -928,6 +1007,7 @@ mod tests { .expect("resolve"); engine .render_texture_for_test( + ®istry, rid, &crate::products::visual::RenderTextureRequest { width: 8, @@ -942,9 +1022,136 @@ mod tests { assert_eq!(graphics.compile_count(), 1); } + #[test] + fn shader_compile_failure_sets_runtime_status_error_and_renders_fallback() { + let (mut engine, registry, _tex_id, sh_id, rid) = build_texture_and_shader_engine(); + let graphics = Arc::new(CountingGraphics::failing()); + engine.set_graphics(Some(graphics.clone())); + + engine.tick(®istry, 500).expect("tick"); + resolve_with_engine_host( + &mut engine, + ®istry, + QueryKey::ProducedSlot { + node: sh_id, + slot: shader_output_path(), + }, + ResolveLogLevel::Off, + ) + .expect("resolve"); + let texture = engine + .render_texture_for_test( + ®istry, + rid, + &crate::products::visual::RenderTextureRequest { + width: 8, + height: 8, + format: lps_shared::TextureStorageFormat::Rgba16Unorm, + time_seconds: 0.5, + }, + ) + .expect("fallback render"); + + assert_eq!(graphics.compile_count(), 1); + assert!( + texture + .try_raw_bytes() + .expect("host texture bytes") + .iter() + .all(|byte| *byte == 0) + ); + + let entry = engine.tree().get(sh_id).expect("shader entry"); + assert!(matches!( + entry.status.value(), + NodeRuntimeStatus::Error(message) + if message.contains("shader compile") + && message.contains("test compile failure") + )); + + engine + .render_texture_for_test( + ®istry, + rid, + &crate::products::visual::RenderTextureRequest { + width: 8, + height: 8, + format: lps_shared::TextureStorageFormat::Rgba16Unorm, + time_seconds: 0.6, + }, + ) + .expect("cached fallback render"); + assert_eq!(graphics.compile_count(), 1); + assert!(matches!( + engine + .tree() + .get(sh_id) + .expect("shader entry") + .status + .value(), + NodeRuntimeStatus::Error(message) + if message.contains("shader compile") + && message.contains("test compile failure") + )); + } + + #[test] + fn shader_compile_failure_is_cached_and_renders_fallback() { + let graphics = Arc::new(CountingGraphics::failing()); + let mut node = ShaderNode::new( + NodeId::new(1), + ShaderDef::default(), + shader_asset_text(DEMO_GLSL, Revision::new(1)), + ); + let product = VisualProduct::new(NodeId::new(1), 0); + let mut ctx = crate::node::RenderContext::new( + NodeId::new(1), + Revision::new(1), + Some(graphics.clone()), + None, + 0.0, + ); + let request = crate::products::visual::RenderTextureRequest { + width: 4, + height: 4, + format: lps_shared::TextureStorageFormat::Rgba16Unorm, + time_seconds: 0.0, + }; + + let mut texture = graphics.alloc_output_buffer(4, 4).expect("texture"); + for _ in 0..3 { + node.render_texture_into(product, &request, &mut texture, &mut ctx) + .expect("fallback render"); + } + assert_eq!(graphics.compile_count(), 1); + assert!(node.compilation_error().is_some()); + assert!(texture.data().iter().all(|byte| *byte == 0)); + + let mut points = graphics.alloc_sample_points(1).expect("points"); + points.data_mut().copy_from_slice(&[0, 0]); + let mut samples = graphics.alloc_sample_rgba16(1).expect("samples"); + node.sample_visual_into( + product, + VisualSampleBufferRequest { + points: &mut points, + output_width: 4, + output_height: 4, + time_seconds: 0.0, + }, + VisualSampleTarget { + samples: &mut samples, + }, + &mut ctx, + ) + .expect("fallback sample"); + assert_eq!(graphics.compile_count(), 1); + assert!(samples.data().iter().all(|channel| *channel == 0)); + } + struct CountingGraphics { inner: crate::Graphics, compile_count: AtomicU32, + fail_compile: bool, } impl CountingGraphics { @@ -952,6 +1159,14 @@ mod tests { Self { inner: crate::Graphics::new(), compile_count: AtomicU32::new(0), + fail_compile: false, + } + } + + fn failing() -> Self { + Self { + fail_compile: true, + ..Self::new() } } @@ -967,6 +1182,11 @@ mod tests { _options: &ShaderCompileOptions, ) -> Result, Error> { self.compile_count.fetch_add(1, Ordering::Relaxed); + if self.fail_compile { + return Err(Error::Other { + message: String::from("test compile failure"), + }); + } Ok(Box::new(CountingShader)) } 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 805714683..7569ea3f5 100644 --- a/lp-core/lpc-engine/src/nodes/texture/texture_node.rs +++ b/lp-core/lpc-engine/src/nodes/texture/texture_node.rs @@ -128,7 +128,6 @@ impl NodeRuntime for TextureNode { #[cfg(test)] mod tests { use super::*; - use crate::artifact::ArtifactLocation; use crate::dataflow::binding::{BindingDraft, BindingPriority, BindingSource, BindingTarget}; use crate::dataflow::resolver::{QueryKey, ResolveLogLevel}; use crate::engine::Engine; @@ -136,18 +135,19 @@ mod tests { use crate::node::test_placeholder_spine; use alloc::boxed::Box; use lpc_model::{Dim2u, Kind, LpValue, NodeDef, Revision, TextureDef, ToLpValue, TreePath}; + use lpc_registry::ProjectRegistry; use lpc_wire::{WireChildKind, WireSlotIndex}; use lps_shared::LpsValueF32; #[test] fn texture_metadata_props_resolve_on_engine() { - let (mut engine, tid) = texture_engine(64, 48); + let (mut engine, registry, tid) = texture_engine(64, 48); let w = QueryKey::ConsumedSlot { node: tid, slot: size_path(), }; - let pv = resolve_with_engine_host(&mut engine, w, ResolveLogLevel::Off) + let pv = resolve_with_engine_host(&mut engine, ®istry, w, ResolveLogLevel::Off) .expect("resolve") .0; assert_dim2u_value(&pv.as_value().expect("value"), 64, 48); @@ -155,10 +155,11 @@ mod tests { #[test] fn texture_tick_reads_authored_size_through_slot_view() { - let (mut engine, tid) = texture_engine(64, 48); + let (mut engine, registry, tid) = texture_engine(64, 48); let pv = resolve_with_engine_host( &mut engine, + ®istry, QueryKey::ProducedSlot { node: tid, slot: SlotPath::parse("width").unwrap(), @@ -175,7 +176,7 @@ mod tests { #[test] fn texture_tick_uses_bound_size_override() { - let (mut engine, tid) = texture_engine(64, 48); + let (mut engine, registry, tid) = texture_engine(64, 48); engine .add_binding( BindingDraft { @@ -194,6 +195,7 @@ mod tests { let pv = resolve_with_engine_host( &mut engine, + ®istry, QueryKey::ProducedSlot { node: tid, slot: SlotPath::parse("height").unwrap(), @@ -207,10 +209,11 @@ mod tests { #[test] fn texture_node_exposes_owned_texture_resource_summary() { - let (mut engine, tid) = texture_engine(64, 48); + let (mut engine, registry, tid) = texture_engine(64, 48); resolve_with_engine_host( &mut engine, + ®istry, QueryKey::ProducedSlot { node: tid, slot: SlotPath::parse("width").unwrap(), @@ -218,7 +221,8 @@ mod tests { ResolveLogLevel::Off, ) .expect("resolve texture width"); - let response = engine.read_project(lpc_wire::ProjectReadRequest::default_debug(None)); + let response = + engine.read_project(®istry, lpc_wire::ProjectReadRequest::default_debug(None)); let lpc_wire::ProjectReadResult::Resources(resources) = &response.results[2] else { panic!("third result should be resources"); @@ -240,20 +244,12 @@ mod tests { assert_eq!(texture.owner, Some(tid)); } - fn texture_engine(width: u32, height: u32) -> (Engine, NodeId) { + fn texture_engine(width: u32, height: u32) -> (Engine, ProjectRegistry, NodeId) { let mut engine = Engine::new(TreePath::parse("/t.show").expect("path")); + let mut registry = ProjectRegistry::new(); let frame = Revision::new(1); let root = engine.tree().root(); - let (spine, _) = test_placeholder_spine(); - let artifact = engine - .artifacts_mut() - .acquire_location(ArtifactLocation::file("/texture.toml"), frame); - engine - .artifacts_mut() - .load_with(&artifact, frame, |_location| { - Ok(NodeDef::Texture(TextureDef::new(width, height))) - }) - .expect("load texture artifact"); + let spine = test_placeholder_spine(); let tid = engine .tree_mut() .add_child( @@ -264,15 +260,21 @@ mod tests { source: WireSlotIndex(0), }, spine, - artifact, frame, ) .expect("add"); + engine + .load_test_node_defs( + &mut registry, + &[(tid, NodeDef::Texture(TextureDef::new(width, height)))], + frame, + ) + .expect("load test defs"); let tex = TextureNode::new(tid); engine .attach_runtime_node(tid, Box::new(tex), frame) .expect("attach"); - (engine, tid) + (engine, registry, tid) } fn texture_size_value(width: u32, height: u32) -> LpValue { diff --git a/lp-core/lpc-engine/src/resources/buffer/runtime_buffer_store.rs b/lp-core/lpc-engine/src/resources/buffer/runtime_buffer_store.rs index 73a41eff3..bf8b8a40c 100644 --- a/lp-core/lpc-engine/src/resources/buffer/runtime_buffer_store.rs +++ b/lp-core/lpc-engine/src/resources/buffer/runtime_buffer_store.rs @@ -1,6 +1,6 @@ //! Engine-managed storage for versioned runtime buffers. -use alloc::collections::BTreeMap; +use lp_collection::VecMap; use lpc_model::{NodeId, Revision, WithRevision}; @@ -25,8 +25,8 @@ impl RuntimeBufferError { /// the lifetime of this store. pub struct RuntimeBufferStore { next_id: u32, - buffers: BTreeMap>, - owners: BTreeMap, + buffers: VecMap>, + owners: VecMap, } impl RuntimeBufferStore { @@ -34,8 +34,8 @@ impl RuntimeBufferStore { pub fn new() -> Self { Self { next_id: 0, - buffers: BTreeMap::new(), - owners: BTreeMap::new(), + buffers: VecMap::new(), + owners: VecMap::new(), } } @@ -70,6 +70,19 @@ impl RuntimeBufferStore { self.owners.get(&id).copied() } + pub fn remove_owned_by(&mut self, owner: NodeId) -> alloc::vec::Vec { + let ids = self + .owners + .iter() + .filter_map(|(&id, &candidate)| (candidate == owner).then_some(id)) + .collect::>(); + for id in &ids { + self.buffers.remove(id); + self.owners.remove(id); + } + ids + } + pub fn get(&self, id: RuntimeBufferId) -> Option<&WithRevision> { self.buffers.get(&id) } @@ -206,4 +219,24 @@ mod tests { .expect_err("unknown id"); assert_eq!(err, RuntimeBufferError::UnknownBuffer { id: missing }); } + + #[test] + fn store_removes_buffers_owned_by_node() { + let mut store = RuntimeBufferStore::new(); + let owner = lpc_model::NodeId::new(7); + let owned = store.insert_owned( + owner, + WithRevision::new(Revision::new(1), RuntimeBuffer::raw(vec![1])), + ); + let other = store.insert_owned( + lpc_model::NodeId::new(8), + WithRevision::new(Revision::new(1), RuntimeBuffer::raw(vec![2])), + ); + + let removed = store.remove_owned_by(owner); + + assert_eq!(removed, vec![owned]); + assert!(store.get(owned).is_none()); + assert_eq!(store.get(other).unwrap().value().bytes, vec![2]); + } } diff --git a/lp-core/lpc-engine/tests/runtime_spine.rs b/lp-core/lpc-engine/tests/runtime_spine.rs index 39a3278a4..836025c54 100644 --- a/lp-core/lpc-engine/tests/runtime_spine.rs +++ b/lp-core/lpc-engine/tests/runtime_spine.rs @@ -5,7 +5,6 @@ extern crate alloc; use alloc::string::String; use alloc::vec::Vec; -use lpc_engine::artifact::{ArtifactLocation, ArtifactState, ArtifactStore}; use lpc_engine::dataflow::binding::{ BindingEntry, BindingPriority, BindingRef, BindingSource, BindingTarget, }; @@ -16,48 +15,19 @@ use lpc_engine::dataflow::resolver::{ use lpc_engine::node::{ MemPressureCtx, NodeError, NodeRuntime, PressureLevel, ProduceResult, TickContext, }; -use lpc_model::node::node_invocation::NodeInvocation; +use lpc_engine::{EngineServices, ProjectLoader}; use lpc_model::{ - ArtifactLocator, Kind, LpValue, NodeDef, NodeId, Revision, TextureDef, bus::ChannelName, + ArtifactLocation, AssetChange, AssetChangeKind, AssetLocation, Kind, LpValue, NodeDefChange, + NodeDefChangeKind, NodeId, NodeUseLocation, Revision, SlotPath, TreePath, bus::ChannelName, }; +use lpc_registry::ParseCtx; +use lpfs::{FsEvent, FsEventKind, LpFsMemory, LpPath, LpPathBuf}; use lps_shared::LpsValueF32; // --- Tests (concise scenarios; helpers below) --- #[test] -fn runtime_spine_artifact_acquire_load_release_idle_content_frame_and_refcount() { - let mut mgr = ArtifactStore::new(); - let location = ArtifactLocation::file("dummy/test.lp"); - let r = mgr.acquire_location(location, Revision::new(1)); - - assert_eq!(mgr.refcount(&r), Some(1)); - assert_eq!(mgr.content_frame(&r), Some(Revision::new(1))); - - mgr.load_with(&r, Revision::new(20), |location| { - let ArtifactLocation::File(path) = location else { - panic!("expected file artifact location"); - }; - assert_eq!(path.as_str(), "dummy/test.lp"); - Ok(texture_def(12, 8)) - }) - .unwrap(); - - assert_eq!(mgr.content_frame(&r), Some(Revision::new(20))); - let ent = mgr.entry(&r).expect("entry"); - assert!( - matches!(&ent.state, ArtifactState::Loaded(NodeDef::Texture(payload)) if payload.width() == 12) - ); - - mgr.release(&r, Revision::new(2)).unwrap(); - let ent = mgr.entry(&r).expect("idle entry kept"); - assert_eq!(ent.refcount, 0); - assert!( - matches!(&ent.state, ArtifactState::Idle(NodeDef::Texture(payload)) if payload.height() == 8) - ); -} - -#[test] -fn runtime_spine_tick_context_resolve_bus_query_and_artifact_frames() { +fn runtime_spine_tick_context_resolve_bus_query() { let channel = ChannelName(String::from("live")); let frame = Revision::new(99); let binding = BindingEntry { @@ -69,18 +39,6 @@ fn runtime_spine_tick_context_resolve_bus_query_and_artifact_frames() { owner: NodeId::new(1), }; - let config = NodeInvocation::new(ArtifactLocator::path("e.lp")); - - let mut mgr = ArtifactStore::new(); - let locator = config.def_locator().unwrap().clone(); - let ar = mgr.acquire_location( - ArtifactLocation::try_from_src_spec(&locator).unwrap(), - Revision::new(0), - ); - mgr.load_with(&ar, Revision::new(40), |_location| Ok(texture_def(7, 7))) - .unwrap(); - let content_frame = mgr.content_frame(&ar).expect("content_frame"); - let mut resolver = Resolver::new(); let mut session = ResolveSession::new( frame, @@ -128,8 +86,6 @@ fn runtime_spine_tick_context_resolve_bus_query_and_artifact_frames() { let mut ctx = TickContext::new( NodeId::new(5), frame, - ar, - content_frame, &mut bridge as &mut dyn TickResolver, &slot_shapes, ); @@ -137,13 +93,6 @@ fn runtime_spine_tick_context_resolve_bus_query_and_artifact_frames() { node.produce(&lpc_model::SlotPath::root(), &mut ctx) .unwrap(); assert_eq!(node.last, Some(2.0)); - - assert!(ctx.artifact_changed_since(Revision::new(39))); - assert!(!ctx.artifact_changed_since(Revision::new(40))); -} - -fn texture_def(width: u32, height: u32) -> NodeDef { - NodeDef::Texture(TextureDef::new(width, height)) } #[test] @@ -155,8 +104,232 @@ fn runtime_spine_node_export_is_reachable() { let _: Option = None; } +#[test] +fn project_apply_body_change_does_not_recreate_runtime_node() { + let mut fs = clock_project_fs(); + let services = EngineServices::new(TreePath::parse("/body_change.show").unwrap()); + let loaded = ProjectLoader::load_from_root(&fs, services).expect("load"); + let (mut engine, mut registry) = loaded.into_parts(); + let clock_use = NodeUseLocation::root().child(SlotPath::parse("nodes[clock]").unwrap()); + let before = engine + .project_runtime_index() + .node_id(&clock_use) + .expect("clock runtime node"); + + fs.write_file_mut( + LpPath::new("/clock.toml"), + br#" +kind = "Clock" + +[controls] +rate = 2.0 +"#, + ) + .expect("write clock"); + let shapes = engine.slot_shapes().clone(); + let changes = registry.refresh_artifacts( + &fs, + &[FsEvent { + path: LpPathBuf::from("/clock.toml"), + kind: FsEventKind::Modify, + }], + Revision::new(2), + &ParseCtx { shapes: &shapes }, + ); + + assert_eq!( + changes.defs.changed, + vec![NodeDefChange::new( + lpc_model::NodeDefLocation::artifact_root(ArtifactLocation::file("/clock.toml")), + NodeDefChangeKind::Body, + )] + ); + assert!(changes.uses.is_empty()); + let apply = engine + .apply_project_changes(&fs, &mut registry, &changes) + .expect("apply changes"); + + assert!(apply.is_empty()); + assert_eq!( + engine.project_runtime_index().node_id(&clock_use), + Some(before) + ); +} + +#[test] +fn project_apply_added_node_use_preserves_existing_runtime_node() { + let mut fs = clock_project_fs(); + let services = EngineServices::new(TreePath::parse("/add_use.show").unwrap()); + let loaded = ProjectLoader::load_from_root(&fs, services).expect("load"); + let (mut engine, mut registry) = loaded.into_parts(); + let clock_use = NodeUseLocation::root().child(SlotPath::parse("nodes[clock]").unwrap()); + let shader_use = NodeUseLocation::root().child(SlotPath::parse("nodes[shader]").unwrap()); + let clock_before = engine + .project_runtime_index() + .node_id(&clock_use) + .expect("clock runtime node"); + + fs.write_file_mut( + LpPath::new("/project.toml"), + br#" +kind = "Project" + +[nodes.clock] +ref = "./clock.toml" + +[nodes.shader] +ref = "./shader.toml" +"#, + ) + .expect("write project"); + fs.write_file_mut( + LpPath::new("/shader.toml"), + br#" +kind = "Shader" +source = { path = "shader.glsl" } +"#, + ) + .expect("write shader def"); + fs.write_file_mut(LpPath::new("/shader.glsl"), b"void main() {}") + .expect("write shader source"); + + let shapes = engine.slot_shapes().clone(); + let changes = registry.refresh_artifacts( + &fs, + &[FsEvent { + path: LpPathBuf::from("/project.toml"), + kind: FsEventKind::Modify, + }], + Revision::new(2), + &ParseCtx { shapes: &shapes }, + ); + + assert_eq!(changes.uses.added, vec![shader_use.clone()]); + let apply = engine + .apply_project_changes(&fs, &mut registry, &changes) + .expect("apply changes"); + + assert_eq!(apply.added_nodes, vec![shader_use.clone()]); + assert_eq!( + engine.project_runtime_index().node_id(&clock_use), + Some(clock_before) + ); + assert!( + engine + .project_runtime_index() + .node_id(&shader_use) + .is_some() + ); +} + +#[test] +fn project_apply_asset_body_change_refreshes_existing_shader_node() { + let mut fs = shader_project_fs(); + let services = EngineServices::new(TreePath::parse("/shader_asset_change.show").unwrap()); + let loaded = ProjectLoader::load_from_root(&fs, services).expect("load"); + let (mut engine, mut registry) = loaded.into_parts(); + let shader_use = NodeUseLocation::root().child(SlotPath::parse("nodes[shader]").unwrap()); + let shader_before = engine + .project_runtime_index() + .node_id(&shader_use) + .expect("shader runtime node"); + let shader_asset = AssetLocation::artifact(ArtifactLocation::file("/shader.glsl")); + + fs.write_file_mut( + LpPath::new("/shader.glsl"), + b"vec4 render(vec2 pos) { return vec4(pos.x, 0.0, 0.0, 1.0); }", + ) + .expect("write shader source"); + let shapes = engine.slot_shapes().clone(); + let changes = registry.refresh_artifacts( + &fs, + &[FsEvent { + path: LpPathBuf::from("/shader.glsl"), + kind: FsEventKind::Modify, + }], + Revision::new(2), + &ParseCtx { shapes: &shapes }, + ); + + assert_eq!( + changes.assets.changed, + vec![AssetChange::new( + shader_asset.clone(), + AssetChangeKind::Body + )] + ); + assert!(changes.defs.is_empty()); + assert!(changes.uses.is_empty()); + let apply = engine + .apply_project_changes(&fs, &mut registry, &changes) + .expect("apply changes"); + + assert_eq!(apply.refreshed_assets, vec![shader_asset]); + assert_eq!(apply.refreshed_nodes, vec![shader_use.clone()]); + assert!(apply.added_nodes.is_empty()); + assert!(apply.removed_nodes.is_empty()); + assert!(apply.reattached_nodes.is_empty()); + assert_eq!( + engine.project_runtime_index().node_id(&shader_use), + Some(shader_before) + ); +} + // --- Helpers --- +fn clock_project_fs() -> LpFsMemory { + let mut fs = LpFsMemory::new(); + fs.write_file_mut( + LpPath::new("/project.toml"), + br#" +kind = "Project" + +[nodes.clock] +ref = "./clock.toml" +"#, + ) + .expect("write project"); + fs.write_file_mut( + LpPath::new("/clock.toml"), + br#" +kind = "Clock" + +[controls] +rate = 1.0 +"#, + ) + .expect("write clock"); + fs +} + +fn shader_project_fs() -> LpFsMemory { + let mut fs = LpFsMemory::new(); + fs.write_file_mut( + LpPath::new("/project.toml"), + br#" +kind = "Project" + +[nodes.shader] +ref = "./shader.toml" +"#, + ) + .expect("write project"); + fs.write_file_mut( + LpPath::new("/shader.toml"), + br#" +kind = "Shader" +source = "shader.glsl" +"#, + ) + .expect("write shader def"); + fs.write_file_mut( + LpPath::new("/shader.glsl"), + b"vec4 render(vec2 pos) { return vec4(0.0, pos.y, 0.0, 1.0); }", + ) + .expect("write shader source"); + fs +} + struct ProduceProbeNode { query: QueryKey, last: Option, diff --git a/lp-core/lpc-hardware/Cargo.toml b/lp-core/lpc-hardware/Cargo.toml new file mode 100644 index 000000000..50e3ee467 --- /dev/null +++ b/lp-core/lpc-hardware/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "lpc-hardware" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[features] +default = ["std"] +std = ["toml/std"] + +[dependencies] +lp-collection = { workspace = true, features = ["serde"] } +lpc-model = { path = "../lpc-model", default-features = false } +serde = { workspace = true, features = ["derive"] } +toml = { version = "0.9", default-features = false, features = ["parse", "serde", "display"] } + +[lints] +workspace = true diff --git a/lp-core/lpc-hardware/README.md b/lp-core/lpc-hardware/README.md new file mode 100644 index 000000000..301b623e7 --- /dev/null +++ b/lp-core/lpc-hardware/README.md @@ -0,0 +1,169 @@ +# lpc-hardware + +`lpc-hardware` owns LightPlayer's board-facing hardware vocabulary: manifests, +claim/lease state, endpoint discovery, and driver traits. + +It is `no_std` + `alloc` and is shared by firmware, emulation, and host tests. +The crate should describe what hardware exists, what can be opened, and how +resources are kept from colliding. It should not own rendering policy or engine +behavior. + +## Layer Map + +```text +Authored project / engine + uses endpoint specs, writes logical output frames + | + v +lpc-shared output providers + bridge engine-facing APIs to hardware-facing APIs + own display pipeline options, dithering, interpolation, LUTs + | + v +lpc-hardware + manifests -> resources -> registry claims -> endpoints -> drivers + | + v +target driver implementations + ESP32 RMT, GPIO button, ESP-NOW, virtual test drivers, etc. + | + v +physical or emulated hardware +``` + +The important boundary: `Ws281xOutput` receives already-rendered RGB bytes. +`DisplayPipeline`, `DisplayPipelineOptions`, brightness, interpolation, +dithering, and white-point LUTs live in `lpc-shared`. + +## Core Concepts + +```text +HwManifest + | + +-- HwResource + | + +-- HwAddress /gpio/18, /rmt/ws281x0, /radio/0 + +-- HwCapability gpio-output, gpio-input, rmt, ws281x-output, radio + +-- labels/aliases D10, GPIO18, board location metadata + +HwRegistry + | + +-- validates resources against the manifest + +-- accepts HwClaim values from drivers + +-- returns HardwareLease values for active ownership + +-- reports HwEndpointStatus for resources + +HardwareSystem + | + +-- owns registered driver trait objects + +-- lists endpoints by capability family + +-- opens endpoints by HwEndpointSpec, HwEndpointId, or HwAddress + +HwDriver family traits + | + +-- Ws281xDriver -> Ws281xOutput + +-- ButtonDriver -> ButtonInput + +-- RadioDriver -> RadioDevice +``` + +## Flow + +Opening a WS281x output looks like this: + +```text +board TOML / default manifest + | + v +HwManifest + | + v +HwRegistry + | + v +HardwareSystem + registered Ws281xDriver + | + v +endpoint spec: ws281x:rmt:D10 + | + v +driver checks endpoint and capabilities + | + v +registry claim: /gpio/18 + /rmt/ws281x0 + | + v +HardwareLease + opened Ws281xOutput + | + v +write raw RGB bytes +``` + +The registry claim is deliberately atomic. If a WS281x output needs both a GPIO +pin and an RMT timing resource, it gets both or neither. That keeps a button, +LED output, radio, or future driver from partially opening hardware and leaving +the board in a confused state. + +## Directory Structure + +`src/resource/` + +Concrete board resources. `HwAddress` is the internal stable address, +`HwCapability` says what a resource can do, and `HwResource` combines those with +human-facing labels and metadata. + +`src/manifest/` + +Board profiles. `HardwareManifestFile` is the TOML-friendly representation; +`HwManifest` is the runtime form used by the registry. + +`src/registry/` + +Runtime ownership. Drivers submit `HwClaim`s and receive `HardwareLease`s. +Dropping or closing an opened device releases the lease. + +`src/endpoint/` + +Openable surfaces reported by drivers. Endpoints connect authored specs such as +`button:gpio:D2` or `ws281x:rmt:D10` to concrete `HwAddress` resources and a +current availability status. + +`src/drivers/` + +Driver traits and virtual implementations. Firmware crates provide target +drivers where needed; this crate provides common contracts and virtual drivers +for tests/emulation. + +`src/hw_system.rs` + +The endpoint router. It owns registered drivers, lists endpoints, and opens +devices by spec, ID, or address. + +`boards/` + +Checked-in board manifest TOML files. Use the `lp-cli hardware manifest` tools +to create, inspect, and validate manifests, and `lp-cli hardware calibrate` to +map board-visible GPIO labels when calibration firmware is running. See +[boards/README.md](boards/README.md) for the workflow. + +## Naming + +Use the `Hw*` prefix for hardware-domain concepts that would otherwise collide +with model, wire, or engine vocabulary: `HwManifest`, `HwResource`, +`HwRegistry`, `HwEndpoint`, `HwAddress`. + +Some older public names still use `Hardware*` where that reads better or avoids +churn in downstream code, such as `HardwareSystem`, `HardwareLease`, and +`HardwareEndpointError`. + +## What Does Not Belong Here + +Keep these outside `lpc-hardware`: + +- Display pipeline options, dithering, interpolation, and color correction. +- Engine/project behavior beyond endpoint specs. +- Target-specific HAL setup that cannot be represented as a common driver + contract. +- Host-only conveniences that would make the crate stop working in `no_std`. + +When in doubt, this crate should answer: "What hardware can this board expose, +who owns it right now, and what raw driver contract opens it?" diff --git a/lp-core/lpc-hardware/boards/README.md b/lp-core/lpc-hardware/boards/README.md new file mode 100644 index 000000000..f1b9689c7 --- /dev/null +++ b/lp-core/lpc-hardware/boards/README.md @@ -0,0 +1,147 @@ +# Hardware Board Manifests + +This directory contains checked-in hardware manifests for boards LightPlayer can +run on. A manifest describes the board metadata, known board-visible labels, and +claimable resources such as GPIOs, RMT timing channels, and radios. + +The default layout is: + +```text +boards/ + vendor/ + product.toml +``` + +The manifest id must match that path, for example: + +```toml +id = "seeed/xiao-esp32-c6" +``` + +## Tooling + +Use `lp-cli hardware manifest` for file management: + +```bash +cargo run -p lp-cli -- hardware manifest list +cargo run -p lp-cli -- hardware manifest show seeed/xiao-esp32-c6 +cargo run -p lp-cli -- hardware manifest validate +``` + +Create a new manifest skeleton with: + +```bash +cargo run -p lp-cli -- hardware manifest new \ + --target esp32c6 \ + --vendor "Seeed" \ + --product "XIAO ESP32-C6" +``` + +The tool slugifies the default id from vendor/product. You can override it with +`--id vendor/product`, and use `--description` or `--url` to seed metadata. + +`cargo run -p lp-cli -- hardware manifest` with no subcommand opens the +interactive manifest manager when stdin/stdout are terminals. + +## Calibration Workflow + +Use `lp-cli hardware calibrate` when a board's silkscreen labels need to be +mapped to real GPIO numbers. The calibrator edits the manifest in this +directory and records `[[board_label]]` entries plus matching `[[gpio]]` +resources. + +Typical workflow: + +1. Create or select a manifest with `hardware manifest`. +2. Flash/run ESP32 calibration firmware built with the `test_gpio_calibrate` + feature. +3. Run the host calibration UI: + +```bash +cargo run -p lp-cli -- hardware calibrate esp32c6 \ + --board seeed/xiao-esp32-c6 \ + --port auto +``` + +You can jump directly to one board-visible label: + +```bash +cargo run -p lp-cli -- hardware calibrate esp32c6 \ + --board seeed/xiao-esp32-c6 \ + --port auto \ + --label D10 +``` + +The calibrator pulses candidate GPIOs over serial. When the connected scope or +LED confirms a match, the tool records the board label and GPIO address. If a +candidate times out or crashes the board, the manifest can keep that GPIO +reserved so normal drivers do not claim it accidentally. + +## Manifest Shape + +Board metadata lives at the top: + +```toml +id = "vendor/product" +target = "esp32c6" +vendor = "Vendor" +product = "Product" +description = "Board profile." +url = "https://example.com/board" +``` + +Board-visible labels are optional mapping notes for humans and calibration: + +```toml +[[board_label]] +label = "D10" +gpio = "/gpio/18" +status = "assigned" +``` + +GPIO resources are claimable hardware resources: + +```toml +[[gpio]] +address = "/gpio/18" +display_label = "D10" +capabilities = [ + "gpio-output", + "gpio-input", +] +aliases = [ + "IO18", + "GPIO18", +] +``` + +Non-GPIO resources use `[[resource]]`: + +```toml +[[resource]] +address = "/rmt/ws281x0" +display_label = "RMT WS281x 0" +capabilities = [ + "rmt", + "ws281x-output", +] +``` + +Use `reserved_reason` for known-dangerous or unavailable resources: + +```toml +reserved_reason = "crashed during manual GPIO scan; keep skipped until recalibrated" +``` + +## Validation + +Before committing a manifest change, run: + +```bash +cargo run -p lp-cli -- hardware manifest validate +cargo test -p lpc-hardware +``` + +`hardware manifest validate` checks TOML shape, duplicate addresses, required +metadata, URL format, and manifest ids. `cargo test -p lpc-hardware` also +exercises the checked-in default ESP32-C6 manifest. diff --git a/lp-core/lpc-shared/boards/seeed/xiao-esp32-c6.toml b/lp-core/lpc-hardware/boards/seeed/xiao-esp32-c6.toml similarity index 98% rename from lp-core/lpc-shared/boards/seeed/xiao-esp32-c6.toml rename to lp-core/lpc-hardware/boards/seeed/xiao-esp32-c6.toml index 8f387b0c3..57087d385 100644 --- a/lp-core/lpc-shared/boards/seeed/xiao-esp32-c6.toml +++ b/lp-core/lpc-hardware/boards/seeed/xiao-esp32-c6.toml @@ -215,7 +215,7 @@ aliases = ["IO15"] [[gpio]] address = "/gpio/16" -display_label = "d6" +display_label = "D6" capabilities = [ "gpio-output", "gpio-input", @@ -227,7 +227,7 @@ aliases = [ [[gpio]] address = "/gpio/17" -display_label = "d7" +display_label = "D7" capabilities = [ "gpio-output", "gpio-input", @@ -239,7 +239,7 @@ aliases = [ [[gpio]] address = "/gpio/18" -display_label = "d10" +display_label = "D10" capabilities = [ "gpio-output", "gpio-input", @@ -252,7 +252,7 @@ location = "known WS281x output header" [[gpio]] address = "/gpio/19" -display_label = "d8" +display_label = "D8" capabilities = [ "gpio-output", "gpio-input", diff --git a/lp-core/lpc-shared/src/hardware/button_debouncer.rs b/lp-core/lpc-hardware/src/drivers/button/button_debouncer.rs similarity index 78% rename from lp-core/lpc-shared/src/hardware/button_debouncer.rs rename to lp-core/lpc-hardware/src/drivers/button/button_debouncer.rs index 005e75cbe..924373726 100644 --- a/lp-core/lpc-shared/src/hardware/button_debouncer.rs +++ b/lp-core/lpc-hardware/src/drivers/button/button_debouncer.rs @@ -1,8 +1,12 @@ -use super::{ButtonEvent, ButtonEventKind, HardwareAddress}; +use crate::{ButtonEvent, ButtonEventKind, HwAddress}; +/// Converts noisy raw button samples into stable press/release events. +/// +/// The debouncer tracks one candidate state and emits only after that state has +/// stayed unchanged for the configured stable interval. #[derive(Debug, Clone)] pub struct ButtonDebouncer { - source: HardwareAddress, + source: HwAddress, stable_state_pressed: bool, candidate_state_pressed: bool, candidate_since_ms: u64, @@ -11,9 +15,10 @@ pub struct ButtonDebouncer { } impl ButtonDebouncer { + /// Default debounce interval used by [`crate::ButtonConfig`]. pub const DEFAULT_STABLE_MS: u64 = 30; - pub fn new(source: HardwareAddress, stable_ms: u64) -> Self { + pub fn new(source: HwAddress, stable_ms: u64) -> Self { Self { source, stable_state_pressed: false, @@ -59,7 +64,7 @@ impl ButtonDebouncer { impl Default for ButtonDebouncer { fn default() -> Self { - Self::new(HardwareAddress::gpio(0), Self::DEFAULT_STABLE_MS) + Self::new(HwAddress::gpio(0), Self::DEFAULT_STABLE_MS) } } @@ -69,13 +74,13 @@ mod tests { #[test] fn emits_after_pressed_state_is_stable() { - let mut debouncer = ButtonDebouncer::new(HardwareAddress::gpio(4), 30); + let mut debouncer = ButtonDebouncer::new(HwAddress::gpio(4), 30); assert_eq!(debouncer.sample(0, true), None); assert_eq!(debouncer.sample(20, true), None); let event = debouncer.sample(30, true).expect("pressed event"); - assert_eq!(event.source(), &HardwareAddress::gpio(4)); + assert_eq!(event.source(), &HwAddress::gpio(4)); assert_eq!(event.sequence(), 1); assert_eq!(event.kind(), ButtonEventKind::Pressed); assert!(debouncer.stable_state_pressed()); @@ -83,7 +88,7 @@ mod tests { #[test] fn ignores_bounces_before_stable_interval() { - let mut debouncer = ButtonDebouncer::new(HardwareAddress::gpio(4), 30); + let mut debouncer = ButtonDebouncer::new(HwAddress::gpio(4), 30); assert_eq!(debouncer.sample(0, true), None); assert_eq!(debouncer.sample(10, false), None); @@ -96,7 +101,7 @@ mod tests { #[test] fn emits_release_after_pressed() { - let mut debouncer = ButtonDebouncer::new(HardwareAddress::gpio(4), 30); + let mut debouncer = ButtonDebouncer::new(HwAddress::gpio(4), 30); assert!(debouncer.sample(0, true).is_none()); assert!(debouncer.sample(30, true).is_some()); diff --git a/lp-core/lpc-hardware/src/drivers/button/button_driver.rs b/lp-core/lpc-hardware/src/drivers/button/button_driver.rs new file mode 100644 index 000000000..99c5f476b --- /dev/null +++ b/lp-core/lpc-hardware/src/drivers/button/button_driver.rs @@ -0,0 +1,56 @@ +use alloc::boxed::Box; + +use crate::{ + ButtonDebouncer, ButtonEvent, HardwareEndpointError, HwAddress, HwDriver, HwEndpoint, + HwEndpointId, +}; + +/// Button endpoint configuration. +/// +/// `stable_ms` controls how long a raw input level must remain unchanged before +/// [`ButtonDebouncer`] emits a [`ButtonEvent`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ButtonConfig { + stable_ms: u64, +} + +impl ButtonConfig { + pub fn new(stable_ms: u64) -> Self { + Self { stable_ms } + } + + pub fn stable_ms(&self) -> u64 { + self.stable_ms + } +} + +impl Default for ButtonConfig { + fn default() -> Self { + Self::new(ButtonDebouncer::DEFAULT_STABLE_MS) + } +} + +/// Opened button input. +/// +/// Implementations usually own a GPIO input lease and use a +/// [`ButtonDebouncer`] to turn raw pressed/released samples into events. +pub trait ButtonInput { + /// Resource address being sampled. + fn source(&self) -> &HwAddress; + + /// Poll the input and return a debounced event when state changes. + fn poll(&mut self, now_ms: u64) -> Option; +} + +/// Driver that exposes GPIO-backed button endpoints. +pub trait ButtonDriver: HwDriver { + /// List currently known button endpoints. + fn endpoints(&self) -> alloc::vec::Vec; + + /// Open one endpoint and claim the underlying input resource. + fn open( + &self, + endpoint_id: &HwEndpointId, + config: ButtonConfig, + ) -> Result, HardwareEndpointError>; +} diff --git a/lp-core/lpc-shared/src/hardware/button_event.rs b/lp-core/lpc-hardware/src/drivers/button/button_event.rs similarity index 52% rename from lp-core/lpc-shared/src/hardware/button_event.rs rename to lp-core/lpc-hardware/src/drivers/button/button_event.rs index 4b5702e49..c85d097d5 100644 --- a/lp-core/lpc-shared/src/hardware/button_event.rs +++ b/lp-core/lpc-hardware/src/drivers/button/button_event.rs @@ -1,20 +1,27 @@ -use super::HardwareAddress; +use crate::HwAddress; +/// Debounced state transition for a button input. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ButtonEventKind { + /// Button became stably pressed. Pressed, + /// Button became stably released. Released, } +/// Event produced by [`crate::ButtonInput`]. +/// +/// The sequence counter is local to the opened input and increments on each +/// debounced transition. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ButtonEvent { - source: HardwareAddress, + source: HwAddress, sequence: u32, kind: ButtonEventKind, } impl ButtonEvent { - pub fn new(source: HardwareAddress, sequence: u32, kind: ButtonEventKind) -> Self { + pub fn new(source: HwAddress, sequence: u32, kind: ButtonEventKind) -> Self { Self { source, sequence, @@ -22,7 +29,7 @@ impl ButtonEvent { } } - pub fn source(&self) -> &HardwareAddress { + pub fn source(&self) -> &HwAddress { &self.source } diff --git a/lp-core/lpc-hardware/src/drivers/button/mod.rs b/lp-core/lpc-hardware/src/drivers/button/mod.rs new file mode 100644 index 000000000..f78dca28f --- /dev/null +++ b/lp-core/lpc-hardware/src/drivers/button/mod.rs @@ -0,0 +1,13 @@ +//! Button input drivers and debounced button events. +//! +//! Button endpoints claim GPIO input resources and return a +//! [`ButtonInput`](crate::ButtonInput). The common +//! [`ButtonDebouncer`](crate::ButtonDebouncer) keeps firmware and virtual +//! drivers aligned on when raw level changes become stable +//! [`ButtonEvent`](crate::ButtonEvent)s. + +pub mod button_debouncer; +pub mod button_driver; +pub mod button_event; +pub mod virtual_button; +pub mod virtual_button_driver; diff --git a/lp-core/lpc-hardware/src/drivers/button/virtual_button.rs b/lp-core/lpc-hardware/src/drivers/button/virtual_button.rs new file mode 100644 index 000000000..19f8fae1a --- /dev/null +++ b/lp-core/lpc-hardware/src/drivers/button/virtual_button.rs @@ -0,0 +1,163 @@ +use alloc::rc::Rc; +use alloc::vec; + +use crate::{ + ButtonDebouncer, ButtonEvent, HardwareLease, HwAddress, HwCapability, HwClaim, HwError, + HwRegistry, +}; + +/// Small standalone virtual button handle. +/// +/// This predates the endpoint-oriented [`VirtualButtonDriver`](crate::VirtualButtonDriver) +/// and remains useful in tests that want to claim a GPIO directly and feed +/// samples manually. +pub struct VirtualButton { + registry: Rc, + source: HwAddress, + lease: Option, + debouncer: ButtonDebouncer, +} + +impl VirtualButton { + pub fn open_gpio(registry: Rc, pin: u32, stable_ms: u64) -> Result { + let source = HwAddress::gpio(pin); + registry.ensure_capability(&source, HwCapability::GpioInput)?; + let lease = registry.claim_bundle(HwClaim::new("virtual-button", vec![source.clone()]))?; + Ok(Self { + registry, + source: source.clone(), + lease: Some(lease), + debouncer: ButtonDebouncer::new(source, stable_ms), + }) + } + + pub fn source(&self) -> &HwAddress { + &self.source + } + + pub fn sample(&mut self, now_ms: u64, pressed: bool) -> Option { + self.debouncer.sample(now_ms, pressed) + } + + pub fn close(&mut self) -> Result<(), HwError> { + if let Some(lease) = self.lease.take() { + self.registry.release(&lease)?; + } + Ok(()) + } +} + +impl Drop for VirtualButton { + fn drop(&mut self) { + let _ = self.close(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + HardwareEndpointError, HardwareSystem, HwEndpointSpec, HwManifest, HwResource, Ws281xConfig, + }; + + #[test] + fn button_claim_blocks_output_on_same_gpio() { + let registry = Rc::new(HwRegistry::new(test_manifest())); + let _button = VirtualButton::open_gpio(Rc::clone(®istry), 4, 30).unwrap(); + let system = HardwareSystem::with_virtual_drivers(registry); + let endpoint = endpoint("ws281x:rmt:GPIO4"); + + let result = system.open_ws281x_by_spec(&endpoint, Ws281xConfig::new(3)); + + assert!(matches!( + result, + Err(HardwareEndpointError::Hardware { + error: HwError::ResourceAlreadyClaimed { .. } + }) + )); + } + + #[test] + fn output_claim_blocks_button_on_same_gpio() { + let registry = Rc::new(HwRegistry::new(test_manifest())); + let system = HardwareSystem::with_virtual_drivers(Rc::clone(®istry)); + let _output = system + .open_ws281x_by_spec(&endpoint("ws281x:rmt:GPIO4"), Ws281xConfig::new(3)) + .unwrap(); + + let result = VirtualButton::open_gpio(registry, 4, 30); + + assert!(matches!( + result, + Err(HwError::ResourceAlreadyClaimed { .. }) + )); + } + + #[test] + fn output_and_button_can_use_different_resources() { + let registry = Rc::new(HwRegistry::new(test_manifest())); + let system = HardwareSystem::with_virtual_drivers(Rc::clone(®istry)); + let _output = system + .open_ws281x_by_spec(&endpoint("ws281x:rmt:GPIO18"), Ws281xConfig::new(3)) + .unwrap(); + + let button = VirtualButton::open_gpio(Rc::clone(®istry), 4, 30).unwrap(); + + assert_eq!(button.source(), &HwAddress::gpio(4)); + assert!(registry.is_claimed(&HwAddress::gpio(18))); + assert!(registry.is_claimed(&HwAddress::gpio(4))); + } + + #[test] + fn reserved_gpio_cannot_be_claimed_for_button() { + let registry = Rc::new(HwRegistry::new(test_manifest())); + + let result = VirtualButton::open_gpio(registry, 12, 30); + + assert!(matches!(result, Err(HwError::ReservedResource { .. }))); + } + + #[test] + fn close_releases_button_gpio() { + let registry = Rc::new(HwRegistry::new(test_manifest())); + let mut button = VirtualButton::open_gpio(Rc::clone(®istry), 4, 30).unwrap(); + + button.close().unwrap(); + + assert!(!registry.is_claimed(&HwAddress::gpio(4))); + } + + fn test_manifest() -> HwManifest { + HwManifest::new( + "test", + "Test Board", + [ + HwResource::new( + HwAddress::gpio(4), + [HwCapability::GpioOutput, HwCapability::GpioInput], + "GPIO4", + ), + HwResource::new( + HwAddress::gpio(12), + [HwCapability::GpioOutput, HwCapability::GpioInput], + "GPIO12", + ) + .reserved("reserved for test"), + HwResource::new( + HwAddress::gpio(18), + [HwCapability::GpioOutput, HwCapability::GpioInput], + "GPIO18", + ), + HwResource::new( + HwAddress::rmt_ws281x(0), + [HwCapability::Rmt, HwCapability::Ws281xOutput], + "RMT WS281x 0", + ), + ], + ) + } + + fn endpoint(spec: &'static str) -> HwEndpointSpec { + HwEndpointSpec::from_static(spec) + } +} diff --git a/lp-core/lpc-shared/src/hardware/virtual_button_driver.rs b/lp-core/lpc-hardware/src/drivers/button/virtual_button_driver.rs similarity index 61% rename from lp-core/lpc-shared/src/hardware/virtual_button_driver.rs rename to lp-core/lpc-hardware/src/drivers/button/virtual_button_driver.rs index a2eb8e220..1ec13a25f 100644 --- a/lp-core/lpc-shared/src/hardware/virtual_button_driver.rs +++ b/lp-core/lpc-hardware/src/drivers/button/virtual_button_driver.rs @@ -1,48 +1,51 @@ use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::rc::Rc; use alloc::string::String; use alloc::vec; use alloc::vec::Vec; use core::cell::RefCell; +use lp_collection::VecMap; -use super::{ - ButtonConfig, ButtonDebouncer, ButtonDriver, ButtonEvent, ButtonInput, HardwareAddress, - HardwareCapability, HardwareClaim, HardwareDriver, HardwareEndpoint, HardwareEndpointError, - HardwareEndpointId, HardwareEndpointKind, HardwareEndpointSpec, HardwareLease, - HardwareRegistry, +use crate::{ + ButtonConfig, ButtonDebouncer, ButtonDriver, ButtonEvent, ButtonInput, HardwareEndpointError, + HardwareLease, HwAddress, HwCapability, HwClaim, HwDriver, HwEndpoint, HwEndpointId, + HwEndpointKind, HwEndpointSpec, HwRegistry, }; +/// Manifest-backed virtual button driver for tests and emulation. +/// +/// The driver exposes one button endpoint for every GPIO input resource. Tests +/// inject raw state with [`VirtualButtonDriver::set_pressed`]. #[derive(Clone)] pub struct VirtualButtonDriver { - registry: Rc, + registry: Rc, driver_id: String, - pressed_by_address: Rc>>, + pressed_by_address: Rc>>, } impl VirtualButtonDriver { - pub fn new(registry: Rc) -> Self { + pub fn new(registry: Rc) -> Self { Self { registry, driver_id: String::from("virtual-button"), - pressed_by_address: Rc::new(RefCell::new(BTreeMap::new())), + pressed_by_address: Rc::new(RefCell::new(VecMap::new())), } } - pub fn set_pressed(&self, address: HardwareAddress, pressed: bool) { + pub fn set_pressed(&self, address: HwAddress, pressed: bool) { self.pressed_by_address .borrow_mut() .insert(address, pressed); } - fn endpoint_id(&self, address: &HardwareAddress) -> HardwareEndpointId { - HardwareEndpointId::for_driver_address(self.driver_id(), address) + fn endpoint_id(&self, address: &HwAddress) -> HwEndpointId { + HwEndpointId::for_driver_address(self.driver_id(), address) } fn gpio_for_endpoint( &self, - endpoint_id: &HardwareEndpointId, - ) -> Result { + endpoint_id: &HwEndpointId, + ) -> Result { for endpoint in self.endpoints() { if endpoint.id() == endpoint_id { return Ok(endpoint.address().clone()); @@ -50,13 +53,13 @@ impl VirtualButtonDriver { } Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Button, + kind: HwEndpointKind::Button, endpoint_id: endpoint_id.clone(), }) } } -impl HardwareDriver for VirtualButtonDriver { +impl HwDriver for VirtualButtonDriver { fn driver_id(&self) -> &str { &self.driver_id } @@ -67,18 +70,18 @@ impl HardwareDriver for VirtualButtonDriver { } impl ButtonDriver for VirtualButtonDriver { - fn endpoints(&self) -> Vec { + fn endpoints(&self) -> Vec { let mut endpoints = Vec::new(); for resource in self.registry.manifest().resources() { - if !resource.supports(HardwareCapability::GpioInput) { + if !resource.supports(HwCapability::GpioInput) { continue; } let address = resource.address().clone(); let spec = button_gpio_spec(resource.display_label()); - endpoints.push(HardwareEndpoint::new( + endpoints.push(HwEndpoint::new( self.endpoint_id(&address), spec, - HardwareEndpointKind::Button, + HwEndpointKind::Button, self.driver_id(), address, resource.display_label(), @@ -90,15 +93,15 @@ impl ButtonDriver for VirtualButtonDriver { fn open( &self, - endpoint_id: &HardwareEndpointId, + endpoint_id: &HwEndpointId, config: ButtonConfig, ) -> Result, HardwareEndpointError> { let source = self.gpio_for_endpoint(endpoint_id)?; self.registry - .ensure_capability(&source, HardwareCapability::GpioInput)?; + .ensure_capability(&source, HwCapability::GpioInput)?; let lease = self .registry - .claim_bundle(HardwareClaim::new(self.driver_id(), vec![source.clone()]))?; + .claim_bundle(HwClaim::new(self.driver_id(), vec![source.clone()]))?; Ok(Box::new(VirtualButtonInput::new( Rc::clone(&self.registry), source, @@ -109,26 +112,26 @@ impl ButtonDriver for VirtualButtonDriver { } } -fn button_gpio_spec(config: &str) -> HardwareEndpointSpec { - HardwareEndpointSpec::parse(alloc::format!("button:gpio:{config}")) +fn button_gpio_spec(config: &str) -> HwEndpointSpec { + HwEndpointSpec::parse(alloc::format!("button:gpio:{config}")) .expect("manifest display label should form a valid endpoint spec") } struct VirtualButtonInput { - registry: Rc, - source: HardwareAddress, + registry: Rc, + source: HwAddress, lease: Option, debouncer: ButtonDebouncer, - pressed_by_address: Rc>>, + pressed_by_address: Rc>>, } impl VirtualButtonInput { fn new( - registry: Rc, - source: HardwareAddress, + registry: Rc, + source: HwAddress, lease: HardwareLease, config: ButtonConfig, - pressed_by_address: Rc>>, + pressed_by_address: Rc>>, ) -> Self { Self { registry, @@ -147,7 +150,7 @@ impl VirtualButtonInput { } impl ButtonInput for VirtualButtonInput { - fn source(&self) -> &HardwareAddress { + fn source(&self) -> &HwAddress { &self.source } @@ -171,31 +174,30 @@ impl Drop for VirtualButtonInput { #[cfg(test)] mod tests { use super::*; - use crate::hardware::{ButtonEventKind, HardwareManifest, HardwareResource}; + use crate::{ButtonEventKind, HwManifest, HwResource}; #[test] fn virtual_button_driver_polls_injected_state() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); + let registry = Rc::new(HwRegistry::new(test_manifest())); let driver = VirtualButtonDriver::new(Rc::clone(®istry)); - let endpoint_id = - HardwareEndpointId::for_driver_address(driver.driver_id(), &HardwareAddress::gpio(4)); + let endpoint_id = HwEndpointId::for_driver_address(driver.driver_id(), &HwAddress::gpio(4)); let mut input = driver .open(&endpoint_id, ButtonConfig::new(10)) .expect("button opens"); - driver.set_pressed(HardwareAddress::gpio(4), true); + driver.set_pressed(HwAddress::gpio(4), true); assert!(input.poll(0).is_none()); let event = input.poll(10).expect("stable press emits event"); assert_eq!(event.kind(), ButtonEventKind::Pressed); } - fn test_manifest() -> HardwareManifest { - HardwareManifest::new( + fn test_manifest() -> HwManifest { + HwManifest::new( "test", "Test Board", - [HardwareResource::new( - HardwareAddress::gpio(4), - [HardwareCapability::GpioInput], + [HwResource::new( + HwAddress::gpio(4), + [HwCapability::GpioInput], "GPIO4", )], ) diff --git a/lp-core/lpc-hardware/src/drivers/hw_driver.rs b/lp-core/lpc-hardware/src/drivers/hw_driver.rs new file mode 100644 index 000000000..2037b1ec1 --- /dev/null +++ b/lp-core/lpc-hardware/src/drivers/hw_driver.rs @@ -0,0 +1,14 @@ +/// Common metadata for all hardware drivers. +/// +/// Family-specific traits such as [`crate::Ws281xDriver`] and +/// [`crate::ButtonDriver`] extend this trait with endpoint discovery and open +/// operations. +pub trait HwDriver { + /// Stable driver identifier used in endpoint IDs and resource claims. + fn driver_id(&self) -> &str; + + /// Human-facing label for diagnostics and endpoint lists. + fn display_label(&self) -> &str { + self.driver_id() + } +} diff --git a/lp-core/lpc-hardware/src/drivers/mod.rs b/lp-core/lpc-hardware/src/drivers/mod.rs new file mode 100644 index 000000000..42e13b4f9 --- /dev/null +++ b/lp-core/lpc-hardware/src/drivers/mod.rs @@ -0,0 +1,11 @@ +//! Driver traits and virtual driver implementations. +//! +//! Each capability family has a small trait pair: one trait for a driver that +//! lists and opens [`crate::HwEndpoint`]s, and one trait for the opened device. +//! Firmware crates provide target-specific drivers; this crate also includes +//! virtual drivers for host tests and emulation. + +pub mod button; +pub mod hw_driver; +pub mod radio; +pub mod ws281x; diff --git a/lp-core/lpc-hardware/src/drivers/radio/mod.rs b/lp-core/lpc-hardware/src/drivers/radio/mod.rs new file mode 100644 index 000000000..e7a96b4e4 --- /dev/null +++ b/lp-core/lpc-hardware/src/drivers/radio/mod.rs @@ -0,0 +1,10 @@ +//! Small packet-radio abstractions for peer/device messages. +//! +//! Radio drivers expose openable devices that can subscribe to logical channels, +//! send [`RadioMessage`](radio_message::RadioMessage)s, and drain received +//! messages with overflow reporting. + +pub mod radio_channel; +pub mod radio_driver; +pub mod radio_message; +pub mod virtual_radio_driver; diff --git a/lp-core/lpc-shared/src/hardware/radio_channel.rs b/lp-core/lpc-hardware/src/drivers/radio/radio_channel.rs similarity index 81% rename from lp-core/lpc-shared/src/hardware/radio_channel.rs rename to lp-core/lpc-hardware/src/drivers/radio/radio_channel.rs index 82fe9a403..eb24b3a9e 100644 --- a/lp-core/lpc-shared/src/hardware/radio_channel.rs +++ b/lp-core/lpc-hardware/src/drivers/radio/radio_channel.rs @@ -1,3 +1,6 @@ +/// Logical radio channel identifier. +/// +/// Channels let one opened radio device multiplex independent message streams. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RadioChannelId(u32); @@ -11,6 +14,7 @@ impl RadioChannelId { } } +/// Device identifier carried on radio messages. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RadioDeviceId(u32); @@ -24,6 +28,7 @@ impl RadioDeviceId { } } +/// Per-device event identifier carried on radio messages. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RadioEventId(u32); @@ -37,6 +42,10 @@ impl RadioEventId { } } +/// Result of draining a radio channel queue. +/// +/// `dropped_count` and `overflowed` report messages lost before this drain, +/// which is important for small embedded queues. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct RadioDrainReport { drained_count: usize, diff --git a/lp-core/lpc-shared/src/hardware/radio_driver.rs b/lp-core/lpc-hardware/src/drivers/radio/radio_driver.rs similarity index 56% rename from lp-core/lpc-shared/src/hardware/radio_driver.rs rename to lp-core/lpc-hardware/src/drivers/radio/radio_driver.rs index 6d331415f..9c5f3e4fd 100644 --- a/lp-core/lpc-shared/src/hardware/radio_driver.rs +++ b/lp-core/lpc-hardware/src/drivers/radio/radio_driver.rs @@ -1,11 +1,15 @@ use alloc::boxed::Box; use alloc::vec::Vec; -use super::{ - HardwareDriver, HardwareEndpoint, HardwareEndpointError, HardwareEndpointId, RadioChannelId, - RadioDrainReport, RadioMessage, RadioMessageKind, +use crate::{ + HardwareEndpointError, HwDriver, HwEndpoint, HwEndpointId, RadioChannelId, RadioDrainReport, + RadioMessage, RadioMessageKind, }; +/// Radio endpoint configuration. +/// +/// The optional channel is target-specific setup metadata; logical +/// subscriptions still happen through [`RadioDevice::subscribe_channel`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RadioConfig { channel: Option, @@ -27,12 +31,16 @@ impl Default for RadioConfig { } } +/// Opened packet-radio device. pub trait RadioDevice { + /// Start receiving messages for a logical channel. fn subscribe_channel(&mut self, channel: RadioChannelId) -> Result<(), HardwareEndpointError>; + /// Stop receiving messages for a logical channel. fn unsubscribe_channel(&mut self, channel: RadioChannelId) -> Result<(), HardwareEndpointError>; + /// Send a message on a logical channel. fn send_channel( &mut self, channel: RadioChannelId, @@ -40,6 +48,7 @@ pub trait RadioDevice { payload: &[u8], ) -> Result<(), HardwareEndpointError>; + /// Drain received messages for a channel into `out`. fn drain_channel( &mut self, channel: RadioChannelId, @@ -47,12 +56,15 @@ pub trait RadioDevice { ) -> Result; } -pub trait RadioDriver: HardwareDriver { - fn endpoints(&self) -> Vec; +/// Driver that exposes radio endpoints. +pub trait RadioDriver: HwDriver { + /// List currently known radio endpoints. + fn endpoints(&self) -> Vec; + /// Open one endpoint and claim the underlying radio resource. fn open( &self, - endpoint_id: &HardwareEndpointId, + endpoint_id: &HwEndpointId, config: RadioConfig, ) -> Result, HardwareEndpointError>; } diff --git a/lp-core/lpc-shared/src/hardware/radio_message.rs b/lp-core/lpc-hardware/src/drivers/radio/radio_message.rs similarity index 95% rename from lp-core/lpc-shared/src/hardware/radio_message.rs rename to lp-core/lpc-hardware/src/drivers/radio/radio_message.rs index e7362b1cb..70fe72f8b 100644 --- a/lp-core/lpc-shared/src/hardware/radio_message.rs +++ b/lp-core/lpc-hardware/src/drivers/radio/radio_message.rs @@ -1,7 +1,7 @@ use alloc::vec::Vec; use core::fmt; -use super::{RadioChannelId, RadioDeviceId, RadioEventId}; +use crate::{RadioChannelId, RadioDeviceId, RadioEventId}; pub const RADIO_WIRE_MAGIC: u16 = 0x4c50; pub const RADIO_WIRE_VERSION: u8 = 1; @@ -9,10 +9,14 @@ pub const RADIO_MAX_PAYLOAD_LEN: usize = 64; pub const RADIO_WIRE_HEADER_LEN: usize = 17; pub const RADIO_MAX_PACKET_LEN: usize = RADIO_WIRE_HEADER_LEN + RADIO_MAX_PAYLOAD_LEN; +/// Application-level kind carried in a radio packet. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RadioMessageKind { + /// Button event notification. ButtonPress, + /// Reserved for control-plane messages. ControlMessage, + /// Caller-defined message kind. Custom(u8), } @@ -34,6 +38,10 @@ impl RadioMessageKind { } } +/// Small fixed-header radio message. +/// +/// The encoding is intentionally allocation-light and bounded by +/// [`RADIO_MAX_PACKET_LEN`] so it can be used on embedded transports. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RadioMessage { source_device_id: RadioDeviceId, @@ -161,6 +169,7 @@ impl RadioMessage { } } +/// Packet encode/decode failure. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RadioPacketError { PacketTooShort { len: usize, min: usize }, diff --git a/lp-core/lpc-shared/src/hardware/virtual_radio_driver.rs b/lp-core/lpc-hardware/src/drivers/radio/virtual_radio_driver.rs similarity index 82% rename from lp-core/lpc-shared/src/hardware/virtual_radio_driver.rs rename to lp-core/lpc-hardware/src/drivers/radio/virtual_radio_driver.rs index d62d27136..58c4d746c 100644 --- a/lp-core/lpc-shared/src/hardware/virtual_radio_driver.rs +++ b/lp-core/lpc-hardware/src/drivers/radio/virtual_radio_driver.rs @@ -1,45 +1,47 @@ use alloc::boxed::Box; -use alloc::collections::{BTreeMap, BTreeSet, VecDeque}; +use alloc::collections::VecDeque; use alloc::rc::Rc; use alloc::string::String; use alloc::vec; use alloc::vec::Vec; use core::cell::RefCell; +use lp_collection::{VecMap, VecSet}; -use super::{ - HardwareAddress, HardwareCapability, HardwareClaim, HardwareDriver, HardwareEndpoint, - HardwareEndpointError, HardwareEndpointId, HardwareEndpointKind, HardwareEndpointSpec, - HardwareLease, HardwareRegistry, RadioChannelId, RadioConfig, RadioDevice, RadioDeviceId, - RadioDrainReport, RadioDriver, RadioEventId, RadioMessage, RadioMessageKind, +use crate::{ + HardwareEndpointError, HardwareLease, HwAddress, HwCapability, HwClaim, HwDriver, HwEndpoint, + HwEndpointId, HwEndpointKind, HwEndpointSpec, HwRegistry, RadioChannelId, RadioConfig, + RadioDevice, RadioDeviceId, RadioDrainReport, RadioDriver, RadioEventId, RadioMessage, + RadioMessageKind, }; const VIRTUAL_RADIO_DEVICE_ID: RadioDeviceId = RadioDeviceId::new(0); const VIRTUAL_RADIO_QUEUE_CAPACITY: usize = 16; +/// Manifest-backed virtual radio driver for tests and emulation. +/// +/// The driver exposes one radio endpoint for a manifest radio resource. Tests +/// can inject received packets with [`VirtualRadioDriver::push_received`] and +/// inspect transmitted packets with [`VirtualRadioDriver::take_sent`]. #[derive(Clone)] pub struct VirtualRadioDriver { - registry: Rc, + registry: Rc, driver_id: String, - address: HardwareAddress, - endpoint_spec: HardwareEndpointSpec, + address: HwAddress, + endpoint_spec: HwEndpointSpec, state: Rc>, } impl VirtualRadioDriver { - pub fn new(registry: Rc, radio_index: u8) -> Self { + pub fn new(registry: Rc, radio_index: u8) -> Self { Self::new_with_spec(registry, radio_index, "radio:virtual:0") } - pub fn new_with_spec( - registry: Rc, - radio_index: u8, - spec: &'static str, - ) -> Self { + pub fn new_with_spec(registry: Rc, radio_index: u8, spec: &'static str) -> Self { Self { registry, driver_id: alloc::format!("virtual-radio-{radio_index}-{spec}"), - address: HardwareAddress::radio(radio_index), - endpoint_spec: HardwareEndpointSpec::from_static(spec), + address: HwAddress::radio(radio_index), + endpoint_spec: HwEndpointSpec::from_static(spec), state: Rc::new(RefCell::new(VirtualRadioState::default())), } } @@ -52,12 +54,12 @@ impl VirtualRadioDriver { self.state.borrow_mut().sent.drain(..).collect() } - fn endpoint_id(&self) -> HardwareEndpointId { - HardwareEndpointId::for_driver_spec(self.driver_id(), &self.endpoint_spec) + fn endpoint_id(&self) -> HwEndpointId { + HwEndpointId::for_driver_spec(self.driver_id(), &self.endpoint_spec) } } -impl HardwareDriver for VirtualRadioDriver { +impl HwDriver for VirtualRadioDriver { fn driver_id(&self) -> &str { &self.driver_id } @@ -68,18 +70,18 @@ impl HardwareDriver for VirtualRadioDriver { } impl RadioDriver for VirtualRadioDriver { - fn endpoints(&self) -> Vec { + fn endpoints(&self) -> Vec { let Some(resource) = self.registry.manifest().resource(&self.address) else { return Vec::new(); }; - if !resource.supports(HardwareCapability::Radio) { + if !resource.supports(HwCapability::Radio) { return Vec::new(); } - vec![HardwareEndpoint::new( + vec![HwEndpoint::new( self.endpoint_id(), self.endpoint_spec.clone(), - HardwareEndpointKind::Radio, + HwEndpointKind::Radio, self.driver_id(), self.address.clone(), resource.display_label(), @@ -89,21 +91,21 @@ impl RadioDriver for VirtualRadioDriver { fn open( &self, - endpoint_id: &HardwareEndpointId, + endpoint_id: &HwEndpointId, config: RadioConfig, ) -> Result, HardwareEndpointError> { let _ = config; let expected_id = self.endpoint_id(); if endpoint_id != &expected_id { return Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Radio, + kind: HwEndpointKind::Radio, endpoint_id: endpoint_id.clone(), }); } let endpoint = self.endpoints().into_iter().next().ok_or_else(|| { HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Radio, + kind: HwEndpointKind::Radio, endpoint_id: endpoint_id.clone(), } })?; @@ -119,11 +121,10 @@ impl RadioDriver for VirtualRadioDriver { } self.registry - .ensure_capability(&self.address, HardwareCapability::Radio)?; - let lease = self.registry.claim_bundle(HardwareClaim::new( - self.driver_id(), - vec![self.address.clone()], - ))?; + .ensure_capability(&self.address, HwCapability::Radio)?; + let lease = self + .registry + .claim_bundle(HwClaim::new(self.driver_id(), vec![self.address.clone()]))?; Ok(Box::new(VirtualRadioDevice::new( Rc::clone(&self.registry), lease, @@ -134,8 +135,8 @@ impl RadioDriver for VirtualRadioDriver { #[derive(Default)] struct VirtualRadioState { - subscriptions: BTreeSet, - received: BTreeMap, + subscriptions: VecSet, + received: VecMap, sent: Vec, next_event_id: u32, } @@ -216,14 +217,14 @@ impl Default for VirtualRadioQueue { } struct VirtualRadioDevice { - registry: Rc, + registry: Rc, lease: Option, state: Rc>, } impl VirtualRadioDevice { fn new( - registry: Rc, + registry: Rc, lease: HardwareLease, state: Rc>, ) -> Self { @@ -289,11 +290,11 @@ impl Drop for VirtualRadioDevice { #[cfg(test)] mod tests { use super::*; - use crate::hardware::{HardwareManifest, HardwareResource}; + use crate::{HwManifest, HwResource}; #[test] fn virtual_radio_records_sent_messages() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); + let registry = Rc::new(HwRegistry::new(test_manifest())); let driver = VirtualRadioDriver::new(Rc::clone(®istry), 0); let mut radio = open_test_radio(&driver); let channel = RadioChannelId::new(7); @@ -313,7 +314,7 @@ mod tests { #[test] fn virtual_radio_subscribes_and_drains_injected_messages() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); + let registry = Rc::new(HwRegistry::new(test_manifest())); let driver = VirtualRadioDriver::new(Rc::clone(®istry), 0); let mut radio = open_test_radio(&driver); let channel = RadioChannelId::new(3); @@ -334,7 +335,7 @@ mod tests { #[test] fn virtual_radio_ignores_unsubscribed_channels() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); + let registry = Rc::new(HwRegistry::new(test_manifest())); let driver = VirtualRadioDriver::new(Rc::clone(®istry), 0); let mut radio = open_test_radio(&driver); let channel = RadioChannelId::new(3); @@ -351,7 +352,7 @@ mod tests { #[test] fn virtual_radio_drops_unsubscribed_channel_queue() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); + let registry = Rc::new(HwRegistry::new(test_manifest())); let driver = VirtualRadioDriver::new(Rc::clone(®istry), 0); let mut radio = open_test_radio(&driver); let channel = RadioChannelId::new(3); @@ -371,7 +372,7 @@ mod tests { #[test] fn virtual_radio_reports_overflow_when_queue_exceeds_capacity() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); + let registry = Rc::new(HwRegistry::new(test_manifest())); let driver = VirtualRadioDriver::new(Rc::clone(®istry), 0); let mut radio = open_test_radio(&driver); let channel = RadioChannelId::new(5); @@ -394,7 +395,7 @@ mod tests { #[test] fn second_radio_open_contends_with_first() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); + let registry = Rc::new(HwRegistry::new(test_manifest())); let driver = VirtualRadioDriver::new(Rc::clone(®istry), 0); let first_radio = open_test_radio(&driver); let endpoint_id = driver.endpoint_id(); @@ -410,7 +411,7 @@ mod tests { #[test] fn dropping_radio_releases_endpoint() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); + let registry = Rc::new(HwRegistry::new(test_manifest())); let driver = VirtualRadioDriver::new(Rc::clone(®istry), 0); let radio = open_test_radio(&driver); drop(radio); @@ -418,13 +419,13 @@ mod tests { let _ = open_test_radio(&driver); } - fn test_manifest() -> HardwareManifest { - HardwareManifest::new( + fn test_manifest() -> HwManifest { + HwManifest::new( "test", "Test Board", - [HardwareResource::new( - HardwareAddress::radio(0), - [HardwareCapability::Radio], + [HwResource::new( + HwAddress::radio(0), + [HwCapability::Radio], "Radio 0", )], ) diff --git a/lp-core/lpc-hardware/src/drivers/ws281x/mod.rs b/lp-core/lpc-hardware/src/drivers/ws281x/mod.rs new file mode 100644 index 000000000..69acdfc2c --- /dev/null +++ b/lp-core/lpc-hardware/src/drivers/ws281x/mod.rs @@ -0,0 +1,9 @@ +//! WS281x LED output contracts. +//! +//! WS281x drivers combine a GPIO output resource with a timing peripheral such +//! as RMT. The opened [`Ws281xOutput`](ws281x_driver::Ws281xOutput) accepts raw +//! 8-bit RGB bytes; color pipeline work happens in higher-level output +//! providers. + +pub mod virtual_ws281x_driver; +pub mod ws281x_driver; diff --git a/lp-core/lpc-shared/src/hardware/virtual_ws281x_driver.rs b/lp-core/lpc-hardware/src/drivers/ws281x/virtual_ws281x_driver.rs similarity index 63% rename from lp-core/lpc-shared/src/hardware/virtual_ws281x_driver.rs rename to lp-core/lpc-hardware/src/drivers/ws281x/virtual_ws281x_driver.rs index be3254c4a..4569e6527 100644 --- a/lp-core/lpc-shared/src/hardware/virtual_ws281x_driver.rs +++ b/lp-core/lpc-hardware/src/drivers/ws281x/virtual_ws281x_driver.rs @@ -5,25 +5,28 @@ use alloc::string::{String, ToString}; use alloc::vec; use alloc::vec::Vec; -use crate::error::OutputError; +use crate::OutputError; -use super::{ - HardwareAddress, HardwareCapability, HardwareClaim, HardwareDriver, HardwareEndpoint, - HardwareEndpointError, HardwareEndpointId, HardwareEndpointKind, HardwareEndpointSpec, - HardwareEndpointStatus, HardwareLease, HardwareRegistry, Ws281xConfig, Ws281xDriver, - Ws281xOutput, +use crate::{ + HardwareEndpointError, HardwareLease, HwAddress, HwCapability, HwClaim, HwDriver, HwEndpoint, + HwEndpointId, HwEndpointKind, HwEndpointSpec, HwEndpointStatus, HwRegistry, Ws281xConfig, + Ws281xDriver, Ws281xOutput, }; +/// Manifest-backed virtual WS281x driver for tests and emulation. +/// +/// The driver exposes WS281x endpoints for GPIO output resources when the +/// manifest also contains the configured RMT/WS281x timing resource. pub struct VirtualWs281xDriver { - registry: Rc, + registry: Rc, driver_id: String, display_label: String, - timing_address: HardwareAddress, + timing_address: HwAddress, } impl VirtualWs281xDriver { - pub fn new(registry: Rc, rmt_channel: u8) -> Self { - let timing_address = HardwareAddress::rmt_ws281x(rmt_channel); + pub fn new(registry: Rc, rmt_channel: u8) -> Self { + let timing_address = HwAddress::rmt_ws281x(rmt_channel); Self { registry, driver_id: format!("virtual-ws281x-rmt{rmt_channel}"), @@ -32,34 +35,32 @@ impl VirtualWs281xDriver { } } - fn endpoint_id(&self, spec: &HardwareEndpointSpec) -> HardwareEndpointId { - HardwareEndpointId::for_driver_spec(self.driver_id(), spec) + fn endpoint_id(&self, spec: &HwEndpointSpec) -> HwEndpointId { + HwEndpointId::for_driver_spec(self.driver_id(), spec) } - fn endpoint_status(&self, gpio: &HardwareAddress) -> HardwareEndpointStatus { + fn endpoint_status(&self, gpio: &HwAddress) -> HwEndpointStatus { let gpio_status = self.registry.endpoint_status_for(gpio); if !gpio_status.is_available() { return gpio_status; } match self.registry.endpoint_status_for(&self.timing_address) { - HardwareEndpointStatus::Available => HardwareEndpointStatus::Available, - HardwareEndpointStatus::Reserved { reason } => HardwareEndpointStatus::Unavailable { + HwEndpointStatus::Available => HwEndpointStatus::Available, + HwEndpointStatus::Reserved { reason } => HwEndpointStatus::Unavailable { reason: format!("WS281x timing resource is reserved: {reason}"), }, - HardwareEndpointStatus::InUse { claimant } => HardwareEndpointStatus::Unavailable { + HwEndpointStatus::InUse { claimant } => HwEndpointStatus::Unavailable { reason: format!("WS281x timing resource is in use by {claimant}"), }, - HardwareEndpointStatus::Unavailable { reason } => { - HardwareEndpointStatus::Unavailable { reason } - } + HwEndpointStatus::Unavailable { reason } => HwEndpointStatus::Unavailable { reason }, } } fn gpio_for_endpoint( &self, - endpoint_id: &HardwareEndpointId, - ) -> Result { + endpoint_id: &HwEndpointId, + ) -> Result { for endpoint in self.endpoints() { if endpoint.id() == endpoint_id { return Ok(endpoint.address().clone()); @@ -67,13 +68,13 @@ impl VirtualWs281xDriver { } Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Ws281x, + kind: HwEndpointKind::Ws281x, endpoint_id: endpoint_id.clone(), }) } } -impl HardwareDriver for VirtualWs281xDriver { +impl HwDriver for VirtualWs281xDriver { fn driver_id(&self) -> &str { &self.driver_id } @@ -84,30 +85,30 @@ impl HardwareDriver for VirtualWs281xDriver { } impl Ws281xDriver for VirtualWs281xDriver { - fn endpoints(&self) -> Vec { + fn endpoints(&self) -> Vec { let mut endpoints = Vec::new(); let timing_supported = self .registry - .ensure_capability(&self.timing_address, HardwareCapability::Rmt) + .ensure_capability(&self.timing_address, HwCapability::Rmt) .is_ok() && self .registry - .ensure_capability(&self.timing_address, HardwareCapability::Ws281xOutput) + .ensure_capability(&self.timing_address, HwCapability::Ws281xOutput) .is_ok(); if !timing_supported { return endpoints; } for resource in self.registry.manifest().resources() { - if !resource.supports(HardwareCapability::GpioOutput) { + if !resource.supports(HwCapability::GpioOutput) { continue; } let address = resource.address().clone(); let spec = ws281x_rmt_spec(resource.display_label()); - endpoints.push(HardwareEndpoint::new( + endpoints.push(HwEndpoint::new( self.endpoint_id(&spec), spec, - HardwareEndpointKind::Ws281x, + HwEndpointKind::Ws281x, self.driver_id(), address, resource.display_label(), @@ -119,18 +120,18 @@ impl Ws281xDriver for VirtualWs281xDriver { fn open( &self, - endpoint_id: &HardwareEndpointId, + endpoint_id: &HwEndpointId, config: Ws281xConfig, ) -> Result, HardwareEndpointError> { validate_ws281x_byte_count(config.byte_count())?; let gpio = self.gpio_for_endpoint(endpoint_id)?; self.registry - .ensure_capability(&gpio, HardwareCapability::GpioOutput)?; + .ensure_capability(&gpio, HwCapability::GpioOutput)?; self.registry - .ensure_capability(&self.timing_address, HardwareCapability::Rmt)?; + .ensure_capability(&self.timing_address, HwCapability::Rmt)?; self.registry - .ensure_capability(&self.timing_address, HardwareCapability::Ws281xOutput)?; - let lease = self.registry.claim_bundle(HardwareClaim::new( + .ensure_capability(&self.timing_address, HwCapability::Ws281xOutput)?; + let lease = self.registry.claim_bundle(HwClaim::new( self.driver_id(), vec![gpio, self.timing_address.clone()], ))?; @@ -142,16 +143,20 @@ impl Ws281xDriver for VirtualWs281xDriver { } } +/// In-memory WS281x output used by [`VirtualWs281xDriver`]. +/// +/// It stores the most recent raw RGB byte frame and releases its hardware lease +/// when dropped. pub struct VirtualWs281xOutput { - registry: Rc, + registry: Rc, lease: Option, byte_count: u32, - data: Vec, + data: Vec, } impl VirtualWs281xOutput { - fn new(registry: Rc, lease: HardwareLease, byte_count: u32) -> Self { - let data_len = u16_len_for_byte_count(byte_count); + fn new(registry: Rc, lease: HardwareLease, byte_count: u32) -> Self { + let data_len = byte_len_for_byte_count(byte_count); Self { registry, lease: Some(lease), @@ -160,7 +165,7 @@ impl VirtualWs281xOutput { } } - pub fn data(&self) -> &[u16] { + pub fn data(&self) -> &[u8] { &self.data } @@ -172,7 +177,7 @@ impl VirtualWs281xOutput { } impl Ws281xOutput for VirtualWs281xOutput { - fn write(&mut self, data: &[u16]) -> Result<(), OutputError> { + fn write(&mut self, data: &[u8]) -> Result<(), OutputError> { let expected_len = self.data.len(); if data.len() > expected_len { let new_len = (data.len() / 3) * 3; @@ -193,7 +198,8 @@ impl Ws281xOutput for VirtualWs281xOutput { fn resize(&mut self, config: Ws281xConfig) -> Result<(), OutputError> { validate_ws281x_byte_count(config.byte_count()).map_err(endpoint_error_to_output_error)?; self.byte_count = config.byte_count(); - self.data.resize(u16_len_for_byte_count(self.byte_count), 0); + self.data + .resize(byte_len_for_byte_count(self.byte_count), 0); Ok(()) } } @@ -213,7 +219,7 @@ fn validate_ws281x_byte_count(byte_count: u32) -> Result<(), HardwareEndpointErr Ok(()) } -fn u16_len_for_byte_count(byte_count: u32) -> usize { +fn byte_len_for_byte_count(byte_count: u32) -> usize { ((byte_count / 3) as usize) * 3 } @@ -226,7 +232,7 @@ fn endpoint_error_to_output_error(error: HardwareEndpointError) -> OutputError { } } -fn ws281x_rmt_spec(config: &str) -> HardwareEndpointSpec { - HardwareEndpointSpec::parse(format!("ws281x:rmt:{config}")) +fn ws281x_rmt_spec(config: &str) -> HwEndpointSpec { + HwEndpointSpec::parse(format!("ws281x:rmt:{config}")) .expect("manifest display label should form a valid endpoint spec") } diff --git a/lp-core/lpc-hardware/src/drivers/ws281x/ws281x_driver.rs b/lp-core/lpc-hardware/src/drivers/ws281x/ws281x_driver.rs new file mode 100644 index 000000000..f340b9b30 --- /dev/null +++ b/lp-core/lpc-hardware/src/drivers/ws281x/ws281x_driver.rs @@ -0,0 +1,52 @@ +use alloc::boxed::Box; + +use crate::OutputError; +use crate::{HardwareEndpointError, HwDriver, HwEndpoint, HwEndpointId}; + +/// Configuration used when opening or resizing a WS281x endpoint. +/// +/// `byte_count` is the number of protocol bytes in one output frame, normally +/// `led_count * 3` for RGB strips. Rendering concerns such as interpolation, +/// dithering, and white-point correction live above this hardware boundary. +#[derive(Debug, Clone)] +pub struct Ws281xConfig { + byte_count: u32, +} + +impl Ws281xConfig { + /// Create a WS281x config for one frame of protocol bytes. + pub fn new(byte_count: u32) -> Self { + Self { byte_count } + } + + /// Number of RGB protocol bytes in one frame. + pub fn byte_count(&self) -> u32 { + self.byte_count + } +} + +/// Opened WS281x hardware output. +/// +/// Implementations receive already-rendered 8-bit protocol bytes. Callers that +/// start from 16-bit RGB samples should run display-pipeline processing before +/// writing here. +pub trait Ws281xOutput { + /// Write one full raw RGB frame. + fn write(&mut self, data: &[u8]) -> Result<(), OutputError>; + + /// Change the expected frame size for subsequent writes. + fn resize(&mut self, config: Ws281xConfig) -> Result<(), OutputError>; +} + +/// Driver that exposes WS281x-capable hardware endpoints. +pub trait Ws281xDriver: HwDriver { + /// List currently known WS281x endpoints. + fn endpoints(&self) -> alloc::vec::Vec; + + /// Open one endpoint and claim its GPIO/timing resources. + fn open( + &self, + endpoint_id: &HwEndpointId, + config: Ws281xConfig, + ) -> Result, HardwareEndpointError>; +} diff --git a/lp-core/lpc-hardware/src/endpoint/hw_endpoint.rs b/lp-core/lpc-hardware/src/endpoint/hw_endpoint.rs new file mode 100644 index 000000000..646e250cc --- /dev/null +++ b/lp-core/lpc-hardware/src/endpoint/hw_endpoint.rs @@ -0,0 +1,75 @@ +use alloc::string::String; + +use lpc_model::HwEndpointSpec; + +use crate::{HwAddress, HwEndpointId, HwEndpointKind, HwEndpointStatus}; + +/// Openable hardware surface reported by a driver. +/// +/// An endpoint binds an authored [`HwEndpointSpec`] to a concrete +/// [`HwAddress`], a driver, and a current [`HwEndpointStatus`]. Callers open +/// endpoints through [`crate::HardwareSystem`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HwEndpoint { + id: HwEndpointId, + spec: HwEndpointSpec, + kind: HwEndpointKind, + driver_id: String, + address: HwAddress, + display_label: String, + status: HwEndpointStatus, +} + +impl HwEndpoint { + pub fn new( + id: HwEndpointId, + spec: HwEndpointSpec, + kind: HwEndpointKind, + driver_id: impl Into, + address: HwAddress, + display_label: impl Into, + status: HwEndpointStatus, + ) -> Self { + Self { + id, + spec, + kind, + driver_id: driver_id.into(), + address, + display_label: display_label.into(), + status, + } + } + + pub fn id(&self) -> &HwEndpointId { + &self.id + } + + pub fn spec(&self) -> &HwEndpointSpec { + &self.spec + } + + pub fn kind(&self) -> HwEndpointKind { + self.kind + } + + pub fn driver_id(&self) -> &str { + &self.driver_id + } + + pub fn address(&self) -> &HwAddress { + &self.address + } + + pub fn display_label(&self) -> &str { + &self.display_label + } + + pub fn status(&self) -> &HwEndpointStatus { + &self.status + } + + pub fn is_available(&self) -> bool { + self.status.is_available() + } +} diff --git a/lp-core/lpc-shared/src/hardware/hardware_endpoint_error.rs b/lp-core/lpc-hardware/src/endpoint/hw_endpoint_error.rs similarity index 59% rename from lp-core/lpc-shared/src/hardware/hardware_endpoint_error.rs rename to lp-core/lpc-hardware/src/endpoint/hw_endpoint_error.rs index ba016f5d8..a41c791bc 100644 --- a/lp-core/lpc-shared/src/hardware/hardware_endpoint_error.rs +++ b/lp-core/lpc-hardware/src/endpoint/hw_endpoint_error.rs @@ -1,27 +1,27 @@ use alloc::string::String; use core::fmt; -use super::{HardwareEndpointId, HardwareEndpointKind, HardwareError}; +use crate::{HwEndpointId, HwEndpointKind, HwError}; +/// Failure while resolving or opening a hardware endpoint. #[derive(Debug, Clone, PartialEq, Eq)] pub enum HardwareEndpointError { + /// No registered driver currently exposes the requested endpoint. UnknownEndpoint { - kind: HardwareEndpointKind, - endpoint_id: HardwareEndpointId, + kind: HwEndpointKind, + endpoint_id: HwEndpointId, }, + /// The endpoint exists but is reserved, already claimed, or not initialized. EndpointUnavailable { - endpoint_id: HardwareEndpointId, + endpoint_id: HwEndpointId, reason: String, }, - UnsupportedConfig { - reason: String, - }, - Hardware { - error: HardwareError, - }, - Other { - message: String, - }, + /// The endpoint exists, but the requested configuration is unsupported. + UnsupportedConfig { reason: String }, + /// Lower-level resource or registry failure. + Hardware { error: HwError }, + /// Target-specific endpoint failure. + Other { message: String }, } impl fmt::Display for HardwareEndpointError { @@ -48,8 +48,8 @@ impl fmt::Display for HardwareEndpointError { } } -impl From for HardwareEndpointError { - fn from(error: HardwareError) -> Self { +impl From for HardwareEndpointError { + fn from(error: HwError) -> Self { Self::Hardware { error } } } diff --git a/lp-core/lpc-hardware/src/endpoint/hw_endpoint_id.rs b/lp-core/lpc-hardware/src/endpoint/hw_endpoint_id.rs new file mode 100644 index 000000000..4ad415ae1 --- /dev/null +++ b/lp-core/lpc-hardware/src/endpoint/hw_endpoint_id.rs @@ -0,0 +1,37 @@ +use alloc::format; +use alloc::string::String; +use core::fmt; + +use crate::HwAddress; +use lpc_model::HwEndpointSpec; + +/// Internal endpoint identity scoped to the driver that exposes it. +/// +/// Endpoint IDs are stable enough for routing inside [`crate::HardwareSystem`]. +/// Authored project files should use [`HwEndpointSpec`] instead. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct HwEndpointId(String); + +impl HwEndpointId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn for_driver_address(driver_id: &str, address: &HwAddress) -> Self { + Self(format!("{driver_id}:{}", address.as_str())) + } + + pub fn for_driver_spec(driver_id: &str, spec: &HwEndpointSpec) -> Self { + Self(format!("{driver_id}:{}", spec.as_str())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for HwEndpointId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/lp-core/lpc-shared/src/hardware/hardware_endpoint_kind.rs b/lp-core/lpc-hardware/src/endpoint/hw_endpoint_kind.rs similarity index 75% rename from lp-core/lpc-shared/src/hardware/hardware_endpoint_kind.rs rename to lp-core/lpc-hardware/src/endpoint/hw_endpoint_kind.rs index 95bd05573..1ee004a52 100644 --- a/lp-core/lpc-shared/src/hardware/hardware_endpoint_kind.rs +++ b/lp-core/lpc-hardware/src/endpoint/hw_endpoint_kind.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; +/// Capability family for an endpoint. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub enum HardwareEndpointKind { +pub enum HwEndpointKind { Ws281x, Button, Radio, diff --git a/lp-core/lpc-shared/src/hardware/hardware_endpoint_status.rs b/lp-core/lpc-hardware/src/endpoint/hw_endpoint_status.rs similarity index 67% rename from lp-core/lpc-shared/src/hardware/hardware_endpoint_status.rs rename to lp-core/lpc-hardware/src/endpoint/hw_endpoint_status.rs index c8f7ce472..fef5ebe4f 100644 --- a/lp-core/lpc-shared/src/hardware/hardware_endpoint_status.rs +++ b/lp-core/lpc-hardware/src/endpoint/hw_endpoint_status.rs @@ -1,14 +1,18 @@ use alloc::string::String; +/// Current availability of a driver endpoint. +/// +/// Drivers calculate this from the [`crate::HwRegistry`] and any target-specific +/// readiness checks, such as whether an ESP32 RMT channel has been initialized. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum HardwareEndpointStatus { +pub enum HwEndpointStatus { Available, InUse { claimant: String }, Unavailable { reason: String }, Reserved { reason: String }, } -impl HardwareEndpointStatus { +impl HwEndpointStatus { pub fn is_available(&self) -> bool { matches!(self, Self::Available) } diff --git a/lp-core/lpc-hardware/src/endpoint/mod.rs b/lp-core/lpc-hardware/src/endpoint/mod.rs new file mode 100644 index 000000000..9752106e6 --- /dev/null +++ b/lp-core/lpc-hardware/src/endpoint/mod.rs @@ -0,0 +1,12 @@ +//! Openable hardware endpoints derived from manifest resources. +//! +//! Endpoints are the bridge between authored specs such as `ws281x:rmt:D10` and +//! the lower-level [`crate::HwAddress`] resources that drivers claim. A driver +//! reports endpoint status from the [`crate::HwRegistry`] so callers can see +//! whether an endpoint is available, reserved, or already in use. + +pub mod hw_endpoint; +pub mod hw_endpoint_error; +pub mod hw_endpoint_id; +pub mod hw_endpoint_kind; +pub mod hw_endpoint_status; diff --git a/lp-core/lpc-shared/src/hardware/hardware_error.rs b/lp-core/lpc-hardware/src/hw_error.rs similarity index 58% rename from lp-core/lpc-shared/src/hardware/hardware_error.rs rename to lp-core/lpc-hardware/src/hw_error.rs index 0bd7238ea..06f5f4077 100644 --- a/lp-core/lpc-shared/src/hardware/hardware_error.rs +++ b/lp-core/lpc-hardware/src/hw_error.rs @@ -1,38 +1,40 @@ use alloc::string::String; use core::fmt; -use super::{HardwareAddress, HardwareCapability, HardwareLeaseId}; +use crate::{HwAddress, HwCapability, HwLeaseId}; +/// Resource-level hardware errors. +/// +/// These errors come from address validation, manifest lookup, and registry +/// claim/release operations. Endpoint-opening code wraps them in +/// [`crate::HardwareEndpointError`] when appropriate. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum HardwareError { - InvalidAddress { - address: String, - }, - UnknownResource { - address: HardwareAddress, - }, - ReservedResource { - address: HardwareAddress, - reason: String, - }, +pub enum HwError { + /// Address path is malformed. + InvalidAddress { address: String }, + /// Address is not present in the manifest. + UnknownResource { address: HwAddress }, + /// Resource is deliberately disabled in the manifest. + ReservedResource { address: HwAddress, reason: String }, + /// Resource exists but does not advertise the requested capability. UnsupportedCapability { - address: HardwareAddress, - capability: HardwareCapability, + address: HwAddress, + capability: HwCapability, }, + /// Resource is already held by another active lease. ResourceAlreadyClaimed { - address: HardwareAddress, + address: HwAddress, claimant: String, }, - DuplicateAddressInClaim { - address: HardwareAddress, - }, + /// One claim listed the same address more than once. + DuplicateAddressInClaim { address: HwAddress }, + /// Claims must reserve at least one resource. EmptyClaim, - UnknownLease { - lease_id: HardwareLeaseId, - }, + /// Attempted to release a lease the registry no longer knows about. + UnknownLease { lease_id: HwLeaseId }, } -impl fmt::Display for HardwareError { +impl fmt::Display for HwError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidAddress { address } => { diff --git a/lp-core/lpc-shared/src/hardware/hardware_system.rs b/lp-core/lpc-hardware/src/hw_system.rs similarity index 65% rename from lp-core/lpc-shared/src/hardware/hardware_system.rs rename to lp-core/lpc-hardware/src/hw_system.rs index 1045804ec..6167908c1 100644 --- a/lp-core/lpc-shared/src/hardware/hardware_system.rs +++ b/lp-core/lpc-hardware/src/hw_system.rs @@ -2,22 +2,27 @@ use alloc::boxed::Box; use alloc::rc::Rc; use alloc::vec::Vec; -use super::{ - ButtonConfig, ButtonDriver, ButtonInput, HardwareAddress, HardwareEndpoint, - HardwareEndpointError, HardwareEndpointId, HardwareEndpointKind, HardwareEndpointSpec, - HardwareRegistry, RadioConfig, RadioDevice, RadioDriver, VirtualButtonDriver, - VirtualRadioDriver, VirtualWs281xDriver, Ws281xConfig, Ws281xDriver, Ws281xOutput, +use crate::{ + ButtonConfig, ButtonDriver, ButtonInput, HardwareEndpointError, HwAddress, HwEndpoint, + HwEndpointId, HwEndpointKind, HwEndpointSpec, HwRegistry, RadioConfig, RadioDevice, + RadioDriver, VirtualButtonDriver, VirtualRadioDriver, VirtualWs281xDriver, Ws281xConfig, + Ws281xDriver, Ws281xOutput, }; +/// Driver registry and endpoint router for one hardware manifest. +/// +/// `HardwareSystem` owns the set of registered drivers for a target. It does not +/// own resources directly; each opened device claims resources through the +/// shared [`HwRegistry`]. pub struct HardwareSystem { - registry: Rc, + registry: Rc, ws281x_drivers: Vec>, button_drivers: Vec>, radio_drivers: Vec>, } impl HardwareSystem { - pub fn new(registry: Rc) -> Self { + pub fn new(registry: Rc) -> Self { Self { registry, ws281x_drivers: Vec::new(), @@ -26,7 +31,7 @@ impl HardwareSystem { } } - pub fn with_virtual_drivers(registry: Rc) -> Self { + pub fn with_virtual_drivers(registry: Rc) -> Self { let mut system = Self::new(Rc::clone(®istry)); system.add_ws281x_driver(Box::new(VirtualWs281xDriver::new(Rc::clone(®istry), 0))); system.add_button_driver(Box::new(VirtualButtonDriver::new(Rc::clone(®istry)))); @@ -39,7 +44,7 @@ impl HardwareSystem { system } - pub fn registry(&self) -> Rc { + pub fn registry(&self) -> Rc { Rc::clone(&self.registry) } @@ -55,21 +60,21 @@ impl HardwareSystem { self.radio_drivers.push(driver); } - pub fn ws281x_endpoints(&self) -> Vec { + pub fn ws281x_endpoints(&self) -> Vec { collect_endpoints(&self.ws281x_drivers) } - pub fn button_endpoints(&self) -> Vec { + pub fn button_endpoints(&self) -> Vec { collect_endpoints(&self.button_drivers) } - pub fn radio_endpoints(&self) -> Vec { + pub fn radio_endpoints(&self) -> Vec { collect_endpoints(&self.radio_drivers) } pub fn open_ws281x( &self, - endpoint_id: &HardwareEndpointId, + endpoint_id: &HwEndpointId, config: Ws281xConfig, ) -> Result, HardwareEndpointError> { for driver in &self.ws281x_drivers { @@ -82,44 +87,44 @@ impl HardwareSystem { } } Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Ws281x, + kind: HwEndpointKind::Ws281x, endpoint_id: endpoint_id.clone(), }) } pub fn open_ws281x_by_address( &self, - address: &HardwareAddress, + address: &HwAddress, config: Ws281xConfig, ) -> Result, HardwareEndpointError> { match endpoint_for_address(self.ws281x_endpoints(), address) { EndpointAddressMatch::Available(endpoint) => self.open_ws281x(endpoint.id(), config), EndpointAddressMatch::Unavailable(endpoint) => self.open_ws281x(endpoint.id(), config), EndpointAddressMatch::Missing => Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Ws281x, - endpoint_id: HardwareEndpointId::new(address.as_str()), + kind: HwEndpointKind::Ws281x, + endpoint_id: HwEndpointId::new(address.as_str()), }), } } pub fn open_ws281x_by_spec( &self, - spec: &HardwareEndpointSpec, + spec: &HwEndpointSpec, config: Ws281xConfig, ) -> Result, HardwareEndpointError> { match endpoint_for_spec(self.ws281x_endpoints(), spec) { EndpointAddressMatch::Available(endpoint) => self.open_ws281x(endpoint.id(), config), EndpointAddressMatch::Unavailable(endpoint) => self.open_ws281x(endpoint.id(), config), EndpointAddressMatch::Missing => Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Ws281x, - endpoint_id: HardwareEndpointId::new(spec.as_str()), + kind: HwEndpointKind::Ws281x, + endpoint_id: HwEndpointId::new(spec.as_str()), }), } } pub fn open_button( &self, - endpoint_id: &HardwareEndpointId, + endpoint_id: &HwEndpointId, config: ButtonConfig, ) -> Result, HardwareEndpointError> { for driver in &self.button_drivers { @@ -132,44 +137,44 @@ impl HardwareSystem { } } Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Button, + kind: HwEndpointKind::Button, endpoint_id: endpoint_id.clone(), }) } pub fn open_button_by_address( &self, - address: &HardwareAddress, + address: &HwAddress, config: ButtonConfig, ) -> Result, HardwareEndpointError> { match endpoint_for_address(self.button_endpoints(), address) { EndpointAddressMatch::Available(endpoint) => self.open_button(endpoint.id(), config), EndpointAddressMatch::Unavailable(endpoint) => self.open_button(endpoint.id(), config), EndpointAddressMatch::Missing => Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Button, - endpoint_id: HardwareEndpointId::new(address.as_str()), + kind: HwEndpointKind::Button, + endpoint_id: HwEndpointId::new(address.as_str()), }), } } pub fn open_button_by_spec( &self, - spec: &HardwareEndpointSpec, + spec: &HwEndpointSpec, config: ButtonConfig, ) -> Result, HardwareEndpointError> { match endpoint_for_spec(self.button_endpoints(), spec) { EndpointAddressMatch::Available(endpoint) => self.open_button(endpoint.id(), config), EndpointAddressMatch::Unavailable(endpoint) => self.open_button(endpoint.id(), config), EndpointAddressMatch::Missing => Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Button, - endpoint_id: HardwareEndpointId::new(spec.as_str()), + kind: HwEndpointKind::Button, + endpoint_id: HwEndpointId::new(spec.as_str()), }), } } pub fn open_radio( &self, - endpoint_id: &HardwareEndpointId, + endpoint_id: &HwEndpointId, config: RadioConfig, ) -> Result, HardwareEndpointError> { for driver in &self.radio_drivers { @@ -182,65 +187,65 @@ impl HardwareSystem { } } Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Radio, + kind: HwEndpointKind::Radio, endpoint_id: endpoint_id.clone(), }) } pub fn open_radio_by_address( &self, - address: &HardwareAddress, + address: &HwAddress, config: RadioConfig, ) -> Result, HardwareEndpointError> { match endpoint_for_address(self.radio_endpoints(), address) { EndpointAddressMatch::Available(endpoint) => self.open_radio(endpoint.id(), config), EndpointAddressMatch::Unavailable(endpoint) => self.open_radio(endpoint.id(), config), EndpointAddressMatch::Missing => Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Radio, - endpoint_id: HardwareEndpointId::new(address.as_str()), + kind: HwEndpointKind::Radio, + endpoint_id: HwEndpointId::new(address.as_str()), }), } } pub fn open_radio_by_spec( &self, - spec: &HardwareEndpointSpec, + spec: &HwEndpointSpec, config: RadioConfig, ) -> Result, HardwareEndpointError> { match endpoint_for_spec(self.radio_endpoints(), spec) { EndpointAddressMatch::Available(endpoint) => self.open_radio(endpoint.id(), config), EndpointAddressMatch::Unavailable(endpoint) => self.open_radio(endpoint.id(), config), EndpointAddressMatch::Missing => Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Radio, - endpoint_id: HardwareEndpointId::new(spec.as_str()), + kind: HwEndpointKind::Radio, + endpoint_id: HwEndpointId::new(spec.as_str()), }), } } } trait EndpointDriver { - fn endpoints(&self) -> Vec; + fn endpoints(&self) -> Vec; } impl EndpointDriver for Box { - fn endpoints(&self) -> Vec { + fn endpoints(&self) -> Vec { (**self).endpoints() } } impl EndpointDriver for Box { - fn endpoints(&self) -> Vec { + fn endpoints(&self) -> Vec { (**self).endpoints() } } impl EndpointDriver for Box { - fn endpoints(&self) -> Vec { + fn endpoints(&self) -> Vec { (**self).endpoints() } } -fn collect_endpoints(drivers: &[D]) -> Vec +fn collect_endpoints(drivers: &[D]) -> Vec where D: EndpointDriver, { @@ -252,15 +257,12 @@ where } enum EndpointAddressMatch { - Available(HardwareEndpoint), - Unavailable(HardwareEndpoint), + Available(HwEndpoint), + Unavailable(HwEndpoint), Missing, } -fn endpoint_for_address( - endpoints: Vec, - address: &HardwareAddress, -) -> EndpointAddressMatch { +fn endpoint_for_address(endpoints: Vec, address: &HwAddress) -> EndpointAddressMatch { let mut first_match = None; for endpoint in endpoints { if endpoint.address() != address { @@ -279,10 +281,7 @@ fn endpoint_for_address( } } -fn endpoint_for_spec( - endpoints: Vec, - spec: &HardwareEndpointSpec, -) -> EndpointAddressMatch { +fn endpoint_for_spec(endpoints: Vec, spec: &HwEndpointSpec) -> EndpointAddressMatch { let mut first_match = None; for endpoint in endpoints { if endpoint.spec() != spec { @@ -304,13 +303,11 @@ fn endpoint_for_spec( #[cfg(test)] mod tests { use super::*; - use crate::hardware::{HardwareManifest, HardwareResource}; + use crate::{HwCapability, HwManifest, HwResource}; #[test] fn virtual_system_lists_three_capability_families() { - let registry = Rc::new(HardwareRegistry::new( - HardwareManifest::virtual_single_rmt_gpio_board(), - )); + let registry = Rc::new(HwRegistry::new(HwManifest::virtual_single_rmt_gpio_board())); let system = HardwareSystem::with_virtual_drivers(registry); assert!(!system.ws281x_endpoints().is_empty()); @@ -320,52 +317,46 @@ mod tests { #[test] fn virtual_system_opens_ws281x_by_gpio_address() { - let registry = Rc::new(HardwareRegistry::new( - HardwareManifest::virtual_single_rmt_gpio_board(), - )); + let registry = Rc::new(HwRegistry::new(HwManifest::virtual_single_rmt_gpio_board())); let system = HardwareSystem::with_virtual_drivers(Rc::clone(®istry)); let output = system - .open_ws281x_by_address(&HardwareAddress::gpio(18), Ws281xConfig::new(3, None)) + .open_ws281x_by_address(&HwAddress::gpio(18), Ws281xConfig::new(3)) .unwrap(); - assert!(registry.is_claimed(&HardwareAddress::gpio(18))); - assert!(registry.is_claimed(&HardwareAddress::rmt_ws281x(0))); + assert!(registry.is_claimed(&HwAddress::gpio(18))); + assert!(registry.is_claimed(&HwAddress::rmt_ws281x(0))); drop(output); - assert!(!registry.is_claimed(&HardwareAddress::gpio(18))); - assert!(!registry.is_claimed(&HardwareAddress::rmt_ws281x(0))); + assert!(!registry.is_claimed(&HwAddress::gpio(18))); + assert!(!registry.is_claimed(&HwAddress::rmt_ws281x(0))); } #[test] fn virtual_system_opens_ws281x_by_endpoint_spec() { - let registry = Rc::new(HardwareRegistry::new( - HardwareManifest::virtual_single_rmt_gpio_board(), - )); + let registry = Rc::new(HwRegistry::new(HwManifest::virtual_single_rmt_gpio_board())); let system = HardwareSystem::with_virtual_drivers(Rc::clone(®istry)); - let spec = HardwareEndpointSpec::from_static("ws281x:rmt:D10"); + let spec = HwEndpointSpec::from_static("ws281x:rmt:D10"); let output = system - .open_ws281x_by_spec(&spec, Ws281xConfig::new(3, None)) + .open_ws281x_by_spec(&spec, Ws281xConfig::new(3)) .unwrap(); - assert!(registry.is_claimed(&HardwareAddress::gpio(18))); - assert!(registry.is_claimed(&HardwareAddress::rmt_ws281x(0))); + assert!(registry.is_claimed(&HwAddress::gpio(18))); + assert!(registry.is_claimed(&HwAddress::rmt_ws281x(0))); drop(output); - assert!(!registry.is_claimed(&HardwareAddress::gpio(18))); - assert!(!registry.is_claimed(&HardwareAddress::rmt_ws281x(0))); + assert!(!registry.is_claimed(&HwAddress::gpio(18))); + assert!(!registry.is_claimed(&HwAddress::rmt_ws281x(0))); } #[test] fn virtual_system_reports_unknown_ws281x_endpoint_spec() { - let registry = Rc::new(HardwareRegistry::new( - HardwareManifest::virtual_single_rmt_gpio_board(), - )); + let registry = Rc::new(HwRegistry::new(HwManifest::virtual_single_rmt_gpio_board())); let system = HardwareSystem::with_virtual_drivers(registry); - let spec = HardwareEndpointSpec::from_static("ws281x:rmt:NOPE"); + let spec = HwEndpointSpec::from_static("ws281x:rmt:NOPE"); - let result = system.open_ws281x_by_spec(&spec, Ws281xConfig::new(3, None)); + let result = system.open_ws281x_by_spec(&spec, Ws281xConfig::new(3)); assert!(matches!( result, @@ -375,31 +366,30 @@ mod tests { #[test] fn virtual_system_opens_button_by_endpoint_spec() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); + let registry = Rc::new(HwRegistry::new(test_manifest())); let mut system = HardwareSystem::new(Rc::clone(®istry)); let driver = VirtualButtonDriver::new(Rc::clone(®istry)); let control = driver.clone(); system.add_button_driver(Box::new(driver)); - let spec = HardwareEndpointSpec::from_static("button:gpio:GPIO4"); + let spec = HwEndpointSpec::from_static("button:gpio:GPIO4"); let mut input = system .open_button_by_spec(&spec, ButtonConfig::new(10)) .unwrap(); - control.set_pressed(HardwareAddress::gpio(4), true); + control.set_pressed(HwAddress::gpio(4), true); assert!(input.poll(0).is_none()); assert!(input.poll(10).is_some()); } #[test] fn virtual_button_and_ws281x_contend_for_same_gpio() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); + let registry = Rc::new(HwRegistry::new(test_manifest())); let system = HardwareSystem::with_virtual_drivers(Rc::clone(®istry)); let _button = system - .open_button_by_address(&HardwareAddress::gpio(4), ButtonConfig::default()) + .open_button_by_address(&HwAddress::gpio(4), ButtonConfig::default()) .unwrap(); - let result = - system.open_ws281x_by_address(&HardwareAddress::gpio(4), Ws281xConfig::new(3, None)); + let result = system.open_ws281x_by_address(&HwAddress::gpio(4), Ws281xConfig::new(3)); assert!(matches!( result, @@ -408,32 +398,22 @@ mod tests { )); } - fn test_manifest() -> HardwareManifest { - HardwareManifest::new( + fn test_manifest() -> HwManifest { + HwManifest::new( "test", "Test Board", [ - HardwareResource::new( - HardwareAddress::gpio(4), - [ - super::super::HardwareCapability::GpioOutput, - super::super::HardwareCapability::GpioInput, - ], + HwResource::new( + HwAddress::gpio(4), + [HwCapability::GpioOutput, HwCapability::GpioInput], "GPIO4", ), - HardwareResource::new( - HardwareAddress::rmt_ws281x(0), - [ - super::super::HardwareCapability::Rmt, - super::super::HardwareCapability::Ws281xOutput, - ], + HwResource::new( + HwAddress::rmt_ws281x(0), + [HwCapability::Rmt, HwCapability::Ws281xOutput], "RMT WS281x 0", ), - HardwareResource::new( - HardwareAddress::radio(0), - [super::super::HardwareCapability::Radio], - "Radio 0", - ), + HwResource::new(HwAddress::radio(0), [HwCapability::Radio], "Radio 0"), ], ) } diff --git a/lp-core/lpc-hardware/src/lib.rs b/lp-core/lpc-hardware/src/lib.rs new file mode 100644 index 000000000..697b47ea8 --- /dev/null +++ b/lp-core/lpc-hardware/src/lib.rs @@ -0,0 +1,73 @@ +//! Hardware discovery, ownership, and driver contracts. +//! +//! `lpc-hardware` describes the board-facing side of LightPlayer without tying +//! it to one firmware target. A [`HwManifest`] lists concrete [`HwResource`]s +//! such as GPIO pins, RMT channels, and radios. A [`HwRegistry`] owns the live +//! claim/lease state for those resources so independent drivers cannot open the +//! same pin or peripheral at the same time. +//! +//! Drivers expose user-facing [`HwEndpoint`]s from those resources. The +//! [`HardwareSystem`] is the small router that collects registered drivers, +//! lists endpoints, and opens an endpoint by authored [`HwEndpointSpec`], +//! internal [`HwEndpointId`], or physical [`HwAddress`]. +//! +//! Rendering and protocol-adjacent color processing live above this crate. For +//! example, [`Ws281xOutput`] accepts already-rendered RGB bytes; display +//! pipeline options remain in `lpc-shared`. + +#![no_std] +extern crate alloc; +#[cfg(feature = "std")] +extern crate std; + +pub mod drivers; +pub mod endpoint; +pub mod hw_error; +pub mod hw_system; +pub mod manifest; +pub mod output_error; +pub mod registry; +pub mod resource; + +pub use output_error::OutputError; + +pub use drivers::button::button_debouncer::ButtonDebouncer; +pub use drivers::button::button_driver::{ButtonConfig, ButtonDriver, ButtonInput}; +pub use drivers::button::button_event::{ButtonEvent, ButtonEventKind}; +pub use drivers::button::virtual_button::VirtualButton; +pub use drivers::button::virtual_button_driver::VirtualButtonDriver; +pub use drivers::hw_driver::HwDriver; +pub use drivers::radio::radio_channel::{ + RadioChannelId, RadioDeviceId, RadioDrainReport, RadioEventId, +}; +pub use drivers::radio::radio_driver::{RadioConfig, RadioDevice, RadioDriver}; +pub use drivers::radio::radio_message::{ + RADIO_MAX_PACKET_LEN, RADIO_MAX_PAYLOAD_LEN, RADIO_WIRE_HEADER_LEN, RADIO_WIRE_MAGIC, + RADIO_WIRE_VERSION, RadioMessage, RadioMessageKind, RadioPacketError, +}; +pub use drivers::radio::virtual_radio_driver::VirtualRadioDriver; +pub use drivers::ws281x::virtual_ws281x_driver::{VirtualWs281xDriver, VirtualWs281xOutput}; +pub use drivers::ws281x::ws281x_driver::{Ws281xConfig, Ws281xDriver, Ws281xOutput}; +pub use endpoint::hw_endpoint::HwEndpoint; +pub use endpoint::hw_endpoint_error::HardwareEndpointError; +pub use endpoint::hw_endpoint_id::HwEndpointId; +pub use endpoint::hw_endpoint_kind::HwEndpointKind; +pub use endpoint::hw_endpoint_status::HwEndpointStatus; +pub use hw_error::HwError; +pub use hw_system::HardwareSystem; +pub use lpc_model::HwEndpointSpec; +pub use manifest::default_manifests::{ + default_esp32c6_hardware_manifest, permissive_emu_hardware_manifest, +}; +pub use manifest::hw_manifest::HwManifest; +pub use manifest::hw_manifest_file::{ + HardwareBoardLabelFile, HardwareBoardLabelStatus, HardwareManifestFile, + HardwareManifestFileError, +}; +pub use manifest::hw_target::HardwareTarget; +pub use registry::hw_claim::HwClaim; +pub use registry::hw_lease::{HardwareLease, HwLeaseId}; +pub use registry::hw_registry::HwRegistry; +pub use resource::hw_address::HwAddress; +pub use resource::hw_capability::HwCapability; +pub use resource::hw_resource::HwResource; diff --git a/lp-core/lpc-shared/src/hardware/default_manifests.rs b/lp-core/lpc-hardware/src/manifest/default_manifests.rs similarity index 67% rename from lp-core/lpc-shared/src/hardware/default_manifests.rs rename to lp-core/lpc-hardware/src/manifest/default_manifests.rs index 0deab8078..430681124 100644 --- a/lp-core/lpc-shared/src/hardware/default_manifests.rs +++ b/lp-core/lpc-hardware/src/manifest/default_manifests.rs @@ -1,13 +1,13 @@ use alloc::collections::BTreeMap; -use super::{ - HardwareAddress, HardwareBoardLabelStatus, HardwareManifest, HardwareManifestFile, - HardwareResource, +use crate::{ + HardwareBoardLabelStatus, HardwareManifestFile, HardwareTarget, HwAddress, HwManifest, + HwResource, }; const XIAO_ESP32_C6_TOML: &str = include_str!("../../boards/seeed/xiao-esp32-c6.toml"); -pub fn default_esp32c6_hardware_manifest() -> HardwareManifest { +pub fn default_esp32c6_hardware_manifest() -> HwManifest { HardwareManifestFile::read_toml(XIAO_ESP32_C6_TOML) .and_then(|manifest| manifest.to_manifest()) .expect("checked-in seeed/xiao-esp32-c6 hardware manifest must parse") @@ -15,14 +15,14 @@ pub fn default_esp32c6_hardware_manifest() -> HardwareManifest { /// Emulator manifest: XIAO ESP32-C6 pin map, board D-labels for endpoint specs, /// and no reserved GPIOs so projects like fyeah-sign load without hardware errors. -pub fn permissive_emu_hardware_manifest() -> HardwareManifest { +pub fn permissive_emu_hardware_manifest() -> HwManifest { let file = HardwareManifestFile::read_toml(XIAO_ESP32_C6_TOML) .expect("checked-in seeed/xiao-esp32-c6 hardware manifest must parse"); let board_labels = assigned_board_label_by_gpio(&file); default_esp32c6_hardware_manifest() .map_resources(|resource| normalize_resource_for_emu(resource, &board_labels)) - .with_target(super::HardwareTarget::Rv32imacEmu) + .with_target(HardwareTarget::Rv32imacEmu) .with_description( "Emulator board profile: production D-pin labels, all GPIOs available, \ virtual WS281x/RMT and ESP-NOW radio drivers.", @@ -31,7 +31,7 @@ pub fn permissive_emu_hardware_manifest() -> HardwareManifest { fn assigned_board_label_by_gpio( file: &HardwareManifestFile, -) -> BTreeMap { +) -> BTreeMap { let mut labels = BTreeMap::new(); for entry in &file.board_label { if entry.status != Some(HardwareBoardLabelStatus::Assigned) { @@ -40,7 +40,7 @@ fn assigned_board_label_by_gpio( let Some(gpio_path) = &entry.gpio else { continue; }; - let Ok(address) = HardwareAddress::new(gpio_path.clone()) else { + let Ok(address) = HwAddress::new(gpio_path.clone()) else { continue; }; labels.insert(address, entry.label.clone()); @@ -49,9 +49,9 @@ fn assigned_board_label_by_gpio( } fn normalize_resource_for_emu( - resource: HardwareResource, - board_labels: &BTreeMap, -) -> HardwareResource { + resource: HwResource, + board_labels: &BTreeMap, +) -> HwResource { let mut resource = resource.clear_reservation(); if let Some(label) = board_labels.get(resource.address()) { resource = resource.with_display_label(label.clone()); @@ -62,20 +62,18 @@ fn normalize_resource_for_emu( #[cfg(test)] mod tests { use super::*; - use crate::hardware::{ - HardwareAddress, HardwareEndpointSpec, HardwareRegistry, HardwareSystem, - }; + use crate::{HardwareSystem, HwEndpointSpec, HwRegistry}; #[test] fn default_esp32c6_manifest_loads_checked_in_board_profile() { let manifest = default_esp32c6_hardware_manifest(); assert_eq!(manifest.board_id(), "seeed/xiao-esp32-c6"); - assert!(manifest.resource(&HardwareAddress::gpio(18)).is_some()); - assert!(manifest.resource(&HardwareAddress::rmt_ws281x(0)).is_some()); + assert!(manifest.resource(&HwAddress::gpio(18)).is_some()); + assert!(manifest.resource(&HwAddress::rmt_ws281x(0)).is_some()); assert!( manifest - .resource(&HardwareAddress::gpio(12)) + .resource(&HwAddress::gpio(12)) .and_then(|resource| resource.reserved_reason()) .is_some() ); @@ -88,21 +86,21 @@ mod tests { assert_eq!(manifest.board_id(), "seeed/xiao-esp32-c6"); assert_eq!( manifest - .resource(&HardwareAddress::gpio(20)) + .resource(&HwAddress::gpio(20)) .expect("gpio20") .display_label(), "D9" ); assert_eq!( manifest - .resource(&HardwareAddress::gpio(18)) + .resource(&HwAddress::gpio(18)) .expect("gpio18") .display_label(), "D10" ); assert!( manifest - .resource(&HardwareAddress::gpio(12)) + .resource(&HwAddress::gpio(12)) .expect("gpio12") .reserved_reason() .is_none() @@ -113,25 +111,25 @@ mod tests { fn permissive_emu_manifest_opens_fyeah_sign_endpoints() { use alloc::rc::Rc; - let registry = Rc::new(HardwareRegistry::new(permissive_emu_hardware_manifest())); + let registry = Rc::new(HwRegistry::new(permissive_emu_hardware_manifest())); let system = HardwareSystem::with_virtual_drivers(registry); system .open_button_by_spec( - &HardwareEndpointSpec::from_static("button:gpio:D9"), - super::super::ButtonConfig::new(30), + &HwEndpointSpec::from_static("button:gpio:D9"), + crate::ButtonConfig::new(30), ) .expect("button D9"); system .open_ws281x_by_spec( - &HardwareEndpointSpec::from_static("ws281x:rmt:D10"), - super::super::Ws281xConfig::new(3, None), + &HwEndpointSpec::from_static("ws281x:rmt:D10"), + crate::Ws281xConfig::new(3), ) .expect("ws281x D10"); system .open_radio_by_spec( - &HardwareEndpointSpec::from_static("radio:espnow:0"), - super::super::RadioConfig::default(), + &HwEndpointSpec::from_static("radio:espnow:0"), + crate::RadioConfig::default(), ) .expect("radio espnow"); } diff --git a/lp-core/lpc-shared/src/hardware/hardware_manifest.rs b/lp-core/lpc-hardware/src/manifest/hw_manifest.rs similarity index 73% rename from lp-core/lpc-shared/src/hardware/hardware_manifest.rs rename to lp-core/lpc-hardware/src/manifest/hw_manifest.rs index 5ab64aee1..468845da9 100644 --- a/lp-core/lpc-shared/src/hardware/hardware_manifest.rs +++ b/lp-core/lpc-hardware/src/manifest/hw_manifest.rs @@ -1,10 +1,15 @@ use alloc::string::String; use alloc::vec::Vec; -use super::{HardwareAddress, HardwareCapability, HardwareResource, HardwareTarget}; +use crate::{HardwareTarget, HwAddress, HwCapability, HwResource}; +/// In-memory hardware profile for one board or virtual target. +/// +/// The manifest is the source of truth for resources known to a +/// [`crate::HwRegistry`]. Drivers derive [`crate::HwEndpoint`]s from these +/// resources instead of hard-coding pins in the driver contract. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct HardwareManifest { +pub struct HwManifest { board_id: String, board_name: String, target: Option, @@ -12,14 +17,14 @@ pub struct HardwareManifest { product: Option, description: Option, url: Option, - resources: Vec, + resources: Vec, } -impl HardwareManifest { +impl HwManifest { pub fn new( board_id: impl Into, board_name: impl Into, - resources: impl Into>, + resources: impl Into>, ) -> Self { Self { board_id: board_id.into(), @@ -41,23 +46,20 @@ impl HardwareManifest { } else { alloc::format!("GPIO{pin}") }; - resources.push(HardwareResource::new( - HardwareAddress::gpio(pin), - [ - HardwareCapability::GpioOutput, - HardwareCapability::GpioInput, - ], + resources.push(HwResource::new( + HwAddress::gpio(pin), + [HwCapability::GpioOutput, HwCapability::GpioInput], display_label, )); } - resources.push(HardwareResource::new( - HardwareAddress::rmt_ws281x(0), - [HardwareCapability::Rmt, HardwareCapability::Ws281xOutput], + resources.push(HwResource::new( + HwAddress::rmt_ws281x(0), + [HwCapability::Rmt, HwCapability::Ws281xOutput], "RMT WS281x 0", )); - resources.push(HardwareResource::new( - HardwareAddress::radio(0), - [HardwareCapability::Radio], + resources.push(HwResource::new( + HwAddress::radio(0), + [HwCapability::Radio], "Virtual Radio 0", )); Self::new("virtual-single-rmt", "Virtual Single-RMT Board", resources) @@ -93,7 +95,7 @@ impl HardwareManifest { self.url.as_deref() } - pub fn resources(&self) -> &[HardwareResource] { + pub fn resources(&self) -> &[HwResource] { &self.resources } @@ -122,13 +124,13 @@ impl HardwareManifest { self } - pub fn resource(&self, address: &HardwareAddress) -> Option<&HardwareResource> { + pub fn resource(&self, address: &HwAddress) -> Option<&HwResource> { self.resources .iter() .find(|resource| resource.address() == address) } - pub fn with_reserved(mut self, address: HardwareAddress, reason: impl Into) -> Self { + pub fn with_reserved(mut self, address: HwAddress, reason: impl Into) -> Self { let reason = reason.into(); if let Some(resource) = self .resources @@ -140,7 +142,7 @@ impl HardwareManifest { self } - pub fn map_resources(mut self, map_fn: impl Fn(HardwareResource) -> HardwareResource) -> Self { + pub fn map_resources(mut self, map_fn: impl Fn(HwResource) -> HwResource) -> Self { self.resources = self.resources.into_iter().map(map_fn).collect(); self } @@ -152,23 +154,23 @@ mod tests { #[test] fn finds_resource_by_internal_address_not_label() { - let manifest = HardwareManifest::new( + let manifest = HwManifest::new( "board", "Board", - [HardwareResource::new( - HardwareAddress::gpio(18), - [HardwareCapability::GpioOutput], + [HwResource::new( + HwAddress::gpio(18), + [HwCapability::GpioOutput], "D6", )], ); - let resource = manifest.resource(&HardwareAddress::gpio(18)).unwrap(); + let resource = manifest.resource(&HwAddress::gpio(18)).unwrap(); assert_eq!(resource.display_label(), "D6"); } #[test] fn stores_optional_board_metadata() { - let manifest = HardwareManifest::new("board", "Board", []) + let manifest = HwManifest::new("board", "Board", []) .with_target(HardwareTarget::Esp32c6) .with_vendor("vendor") .with_product("product") diff --git a/lp-core/lpc-shared/src/hardware/hardware_manifest_file.rs b/lp-core/lpc-hardware/src/manifest/hw_manifest_file.rs similarity index 83% rename from lp-core/lpc-shared/src/hardware/hardware_manifest_file.rs rename to lp-core/lpc-hardware/src/manifest/hw_manifest_file.rs index 645e66480..41aa669ca 100644 --- a/lp-core/lpc-shared/src/hardware/hardware_manifest_file.rs +++ b/lp-core/lpc-hardware/src/manifest/hw_manifest_file.rs @@ -1,14 +1,17 @@ -use alloc::collections::BTreeSet; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; +use lp_collection::VecSet; use serde::{Deserialize, Serialize}; -use super::{ - HardwareAddress, HardwareCapability, HardwareManifest, HardwareResource, HardwareTarget, -}; +use crate::{HardwareTarget, HwAddress, HwCapability, HwError, HwManifest, HwResource}; +/// TOML-friendly board manifest file. +/// +/// This is the serializable form checked into the repo for board profiles. Use +/// [`HardwareManifestFile::to_manifest`] to validate and convert it into the +/// runtime [`HwManifest`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HardwareManifestFile { pub id: String, @@ -84,7 +87,7 @@ impl HardwareManifestFile { } } - let mut seen = BTreeSet::new(); + let mut seen = VecSet::new(); for label in &self.board_label { if label.label.trim().is_empty() { return Err(HardwareManifestFileError::Invalid { @@ -98,9 +101,9 @@ impl HardwareManifestFile { } } - let mut seen = BTreeSet::new(); + let mut seen = VecSet::new(); for resource in self.gpio.iter().chain(self.resource.iter()) { - let address = HardwareAddress::new(resource.address.clone())?; + let address = HwAddress::new(resource.address.clone())?; if !seen.insert(address.clone()) { return Err(HardwareManifestFileError::Invalid { message: alloc::format!("duplicate resource address: {address}"), @@ -110,10 +113,10 @@ impl HardwareManifestFile { Ok(()) } - pub fn to_manifest(&self) -> Result { + pub fn to_manifest(&self) -> Result { self.validate()?; let resources = self.resources()?; - let mut manifest = HardwareManifest::new(self.id.clone(), self.product.clone(), resources) + let mut manifest = HwManifest::new(self.id.clone(), self.product.clone(), resources) .with_target(self.target) .with_vendor(self.vendor.clone()) .with_product(self.product.clone()); @@ -126,7 +129,7 @@ impl HardwareManifestFile { Ok(manifest) } - fn resources(&self) -> Result, HardwareManifestFileError> { + fn resources(&self) -> Result, HardwareManifestFileError> { self.gpio .iter() .chain(self.resource.iter()) @@ -135,6 +138,10 @@ impl HardwareManifestFile { } } +/// Board-silkscreen label discovered or recorded during board mapping. +/// +/// Labels are metadata for humans and tooling. They do not create claimable +/// resources by themselves; resources come from [`HardwareResourceFile`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HardwareBoardLabelFile { pub label: String, @@ -157,6 +164,7 @@ impl HardwareBoardLabelFile { } } +/// Verification status for a board label in a manifest file. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum HardwareBoardLabelStatus { @@ -167,11 +175,15 @@ pub enum HardwareBoardLabelStatus { Skipped, } +/// Serializable resource entry in a board manifest file. +/// +/// GPIO resources are often grouped under `gpio` in TOML for readability, while +/// non-GPIO resources live under `resource`; both convert to [`HwResource`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HardwareResourceFile { pub address: String, pub display_label: String, - pub capabilities: Vec, + pub capabilities: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub aliases: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -184,7 +196,7 @@ impl HardwareResourceFile { pub fn new( address: impl Into, display_label: impl Into, - capabilities: impl Into>, + capabilities: impl Into>, ) -> Self { Self { address: address.into(), @@ -196,7 +208,7 @@ impl HardwareResourceFile { } } - fn to_resource(&self) -> Result { + fn to_resource(&self) -> Result { if self.display_label.trim().is_empty() { return Err(HardwareManifestFileError::Invalid { message: alloc::format!("{} display_label must not be empty", self.address), @@ -207,8 +219,8 @@ impl HardwareResourceFile { message: alloc::format!("{} must have at least one capability", self.address), }); } - let mut resource = HardwareResource::new( - HardwareAddress::new(self.address.clone())?, + let mut resource = HwResource::new( + HwAddress::new(self.address.clone())?, self.capabilities.clone(), self.display_label.clone(), ) @@ -223,12 +235,13 @@ impl HardwareResourceFile { } } +/// Errors produced while parsing, validating, or converting a manifest file. #[derive(Debug, Clone, PartialEq, Eq)] pub enum HardwareManifestFileError { Parse { message: String }, Serialize { message: String }, Invalid { message: String }, - Hardware(super::HardwareError), + Hardware(HwError), } impl fmt::Display for HardwareManifestFileError { @@ -242,8 +255,8 @@ impl fmt::Display for HardwareManifestFileError { } } -impl From for HardwareManifestFileError { - fn from(error: super::HardwareError) -> Self { +impl From for HardwareManifestFileError { + fn from(error: HwError) -> Self { Self::Hardware(error) } } @@ -278,7 +291,7 @@ aliases = ["GPIO18", "IO18"] assert_eq!(runtime.target(), Some(HardwareTarget::Esp32c6)); assert_eq!(runtime.vendor(), Some("seeed")); assert_eq!(runtime.product(), Some("XIAO ESP32-C6")); - assert!(runtime.resource(&HardwareAddress::gpio(18)).is_some()); + assert!(runtime.resource(&HwAddress::gpio(18)).is_some()); } #[test] @@ -292,8 +305,8 @@ aliases = ["GPIO18", "IO18"] url: None, board_label: Vec::new(), gpio: alloc::vec![ - HardwareResourceFile::new("/gpio/1", "GPIO1", [HardwareCapability::GpioOutput]), - HardwareResourceFile::new("/gpio/1", "GPIO1", [HardwareCapability::GpioInput]), + HardwareResourceFile::new("/gpio/1", "GPIO1", [HwCapability::GpioOutput]), + HardwareResourceFile::new("/gpio/1", "GPIO1", [HwCapability::GpioInput]), ], resource: Vec::new(), }; diff --git a/lp-core/lpc-shared/src/hardware/hardware_target.rs b/lp-core/lpc-hardware/src/manifest/hw_target.rs similarity index 90% rename from lp-core/lpc-shared/src/hardware/hardware_target.rs rename to lp-core/lpc-hardware/src/manifest/hw_target.rs index feb7f71db..e39e04a29 100644 --- a/lp-core/lpc-shared/src/hardware/hardware_target.rs +++ b/lp-core/lpc-hardware/src/manifest/hw_target.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; +/// Build or runtime target that a board manifest describes. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum HardwareTarget { diff --git a/lp-core/lpc-hardware/src/manifest/mod.rs b/lp-core/lpc-hardware/src/manifest/mod.rs new file mode 100644 index 000000000..286815e51 --- /dev/null +++ b/lp-core/lpc-hardware/src/manifest/mod.rs @@ -0,0 +1,10 @@ +//! Board manifests and manifest file conversion. +//! +//! A [`HwManifest`](hw_manifest::HwManifest) is the in-memory board profile used +//! by the registry. [`HardwareManifestFile`](hw_manifest_file::HardwareManifestFile) +//! is the TOML-friendly representation used for checked-in board descriptions. + +pub mod default_manifests; +pub mod hw_manifest; +pub mod hw_manifest_file; +pub mod hw_target; diff --git a/lp-core/lpc-hardware/src/output_error.rs b/lp-core/lpc-hardware/src/output_error.rs new file mode 100644 index 000000000..96ba2c13d --- /dev/null +++ b/lp-core/lpc-hardware/src/output_error.rs @@ -0,0 +1,53 @@ +use alloc::string::String; +use core::fmt; + +use crate::HwError; + +/// Output-channel error type shared by hardware and higher-level providers. +/// +/// The hardware crate owns this small error so opened outputs such as +/// [`crate::Ws281xOutput`] can report invalid writes without depending on the +/// engine/output provider layer. +#[derive(Debug, Clone)] +pub enum OutputError { + /// Pin is already open. + PinAlreadyOpen { pin: u32 }, + /// Hardware resource claim failed. + Hardware { error: HwError }, + /// Invalid handle. + InvalidHandle { handle: i32 }, + /// Invalid configuration. + InvalidConfig { reason: String }, + /// Data length mismatch. + DataLengthMismatch { expected: u32, actual: usize }, + /// Other error. + Other { message: String }, +} + +impl fmt::Display for OutputError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OutputError::PinAlreadyOpen { pin } => { + write!(f, "Pin {pin} is already open") + } + OutputError::Hardware { error } => { + write!(f, "Hardware error: {error}") + } + OutputError::InvalidHandle { handle } => { + write!(f, "Invalid handle: {handle}") + } + OutputError::InvalidConfig { reason } => { + write!(f, "Invalid config: {reason}") + } + OutputError::DataLengthMismatch { expected, actual } => { + write!( + f, + "Data length {actual} doesn't match expected byte_count {expected}" + ) + } + OutputError::Other { message } => { + write!(f, "Error: {message}") + } + } + } +} diff --git a/lp-core/lpc-hardware/src/registry/hw_claim.rs b/lp-core/lpc-hardware/src/registry/hw_claim.rs new file mode 100644 index 000000000..a3e37213b --- /dev/null +++ b/lp-core/lpc-hardware/src/registry/hw_claim.rs @@ -0,0 +1,32 @@ +use alloc::string::String; +use alloc::vec::Vec; + +use crate::HwAddress; + +/// Request to reserve one or more hardware resources atomically. +/// +/// Drivers construct claims before opening a device. The registry either turns +/// the whole claim into a [`crate::HardwareLease`] or rejects it without taking +/// any partial ownership. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HwClaim { + claimant: String, + addresses: Vec, +} + +impl HwClaim { + pub fn new(claimant: impl Into, addresses: impl Into>) -> Self { + Self { + claimant: claimant.into(), + addresses: addresses.into(), + } + } + + pub fn claimant(&self) -> &str { + &self.claimant + } + + pub fn addresses(&self) -> &[HwAddress] { + &self.addresses + } +} diff --git a/lp-core/lpc-shared/src/hardware/hardware_lease.rs b/lp-core/lpc-hardware/src/registry/hw_lease.rs similarity index 53% rename from lp-core/lpc-shared/src/hardware/hardware_lease.rs rename to lp-core/lpc-hardware/src/registry/hw_lease.rs index 40ddc0c13..968050437 100644 --- a/lp-core/lpc-shared/src/hardware/hardware_lease.rs +++ b/lp-core/lpc-hardware/src/registry/hw_lease.rs @@ -1,12 +1,13 @@ use alloc::string::String; use alloc::vec::Vec; -use super::HardwareAddress; +use crate::HwAddress; +/// Opaque identifier assigned by [`crate::HwRegistry`] to an active lease. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct HardwareLeaseId(u64); +pub struct HwLeaseId(u64); -impl HardwareLeaseId { +impl HwLeaseId { pub fn new(id: u64) -> Self { Self(id) } @@ -16,18 +17,23 @@ impl HardwareLeaseId { } } +/// Active reservation of hardware resources. +/// +/// Opened devices keep their lease while they own the underlying resource. When +/// the device closes or is dropped, the lease is released back to the +/// [`crate::HwRegistry`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct HardwareLease { - id: HardwareLeaseId, + id: HwLeaseId, claimant: String, - addresses: Vec, + addresses: Vec, } impl HardwareLease { pub fn new( - id: HardwareLeaseId, + id: HwLeaseId, claimant: impl Into, - addresses: impl Into>, + addresses: impl Into>, ) -> Self { Self { id, @@ -36,7 +42,7 @@ impl HardwareLease { } } - pub fn id(&self) -> HardwareLeaseId { + pub fn id(&self) -> HwLeaseId { self.id } @@ -44,7 +50,7 @@ impl HardwareLease { &self.claimant } - pub fn addresses(&self) -> &[HardwareAddress] { + pub fn addresses(&self) -> &[HwAddress] { &self.addresses } } diff --git a/lp-core/lpc-hardware/src/registry/hw_registry.rs b/lp-core/lpc-hardware/src/registry/hw_registry.rs new file mode 100644 index 000000000..3da5544a9 --- /dev/null +++ b/lp-core/lpc-hardware/src/registry/hw_registry.rs @@ -0,0 +1,285 @@ +use alloc::string::{String, ToString}; +use core::cell::RefCell; +use lp_collection::{VecMap, VecSet}; + +use crate::{ + HardwareLease, HwAddress, HwCapability, HwClaim, HwEndpointStatus, HwError, HwLeaseId, + HwManifest, +}; + +/// Live ownership registry for a hardware manifest. +/// +/// The registry validates capabilities against the [`HwManifest`], tracks active +/// [`HardwareLease`]s, and reports endpoint status for drivers. It uses interior +/// mutability so shared driver handles can coordinate claims in `no_std` code. +#[derive(Debug)] +pub struct HwRegistry { + manifest: HwManifest, + state: RefCell, +} + +#[derive(Debug, Clone)] +struct ActiveClaim { + claimant: String, +} + +#[derive(Debug, Clone)] +struct HwRegistryState { + next_lease_id: u64, + active_by_address: VecMap, + addresses_by_lease: VecMap>, +} + +impl HwRegistry { + pub fn new(manifest: HwManifest) -> Self { + Self { + manifest, + state: RefCell::new(HwRegistryState { + next_lease_id: 1, + active_by_address: VecMap::new(), + addresses_by_lease: VecMap::new(), + }), + } + } + + pub fn manifest(&self) -> &HwManifest { + &self.manifest + } + + pub fn claim_bundle(&self, claim: HwClaim) -> Result { + self.validate_claim(&claim)?; + + let mut state = self.state.borrow_mut(); + let lease_id = HwLeaseId::new(state.next_lease_id); + state.next_lease_id += 1; + + let mut addresses = VecSet::new(); + for address in claim.addresses() { + state.active_by_address.insert( + address.clone(), + ActiveClaim { + claimant: claim.claimant().to_string(), + }, + ); + addresses.insert(address.clone()); + } + state.addresses_by_lease.insert(lease_id, addresses); + + Ok(HardwareLease::new( + lease_id, + claim.claimant().to_string(), + claim.addresses().to_vec(), + )) + } + + pub fn release(&self, lease: &HardwareLease) -> Result<(), HwError> { + let mut state = self.state.borrow_mut(); + let addresses = + state + .addresses_by_lease + .remove(&lease.id()) + .ok_or(HwError::UnknownLease { + lease_id: lease.id(), + })?; + + for address in addresses { + state.active_by_address.remove(&address); + } + Ok(()) + } + + pub fn is_claimed(&self, address: &HwAddress) -> bool { + self.state.borrow().active_by_address.contains_key(address) + } + + pub fn claimant_for(&self, address: &HwAddress) -> Option { + self.state + .borrow() + .active_by_address + .get(address) + .map(|claim| claim.claimant.clone()) + } + + pub fn endpoint_status_for(&self, address: &HwAddress) -> HwEndpointStatus { + match self.manifest.resource(address) { + Some(resource) => { + if let Some(reason) = resource.reserved_reason() { + HwEndpointStatus::Reserved { + reason: reason.into(), + } + } else if let Some(claimant) = self.claimant_for(address) { + HwEndpointStatus::InUse { claimant } + } else { + HwEndpointStatus::Available + } + } + None => HwEndpointStatus::Unavailable { + reason: alloc::format!("unknown hardware resource: {address}"), + }, + } + } + + pub fn ensure_capability( + &self, + address: &HwAddress, + capability: HwCapability, + ) -> Result<(), HwError> { + let resource = self + .manifest + .resource(address) + .ok_or_else(|| HwError::UnknownResource { + address: address.clone(), + })?; + if !resource.supports(capability) { + return Err(HwError::UnsupportedCapability { + address: address.clone(), + capability, + }); + } + Ok(()) + } + + fn validate_claim(&self, claim: &HwClaim) -> Result<(), HwError> { + if claim.addresses().is_empty() { + return Err(HwError::EmptyClaim); + } + + let mut seen = VecSet::new(); + let state = self.state.borrow(); + for address in claim.addresses() { + if !seen.insert(address.clone()) { + return Err(HwError::DuplicateAddressInClaim { + address: address.clone(), + }); + } + + let resource = + self.manifest + .resource(address) + .ok_or_else(|| HwError::UnknownResource { + address: address.clone(), + })?; + if let Some(reason) = resource.reserved_reason() { + return Err(HwError::ReservedResource { + address: address.clone(), + reason: reason.into(), + }); + } + + if let Some(active) = state.active_by_address.get(address) { + return Err(HwError::ResourceAlreadyClaimed { + address: address.clone(), + claimant: active.claimant.clone(), + }); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::HwResource; + use alloc::vec; + + #[test] + fn claim_bundle_claims_and_releases_resources() { + let registry = registry(); + let lease = registry + .claim_bundle(HwClaim::new( + "output", + vec![HwAddress::gpio(18), HwAddress::rmt_ws281x(0)], + )) + .unwrap(); + + assert!(registry.is_claimed(&HwAddress::gpio(18))); + assert!(registry.is_claimed(&HwAddress::rmt_ws281x(0))); + + registry.release(&lease).unwrap(); + + assert!(!registry.is_claimed(&HwAddress::gpio(18))); + assert!(!registry.is_claimed(&HwAddress::rmt_ws281x(0))); + } + + #[test] + fn claim_bundle_is_atomic_when_later_resource_is_claimed() { + let registry = registry(); + let rmt_lease = registry + .claim_bundle(HwClaim::new("output-a", vec![HwAddress::rmt_ws281x(0)])) + .unwrap(); + + let result = registry.claim_bundle(HwClaim::new( + "output-b", + vec![HwAddress::gpio(18), HwAddress::rmt_ws281x(0)], + )); + + assert!(matches!( + result, + Err(HwError::ResourceAlreadyClaimed { .. }) + )); + assert!(!registry.is_claimed(&HwAddress::gpio(18))); + assert!(registry.is_claimed(&HwAddress::rmt_ws281x(0))); + + registry.release(&rmt_lease).unwrap(); + } + + #[test] + fn duplicate_address_in_claim_fails() { + let registry = registry(); + let result = registry.claim_bundle(HwClaim::new( + "output", + vec![HwAddress::gpio(18), HwAddress::gpio(18)], + )); + + assert!(matches!( + result, + Err(HwError::DuplicateAddressInClaim { .. }) + )); + } + + #[test] + fn reserved_resource_fails() { + let manifest = HwManifest::new( + "board", + "Board", + [ + HwResource::new(HwAddress::gpio(12), [HwCapability::GpioOutput], "GPIO12") + .reserved("crashes during GPIO scan"), + ], + ); + let registry = HwRegistry::new(manifest); + + let result = registry.claim_bundle(HwClaim::new("output", vec![HwAddress::gpio(12)])); + + assert!(matches!(result, Err(HwError::ReservedResource { .. }))); + } + + #[test] + fn unsupported_capability_fails() { + let registry = registry(); + + let result = registry.ensure_capability(&HwAddress::gpio(18), HwCapability::Radio); + + assert!(matches!(result, Err(HwError::UnsupportedCapability { .. }))); + } + + fn registry() -> HwRegistry { + HwRegistry::new(HwManifest::new( + "board", + "Board", + [ + HwResource::new( + HwAddress::gpio(18), + [HwCapability::GpioOutput, HwCapability::GpioInput], + "D6", + ), + HwResource::new( + HwAddress::rmt_ws281x(0), + [HwCapability::Rmt, HwCapability::Ws281xOutput], + "RMT0", + ), + ], + )) + } +} diff --git a/lp-core/lpc-hardware/src/registry/mod.rs b/lp-core/lpc-hardware/src/registry/mod.rs new file mode 100644 index 000000000..dd7c4ad64 --- /dev/null +++ b/lp-core/lpc-hardware/src/registry/mod.rs @@ -0,0 +1,10 @@ +//! Runtime ownership for hardware resources. +//! +//! Drivers submit a [`HwClaim`](hw_claim::HwClaim) for one or more addresses. +//! The [`HwRegistry`](hw_registry::HwRegistry) validates the claim against the +//! manifest and returns a [`HardwareLease`](hw_lease::HardwareLease) that keeps +//! those resources reserved until it is released or the owning handle is dropped. + +pub mod hw_claim; +pub mod hw_lease; +pub mod hw_registry; diff --git a/lp-core/lpc-shared/src/hardware/hardware_address.rs b/lp-core/lpc-hardware/src/resource/hw_address.rs similarity index 55% rename from lp-core/lpc-shared/src/hardware/hardware_address.rs rename to lp-core/lpc-hardware/src/resource/hw_address.rs index a5676392b..15d3bc8b1 100644 --- a/lp-core/lpc-shared/src/hardware/hardware_address.rs +++ b/lp-core/lpc-hardware/src/resource/hw_address.rs @@ -2,13 +2,18 @@ use alloc::format; use alloc::string::String; use core::fmt; -use super::HardwareError; +use crate::HwError; +/// Stable address for a concrete board resource. +/// +/// Addresses identify physical or logical hardware resources inside a +/// [`crate::HwManifest`]. They are intentionally separate from user-facing +/// endpoint specs and board labels, which may vary by driver or board profile. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct HardwareAddress(String); +pub struct HwAddress(String); -impl HardwareAddress { - pub fn new(path: impl Into) -> Result { +impl HwAddress { + pub fn new(path: impl Into) -> Result { let path = path.into(); validate_path(&path)?; Ok(Self(path)) @@ -31,20 +36,20 @@ impl HardwareAddress { } } -impl fmt::Display for HardwareAddress { +impl fmt::Display for HwAddress { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } -fn validate_path(path: &str) -> Result<(), HardwareError> { +fn validate_path(path: &str) -> Result<(), HwError> { if !path.starts_with('/') || path.len() <= 1 { - return Err(HardwareError::InvalidAddress { + return Err(HwError::InvalidAddress { address: path.into(), }); } if path.as_bytes().windows(2).any(|w| w == b"//") { - return Err(HardwareError::InvalidAddress { + return Err(HwError::InvalidAddress { address: path.into(), }); } @@ -57,18 +62,18 @@ mod tests { #[test] fn normalizes_gpio_address() { - assert_eq!(HardwareAddress::gpio(18).as_str(), "/gpio/18"); + assert_eq!(HwAddress::gpio(18).as_str(), "/gpio/18"); } #[test] fn normalizes_radio_address() { - assert_eq!(HardwareAddress::radio(0).as_str(), "/radio/0"); + assert_eq!(HwAddress::radio(0).as_str(), "/radio/0"); } #[test] fn rejects_invalid_address() { - assert!(HardwareAddress::new("gpio/18").is_err()); - assert!(HardwareAddress::new("/").is_err()); - assert!(HardwareAddress::new("/gpio//18").is_err()); + assert!(HwAddress::new("gpio/18").is_err()); + assert!(HwAddress::new("/").is_err()); + assert!(HwAddress::new("/gpio//18").is_err()); } } diff --git a/lp-core/lpc-hardware/src/resource/hw_capability.rs b/lp-core/lpc-hardware/src/resource/hw_capability.rs new file mode 100644 index 000000000..32fb221f3 --- /dev/null +++ b/lp-core/lpc-hardware/src/resource/hw_capability.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +/// Capability advertised by a [`crate::HwResource`]. +/// +/// Drivers check capabilities before claiming resources. A single resource may +/// expose multiple capabilities, such as a GPIO that can be used for input or +/// output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum HwCapability { + /// GPIO can drive an output level or waveform. + GpioOutput, + /// GPIO can be sampled as an input. + GpioInput, + /// Timing resource can drive WS281x-class LED protocols. + Ws281xOutput, + /// ESP-style remote-control timing peripheral. + Rmt, + /// Packet radio peripheral. + Radio, +} diff --git a/lp-core/lpc-shared/src/hardware/hardware_resource.rs b/lp-core/lpc-hardware/src/resource/hw_resource.rs similarity index 73% rename from lp-core/lpc-shared/src/hardware/hardware_resource.rs rename to lp-core/lpc-hardware/src/resource/hw_resource.rs index 79151a022..bb754697a 100644 --- a/lp-core/lpc-shared/src/hardware/hardware_resource.rs +++ b/lp-core/lpc-hardware/src/resource/hw_resource.rs @@ -1,22 +1,26 @@ use alloc::string::String; use alloc::vec::Vec; -use super::{HardwareAddress, HardwareCapability}; +use crate::{HwAddress, HwCapability}; +/// One claimable resource in a board manifest. +/// +/// A resource is addressed by [`HwAddress`], declares the capabilities drivers +/// may require, and carries human-facing labels/aliases from the board profile. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct HardwareResource { - address: HardwareAddress, - capabilities: Vec, +pub struct HwResource { + address: HwAddress, + capabilities: Vec, display_label: String, aliases: Vec, location: Option, reserved_reason: Option, } -impl HardwareResource { +impl HwResource { pub fn new( - address: HardwareAddress, - capabilities: impl Into>, + address: HwAddress, + capabilities: impl Into>, display_label: impl Into, ) -> Self { Self { @@ -54,11 +58,11 @@ impl HardwareResource { self } - pub fn address(&self) -> &HardwareAddress { + pub fn address(&self) -> &HwAddress { &self.address } - pub fn capabilities(&self) -> &[HardwareCapability] { + pub fn capabilities(&self) -> &[HwCapability] { &self.capabilities } @@ -78,7 +82,7 @@ impl HardwareResource { self.reserved_reason.as_deref() } - pub fn supports(&self, capability: HardwareCapability) -> bool { + pub fn supports(&self, capability: HwCapability) -> bool { self.capabilities.contains(&capability) } } diff --git a/lp-core/lpc-hardware/src/resource/mod.rs b/lp-core/lpc-hardware/src/resource/mod.rs new file mode 100644 index 000000000..0e64930d5 --- /dev/null +++ b/lp-core/lpc-hardware/src/resource/mod.rs @@ -0,0 +1,10 @@ +//! Board resources and the capabilities they support. +//! +//! A resource is a concrete thing on the board, identified by +//! [`HwAddress`](crate::HwAddress). +//! Resources are declared in a [`crate::HwManifest`], checked by +//! [`crate::HwRegistry`], and claimed by drivers before an endpoint is opened. + +pub mod hw_address; +pub mod hw_capability; +pub mod hw_resource; diff --git a/lp-core/lpc-model/Cargo.toml b/lp-core/lpc-model/Cargo.toml index 5f74075c4..5ebce0826 100644 --- a/lp-core/lpc-model/Cargo.toml +++ b/lp-core/lpc-model/Cargo.toml @@ -14,6 +14,7 @@ derive = [] schema-gen = ["std", "dep:schemars"] [dependencies] +lp-collection = { workspace = true, features = ["serde"] } base64 = { workspace = true } hashbrown = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/lp-core/lpc-model/src/artifact/artifact_location.rs b/lp-core/lpc-model/src/artifact/artifact_location.rs new file mode 100644 index 000000000..be2ccc4da --- /dev/null +++ b/lp-core/lpc-model/src/artifact/artifact_location.rs @@ -0,0 +1,127 @@ +//! Resolved artifact identity. + +use alloc::format; +use alloc::string::String; + +use crate::{ArtifactLocationError, ArtifactSpec, LpPath, LpPathBuf}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +const FILE_URI_PREFIX: &str = "file:"; + +/// Canonical project identity for a file-backed artifact. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ArtifactLocation { + path: LpPathBuf, +} + +impl ArtifactLocation { + pub fn file(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn from_absolute_path(path: LpPathBuf) -> Self { + Self { path } + } + + pub fn try_from_specifier(specifier: &ArtifactSpec) -> Result { + match specifier { + ArtifactSpec::Path(path) => Ok(Self::file(path.clone())), + ArtifactSpec::Lib(lib) => Err(ArtifactLocationError::Resolution(format!( + "library artifact references are not supported yet ({lib})" + ))), + } + } + + pub fn file_path(&self) -> &LpPathBuf { + &self.path + } + + pub fn to_uri(&self) -> String { + format!("{FILE_URI_PREFIX}{}", self.path.as_str()) + } + + pub fn parse_uri(raw: &str) -> Result { + let raw = raw.trim(); + if let Some(rest) = raw.strip_prefix(FILE_URI_PREFIX) { + if rest.is_empty() { + return Err(ArtifactLocationError::Resolution(format!( + "invalid artifact uri `{raw}`" + ))); + } + return Ok(Self::file(rest)); + } + if raw.starts_with('/') { + return Ok(Self::file(raw)); + } + Err(ArtifactLocationError::Resolution(format!( + "artifact uri must start with `{FILE_URI_PREFIX}` or be an absolute path, got `{raw}`" + ))) + } + + pub fn location_for_path(path: &LpPath) -> Self { + Self::file(path.to_path_buf()) + } +} + +impl Serialize for ArtifactLocation { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_uri()) + } +} + +impl<'de> Deserialize<'de> for ArtifactLocation { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::parse_uri(&raw).map_err(|err| serde::de::Error::custom(format!("{err:?}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::artifact::src_artifact_lib_ref::SrcArtifactLibRef; + + #[test] + fn path_specifier_resolves_to_file() { + let spec = ArtifactSpec::path("./shader.glsl"); + let location = ArtifactLocation::try_from_specifier(&spec).unwrap(); + assert_eq!(location, ArtifactLocation::file("./shader.glsl")); + } + + #[test] + fn lib_specifier_returns_resolution_error() { + let spec = ArtifactSpec::lib_ref( + SrcArtifactLibRef::try_from_suffix("core/x").expect("valid lib ref"), + ); + let err = ArtifactLocation::try_from_specifier(&spec).unwrap_err(); + assert!( + matches!(err, ArtifactLocationError::Resolution(msg) if msg.contains("not supported")) + ); + } + + #[test] + fn uri_roundtrip_and_serde() { + let location = ArtifactLocation::file("/shader.toml"); + assert_eq!(location.to_uri(), "file:/shader.toml"); + assert_eq!( + ArtifactLocation::parse_uri("file:/shader.toml").unwrap(), + location + ); + let json = serde_json::to_string(&location).unwrap(); + assert_eq!(json, "\"file:/shader.toml\""); + let back: ArtifactLocation = serde_json::from_str(&json).unwrap(); + assert_eq!(back, location); + } + + #[test] + fn parse_absolute_path_without_prefix() { + let location = ArtifactLocation::parse_uri("/shader.toml").unwrap(); + assert_eq!(location, ArtifactLocation::file("/shader.toml")); + } +} diff --git a/lp-core/lpc-model/src/artifact/artifact_location_error.rs b/lp-core/lpc-model/src/artifact/artifact_location_error.rs new file mode 100644 index 000000000..e81e8c370 --- /dev/null +++ b/lp-core/lpc-model/src/artifact/artifact_location_error.rs @@ -0,0 +1,9 @@ +//! Errors from parsing or resolving artifact locations. + +use alloc::string::String; + +/// Location parse/resolution failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArtifactLocationError { + Resolution(String), +} diff --git a/lp-core/lpc-model/src/artifact/artifact_loc.rs b/lp-core/lpc-model/src/artifact/artifact_spec.rs similarity index 65% rename from lp-core/lpc-model/src/artifact/artifact_loc.rs rename to lp-core/lpc-model/src/artifact/artifact_spec.rs index af12bfacc..298352f02 100644 --- a/lp-core/lpc-model/src/artifact/artifact_loc.rs +++ b/lp-core/lpc-model/src/artifact/artifact_spec.rs @@ -6,21 +6,21 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::artifact::src_artifact_lib_ref::SrcArtifactLibRef; -/// Author-facing locator for a loadable artifact carried in source as a string. +/// Author-facing specifier for a loadable artifact carried in source as a string. /// -/// - `./effects/tint.effect.toml` parses as [`ArtifactLocator::Path`]. -/// - `lib:core/visual/checkerboard` parses as [`ArtifactLocator::Lib`]. +/// - `./effects/tint.effect.toml` parses as [`ArtifactSpec::Path`]. +/// - `lib:core/visual/checkerboard` parses as [`ArtifactSpec::Lib`]. /// -/// Path locators are contextual: relative paths resolve relative to the file -/// that contains the locator. Engine-side resolved identity is -/// `ArtifactLocation` in `lpc-engine`; this type stays authored and contextual. +/// Path specifiers are contextual: relative paths resolve relative to the file +/// that contains the specifier. Resolved catalog identity is `ArtifactLocation` +/// in `lpc-registry`; this type stays authored and contextual. #[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum ArtifactLocator { +pub enum ArtifactSpec { Path(LpPathBuf), Lib(SrcArtifactLibRef), } -impl ArtifactLocator { +impl ArtifactSpec { /// Path reference (possibly relative). #[must_use] pub fn path(p: impl Into) -> Self { @@ -42,7 +42,7 @@ impl ArtifactLocator { } } -impl fmt::Display for ArtifactLocator { +impl fmt::Display for ArtifactSpec { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Path(path) => f.write_str(path.as_str()), @@ -51,7 +51,7 @@ impl fmt::Display for ArtifactLocator { } } -impl Serialize for ArtifactLocator { +impl Serialize for ArtifactSpec { fn serialize(&self, serializer: S) -> Result where S: Serializer, @@ -60,7 +60,7 @@ impl Serialize for ArtifactLocator { } } -impl<'de> Deserialize<'de> for ArtifactLocator { +impl<'de> Deserialize<'de> for ArtifactSpec { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, @@ -71,7 +71,7 @@ impl<'de> Deserialize<'de> for ArtifactLocator { } #[cfg(feature = "schema-gen")] -impl schemars::JsonSchema for ArtifactLocator { +impl schemars::JsonSchema for ArtifactSpec { fn schema_name() -> alloc::borrow::Cow<'static, str> { ::schema_name() } @@ -89,41 +89,38 @@ impl schemars::JsonSchema for ArtifactLocator { mod tests { use alloc::string::ToString; - use super::ArtifactLocator; + use super::ArtifactSpec; use crate::artifact::src_artifact_lib_ref::SrcArtifactLibRef; #[test] fn display_normalizes_path() { - assert_eq!( - ArtifactLocator::path("./fluid.vis").to_string(), - "fluid.vis", - ); + assert_eq!(ArtifactSpec::path("./fluid.vis").to_string(), "fluid.vis",); } #[test] fn display_lib_form() { - let s = ArtifactLocator::lib_ref(SrcArtifactLibRef::try_from_suffix("core/x").unwrap()); + let s = ArtifactSpec::lib_ref(SrcArtifactLibRef::try_from_suffix("core/x").unwrap()); assert_eq!(s.to_string(), "lib:core/x"); } #[test] fn serde_json_round_trip_path_and_lib() { - let path = ArtifactLocator::path("effects/tint.effect.toml"); + let path = ArtifactSpec::path("effects/tint.effect.toml"); let j = serde_json::to_string(&path).unwrap(); assert_eq!(j, "\"effects/tint.effect.toml\""); - let back: ArtifactLocator = serde_json::from_str(&j).unwrap(); + let back: ArtifactSpec = serde_json::from_str(&j).unwrap(); assert_eq!(back, path); - let lib = ArtifactLocator::parse("lib:core/visual/checkerboard").unwrap(); + let lib = ArtifactSpec::parse("lib:core/visual/checkerboard").unwrap(); let j = serde_json::to_string(&lib).unwrap(); assert_eq!(j, "\"lib:core/visual/checkerboard\""); - let back: ArtifactLocator = serde_json::from_str(&j).unwrap(); + let back: ArtifactSpec = serde_json::from_str(&j).unwrap(); assert_eq!(back, lib); } #[test] fn parse_rejects_empty_lib_suffix() { - assert!(ArtifactLocator::parse("lib:").is_err()); - assert!(ArtifactLocator::parse("lib: ").is_err()); + assert!(ArtifactSpec::parse("lib:").is_err()); + assert!(ArtifactSpec::parse("lib: ").is_err()); } } diff --git a/lp-core/lpc-model/src/artifact/mod.rs b/lp-core/lpc-model/src/artifact/mod.rs index 4056c806d..5d4ea35ca 100644 --- a/lp-core/lpc-model/src/artifact/mod.rs +++ b/lp-core/lpc-model/src/artifact/mod.rs @@ -1,7 +1,12 @@ -pub mod artifact_loc; +pub mod artifact_location; +pub mod artifact_location_error; pub mod artifact_read_root; +pub mod artifact_spec; pub mod src_artifact_lib_ref; -pub use artifact_loc::ArtifactLocator; +pub use crate::project::overlay_commit::artifact_change_summary::ArtifactChangeSummary; +pub use artifact_location::ArtifactLocation; +pub use artifact_location_error::ArtifactLocationError; pub use artifact_read_root::ArtifactReadRoot; +pub use artifact_spec::ArtifactSpec; pub use src_artifact_lib_ref::SrcArtifactLibRef; diff --git a/lp-core/lpc-model/src/binding/binding_defs.rs b/lp-core/lpc-model/src/binding/binding_defs.rs index fa7fc7be8..b93862c02 100644 --- a/lp-core/lpc-model/src/binding/binding_defs.rs +++ b/lp-core/lpc-model/src/binding/binding_defs.rs @@ -3,9 +3,9 @@ use crate::{ FieldSlot, FieldSlotMut, MapSlot, SlotDataAccess, SlotDataMutAccess, SlotMapKeyShape, SlotMeta, SlotShape, StaticSlotMeta, StaticSlotShape, StaticSlotShapeDescriptor, }; -use alloc::collections::BTreeMap; use alloc::string::String; use core::fmt; +use lp_collection::VecMap; use serde::{Deserialize, Serialize}; /// Authored bindings attached to a node definition. @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; pub struct BindingDefs(pub MapSlot); impl BindingDefs { - pub fn new(entries: BTreeMap) -> Self { + pub fn new(entries: VecMap) -> Self { Self(MapSlot::new(entries)) } @@ -27,7 +27,7 @@ impl BindingDefs { self.0.is_empty() } - pub fn entries(&self) -> &BTreeMap { + pub fn entries(&self) -> &VecMap { &self.0.entries } @@ -123,7 +123,7 @@ target = "bus#visual.out" #[test] fn binding_defs_expose_slot_data_as_map() { - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); entries.insert( String::from("output"), BindingDef::target(BindingRef::parse("bus#visual.out").unwrap()), @@ -135,7 +135,7 @@ target = "bus#visual.out" #[test] fn validate_reports_slot_name_for_invalid_binding() { - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); entries.insert(String::from("bad"), BindingDef::default()); let defs = BindingDefs::new(entries); diff --git a/lp-core/lpc-model/src/hardware_endpoint_spec.rs b/lp-core/lpc-model/src/hardware_endpoint_spec.rs index c9546c4b9..04c8c15d8 100644 --- a/lp-core/lpc-model/src/hardware_endpoint_spec.rs +++ b/lp-core/lpc-model/src/hardware_endpoint_spec.rs @@ -12,9 +12,9 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -pub struct HardwareEndpointSpec(String); +pub struct HwEndpointSpec(String); -impl HardwareEndpointSpec { +impl HwEndpointSpec { pub fn parse(spec: impl Into) -> Result { let spec = spec.into(); validate_spec(&spec)?; @@ -42,19 +42,19 @@ impl HardwareEndpointSpec { } } -impl Default for HardwareEndpointSpec { +impl Default for HwEndpointSpec { fn default() -> Self { Self::from_static("unset:unset:unset") } } -impl fmt::Display for HardwareEndpointSpec { +impl fmt::Display for HwEndpointSpec { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } -impl FromStr for HardwareEndpointSpec { +impl FromStr for HwEndpointSpec { type Err = HardwareEndpointSpecError; fn from_str(spec: &str) -> Result { @@ -62,7 +62,7 @@ impl FromStr for HardwareEndpointSpec { } } -impl Serialize for HardwareEndpointSpec { +impl Serialize for HwEndpointSpec { fn serialize(&self, serializer: S) -> Result where S: Serializer, @@ -71,7 +71,7 @@ impl Serialize for HardwareEndpointSpec { } } -impl<'de> Deserialize<'de> for HardwareEndpointSpec { +impl<'de> Deserialize<'de> for HwEndpointSpec { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, @@ -81,13 +81,13 @@ impl<'de> Deserialize<'de> for HardwareEndpointSpec { } } -impl ToLpValue for HardwareEndpointSpec { +impl ToLpValue for HwEndpointSpec { fn to_lp_value(&self) -> LpValue { LpValue::String(self.0.clone()) } } -impl FromLpValue for HardwareEndpointSpec { +impl FromLpValue for HwEndpointSpec { fn from_lp_value(value: &LpValue) -> Result { match value { LpValue::String(value) => { @@ -100,7 +100,7 @@ impl FromLpValue for HardwareEndpointSpec { } } -impl SlotValue for HardwareEndpointSpec { +impl SlotValue for HwEndpointSpec { const SHAPE_ID: SlotShapeId = SlotShapeId::from_static_name("HardwareEndpointSpec"); const STATIC_VALUE_SHAPE_DESCRIPTOR: Option = Some( StaticSlotValueShape::new(Self::SHAPE_ID, StaticLpType::String), @@ -193,7 +193,7 @@ mod tests { #[test] fn endpoint_spec_splits_three_parts() { - let spec = HardwareEndpointSpec::parse("ws281x:rmt:D10").unwrap(); + let spec = HwEndpointSpec::parse("ws281x:rmt:D10").unwrap(); assert_eq!(spec.capability(), "ws281x"); assert_eq!(spec.driver(), "rmt"); @@ -205,7 +205,7 @@ mod tests { fn endpoint_spec_rejects_malformed_values() { for spec in ["ws281x:rmt", "ws281x:rmt:", ":rmt:D10", "ws281x:rmt:D10:x"] { assert!( - HardwareEndpointSpec::parse(spec).is_err(), + HwEndpointSpec::parse(spec).is_err(), "{spec} should be rejected" ); } @@ -213,10 +213,10 @@ mod tests { #[test] fn endpoint_spec_round_trips_through_lp_value() { - let spec = HardwareEndpointSpec::parse("ws281x:rmt:D10").unwrap(); + let spec = HwEndpointSpec::parse("ws281x:rmt:D10").unwrap(); assert_eq!( - HardwareEndpointSpec::from_lp_value(&spec.to_lp_value()).unwrap(), + HwEndpointSpec::from_lp_value(&spec.to_lp_value()).unwrap(), spec ); } diff --git a/lp-core/lpc-model/src/lib.rs b/lp-core/lpc-model/src/lib.rs index 64753c7b4..7dca1a986 100644 --- a/lp-core/lpc-model/src/lib.rs +++ b/lp-core/lpc-model/src/lib.rs @@ -59,7 +59,10 @@ pub mod sync; pub use value::constraint; pub use value::kind; -pub use artifact::{ArtifactLocator, ArtifactReadRoot, SrcArtifactLibRef}; +pub use artifact::{ + ArtifactChangeSummary, ArtifactLocation, ArtifactLocationError, ArtifactReadRoot, ArtifactSpec, + SrcArtifactLibRef, +}; pub use binding::{ BindingDef, BindingDefError, BindingDefView, BindingDefs, BindingRef, BindingRefError, BusSlotRef, BusSlotRefError, NodeSlotRef, NodeSlotRefError, @@ -71,45 +74,64 @@ pub use constraint::{Constraint, ConstraintChoice, ConstraintFree, ConstraintRan /// New slot-model code should prefer typed slot leaf descriptors whose semantic /// meaning owns its storage shape. pub use kind::Kind; +pub use project::inventory::{ + AssetBodyOrigin, AssetChange, AssetChangeKind, AssetChangeSummary, AssetContentType, + AssetEntry, AssetLocation, AssetState, NodeUseChange, NodeUseChangeKind, NodeUseChangeSummary, + ReferencedAsset, +}; pub use value::WithRevision; pub use value::{LpType, LpValue, ModelEnumVariant, ModelStructMember}; pub use config::DEFAULT_SERIAL_BAUD_RATE; pub use control::{CONTROL_MESSAGE_SHAPE_NAME, ControlMessage, TriggerEvent}; -pub use hardware_endpoint_spec::{HardwareEndpointSpec, HardwareEndpointSpecError}; +pub use hardware_endpoint_spec::{HardwareEndpointSpecError, HwEndpointSpec}; pub use lpfs::lp_path::{AsLpPath, AsLpPathBuf, LpPath, LpPathBuf}; pub use node::node_prop_spec::NodePropSpec; pub use node::tree_path::{NodePathSegment, PathError, TreePath}; pub use node::{ - NodeArtifact, NodeDef, NodeDefRef, NodeId, NodeInvocation, NodeKind, NodeName, NodeNameError, - RelativeNodeRef, RelativeNodeRefError, RelativeNodeRefSrc, + NodeArtifact, NodeDef, NodeDefChange, NodeDefChangeKind, NodeDefChangeSummary, NodeDefEntry, + NodeDefLocation, NodeDefState, NodeDefValidationError, NodeId, NodeInvocation, + NodeInvocationSlot, NodeKind, NodeName, NodeNameError, NodeRuntimeStatus, RelativeNodeRef, + RelativeNodeRefError, RelativeNodeRefSrc, }; pub use nodes::{ - AddSubMode, ButtonDef, ButtonDefView, ButtonState, ButtonStateView, ClockControls, ClockDef, - ClockDefView, ClockState, ColorOrder, ComputeShaderDef, ComputeShaderDefView, ControlRadioDef, - ControlRadioDefView, ControlRadioState, ControlRadioStateView, DivMode, FixtureDef, - FixtureDefView, FixtureDiagnosticMode, FixtureSamplingConfig, FixtureState, FixtureStateView, - FluidDef, FluidDefView, FluidEmitter, FluidState, GlslOpts, GlslOptsView, MappingConfig, - MulMode, NodeDefParseError, OutputDef, OutputDefView, OutputDriverOptionsConfig, - OutputDriverOptionsConfigView, PathSpec, PlaylistDef, PlaylistDefView, PlaylistEntry, - PlaylistEntryView, PlaylistState, PlaylistStateView, ProjectDef, ProjectDefView, RingOrder, - ScalarHint, ScalarHintView, ShaderDef, ShaderDefView, ShaderHeaderGenError, ShaderMapKeyDef, - ShaderParamDef, ShaderParamDefView, ShaderSlotDef, ShaderSlotKind, ShaderSlotMappingDef, - ShaderSlotMappingKind, ShaderSource, ShaderState, ShaderStateView, ShaderValueShapeRef, - TextureDef, TextureDefView, TextureFormat, TextureState, TextureStateView, - generate_compute_shader_header, + AddSubMode, ArtifactPathResolutionError, ButtonDef, ButtonDefView, ButtonState, + ButtonStateView, ClockControls, ClockDef, ClockDefView, ClockState, ColorOrder, + ComputeShaderDef, ComputeShaderDefView, ControlRadioDef, ControlRadioDefView, + ControlRadioState, ControlRadioStateView, DivMode, FixtureDef, FixtureDefView, + FixtureDiagnosticMode, FixtureSamplingConfig, FixtureState, FixtureStateView, FluidDef, + FluidDefView, FluidEmitter, FluidState, GlslOpts, GlslOptsView, InlineAssetBytes, + InlineAssetText, InvocationSite, MappingConfig, MulMode, NodeDefParseError, OutputDef, + OutputDefView, OutputDriverOptionsConfig, OutputDriverOptionsConfigView, PathSpec, PlaylistDef, + PlaylistDefView, PlaylistEntry, PlaylistEntryView, PlaylistState, PlaylistStateView, + ProjectDef, ProjectDefView, RingOrder, ScalarHint, ScalarHintView, ShaderDef, ShaderDefView, + ShaderHeaderGenError, ShaderMapKeyDef, ShaderParamDef, ShaderParamDefView, ShaderSlotDef, + ShaderSlotKind, ShaderSlotMappingDef, ShaderSlotMappingKind, ShaderState, ShaderStateView, + ShaderValueShapeRef, TextureDef, TextureDefView, TextureFormat, TextureState, TextureStateView, + generate_compute_shader_header, resolve_artifact_specifier, }; pub use product::{ControlExtent, ControlProduct, ProductKind, ProductRef, VisualProduct}; -pub use project::{ProjectConfig, Revision}; +pub use project::overlay::{ + ArtifactOverlay, AssetBodyOverlay, ProjectOverlay, SlotEdit, SlotEditOp, SlotOverlay, +}; +pub use project::overlay_mutation::{ + MutationCmd, MutationCmdBatch, MutationCmdBatchResult, MutationCmdId, MutationCmdResult, + MutationCmdStatus, MutationEffect, MutationOp, MutationRejection, MutationRejectionReason, +}; +pub use project::{ + ChangeSummary, CommitResult, LocationSeg, MutationBatchResults, MutationResult, + NodeUseLocation, ProjectChangeSummary, ProjectConfig, ProjectInventory, 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}; pub use server::server_config::ServerConfig; pub use slot::{ - Affine2d, Affine2dSlot, ArtifactPath, ArtifactPathSlot, ColorOrderSlot, ColorOrderValue, - ControlProductSlot, Dim2u, Dim2uSlot, FromLpValue, OrderedF32, PositiveF32, PositiveF32Slot, - Ratio, RatioSlot, RelativeNodeRefSlot, RenderOrder, RenderOrderSlot, ResourceRefSlot, - SlotEnumOption, SlotMapValueAccess, SlotValue, SlotValueShape, SourcePath, SourcePathSlot, - ToLpValue, ValueEditorHint, ValueRootError, VisualProductSlot, Xy, XySlot, + Affine2d, Affine2dSlot, ArtifactPath, ArtifactPathSlot, AssetSlot, AssetSlotValue, + ColorOrderSlot, ColorOrderValue, ControlProductSlot, Dim2u, Dim2uSlot, FromLpValue, OrderedF32, + PositiveF32, PositiveF32Slot, Ratio, RatioSlot, RelativeNodeRefSlot, RenderOrder, + RenderOrderSlot, ResourceRefSlot, SlotEnumOption, SlotMapValueAccess, SlotValue, + SlotValueShape, ToLpValue, ValueEditorHint, ValueRootError, VisualProductSlot, Xy, XySlot, }; pub use slot::{ DynamicSlotObject, EnumSlot, FieldSlot, FieldSlotMut, MapSlot, MapSlotAccess, MapSlotAccessMut, @@ -130,8 +152,8 @@ pub use slot::{ StaticModelStructMember, StaticSlotAccess, StaticSlotEnumEncoding, StaticSlotEnumOption, StaticSlotFieldShape, StaticSlotMeta, StaticSlotShape, StaticSlotShapeDescriptor, StaticSlotValueShape, StaticSlotVariantShape, StaticValueEditorHint, ValueRef, ValueSlot, - create_dynamic_slot_data, insert_slot_map_entry_default, lookup_slot_data, - lookup_slot_data_and_shape, lookup_slot_data_mut, set_slot_option_some_default, set_slot_value, - set_slot_variant_default, slot_data_revision, + create_dynamic_slot_data, ensure_slot_present, insert_slot_map_entry_default, lookup_slot_data, + lookup_slot_data_and_shape, lookup_slot_data_mut, remove_slot_map_entry, set_slot_option_none, + set_slot_option_some_default, set_slot_value, set_slot_variant_default, slot_data_revision, }; pub use value::value_path::ValuePath; diff --git a/lp-core/lpc-model/src/node/mod.rs b/lp-core/lpc-model/src/node/mod.rs index 1c147b6c8..da59fdc86 100644 --- a/lp-core/lpc-model/src/node/mod.rs +++ b/lp-core/lpc-model/src/node/mod.rs @@ -1,8 +1,8 @@ -//! **Shared** graph node identifiers and authored node-tree locators. +//! Shared node identities, authored definition locators, and project use locators. pub mod kind; +pub mod node_def_location; pub mod node_id; -pub mod node_invocation; pub mod node_name; /// Legacy node/property string parser from the pre-slot property model. /// @@ -10,13 +10,23 @@ pub mod node_name; /// [`crate::ValueRef`] only when it explicitly needs to project inside an /// atomic slot value. pub mod node_prop_spec; +pub mod node_runtime_status; +pub mod node_use_location; pub mod relative_node_ref; pub mod tree_path; pub use crate::nodes::node_def::{NodeArtifact, NodeDef}; +pub use crate::project::inventory::node_def_entry::NodeDefEntry; +pub use crate::project::inventory::node_def_state::{NodeDefState, NodeDefValidationError}; +pub use crate::project::overlay_mutation::node_def_change_summary::{ + NodeDefChange, NodeDefChangeKind, NodeDefChangeSummary, +}; +pub use crate::slots::node_invocation_slot::{NodeInvocation, NodeInvocationSlot}; pub use kind::NodeKind; +pub use node_def_location::NodeDefLocation; pub use node_id::NodeId; -pub use node_invocation::{NodeDefRef, NodeInvocation}; pub use node_name::{NodeName, NodeNameError}; +pub use node_runtime_status::NodeRuntimeStatus; +pub use node_use_location::{LocationSeg, NodeUseLocation}; pub use relative_node_ref::{RelativeNodeRef, RelativeNodeRefError, RelativeNodeRefSrc}; pub use tree_path::TreePath; diff --git a/lp-core/lpc-model/src/node/node_def_location.rs b/lp-core/lpc-model/src/node/node_def_location.rs new file mode 100644 index 000000000..e424388e5 --- /dev/null +++ b/lp-core/lpc-model/src/node/node_def_location.rs @@ -0,0 +1,29 @@ +use crate::{ArtifactLocation, SlotPath}; + +/// Location of authored node definition data within project artifacts. +/// +/// `NodeDefLocation` identifies the definition payload itself, not a node use +/// in the project tree and not a runtime node. Multiple [`crate::ProjectNode`] +/// uses can point at the same definition when an artifact is referenced more +/// than once. +#[derive( + Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize, +)] +pub struct NodeDefLocation { + /// Artifact containing the node definition. + pub artifact: ArtifactLocation, + /// Slot path of the definition inside the artifact. + /// + /// Artifact-root definitions use [`SlotPath::root`]. Inline child + /// definitions use the parent-owned invocation slot path. + pub path: SlotPath, +} + +impl NodeDefLocation { + pub fn artifact_root(artifact: ArtifactLocation) -> Self { + Self { + artifact, + path: SlotPath::root(), + } + } +} diff --git a/lp-core/lpc-model/src/node/node_invocation.rs b/lp-core/lpc-model/src/node/node_invocation.rs deleted file mode 100644 index 3c8476830..000000000 --- a/lp-core/lpc-model/src/node/node_invocation.rs +++ /dev/null @@ -1,441 +0,0 @@ -//! Parent-owned instruction to instantiate a child node. -//! -//! The parent owns the invocation namespace. The child node definition itself -//! lives under `def`, either as a relative path locator or as an inline -//! [`NodeDef`]. - -use alloc::boxed::Box; -use alloc::string::{String, ToString}; - -use crate::artifact::artifact_loc::ArtifactLocator; -use crate::nodes::node_def::{NodeArtifact, NodeDef}; -use crate::slot_codec::{ - ObjectReader, SlotDataWriteError, SlotValueWriter, SlotWrite, SlotWriteError, SyntaxError, - SyntaxEventSource, ValueReader, -}; -use crate::{ - ArtifactPath, ArtifactPathSlot, FieldSlot, FieldSlotMut, Revision, SlotAccess, - SlotCustomAccess, SlotCustomMutAccess, SlotDataAccess, SlotDataMutAccess, SlotMapValueAccess, - SlotMapValueMutAccess, SlotRecordAccess, SlotRecordMutAccess, SlotShape, SlotShapeId, - SlotShapeRegistry, SlotValueAccess, StaticSlotFieldShape, StaticSlotMeta, StaticSlotShape, - StaticSlotShapeDescriptor, -}; - -pub(crate) const NODE_INVOCATION_CODEC_ID: SlotShapeId = - SlotShapeId::from_static_name("lp::node::NodeInvocationCodec"); - -/// Parent-owned child node invocation. -#[derive(Clone, Debug, PartialEq)] -pub struct NodeInvocation { - pub def: NodeDefRef, - def_slot: ArtifactPathSlot, -} - -/// Authored child node definition reference. -#[derive(Clone, Debug, PartialEq)] -pub enum NodeDefRef { - Path(ArtifactLocator), - Inline(Box), -} - -impl NodeInvocation { - /// New path-backed invocation. - #[must_use] - pub fn new(def: ArtifactLocator) -> Self { - Self::path(def) - } - - #[must_use] - pub fn path(def: ArtifactLocator) -> Self { - let def_slot = ArtifactPathSlot::new(ArtifactPath(def.to_string())); - Self { - def: NodeDefRef::Path(def), - def_slot, - } - } - - #[must_use] - pub fn inline(def: NodeDef) -> Self { - Self { - def: NodeDefRef::Inline(Box::new(def)), - def_slot: ArtifactPathSlot::new(ArtifactPath(String::new())), - } - } - - pub fn def_locator(&self) -> Option<&ArtifactLocator> { - match &self.def { - NodeDefRef::Path(locator) => Some(locator), - NodeDefRef::Inline(_) => None, - } - } - - pub fn inline_def(&self) -> Option<&NodeDef> { - match &self.def { - NodeDefRef::Path(_) => None, - NodeDefRef::Inline(def) => Some(def), - } - } - - pub(crate) fn read_invocation_slot( - &mut self, - registry: &SlotShapeRegistry, - value: ValueReader<'_, '_, S>, - ) -> Result<(), SyntaxError> - where - S: SyntaxEventSource, - { - let mut object = value.object()?; - let Some(mut prop) = object.next_prop()? else { - return Err(object.missing_required_field("def")); - }; - let name = prop.name().to_string(); - if name != "def" { - return Err(prop.unknown_field(&name, &["def"])); - } - self.read_def_slot(registry, prop.value())?; - drop(prop); - - if let Some(prop) = object.next_prop()? { - let name = prop.name().to_string(); - return Err(prop.unknown_field(&name, &[])); - } - Ok(()) - } - - pub(crate) fn write_invocation_slot_json( - &self, - registry: &SlotShapeRegistry, - value: SlotValueWriter<'_, W>, - ) -> Result<(), SlotWriteError> - where - W: SlotWrite, - { - let mut object = value.object()?; - self.write_def_slot_json(registry, object.prop("def")?)?; - object.finish() - } - - pub(crate) fn write_invocation_slot_toml( - &self, - registry: &SlotShapeRegistry, - ) -> Result { - let mut table = toml::Table::new(); - table.insert("def".into(), self.write_def_slot_toml(registry)?); - Ok(toml::Value::Table(table)) - } - - pub(crate) fn read_def_slot( - &mut self, - registry: &SlotShapeRegistry, - value: ValueReader<'_, '_, S>, - ) -> Result<(), SyntaxError> - where - S: SyntaxEventSource, - { - let mut object = value.object()?; - let Some(first) = object.peek_prop_name()? else { - return Err(object.missing_required_field("path")); - }; - - match first.as_str() { - "path" => self.read_path_def(object), - "kind" => { - let artifact = read_node_artifact_from_object(registry, object)?; - *self = Self::inline(artifact.into_node_def()); - Ok(()) - } - _ => Err(SyntaxError::new( - "def", - None, - "node def reference must contain `path` or inline `kind`", - )), - } - } - - pub(crate) fn write_def_slot_json( - &self, - registry: &SlotShapeRegistry, - value: SlotValueWriter<'_, W>, - ) -> Result<(), SlotWriteError> - where - W: SlotWrite, - { - match &self.def { - NodeDefRef::Path(locator) => { - let path = locator.to_string(); - let mut object = value.object()?; - object.prop("path")?.string(&path)?; - object.finish() - } - NodeDefRef::Inline(def) => { - let artifact = NodeArtifact::new((**def).clone()); - registry.write_slot_json_value(NodeArtifact::SHAPE_ID, artifact.data(), value) - } - } - } - - pub(crate) fn write_def_slot_toml( - &self, - registry: &SlotShapeRegistry, - ) -> Result { - match &self.def { - NodeDefRef::Path(locator) => { - let mut table = toml::Table::new(); - table.insert("path".into(), toml::Value::String(locator.to_string())); - Ok(toml::Value::Table(table)) - } - NodeDefRef::Inline(def) => { - let artifact = NodeArtifact::new((**def).clone()); - registry.write_slot_toml(&artifact) - } - } - } - - fn read_path_def(&mut self, mut object: ObjectReader<'_, '_, S>) -> Result<(), SyntaxError> - where - S: SyntaxEventSource, - { - let Some(mut prop) = object.next_prop()? else { - return Err(object.missing_required_field("path")); - }; - let path = prop.value().string()?; - drop(prop); - - if object.next_prop()?.is_some() { - return Err(SyntaxError::new( - "def", - None, - "`def.path` cannot be combined with inline node definition fields", - )); - } - - let locator = ArtifactLocator::parse(&path) - .map_err(|error| SyntaxError::new("def.path", None, alloc::format!("{error}")))?; - *self = Self::path(locator); - Ok(()) - } -} - -impl Default for NodeInvocation { - fn default() -> Self { - Self::path(ArtifactLocator::path("")) - } -} - -impl From for NodeInvocation { - fn from(def: NodeDefRef) -> Self { - match def { - NodeDefRef::Path(locator) => Self::path(locator), - NodeDefRef::Inline(def) => Self::inline(*def), - } - } -} - -impl FieldSlot for NodeInvocation { - const STATIC_SLOT_FIELD_SHAPE_DESCRIPTOR: Option<&'static StaticSlotShapeDescriptor> = - match ::STATIC_SLOT_FIELD_SHAPE_DESCRIPTOR { - Some(def_shape) => Some(&StaticSlotShapeDescriptor::Custom { - meta: StaticSlotMeta::EMPTY, - codec: NODE_INVOCATION_CODEC_ID, - shape: &StaticSlotShapeDescriptor::Record { - meta: StaticSlotMeta::EMPTY, - fields: &[StaticSlotFieldShape { - name: "def", - shape: def_shape, - semantics: crate::SlotSemantics::local(), - policy: crate::SlotPolicy::writable_persisted(), - }], - }, - refs: &[NodeArtifact::SHAPE_ID], - }), - None => None, - }; - - fn slot_field_shape() -> SlotShape { - crate::slot::shape::custom( - NODE_INVOCATION_CODEC_ID, - node_invocation_sync_shape(), - alloc::vec![NodeArtifact::SHAPE_ID], - ) - } - - fn slot_field_data(&self) -> SlotDataAccess<'_> { - SlotDataAccess::Custom(self) - } -} - -impl FieldSlotMut for NodeInvocation { - fn slot_field_data_mut(&mut self) -> SlotDataMutAccess<'_> { - SlotDataMutAccess::Custom(self) - } -} - -impl SlotMapValueAccess for NodeInvocation { - fn slot_data(&self) -> SlotDataAccess<'_> { - SlotDataAccess::Custom(self) - } -} - -impl SlotMapValueMutAccess for NodeInvocation { - fn slot_data_mut(&mut self) -> SlotDataMutAccess<'_> { - SlotDataMutAccess::Custom(self) - } -} - -impl SlotRecordAccess for NodeInvocation { - fn fields_revision(&self) -> Revision { - self.def_slot.changed_at() - } - - fn field(&self, index: usize) -> Option> { - match index { - 0 => Some(self.def_slot.slot_field_data()), - _ => None, - } - } -} - -impl SlotRecordMutAccess for NodeInvocation { - fn fields_revision(&self) -> Revision { - self.def_slot.changed_at() - } - - fn field_mut(&mut self, index: usize) -> Option> { - match index { - 0 => Some(self.def_slot.slot_field_data_mut()), - _ => None, - } - } -} - -impl SlotCustomAccess for NodeInvocation { - fn custom_codec_id(&self) -> SlotShapeId { - NODE_INVOCATION_CODEC_ID - } - - fn custom_revision(&self) -> Revision { - self.def_slot.changed_at() - } - - fn as_any(&self) -> &dyn core::any::Any { - self - } -} - -impl SlotCustomMutAccess for NodeInvocation { - fn as_any_mut(&mut self) -> &mut dyn core::any::Any { - self - } -} - -fn node_invocation_sync_shape() -> SlotShape { - crate::slot::shape::record(alloc::vec![crate::slot::shape::field( - "def", - ::slot_field_shape(), - )]) -} - -fn read_node_artifact_from_object( - registry: &SlotShapeRegistry, - object: ObjectReader<'_, '_, S>, -) -> Result -where - S: SyntaxEventSource, -{ - let object = - crate::slot_codec::read_dynamic_slot_from_object(registry, NodeArtifact::SHAPE_ID, object)?; - object - .into_any() - .downcast::() - .map(|artifact| *artifact) - .map_err(|_| { - SyntaxError::new( - "", - None, - alloc::format!( - "slot reader returned unexpected type for shape {}", - NodeArtifact::SHAPE_ID - ), - ) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn node_invocation_toml_path_form_loads() { - let invocation = read_invocation( - r#" -def = { path = "./texture.toml" } -"#, - ); - - assert_eq!( - invocation.def_locator().unwrap(), - &ArtifactLocator::path("./texture.toml") - ); - } - - #[test] - fn node_invocation_rejects_legacy_artifact() { - let err = read_invocation_err( - r#" -artifact = "./texture.toml" -"#, - ); - - assert!(err.to_string().contains("def")); - } - - #[test] - fn node_invocation_toml_inline_form_loads() { - let invocation = read_invocation( - r#" -[def] -kind = "Clock" -"#, - ); - - assert!(matches!(invocation.inline_def(), Some(NodeDef::Clock(_)))); - } - - #[test] - fn node_invocation_rejects_path_plus_inline_fields() { - let err = read_invocation_err( - r#" -[def] -path = "./clock.toml" -kind = "Clock" -"#, - ); - - assert!(err.to_string().contains("path")); - } - - fn read_invocation(text: &str) -> NodeInvocation { - read_invocation_result(text).unwrap() - } - - fn read_invocation_err(text: &str) -> SyntaxError { - read_invocation_result(text).unwrap_err() - } - - fn read_invocation_result(text: &str) -> Result { - let registry = SlotShapeRegistry::default(); - let value = toml::from_str::(text).unwrap(); - let mut reader = crate::slot_codec::SlotReader::new( - crate::slot_codec::TomlSyntaxSource::new(&value).unwrap(), - ®istry, - ); - let mut invocation = NodeInvocation::default(); - crate::slot_codec::apply_reader_to_slot( - invocation.slot_field_data_mut(), - &NodeInvocation::slot_field_shape(), - ®istry, - reader.value(), - )?; - Ok(invocation) - } -} diff --git a/lp-core/lpc-wire/src/project/wire_node_status.rs b/lp-core/lpc-model/src/node/node_runtime_status.rs similarity index 56% rename from lp-core/lpc-wire/src/project/wire_node_status.rs rename to lp-core/lpc-model/src/node/node_runtime_status.rs index 4743ea665..7df5910a7 100644 --- a/lp-core/lpc-wire/src/project/wire_node_status.rs +++ b/lp-core/lpc-model/src/node/node_runtime_status.rs @@ -1,12 +1,12 @@ -//! Wire-visible node lifecycle status. +//! Shared runtime node lifecycle and health status. use alloc::string::String; use serde::{Deserialize, Serialize}; -/// Node lifecycle / health status on the wire. +/// Node lifecycle and health status reported by the runtime tree. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -pub enum WireNodeStatus { +pub enum NodeRuntimeStatus { /// Created but not yet initialized. Created, /// Error initializing the node. @@ -24,13 +24,13 @@ mod tests { use super::*; #[test] - fn wire_node_status_variants() { - let status = WireNodeStatus::Created; - assert_eq!(status, WireNodeStatus::Created); + fn node_runtime_status_variants() { + let status = NodeRuntimeStatus::Created; + assert_eq!(status, NodeRuntimeStatus::Created); - let status = WireNodeStatus::InitError("test error".into()); + let status = NodeRuntimeStatus::InitError("test error".into()); match status { - WireNodeStatus::InitError(msg) => assert_eq!(msg, "test error"), + NodeRuntimeStatus::InitError(msg) => assert_eq!(msg, "test error"), _ => panic!("Expected InitError"), } } diff --git a/lp-core/lpc-model/src/node/node_use_location.rs b/lp-core/lpc-model/src/node/node_use_location.rs new file mode 100644 index 000000000..53f59a6d9 --- /dev/null +++ b/lp-core/lpc-model/src/node/node_use_location.rs @@ -0,0 +1,94 @@ +use alloc::vec::Vec; + +use crate::SlotPath; + +/// Deterministic identity for one use of a node definition in a project tree. +/// +/// `NodeUseLocation` identifies a node use in the expanded +/// [`crate::ProjectTree`]. It is distinct from [`crate::NodeDefLocation`]: +/// definition locations identify authored data, while use locations identify +/// places where definitions are invoked from the project root. +/// +/// The root location has no segments. A child location appends the child +/// invocation slot path to its parent's segment list, so nested children are +/// identified by invocation ancestry rather than by definition location or +/// runtime [`crate::NodeId`]. +#[derive( + Clone, + Debug, + Default, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, + serde::Serialize, + serde::Deserialize, +)] +pub struct NodeUseLocation { + /// Authored invocation path segments from the project root to this use. + pub segments: Vec, +} + +impl NodeUseLocation { + pub fn root() -> Self { + Self { + segments: Vec::new(), + } + } + + pub fn child(&self, slot: SlotPath) -> Self { + let mut segments = self.segments.clone(); + segments.push(LocationSeg { slot }); + Self { segments } + } + + pub fn is_root(&self) -> bool { + self.segments.is_empty() + } +} + +/// One authored invocation step in a [`NodeUseLocation`]. +#[derive( + Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize, +)] +pub struct LocationSeg { + /// Slot path of the child invocation within its parent definition. + pub slot: SlotPath, +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::ToString; + + #[test] + fn root_key_is_empty() { + let key = NodeUseLocation::root(); + + assert!(key.is_root()); + assert!(key.segments.is_empty()); + } + + #[test] + fn child_key_appends_slot_ancestry() { + let root = NodeUseLocation::root(); + let first = root.child(SlotPath::parse("nodes[playlist]").unwrap()); + let second = first.child(SlotPath::parse("entries[1].node").unwrap()); + + assert_eq!(first.segments.len(), 1); + assert_eq!(second.segments.len(), 2); + assert_eq!(second.segments[0].slot.to_string(), "nodes[playlist]"); + assert_eq!(second.segments[1].slot.to_string(), "entries[1].node"); + } + + #[test] + fn key_serializes_as_slot_path_segments() { + let key = NodeUseLocation::root().child(SlotPath::parse("nodes[shader]").unwrap()); + + let json = serde_json::to_string(&key).unwrap(); + let round_trip: NodeUseLocation = serde_json::from_str(&json).unwrap(); + + assert_eq!(round_trip, key); + } +} diff --git a/lp-core/lpc-model/src/nodes/button/button_def.rs b/lp-core/lpc-model/src/nodes/button/button_def.rs index 0bc8e2ed0..0e093f3be 100644 --- a/lp-core/lpc-model/src/nodes/button/button_def.rs +++ b/lp-core/lpc-model/src/nodes/button/button_def.rs @@ -1,4 +1,4 @@ -use crate::{BindingDefs, ControlMessage, HardwareEndpointSpec, MapSlot, Slotted, ValueSlot}; +use crate::{BindingDefs, ControlMessage, HwEndpointSpec, MapSlot, Slotted, ValueSlot}; pub const DEFAULT_BUTTON_ENDPOINT_SPEC: &str = "button:gpio:D9"; @@ -13,7 +13,7 @@ pub struct ButtonDef { pub bindings: BindingDefs, /// Hardware endpoint spec, for example `button:gpio:D9`. - pub endpoint: ValueSlot, + pub endpoint: ValueSlot, /// Stable message id used as the key and payload id for this button. pub id: ValueSlot, @@ -40,7 +40,7 @@ impl ButtonDef { crate::NodeKind::Button } - pub fn endpoint(&self) -> &HardwareEndpointSpec { + pub fn endpoint(&self) -> &HwEndpointSpec { self.endpoint.value() } } @@ -66,10 +66,8 @@ fn default_id() -> ValueSlot { ValueSlot::new(1) } -fn default_endpoint() -> ValueSlot { - ValueSlot::new(HardwareEndpointSpec::from_static( - DEFAULT_BUTTON_ENDPOINT_SPEC, - )) +fn default_endpoint() -> ValueSlot { + ValueSlot::new(HwEndpointSpec::from_static(DEFAULT_BUTTON_ENDPOINT_SPEC)) } fn default_stable_ms() -> ValueSlot { diff --git a/lp-core/lpc-model/src/nodes/fixture/fixture_def.rs b/lp-core/lpc-model/src/nodes/fixture/fixture_def.rs index 2da629ced..bea882934 100644 --- a/lp-core/lpc-model/src/nodes/fixture/fixture_def.rs +++ b/lp-core/lpc-model/src/nodes/fixture/fixture_def.rs @@ -316,13 +316,13 @@ mod tests { use crate::NodeKind; use crate::nodes::fixture::mapping::{PathSpec, RingOrder}; use crate::{Affine2d, FixtureDefView, MapSlot, SlotPath, SlotShapeRegistry}; - use alloc::collections::BTreeMap; + use lp_collection::VecMap; #[test] fn test_fixture_def_kind() { - let mut ring_lamp_counts = BTreeMap::new(); + let mut ring_lamp_counts = VecMap::new(); ring_lamp_counts.insert(0, ValueSlot::new(1_u32)); - let mut paths = BTreeMap::new(); + let mut paths = VecMap::new(); paths.insert( 0, EnumSlot::new(PathSpec::ring_array( diff --git a/lp-core/lpc-model/src/nodes/fixture/mapping.rs b/lp-core/lpc-model/src/nodes/fixture/mapping.rs index 869d3d5ad..7cecd19a3 100644 --- a/lp-core/lpc-model/src/nodes/fixture/mapping.rs +++ b/lp-core/lpc-model/src/nodes/fixture/mapping.rs @@ -1,10 +1,10 @@ -use alloc::collections::BTreeMap; use alloc::vec::Vec; +use lp_collection::VecMap; use serde::{Deserialize, Serialize}; use crate::{ - EnumSlot, FromLpValue, LpType, LpValue, MapSlot, PositiveF32, PositiveF32Slot, SlotEnumOption, - SlotMeta, SlotShapeId, SlotValue, SlotValueShape, Slotted, SourcePath, SourcePathSlot, + AssetSlot, EnumSlot, FromLpValue, LpPathBuf, LpType, LpValue, MapSlot, PositiveF32, + PositiveF32Slot, SlotEnumOption, SlotMeta, SlotShapeId, SlotValue, SlotValueShape, Slotted, StaticLpType, StaticSlotEnumOption, StaticSlotMeta, StaticSlotValueShape, StaticValueEditorHint, ToLpValue, ValueEditorHint, ValueRootError, ValueSlot, Xy, XySlot, }; @@ -24,7 +24,7 @@ pub enum MappingConfig { /// A mapping imported from a small, strict SVG path subset at project-load time. SvgPath { - source: SourcePathSlot, + source: AssetSlot, sample_diameter: PositiveF32Slot, }, } @@ -68,16 +68,16 @@ impl MappingConfig { } pub fn path_points_vec(paths: Vec, sample_diameter: f32) -> Self { - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); for (index, path) in paths.into_iter().enumerate() { entries.insert(index as u32, EnumSlot::new(path)); } Self::path_points(MapSlot::new(entries), sample_diameter) } - pub fn svg_path(source: impl Into, sample_diameter: f32) -> Self { + pub fn svg_path(source: impl Into, sample_diameter: f32) -> Self { Self::SvgPath { - source: SourcePathSlot::new(source.into()), + source: AssetSlot::path(source), sample_diameter: PositiveF32Slot::new(PositiveF32(sample_diameter)), } } @@ -113,7 +113,7 @@ impl PathSpec { offset_angle: f32, order: RingOrder, ) -> Self { - let mut counts = BTreeMap::new(); + let mut counts = VecMap::new(); for (index, count) in ring_lamp_counts.iter().copied().enumerate() { counts.insert(index as u32, ValueSlot::new(count)); } @@ -129,7 +129,7 @@ impl PathSpec { } pub fn point_list(first_channel: u32, points: impl IntoIterator) -> Self { - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); for (index, point) in points.into_iter().enumerate() { entries.insert(index as u32, XySlot::new(Xy(point))); } diff --git a/lp-core/lpc-model/src/nodes/fixture/sampling.rs b/lp-core/lpc-model/src/nodes/fixture/sampling.rs index 5cdc0c1d7..90f4c2c8f 100644 --- a/lp-core/lpc-model/src/nodes/fixture/sampling.rs +++ b/lp-core/lpc-model/src/nodes/fixture/sampling.rs @@ -10,7 +10,7 @@ use crate::{ /// How a fixture evaluates its input visual product before writing control samples. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] pub enum FixtureSamplingConfig { /// Sample the shader directly once per fixture lamp. Direct, diff --git a/lp-core/lpc-model/src/nodes/mod.rs b/lp-core/lpc-model/src/nodes/mod.rs index b1acc3ce8..2bfa58c6a 100644 --- a/lp-core/lpc-model/src/nodes/mod.rs +++ b/lp-core/lpc-model/src/nodes/mod.rs @@ -17,7 +17,10 @@ pub use fixture::{ FixtureState, FixtureStateView, MappingConfig, PathSpec, RingOrder, }; pub use fluid::{FluidDef, FluidDefView, FluidEmitter, FluidState}; -pub use node_def::{NodeArtifact, NodeDef, NodeDefParseError, NodeDefWriteError}; +pub use node_def::{ + ArtifactPathResolutionError, InlineAssetBytes, InlineAssetText, InvocationSite, NodeArtifact, + NodeDef, NodeDefParseError, NodeDefWriteError, resolve_artifact_specifier, +}; pub use output::{ OutputDef, OutputDefView, OutputDriverOptionsConfig, OutputDriverOptionsConfigView, }; @@ -31,7 +34,7 @@ pub use shader::{ AddSubMode, ComputeShaderDef, ComputeShaderDefView, DivMode, GlslOpts, GlslOptsView, MulMode, ScalarHint, ScalarHintView, ShaderDef, ShaderDefView, ShaderHeaderGenError, ShaderMapKeyDef, ShaderParamDef, ShaderParamDefView, ShaderSlotDef, ShaderSlotKind, ShaderSlotMappingDef, - ShaderSlotMappingKind, ShaderSource, ShaderState, ShaderStateView, ShaderValueShapeRef, + ShaderSlotMappingKind, ShaderState, ShaderStateView, ShaderValueShapeRef, generate_compute_shader_header, }; pub use texture::{TextureDef, TextureDefView, TextureFormat, TextureState, TextureStateView}; diff --git a/lp-core/lpc-model/src/nodes/node_def.rs b/lp-core/lpc-model/src/nodes/node_def.rs index e99ea69b9..34408d0f2 100644 --- a/lp-core/lpc-model/src/nodes/node_def.rs +++ b/lp-core/lpc-model/src/nodes/node_def.rs @@ -7,11 +7,14 @@ use alloc::boxed::Box; use alloc::format; use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use crate::artifact::artifact_spec::ArtifactSpec; use crate::node::kind::NodeKind; use crate::nodes::button::ButtonDef; use crate::nodes::clock::ClockDef; -use crate::nodes::fixture::FixtureDef; +use crate::nodes::fixture::{FixtureDef, MappingConfig}; use crate::nodes::fluid::FluidDef; use crate::nodes::output::OutputDef; use crate::nodes::playlist::PlaylistDef; @@ -20,7 +23,9 @@ use crate::nodes::radio::ControlRadioDef; use crate::nodes::shader::{ComputeShaderDef, ShaderDef}; use crate::nodes::texture::TextureDef; use crate::{ - EnumSlot, SlotAccess, SlotDataAccess, SlotDataMutAccess, SlotMutAccess, SlotShapeId, + ArtifactLocation, AssetContentType, AssetLocation, AssetSlot, AssetSlotValue, EnumSlot, LpPath, + LpPathBuf, NodeDefLocation, NodeInvocation, ProjectNodePlacement, ReferencedAsset, SlotAccess, + SlotDataAccess, SlotDataMutAccess, SlotMapKey, SlotMutAccess, SlotName, SlotPath, SlotShapeId, SlotShapeRegistry, Slotted, StaticSlotShape, }; @@ -78,6 +83,54 @@ pub enum NodeDef { #[derive(Clone, Debug, Default, PartialEq, Slotted)] pub struct NodeArtifact(pub EnumSlot); +/// One child node invocation and its path within the owning artifact. +#[derive(Clone, Debug, PartialEq)] +pub struct InvocationSite { + pub path: SlotPath, + pub invocation: NodeInvocation, + pub role: ProjectNodePlacement, +} + +/// Borrowed inline text asset body owned by a node definition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InlineAssetText<'a> { + pub extension: &'a str, + pub text: &'a str, +} + +/// Borrowed inline byte asset body owned by a node definition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InlineAssetBytes<'a> { + pub extension: Option<&'a str>, + pub bytes: &'a [u8], +} + +/// Failure resolving model-authored artifact path references. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ArtifactPathResolutionError { + LibUnsupported { specifier: String }, + RelativePath { path: String, base_dir: String }, +} + +impl core::fmt::Display for ArtifactPathResolutionError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::LibUnsupported { specifier } => { + write!( + f, + "library artifact specifiers are not supported: {specifier}" + ) + } + Self::RelativePath { path, base_dir } => { + write!( + f, + "path `{path}` cannot be resolved relative to `{base_dir}`" + ) + } + } + } +} + impl NodeArtifact { pub fn new(def: NodeDef) -> Self { Self(EnumSlot::new(def)) @@ -105,6 +158,23 @@ impl NodeArtifact { } 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::Button => Self::Button(ButtonDef::default()), + NodeKind::Clock => Self::Clock(ClockDef::default()), + NodeKind::Texture => Self::Texture(TextureDef::default()), + NodeKind::Shader => Self::Shader(ShaderDef::default()), + NodeKind::ComputeShader => Self::ComputeShader(ComputeShaderDef::default()), + NodeKind::Fluid => Self::Fluid(FluidDef::default()), + NodeKind::Playlist => Self::Playlist(PlaylistDef::default()), + NodeKind::ControlRadio => Self::ControlRadio(ControlRadioDef::default()), + NodeKind::Output => Self::Output(OutputDef::default()), + NodeKind::Fixture => Self::Fixture(FixtureDef::default()), + } + } + /// Core node kind for this definition. pub fn kind(&self) -> NodeKind { match self { @@ -156,6 +226,151 @@ impl NodeDef { } } + pub fn is_variant_name(name: &str) -> bool { + NODE_DEF_VARIANT_NAMES.contains(&name) + } + + /// Child invocation slots reachable directly from this definition under `base`. + pub fn invocation_sites(&self, base: &SlotPath) -> Vec { + match self { + Self::Project(project) => project + .nodes + .entries + .iter() + .filter_map(|(name, invocation)| { + Some(InvocationSite { + path: project_node_path(base, name)?, + invocation: invocation.value().clone(), + role: ProjectNodePlacement::ProjectChild { name: name.clone() }, + }) + }) + .collect(), + Self::Playlist(playlist) => playlist + .entries + .entries + .iter() + .filter_map(|(key, entry)| { + Some(InvocationSite { + path: playlist_entry_node_path(base, *key)?, + invocation: entry.node.value().clone(), + role: ProjectNodePlacement::PlaylistEntry { + entry: *key, + name: entry.name.data.as_ref().map(|name| name.value().clone()), + }, + }) + }) + .collect(), + _ => Vec::new(), + } + } + + /// File-backed asset paths referenced by this definition. + pub fn referenced_asset_paths( + &self, + containing_file: &LpPath, + ) -> Result, ArtifactPathResolutionError> { + let owner = + NodeDefLocation::artifact_root(ArtifactLocation::location_for_path(containing_file)); + let mut paths = Vec::new(); + for asset in self.referenced_assets(containing_file, &owner, &SlotPath::root())? { + if let AssetLocation::Artifact { location } = asset.location { + paths.push(location.file_path().clone()); + } + } + Ok(paths) + } + + /// Assets referenced by this definition. + pub fn referenced_assets( + &self, + containing_file: &LpPath, + owner: &NodeDefLocation, + base: &SlotPath, + ) -> Result, ArtifactPathResolutionError> { + match self { + Self::Shader(shader) => assets_for_shader( + shader.shader_source(), + containing_file, + owner, + base, + AssetContentType::ShaderSource, + ), + Self::ComputeShader(shader) => assets_for_shader( + shader.shader_source(), + containing_file, + owner, + base, + AssetContentType::ComputeShaderSource, + ), + Self::Fixture(fixture) => assets_for_fixture(fixture, containing_file, owner, base), + _ => Ok(Vec::new()), + } + } + + /// Inline UTF-8 asset text at `asset_path`, when this definition owns one. + pub fn inline_asset_text( + &self, + owner_path: &SlotPath, + asset_path: &SlotPath, + ) -> Option> { + match self { + Self::Shader(shader) if asset_path == &source_slot_path(owner_path) => { + inline_text_from_slot(shader.shader_source(), "glsl") + } + Self::ComputeShader(shader) if asset_path == &source_slot_path(owner_path) => { + inline_text_from_slot(shader.shader_source(), "glsl") + } + Self::Fixture(fixture) + if asset_path == &fixture_mapping_source_slot_path(owner_path) => + { + let MappingConfig::SvgPath { source, .. } = fixture.mapping.value() else { + return None; + }; + inline_text_from_slot(source, "svg") + } + _ => None, + } + } + + /// Inline binary asset bytes at `asset_path`, when this definition owns one. + pub fn inline_asset_bytes( + &self, + owner_path: &SlotPath, + asset_path: &SlotPath, + ) -> Option> { + match self { + Self::Shader(shader) if asset_path == &source_slot_path(owner_path) => { + inline_bytes_from_slot(shader.shader_source()) + } + Self::ComputeShader(shader) if asset_path == &source_slot_path(owner_path) => { + inline_bytes_from_slot(shader.shader_source()) + } + Self::Fixture(fixture) + if asset_path == &fixture_mapping_source_slot_path(owner_path) => + { + let MappingConfig::SvgPath { source, .. } = fixture.mapping.value() else { + return None; + }; + inline_bytes_from_slot(source) + } + _ => None, + } + } + + /// True when full authored bodies differ. + pub fn body_changed(before: &Self, after: &Self) -> bool { + before != after + } + + /// True when parent-facing shell views differ. + /// + /// Inline child definition bodies are reduced to kind-only stubs so parent + /// containers only report a shell change when child presence, references, + /// ordering, or child kind changes. + pub fn shell_changed(before: &Self, after: &Self) -> bool { + def_shell(before) != def_shell(after) + } + pub fn as_project(&self) -> Option<&ProjectDef> { match self { Self::Project(def) => Some(def), @@ -250,6 +465,160 @@ impl NodeDef { } } +fn project_node_path(base: &SlotPath, name: &str) -> Option { + let nodes = SlotName::parse("nodes").ok()?; + let key = SlotMapKey::String(String::from(name)); + Some(base.child(nodes).child_key(key)) +} + +fn playlist_entry_node_path(base: &SlotPath, key: u32) -> Option { + let entries = SlotName::parse("entries").ok()?; + let node = SlotName::parse("node").ok()?; + Some( + base.child(entries) + .child_key(SlotMapKey::U32(key)) + .child(node), + ) +} + +fn assets_for_shader( + source: &AssetSlot, + containing_file: &LpPath, + owner: &NodeDefLocation, + base: &SlotPath, + content_type: AssetContentType, +) -> Result, ArtifactPathResolutionError> { + assets_for_slot( + source, + containing_file, + owner, + source_slot_path(base), + content_type, + ) +} + +fn assets_for_fixture( + fixture: &FixtureDef, + containing_file: &LpPath, + owner: &NodeDefLocation, + base: &SlotPath, +) -> Result, ArtifactPathResolutionError> { + let MappingConfig::SvgPath { source, .. } = fixture.mapping.value() else { + return Ok(Vec::new()); + }; + assets_for_slot( + source, + containing_file, + owner, + fixture_mapping_source_slot_path(base), + AssetContentType::FixtureSvg, + ) +} + +fn assets_for_slot( + slot: &AssetSlot, + containing_file: &LpPath, + owner: &NodeDefLocation, + asset_path: SlotPath, + content_type: AssetContentType, +) -> Result, ArtifactPathResolutionError> { + match slot.value() { + AssetSlotValue::Artifact(specifier) => { + let location = + ArtifactLocation::file(resolve_artifact_specifier(containing_file, specifier)?); + Ok(vec![ReferencedAsset::new( + AssetLocation::artifact(location), + content_type, + )]) + } + AssetSlotValue::InlineText { .. } | AssetSlotValue::InlineBytes { .. } => { + Ok(vec![ReferencedAsset::new( + AssetLocation::inline(owner.clone(), asset_path), + content_type, + )]) + } + } +} + +fn source_slot_path(base: &SlotPath) -> SlotPath { + base.child(SlotName::parse("source").expect("source is a valid slot name")) +} + +fn fixture_mapping_source_slot_path(base: &SlotPath) -> SlotPath { + base.child(SlotName::parse("mapping").expect("mapping is a valid slot name")) + .child(SlotName::parse("source").expect("source is a valid slot name")) +} + +fn inline_text_from_slot<'a>( + slot: &'a AssetSlot, + default_extension: &'static str, +) -> Option> { + let (extension, text) = slot.inline_text_value()?; + Some(InlineAssetText { + extension: extension.unwrap_or(default_extension), + text, + }) +} + +fn inline_bytes_from_slot(slot: &AssetSlot) -> Option> { + let (extension, bytes) = slot.inline_bytes_value()?; + Some(InlineAssetBytes { extension, bytes }) +} + +pub fn resolve_artifact_specifier( + containing_file: &LpPath, + specifier: &ArtifactSpec, +) -> Result { + let base_dir = containing_file.parent().unwrap_or_else(|| LpPath::new("/")); + match specifier { + ArtifactSpec::Path(path) => { + if path.is_absolute() { + Ok(path.clone()) + } else { + base_dir + .to_path_buf() + .join_relative(path.as_str()) + .ok_or_else(|| ArtifactPathResolutionError::RelativePath { + path: String::from(path.as_str()), + base_dir: String::from(base_dir.as_str()), + }) + } + } + ArtifactSpec::Lib(lib) => Err(ArtifactPathResolutionError::LibUnsupported { + specifier: lib.to_string(), + }), + } +} + +fn def_shell(def: &NodeDef) -> NodeDef { + match def { + NodeDef::Project(project) => { + let mut shell = project.clone(); + for invocation in shell.nodes.entries.values_mut() { + *invocation = EnumSlot::new(invocation_shell(invocation.value())); + } + NodeDef::Project(shell) + } + NodeDef::Playlist(playlist) => { + let mut shell = playlist.clone(); + for entry in shell.entries.entries.values_mut() { + entry.node = EnumSlot::new(invocation_shell(entry.node.value())); + } + NodeDef::Playlist(shell) + } + other => other.clone(), + } +} + +fn invocation_shell(invocation: &NodeInvocation) -> NodeInvocation { + match invocation { + NodeInvocation::Unset | NodeInvocation::Ref(_) => invocation.clone(), + NodeInvocation::Def(body) => { + NodeInvocation::inline(NodeDef::default_for_kind(body.value().kind())) + } + } +} + impl SlotAccess for NodeDef { fn shape_id(&self) -> SlotShapeId { match self { @@ -434,7 +803,7 @@ mod tests { kind = "Project" [nodes.texture] -def = { path = "./texture.toml" } +ref = "./texture.toml" "#, ) .expect("project"); @@ -523,7 +892,10 @@ sample_diameter = 2.0 else { panic!("expected SvgPath mapping"); }; - assert_eq!(source.value().as_str(), "./fyeah-mapping.svg"); + assert_eq!( + source.artifact_value().unwrap().to_string(), + "fyeah-mapping.svg" + ); assert_eq!(sample_diameter.value().0, 2.0); } @@ -696,6 +1068,196 @@ target = "bus#control.out" assert!(matches!(binding.target_ref(), Some(BindingRef::Bus(_)))); } + #[test] + fn node_def_invocation_sites_cover_project_and_playlist() { + let project = NodeDef::from_toml_str( + r#" +kind = "Project" + +[nodes.clock] +ref = "./clock.toml" +"#, + ) + .expect("project"); + let sites = project.invocation_sites(&SlotPath::root()); + assert_eq!(sites.len(), 1); + assert_eq!(sites[0].path.to_string(), "nodes[clock]"); + assert!(matches!(sites[0].invocation, NodeInvocation::Ref(_))); + + let playlist = NodeDef::from_toml_str( + r#" +kind = "Playlist" + +[entries.2] +name = "active" + +[entries.2.node.def] +kind = "Shader" +source = { path = "active.glsl" } +"#, + ) + .expect("playlist"); + let sites = playlist.invocation_sites(&SlotPath::root()); + assert_eq!(sites.len(), 1); + assert_eq!(sites[0].path.to_string(), "entries[2].node"); + assert!(matches!(sites[0].invocation, NodeInvocation::Def(_))); + } + + #[test] + fn node_def_referenced_asset_paths_resolve_relative_shader_compute_and_fixture_paths() { + let shader = NodeDef::from_toml_str( + r#" +kind = "Shader" +source = { path = "shader.glsl" } +"#, + ) + .expect("shader"); + assert_eq!( + shader + .referenced_asset_paths(LpPath::new("/nodes/shader.toml")) + .unwrap(), + vec![LpPathBuf::from("/nodes/shader.glsl")] + ); + + let compute = NodeDef::from_toml_str( + r#" +kind = "ComputeShader" +source = { path = "../compute.glsl" } +"#, + ) + .expect("compute"); + assert_eq!( + compute + .referenced_asset_paths(LpPath::new("/nodes/compute.toml")) + .unwrap(), + vec![LpPathBuf::from("/compute.glsl")] + ); + + let fixture = NodeDef::from_toml_str( + r#" +kind = "Fixture" +render_size = { width = 64, height = 16 } + +[mapping] +kind = "SvgPath" +source = "fixture.svg" +sample_diameter = 2.0 +"#, + ) + .expect("fixture"); + assert_eq!( + fixture + .referenced_asset_paths(LpPath::new("/fixtures/fixture.toml")) + .unwrap(), + vec![LpPathBuf::from("/fixtures/fixture.svg")] + ); + } + + #[test] + fn node_def_referenced_assets_include_source_identity_and_kind() { + let owner = NodeDefLocation { + artifact: ArtifactLocation::file("/project.toml"), + path: SlotPath::parse("nodes[shader]").unwrap(), + }; + let shader = NodeDef::from_toml_str( + r#" +kind = "Shader" +source = { glsl = "void main() {}" } +"#, + ) + .expect("shader"); + + assert_eq!( + shader + .referenced_assets(LpPath::new("/project.toml"), &owner, &owner.path) + .unwrap(), + vec![ReferencedAsset::new( + AssetLocation::inline(owner, SlotPath::parse("nodes[shader].source").unwrap()), + AssetContentType::ShaderSource, + )] + ); + + let fixture = NodeDef::from_toml_str( + r#" +kind = "Fixture" +render_size = { width = 64, height = 16 } + +[mapping] +kind = "SvgPath" +source = "fixture.svg" +sample_diameter = 2.0 +"#, + ) + .expect("fixture"); + let owner = NodeDefLocation::artifact_root(ArtifactLocation::file("/fixtures/f.toml")); + + assert_eq!( + fixture + .referenced_assets(LpPath::new("/fixtures/f.toml"), &owner, &owner.path) + .unwrap(), + vec![ReferencedAsset::new( + AssetLocation::artifact(ArtifactLocation::file("/fixtures/fixture.svg")), + AssetContentType::FixtureSvg, + )] + ); + } + + #[test] + fn node_def_shell_change_ignores_inline_body_but_tracks_inline_kind() { + let before = NodeDef::from_toml_str( + r#" +kind = "Playlist" + +[entries.2.node.def] +kind = "Shader" +source = { path = "a.glsl" } +"#, + ) + .expect("before"); + let body_changed = NodeDef::from_toml_str( + r#" +kind = "Playlist" + +[entries.2.node.def] +kind = "Shader" +source = { path = "b.glsl" } +"#, + ) + .expect("body changed"); + let kind_changed = NodeDef::from_toml_str( + r#" +kind = "Playlist" + +[entries.2.node.def] +kind = "Clock" +"#, + ) + .expect("kind changed"); + + assert!(NodeDef::body_changed(&before, &body_changed)); + assert!(!NodeDef::shell_changed(&before, &body_changed)); + assert!(NodeDef::shell_changed(&before, &kind_changed)); + } + + #[test] + fn node_def_default_for_kind_covers_every_kind() { + for kind in [ + NodeKind::Project, + NodeKind::Button, + NodeKind::Clock, + NodeKind::Texture, + NodeKind::Shader, + NodeKind::ComputeShader, + NodeKind::Fluid, + NodeKind::Playlist, + NodeKind::ControlRadio, + NodeKind::Output, + NodeKind::Fixture, + ] { + assert_eq!(NodeDef::default_for_kind(kind).kind(), kind); + } + } + fn registry() -> SlotShapeRegistry { SlotShapeRegistry::default() } diff --git a/lp-core/lpc-model/src/nodes/output/output_def.rs b/lp-core/lpc-model/src/nodes/output/output_def.rs index dde801b50..7e8e6ea08 100644 --- a/lp-core/lpc-model/src/nodes/output/output_def.rs +++ b/lp-core/lpc-model/src/nodes/output/output_def.rs @@ -1,11 +1,11 @@ -use crate::{BindingDefs, HardwareEndpointSpec, OptionSlot, Ratio, RatioSlot, Slotted, ValueSlot}; +use crate::{BindingDefs, HwEndpointSpec, OptionSlot, Ratio, RatioSlot, Slotted, ValueSlot}; pub const DEFAULT_OUTPUT_ENDPOINT_SPEC: &str = "ws281x:rmt:D10"; /// Authored hardware output node definition. #[derive(Debug, Clone, PartialEq, Slotted)] pub struct OutputDef { - pub endpoint: ValueSlot, + pub endpoint: ValueSlot, /// Authored slot bindings for output inputs. pub bindings: BindingDefs, /// Optional display pipeline options. @@ -15,7 +15,7 @@ pub struct OutputDef { impl OutputDef { pub const KIND: &'static str = "output"; - pub fn new(endpoint: HardwareEndpointSpec) -> Self { + pub fn new(endpoint: HwEndpointSpec) -> Self { Self { endpoint: ValueSlot::new(endpoint), bindings: BindingDefs::default(), @@ -23,11 +23,11 @@ impl OutputDef { } } - pub fn default_endpoint() -> HardwareEndpointSpec { - HardwareEndpointSpec::from_static(DEFAULT_OUTPUT_ENDPOINT_SPEC) + pub fn default_endpoint() -> HwEndpointSpec { + HwEndpointSpec::from_static(DEFAULT_OUTPUT_ENDPOINT_SPEC) } - pub fn endpoint(&self) -> &HardwareEndpointSpec { + pub fn endpoint(&self) -> &HwEndpointSpec { self.endpoint.value() } @@ -94,7 +94,7 @@ mod tests { #[test] fn test_output_def_kind() { - let def = OutputDef::new(HardwareEndpointSpec::from_static("ws281x:rmt:D10")); + let def = OutputDef::new(HwEndpointSpec::from_static("ws281x:rmt:D10")); assert_eq!(def.kind(), NodeKind::Output); assert_eq!(def.endpoint().as_str(), "ws281x:rmt:D10"); } diff --git a/lp-core/lpc-model/src/nodes/playlist/playlist_entry.rs b/lp-core/lpc-model/src/nodes/playlist/playlist_entry.rs index 5105e89c6..0fd12a493 100644 --- a/lp-core/lpc-model/src/nodes/playlist/playlist_entry.rs +++ b/lp-core/lpc-model/src/nodes/playlist/playlist_entry.rs @@ -1,8 +1,8 @@ use alloc::string::String; use crate::{ - BindingDefs, ControlMessage, MapSlot, NodeInvocation, OptionSlot, PositiveF32Slot, Slotted, - ValueSlot, + BindingDefs, ControlMessage, MapSlot, NodeInvocation, NodeInvocationSlot, OptionSlot, + PositiveF32Slot, Slotted, ValueSlot, }; /// One authored playlist entry. @@ -28,8 +28,8 @@ pub struct PlaylistEntry { /// Outgoing crossfade duration override in seconds. pub fade_after: OptionSlot, - /// Visual child node invocation. - pub node: NodeInvocation, + /// Visual child node position owned by this playlist entry. + pub node: NodeInvocationSlot, } impl Default for PlaylistEntry { @@ -40,7 +40,7 @@ impl Default for PlaylistEntry { name: OptionSlot::none(), duration: OptionSlot::none(), fade_after: OptionSlot::none(), - node: NodeInvocation::default(), + node: NodeInvocationSlot::new(NodeInvocation::default()), } } } @@ -48,9 +48,7 @@ impl Default for PlaylistEntry { #[cfg(test)] mod tests { use super::*; - use crate::{ - BindingRef, NodeDef, NodeDefRef, SlotDirection, SlotMerge, SlotShape, StaticSlotShape, - }; + use crate::{BindingRef, NodeDef, SlotDirection, SlotMerge, SlotShape, StaticSlotShape}; #[test] fn playlist_entry_parses_path_child_and_trigger_binding() { @@ -62,7 +60,8 @@ kind = "Playlist" name = "active" duration = 4.0 fade_after = 0.8 -node = { def = { path = "./active.toml" } } +[entries.2.node] +ref = "./active.toml" [entries.2.bindings.trigger] source = "bus#trigger" @@ -76,7 +75,7 @@ source = "bus#trigger" let entry = def.entries.entries.get(&2).expect("entry"); assert_eq!(entry.name.data.as_ref().unwrap().value().as_str(), "active"); assert_eq!(entry.duration.data.as_ref().unwrap().value().0, 4.0); - assert!(matches!(entry.node.def, NodeDefRef::Path(_))); + assert!(matches!(entry.node.value(), NodeInvocation::Ref(_))); assert!(matches!( entry.bindings.entries()["trigger"].source_ref(), Some(BindingRef::Bus(_)) @@ -104,7 +103,10 @@ source = { path = "active.glsl" } panic!("playlist def"); }; let entry = def.entries.entries.get(&2).expect("entry"); - assert!(matches!(entry.node.inline_def(), Some(NodeDef::Shader(_)))); + assert!(matches!( + entry.node.value().inline_def(), + Some(NodeDef::Shader(_)) + )); } #[test] diff --git a/lp-core/lpc-model/src/nodes/project/project_def.rs b/lp-core/lpc-model/src/nodes/project/project_def.rs index 3b5c332e4..8ded84d27 100644 --- a/lp-core/lpc-model/src/nodes/project/project_def.rs +++ b/lp-core/lpc-model/src/nodes/project/project_def.rs @@ -1,18 +1,18 @@ use alloc::string::String; -use crate::node::node_invocation::NodeInvocation; -use crate::{MapSlot, OptionSlot, Slotted, ValueSlot}; +use crate::{MapSlot, NodeInvocationSlot, OptionSlot, Slotted, ValueSlot}; /// Authored root project node definition. /// -/// A project is a node artifact with `kind = "Project"`. Its `nodes` table is -/// the explicit source of child node invocations; the runtime no longer -/// discovers children from filesystem directories. +/// 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 { pub name: OptionSlot>, - pub nodes: MapSlot, + /// Named child node positions owned by this project. + pub nodes: MapSlot, } impl ProjectDef { @@ -43,10 +43,10 @@ mod tests { name = "basic" [nodes.texture] - def = { path = "./texture.toml" } + ref = "./texture.toml" [nodes.shader] - def = { path = "./shader.toml" } + ref = "./shader.toml" "#; let def = NodeDef::read_toml(®istry(), toml).unwrap(); let NodeDef::Project(def) = def else { @@ -68,7 +68,7 @@ mod tests { artifact = "./texture.toml" "#; let err = NodeDef::read_toml(®istry(), toml).unwrap_err(); - assert!(err.to_string().contains("def")); + assert!(err.to_string().contains("ref")); } #[test] @@ -76,15 +76,18 @@ mod tests { let toml = r#" kind = "Project" - [nodes.clock] - def = { kind = "Clock" } + [nodes.clock.def] + kind = "Clock" "#; let def = NodeDef::read_toml(®istry(), toml).unwrap(); let NodeDef::Project(def) = def else { panic!("expected project def"); }; let clock = def.nodes.entries.get("clock").expect("clock"); - assert!(matches!(clock.inline_def(), Some(NodeDef::Clock(_)))); + assert!(matches!( + clock.value().inline_def(), + Some(NodeDef::Clock(_)) + )); } fn registry() -> SlotShapeRegistry { diff --git a/lp-core/lpc-model/src/nodes/radio/control_radio_def.rs b/lp-core/lpc-model/src/nodes/radio/control_radio_def.rs index f446599fd..a77c3c7f2 100644 --- a/lp-core/lpc-model/src/nodes/radio/control_radio_def.rs +++ b/lp-core/lpc-model/src/nodes/radio/control_radio_def.rs @@ -1,4 +1,4 @@ -use crate::{BindingDefs, ControlMessage, HardwareEndpointSpec, MapSlot, Slotted, ValueSlot}; +use crate::{BindingDefs, ControlMessage, HwEndpointSpec, MapSlot, Slotted, ValueSlot}; pub const DEFAULT_CONTROL_RADIO_ENDPOINT_SPEC: &str = "radio:espnow:0"; pub const DEFAULT_CONTROL_RADIO_CHANNEL: u32 = 1; @@ -12,7 +12,7 @@ pub struct ControlRadioDef { pub bindings: BindingDefs, /// Hardware endpoint spec, for example `radio:espnow:0`. - pub endpoint: ValueSlot, + pub endpoint: ValueSlot, /// Logical LightPlayer radio channel. pub channel: ValueSlot, @@ -52,7 +52,7 @@ impl ControlRadioDef { crate::NodeKind::ControlRadio } - pub fn endpoint(&self) -> &HardwareEndpointSpec { + pub fn endpoint(&self) -> &HwEndpointSpec { self.endpoint.value() } } @@ -66,8 +66,8 @@ pub struct ControlRadioState { pub output: MapSlot, } -fn default_endpoint() -> ValueSlot { - ValueSlot::new(HardwareEndpointSpec::from_static( +fn default_endpoint() -> ValueSlot { + ValueSlot::new(HwEndpointSpec::from_static( DEFAULT_CONTROL_RADIO_ENDPOINT_SPEC, )) } diff --git a/lp-core/lpc-model/src/nodes/shader/compute_shader_def.rs b/lp-core/lpc-model/src/nodes/shader/compute_shader_def.rs index 8fa6302ac..5484310f5 100644 --- a/lp-core/lpc-model/src/nodes/shader/compute_shader_def.rs +++ b/lp-core/lpc-model/src/nodes/shader/compute_shader_def.rs @@ -6,14 +6,14 @@ use alloc::string::String; -use crate::nodes::shader::{GlslOpts, ShaderSlotDef, ShaderSource}; -use crate::{BindingDefs, EnumSlot, MapSlot, Slotted}; +use crate::nodes::shader::{GlslOpts, ShaderSlotDef}; +use crate::{AssetSlot, BindingDefs, MapSlot, Slotted}; /// Authored serial compute shader definition. #[derive(Debug, Clone, PartialEq, Slotted)] pub struct ComputeShaderDef { /// Authored shader source. - pub source: EnumSlot, + pub source: AssetSlot, /// Authored slot bindings for compute shader consumed and produced slots. pub bindings: BindingDefs, /// GLSL compilation options. @@ -29,7 +29,7 @@ pub struct ComputeShaderDef { impl Default for ComputeShaderDef { fn default() -> Self { Self { - source: EnumSlot::new(ShaderSource::path("main.glsl")), + source: AssetSlot::path("main.glsl"), bindings: BindingDefs::default(), glsl_opts: GlslOpts::default(), consumed_slots: MapSlot::default(), @@ -41,8 +41,8 @@ impl Default for ComputeShaderDef { impl ComputeShaderDef { pub const KIND: &'static str = "shader/compute"; - pub fn shader_source(&self) -> &ShaderSource { - self.source.value() + pub fn shader_source(&self) -> &AssetSlot { + &self.source } pub fn kind(&self) -> crate::NodeKind { diff --git a/lp-core/lpc-model/src/nodes/shader/mod.rs b/lp-core/lpc-model/src/nodes/shader/mod.rs index e177cf2ae..fa7871fd2 100644 --- a/lp-core/lpc-model/src/nodes/shader/mod.rs +++ b/lp-core/lpc-model/src/nodes/shader/mod.rs @@ -5,7 +5,6 @@ pub mod shader_header_gen; pub mod shader_param_def; pub mod shader_slot_def; pub mod shader_slot_mapping; -pub mod shader_source; pub mod shader_state; pub use crate::slot_views::{ @@ -19,5 +18,4 @@ pub use shader_header_gen::{ShaderHeaderGenError, generate_compute_shader_header pub use shader_param_def::{ScalarHint, ShaderParamDef}; pub use shader_slot_def::{ShaderMapKeyDef, ShaderSlotDef, ShaderSlotKind, ShaderValueShapeRef}; pub use shader_slot_mapping::{ShaderSlotMappingDef, ShaderSlotMappingKind}; -pub use shader_source::ShaderSource; pub use shader_state::ShaderState; diff --git a/lp-core/lpc-model/src/nodes/shader/shader_def.rs b/lp-core/lpc-model/src/nodes/shader/shader_def.rs index 92598e720..a647e55ac 100644 --- a/lp-core/lpc-model/src/nodes/shader/shader_def.rs +++ b/lp-core/lpc-model/src/nodes/shader/shader_def.rs @@ -1,13 +1,13 @@ use alloc::string::String; -use crate::nodes::shader::{GlslOpts, ShaderParamDef, ShaderSlotDef, ShaderSource}; -use crate::{BindingDefs, EnumSlot, MapSlot, RenderOrderSlot, Slotted}; +use crate::nodes::shader::{GlslOpts, ShaderParamDef, ShaderSlotDef}; +use crate::{AssetSlot, BindingDefs, MapSlot, RenderOrderSlot, Slotted}; /// Authored shader node definition. #[derive(Debug, Clone, PartialEq, Slotted)] pub struct ShaderDef { /// Authored shader source. - pub source: EnumSlot, + pub source: AssetSlot, /// Render order - lower numbers render first (default 0) pub render_order: RenderOrderSlot, /// Authored slot bindings for shader inputs and outputs. @@ -23,7 +23,7 @@ pub struct ShaderDef { impl Default for ShaderDef { fn default() -> Self { Self { - source: EnumSlot::new(ShaderSource::path("main.glsl")), + source: AssetSlot::path("main.glsl"), render_order: RenderOrderSlot::default(), bindings: BindingDefs::default(), glsl_opts: GlslOpts::default(), @@ -36,8 +36,8 @@ impl Default for ShaderDef { impl ShaderDef { pub const KIND: &'static str = "shader"; - pub fn shader_source(&self) -> &ShaderSource { - self.source.value() + pub fn shader_source(&self) -> &AssetSlot { + &self.source } pub fn render_order(&self) -> i32 { @@ -62,7 +62,7 @@ mod tests { #[test] fn test_shader_def_kind() { let def = ShaderDef { - source: EnumSlot::new(ShaderSource::path("main.glsl")), + source: AssetSlot::path("main.glsl"), render_order: RenderOrderSlot::new(RenderOrder(0)), bindings: BindingDefs::default(), glsl_opts: GlslOpts::default(), @@ -76,7 +76,7 @@ mod tests { fn test_shader_def_default() { let def = ShaderDef::default(); assert_eq!( - def.shader_source().path_value().unwrap().as_str(), + def.shader_source().artifact_value().unwrap().to_string(), "main.glsl" ); assert_eq!(def.render_order(), 0); @@ -114,6 +114,9 @@ mod tests { panic!("shader record shape"); }; + // Nested static records are referenced by shape id, not re-inlined, so + // the registry describes each record exactly once. See the `FieldSlot` + // derive in lpc-slot-macros. let glsl_opts = shader_fields .iter() .find(|field| field.name.as_str() == "glsl_opts") @@ -211,7 +214,7 @@ source = { path = "main.glsl" } panic!("expected shader"); }; assert_eq!( - def.shader_source().path_value().unwrap().as_str(), + def.shader_source().artifact_value().unwrap().to_string(), "main.glsl" ); } @@ -231,7 +234,13 @@ glsl = "vec4 render(vec2 pos) { return vec4(pos, 0.0, 1.0); }" let NodeDef::Shader(def) = def else { panic!("expected shader"); }; - assert!(def.shader_source().glsl_value().unwrap().contains("render")); + assert!( + def.shader_source() + .inline_text_value() + .unwrap() + .1 + .contains("render") + ); } #[test] diff --git a/lp-core/lpc-model/src/nodes/shader/shader_header_gen.rs b/lp-core/lpc-model/src/nodes/shader/shader_header_gen.rs index 5afac5715..8287e01f0 100644 --- a/lp-core/lpc-model/src/nodes/shader/shader_header_gen.rs +++ b/lp-core/lpc-model/src/nodes/shader/shader_header_gen.rs @@ -221,14 +221,14 @@ fn glsl_type_for_lp_type(ty: &LpType) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::{MapSlot, ShaderSlotDef, ShaderSlotMappingDef, ShaderSource}; - use alloc::collections::BTreeMap; + use crate::{AssetSlot, MapSlot, ShaderSlotDef, ShaderSlotMappingDef}; + use lp_collection::VecMap; #[test] fn shader_header_generates_fluid_emitter_output() { let registry = SlotShapeRegistry::default(); - let mut consumed = BTreeMap::new(); + let mut consumed = VecMap::new(); consumed.insert( String::from("time"), ShaderSlotDef { @@ -244,7 +244,7 @@ mod tests { }, ); - let mut produced = BTreeMap::new(); + let mut produced = VecMap::new(); produced.insert( String::from("emitters"), ShaderSlotDef::map_u32_native( @@ -254,7 +254,7 @@ mod tests { ); let def = ComputeShaderDef { - source: crate::EnumSlot::new(ShaderSource::path("emitters.glsl")), + source: AssetSlot::path("emitters.glsl"), bindings: crate::BindingDefs::default(), glsl_opts: crate::GlslOpts::default(), consumed_slots: MapSlot::new(consumed), @@ -273,7 +273,7 @@ mod tests { #[test] fn shader_header_rejects_unknown_native_shape() { - let mut produced = BTreeMap::new(); + let mut produced = VecMap::new(); produced.insert( String::from("emitters"), ShaderSlotDef::map_u32_native( @@ -282,7 +282,7 @@ mod tests { ), ); let def = ComputeShaderDef { - source: crate::EnumSlot::new(ShaderSource::path("emitters.glsl")), + source: AssetSlot::path("emitters.glsl"), bindings: crate::BindingDefs::default(), glsl_opts: crate::GlslOpts::default(), consumed_slots: MapSlot::default(), diff --git a/lp-core/lpc-model/src/nodes/shader/shader_source.rs b/lp-core/lpc-model/src/nodes/shader/shader_source.rs deleted file mode 100644 index f1b014ad6..000000000 --- a/lp-core/lpc-model/src/nodes/shader/shader_source.rs +++ /dev/null @@ -1,85 +0,0 @@ -use alloc::string::String; - -use crate::{Slotted, SourcePath, SourcePathSlot, ValueSlot}; - -/// Authored shader source. -/// -/// File-backed sources use `path` and resolve relative to the containing node -/// definition artifact. Inline GLSL uses `glsl`. -#[derive(Debug, Clone, PartialEq, Slotted)] -#[slot(enum_encoding = "external", rename_all = "snake_case")] -pub enum ShaderSource { - #[default] - Path(SourcePathSlot), - Glsl(ValueSlot), -} - -impl ShaderSource { - pub fn path(path: impl Into) -> Self { - Self::Path(SourcePathSlot::new(path.into())) - } - - pub fn glsl(source: impl Into) -> Self { - Self::Glsl(ValueSlot::new(source.into())) - } - - pub fn path_value(&self) -> Option<&SourcePath> { - match self { - Self::Path(path) => Some(path.value()), - Self::Glsl(_) => None, - } - } - - pub fn glsl_value(&self) -> Option<&str> { - match self { - Self::Path(_) => None, - Self::Glsl(source) => Some(source.value().as_str()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{EnumSlot, FieldSlotMut, SlotEnumShape, SlotShapeRegistry}; - - #[test] - fn shader_source_parses_path() { - let source = read_source( - r#" -path = "./visual.glsl" -"#, - ); - - assert_eq!(source.path_value().unwrap().as_str(), "./visual.glsl"); - } - - #[test] - fn shader_source_parses_glsl() { - let source = read_source( - r#" -glsl = "vec4 render(vec2 pos) { return vec4(pos, 0.0, 1.0); }" -"#, - ); - - assert!(source.glsl_value().unwrap().contains("render")); - } - - fn read_source(text: &str) -> ShaderSource { - let registry = SlotShapeRegistry::default(); - let value = toml::from_str::(text).unwrap(); - let mut reader = crate::slot_codec::SlotReader::new( - crate::slot_codec::TomlSyntaxSource::new(&value).unwrap(), - ®istry, - ); - let mut source = EnumSlot::new(ShaderSource::default()); - crate::slot_codec::apply_reader_to_slot( - source.slot_field_data_mut(), - &ShaderSource::slot_enum_shape(), - ®istry, - reader.value(), - ) - .unwrap(); - source.into_inner() - } -} diff --git a/lp-core/lpc-model/src/project/change_summary.rs b/lp-core/lpc-model/src/project/change_summary.rs new file mode 100644 index 000000000..f86214b9f --- /dev/null +++ b/lp-core/lpc-model/src/project/change_summary.rs @@ -0,0 +1,32 @@ +use alloc::vec::Vec; + +/// Added, changed, and removed identities for a comparable project collection. +/// +/// `Id` is the stable identity used for additions and removals. `Changed` is +/// the payload used for changed entries; it defaults to `Id` for collections +/// that only need to report which identities changed. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ChangeSummary { + /// Newly present identities. + pub added: Vec, + /// Previously present identities whose effective contents or state changed. + pub changed: Vec, + /// Identities that are no longer present. + pub removed: Vec, +} + +impl Default for ChangeSummary { + fn default() -> Self { + Self { + added: Vec::new(), + changed: Vec::new(), + removed: Vec::new(), + } + } +} + +impl ChangeSummary { + pub fn is_empty(&self) -> bool { + self.added.is_empty() && self.changed.is_empty() && self.removed.is_empty() + } +} diff --git a/lp-core/lpc-model/src/project/config.rs b/lp-core/lpc-model/src/project/config.rs index 47d63d4c3..976115788 100644 --- a/lp-core/lpc-model/src/project/config.rs +++ b/lp-core/lpc-model/src/project/config.rs @@ -1,12 +1,16 @@ use alloc::string::String; use serde::{Deserialize, Serialize}; -/// Project configuration - minimal, no nodes field +/// Lightweight metadata that can travel with a project. /// -/// Nodes are discovered from filesystem, not stored in config. +/// This is separate from the authored project node definition in +/// [`crate::ProjectDef`]. The project node defines runtime graph structure; +/// `ProjectConfig` holds user-facing metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProjectConfig { + /// Stable project identifier. pub uid: String, + /// Human-readable project name. pub name: String, } diff --git a/lp-core/lpc-model/src/project/inventory/asset_content_type.rs b/lp-core/lpc-model/src/project/inventory/asset_content_type.rs new file mode 100644 index 000000000..5bbf50e72 --- /dev/null +++ b/lp-core/lpc-model/src/project/inventory/asset_content_type.rs @@ -0,0 +1,23 @@ +/// Coarse specialization for a referenced project asset. +/// +/// Asset content type lets registry and engine code choose materialization and +/// validation paths without making the asset identity itself shader-, fixture-, +/// or image-specific. +#[derive( + Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum AssetContentType { + /// GLSL source consumed by a visual shader node. + ShaderSource, + /// GLSL source consumed by a compute shader node. + ComputeShaderSource, + /// SVG path mapping consumed by a fixture node. + FixtureSvg, + /// Image data; decoding details are future work. + Image, + /// Generic UTF-8 text. + Text, + /// Generic binary data. + Binary, +} diff --git a/lp-core/lpc-model/src/project/inventory/asset_entry.rs b/lp-core/lpc-model/src/project/inventory/asset_entry.rs new file mode 100644 index 000000000..24940f457 --- /dev/null +++ b/lp-core/lpc-model/src/project/inventory/asset_entry.rs @@ -0,0 +1,34 @@ +use crate::{AssetContentType, AssetLocation, AssetState, Revision}; + +/// One referenced asset in the effective project inventory. +/// +/// This is the per-asset record stored in [`crate::ProjectInventory::assets`]. +/// It keeps asset identity, expected content type, effective availability, and the +/// revision of the body or owning inline definition. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct AssetEntry { + /// Stable identity for the referenced asset. + pub location: AssetLocation, + /// Coarse specialization used by registry/engine consumers. + pub content_type: AssetContentType, + /// Effective availability after artifact state and overlay edits. + pub state: AssetState, + /// Revision of the effective asset body or owning inline definition. + pub revision: Revision, +} + +impl AssetEntry { + pub fn new( + location: AssetLocation, + content_type: AssetContentType, + state: AssetState, + revision: Revision, + ) -> Self { + Self { + location, + content_type, + state, + revision, + } + } +} diff --git a/lp-core/lpc-model/src/project/inventory/asset_location.rs b/lp-core/lpc-model/src/project/inventory/asset_location.rs new file mode 100644 index 000000000..df32a1533 --- /dev/null +++ b/lp-core/lpc-model/src/project/inventory/asset_location.rs @@ -0,0 +1,37 @@ +use crate::{ArtifactLocation, NodeDefLocation, SlotPath}; + +/// Identity for a project asset referenced by the effective project graph. +/// +/// `AssetLocation` identifies an asset independently of how the registry later +/// materializes it. Artifact-backed assets point at artifact locations, and +/// inline assets point back into the owning node definition. Remote or otherwise +/// external asset bodies should be modeled as artifact locations, not as a +/// separate asset-location kind. +#[derive( + Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum AssetLocation { + /// Asset body lives in a project artifact. + Artifact { + /// Artifact location containing the asset body. + location: ArtifactLocation, + }, + /// Asset body is embedded inside an effective node definition. + Inline { + /// Node definition that owns the inline asset body. + owner: NodeDefLocation, + /// Slot path of the inline asset field within `owner`. + path: SlotPath, + }, +} + +impl AssetLocation { + pub fn artifact(location: ArtifactLocation) -> Self { + Self::Artifact { location } + } + + pub fn inline(owner: NodeDefLocation, path: SlotPath) -> Self { + Self::Inline { owner, path } + } +} diff --git a/lp-core/lpc-model/src/project/inventory/asset_state.rs b/lp-core/lpc-model/src/project/inventory/asset_state.rs new file mode 100644 index 000000000..bc5b6318f --- /dev/null +++ b/lp-core/lpc-model/src/project/inventory/asset_state.rs @@ -0,0 +1,39 @@ +//! Effective state for a referenced project asset. +//! +//! The registry derives this state by combining referenced assets, artifact +//! availability, and pending overlay edits. It is inventory state, not the asset +//! body itself. + +use alloc::string::String; + +/// Whether an available asset body comes from committed artifacts or overlay. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AssetBodyOrigin { + /// Body comes from committed artifact storage. + Committed, + /// Body is embedded inside the owning node definition. + Inline, + /// Body is supplied by a pending overlay replacement. + OverlayReplace, +} + +/// Effective state for a referenced project asset. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AssetState { + /// The asset body can be materialized from the indicated source. + Available { origin: AssetBodyOrigin }, + /// The referenced artifact does not exist. + NotFound, + /// The referenced artifact has been deleted or is pending deletion. + Deleted, + /// The registry attempted to read or interpret the asset and failed. + ReadError { message: String }, +} + +impl AssetState { + pub fn is_available(&self) -> bool { + matches!(self, Self::Available { .. }) + } +} diff --git a/lp-core/lpc-model/src/project/inventory/mod.rs b/lp-core/lpc-model/src/project/inventory/mod.rs new file mode 100644 index 000000000..ecd0dafc6 --- /dev/null +++ b/lp-core/lpc-model/src/project/inventory/mod.rs @@ -0,0 +1,35 @@ +//! Effective project inventory. +//! +//! Inventory is the read-side model produced after project artifacts and the +//! current [`crate::ProjectOverlay`] have been combined. It contains: +//! +//! - unique referenced node definitions keyed by [`crate::NodeDefLocation`], +//! - unique referenced assets keyed by [`crate::AssetLocation`], +//! - an expanded [`crate::ProjectTree`] keyed by [`crate::NodeUseLocation`]. +//! +//! The registry owns deriving inventory; these types only describe the portable +//! model shape. + +pub mod asset_content_type; +pub mod asset_entry; +pub mod asset_location; +pub mod asset_state; +pub mod node_def_entry; +pub mod node_def_state; +pub mod project_inventory; +pub mod project_node; +pub mod project_node_placement; +pub mod project_tree; +pub mod referenced_asset; + +pub use crate::project::overlay_mutation::asset_change_summary::{ + AssetChange, AssetChangeKind, AssetChangeSummary, +}; +pub use crate::project::overlay_mutation::node_use_change_summary::{ + NodeUseChange, NodeUseChangeKind, NodeUseChangeSummary, +}; +pub use asset_content_type::AssetContentType; +pub use asset_entry::AssetEntry; +pub use asset_location::AssetLocation; +pub use asset_state::{AssetBodyOrigin, AssetState}; +pub use referenced_asset::ReferencedAsset; diff --git a/lp-core/lpc-model/src/project/inventory/node_def_entry.rs b/lp-core/lpc-model/src/project/inventory/node_def_entry.rs new file mode 100644 index 000000000..88ec63113 --- /dev/null +++ b/lp-core/lpc-model/src/project/inventory/node_def_entry.rs @@ -0,0 +1,26 @@ +use crate::{NodeDefLocation, NodeDefState, Revision}; + +/// One referenced node definition in the effective project inventory. +/// +/// The registry stores one entry for each referenced [`crate::NodeDefLocation`]. +/// Entries can be loaded successfully or preserve an error state so clients can +/// display missing/invalid project structure without losing identity. +#[derive(Clone, Debug, PartialEq)] +pub struct NodeDefEntry { + /// Definition identity. + pub location: NodeDefLocation, + /// Loaded definition or structured failure state. + pub state: NodeDefState, + /// Effective revision of this definition state. + pub revision: Revision, +} + +impl NodeDefEntry { + pub fn new(location: NodeDefLocation, state: NodeDefState, revision: Revision) -> Self { + Self { + location, + state, + revision, + } + } +} diff --git a/lp-core/lpc-model/src/project/inventory/node_def_state.rs b/lp-core/lpc-model/src/project/inventory/node_def_state.rs new file mode 100644 index 000000000..c55b6b79e --- /dev/null +++ b/lp-core/lpc-model/src/project/inventory/node_def_state.rs @@ -0,0 +1,66 @@ +//! Parsed payload or error state for a node definition. +//! +//! Definition state belongs to [`crate::NodeDefEntry`]. It lets project views +//! and registry consumers keep referenced definitions in inventory even when +//! files are missing, deleted, unreadable, syntactically invalid, or +//! semantically invalid. + +use alloc::string::String; + +use crate::{NodeDef, NodeDefParseError, NodeKind}; + +/// Semantic validation failure payload. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct NodeDefValidationError { + /// Human-readable validation message. + pub message: String, +} + +impl NodeDefValidationError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +/// Loaded definition or structured failure for a known definition identity. +#[derive(Clone, Debug, PartialEq)] +pub enum NodeDefState { + /// Definition parsed and validated into an authored node body. + Loaded(NodeDef), + /// Referenced artifact was not found. + NotFound, + /// Referenced artifact was deleted or is pending deletion. + Deleted, + /// Filesystem or artifact read failed. + ReadError { message: String }, + /// Artifact was readable but could not be parsed as a node definition. + ParseError(NodeDefParseError), + /// Artifact parsed but failed semantic validation. + ValidationError(NodeDefValidationError), +} + +impl NodeDefState { + pub fn is_loaded(&self) -> bool { + matches!(self, Self::Loaded(_)) + } + + pub fn is_error(&self) -> bool { + !self.is_loaded() + } + + pub fn kind(&self) -> Option { + match self { + Self::Loaded(def) => Some(def.kind()), + _ => None, + } + } + + pub fn loaded_def(&self) -> Option<&NodeDef> { + match self { + Self::Loaded(def) => Some(def), + _ => None, + } + } +} diff --git a/lp-core/lpc-model/src/project/inventory/project_inventory.rs b/lp-core/lpc-model/src/project/inventory/project_inventory.rs new file mode 100644 index 000000000..5a75f518b --- /dev/null +++ b/lp-core/lpc-model/src/project/inventory/project_inventory.rs @@ -0,0 +1,28 @@ +use lp_collection::VecMap; + +use crate::{AssetEntry, AssetLocation, NodeDefEntry, NodeDefLocation, ProjectTree}; + +/// Effective post-overlay project state derived from artifacts plus overlay. +/// +/// `ProjectInventory` is the complete effective project read model. It contains +/// both unique referenced things (`defs`, `assets`) and the expanded project +/// node tree (`tree`). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ProjectInventory { + /// Unique referenced node definitions keyed by definition location. + pub defs: VecMap, + /// Unique referenced assets keyed by asset source. + pub assets: VecMap, + /// Expanded effective node uses reachable from the project root. + pub tree: ProjectTree, +} + +impl ProjectInventory { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.defs.is_empty() && self.assets.is_empty() && self.tree.is_empty() + } +} diff --git a/lp-core/lpc-model/src/project/inventory/project_node.rs b/lp-core/lpc-model/src/project/inventory/project_node.rs new file mode 100644 index 000000000..0159736c1 --- /dev/null +++ b/lp-core/lpc-model/src/project/inventory/project_node.rs @@ -0,0 +1,66 @@ +use crate::{NodeDefLocation, NodeInvocation, NodeUseLocation, ProjectNodePlacement, SlotPath}; + +/// One effective project node instance. +/// +/// A project node is one use of a node definition in the expanded +/// [`crate::ProjectTree`]. It points at the [`crate::NodeDefLocation`] that +/// supplies its definition, but it is not itself definition identity and is not +/// a runtime node. +#[derive(Clone, Debug, PartialEq)] +pub struct ProjectNode { + /// Stable project-tree location for this node use. + pub key: NodeUseLocation, + /// Parent node use, or `None` for the project root. + pub parent: Option, + /// Effective definition used by this node use. + pub def_location: NodeDefLocation, + /// Authored origin of this occurrence. + pub origin: ProjectNodeOrigin, +} + +impl ProjectNode { + pub fn root(key: NodeUseLocation, def_location: NodeDefLocation) -> Self { + Self { + key, + parent: None, + def_location, + origin: ProjectNodeOrigin::Root, + } + } + + pub fn invocation( + key: NodeUseLocation, + parent: NodeUseLocation, + def_location: NodeDefLocation, + slot: SlotPath, + role: ProjectNodePlacement, + invocation: NodeInvocation, + ) -> Self { + Self { + key, + parent: Some(parent), + def_location, + origin: ProjectNodeOrigin::Invocation { + slot, + role, + invocation, + }, + } + } +} + +/// How a project node use appears in authored project topology. +#[derive(Clone, Debug, PartialEq)] +pub enum ProjectNodeOrigin { + /// Root project node use. + Root, + /// Child produced by a parent-owned [`crate::NodeInvocation`] slot. + Invocation { + /// Slot path of the invocation within the parent definition. + slot: SlotPath, + /// Placement metadata for the parent container. + role: ProjectNodePlacement, + /// Authored invocation value at `slot`. + invocation: NodeInvocation, + }, +} 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 new file mode 100644 index 000000000..b405f4f06 --- /dev/null +++ b/lp-core/lpc-model/src/project/inventory/project_node_placement.rs @@ -0,0 +1,15 @@ +use alloc::string::String; + +/// Parent-owned placement for a child project node use. +/// +/// Placement describes the authored container position that produced a child +/// use. It is model-owned so registry and engine code do not have to parse +/// project or playlist internals to understand how a child was placed. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProjectNodePlacement { + /// Child from `ProjectDef.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/inventory/project_tree.rs b/lp-core/lpc-model/src/project/inventory/project_tree.rs new file mode 100644 index 000000000..7fe25e928 --- /dev/null +++ b/lp-core/lpc-model/src/project/inventory/project_tree.rs @@ -0,0 +1,61 @@ +use alloc::vec::Vec; +use lp_collection::VecMap; + +use crate::{AssetLocation, NodeDefLocation, NodeUseLocation, ProjectNode}; + +/// Effective post-overlay project node uses and reverse indexes. +/// +/// `ProjectTree` contains expanded node uses reachable from the project root. +/// It is tree-shaped because each use has one parent, even when multiple uses +/// point at the same [`crate::NodeDefLocation`]. +/// +/// Reverse indexes connect tree uses back to shared definitions and assets so +/// runtime projection can answer "which node uses this?" without re-walking +/// authored definitions. +#[derive(Clone, Debug, PartialEq)] +pub struct ProjectTree { + /// Location of the project root use. + pub root: NodeUseLocation, + /// All effective node uses keyed by use location. + pub nodes: VecMap, + /// Reverse index from definition location to node uses using it. + pub def_instances: VecMap>, + /// Reverse index from asset source to node uses whose definitions reference it. + pub asset_consumers: VecMap>, +} + +impl ProjectTree { + pub fn new(root: NodeUseLocation) -> Self { + Self { + root, + nodes: VecMap::new(), + def_instances: VecMap::new(), + asset_consumers: VecMap::new(), + } + } + + pub fn insert_node(&mut self, entry: ProjectNode) { + self.def_instances + .entry(entry.def_location.clone()) + .or_default() + .push(entry.key.clone()); + self.nodes.insert(entry.key.clone(), entry); + } + + pub fn add_asset_consumer(&mut self, source: AssetLocation, consumer: NodeUseLocation) { + self.asset_consumers + .entry(source) + .or_default() + .push(consumer); + } + + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } +} + +impl Default for ProjectTree { + fn default() -> Self { + Self::new(NodeUseLocation::root()) + } +} diff --git a/lp-core/lpc-model/src/project/inventory/referenced_asset.rs b/lp-core/lpc-model/src/project/inventory/referenced_asset.rs new file mode 100644 index 000000000..dabd5226a --- /dev/null +++ b/lp-core/lpc-model/src/project/inventory/referenced_asset.rs @@ -0,0 +1,23 @@ +use crate::{AssetContentType, AssetLocation}; + +/// One asset referenced by a node definition. +/// +/// Node definition topology APIs return `ReferencedAsset` values while walking +/// authored definitions. The registry turns those references into +/// [`crate::AssetEntry`] records in the effective project inventory. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ReferencedAsset { + /// Identity of the referenced asset. + pub location: AssetLocation, + /// Coarse content type expected by the referring node definition. + pub content_type: AssetContentType, +} + +impl ReferencedAsset { + pub fn new(location: AssetLocation, content_type: AssetContentType) -> Self { + Self { + location, + content_type, + } + } +} diff --git a/lp-core/lpc-model/src/project/mod.rs b/lp-core/lpc-model/src/project/mod.rs index 4fbcd84f9..5d36fb8e9 100644 --- a/lp-core/lpc-model/src/project/mod.rs +++ b/lp-core/lpc-model/src/project/mod.rs @@ -1,5 +1,37 @@ +//! Project-level model types. +//! +//! This module owns the shared vocabulary for loaded LightPlayer projects: +//! referenced node definitions, referenced assets, the effective project node +//! tree, pending overlays, and the mutation/change-summary shapes used to edit +//! overlays. It deliberately stays below `lpc-registry`: these are portable +//! model types, while registry code performs filesystem reads, parsing, overlay +//! application, and materialization. +//! +//! Related modules: +//! +//! - [`crate::nodes`] defines authored [`crate::NodeDef`] payloads and +//! [`crate::NodeInvocation`] slot values. +//! - [`crate::artifact`] defines artifact identities used by project assets and +//! node definitions. +//! - [`crate::slot`] defines [`crate::SlotPath`], the path language used by +//! overlays and [`crate::NodeUseLocation`]. + +pub mod change_summary; pub mod config; +pub mod inventory; +pub mod overlay; +pub mod overlay_commit; +pub mod overlay_mutation; +pub use crate::node::node_use_location::{LocationSeg, NodeUseLocation}; pub use crate::sync::current_revision::{advance_revision, current_revision, set_current_revision}; pub use crate::sync::revision::Revision; +pub use change_summary::ChangeSummary; pub use config::ProjectConfig; +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 overlay_commit::commit_result::CommitResult; +pub use overlay_mutation::mutation_result::{MutationBatchResults, MutationResult}; +pub use overlay_mutation::project_change_summary::ProjectChangeSummary; diff --git a/lp-core/lpc-model/src/project/overlay/artifact_overlay.rs b/lp-core/lpc-model/src/project/overlay/artifact_overlay.rs new file mode 100644 index 000000000..7a3519d3f --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay/artifact_overlay.rs @@ -0,0 +1,55 @@ +use super::{AssetBodyOverlay, SlotEdit, SlotOverlay}; + +/// Current pending intent for one artifact. +/// +/// An artifact overlay is exclusive: an artifact is either edited structurally +/// through slot edits or edited as a whole asset body. Switching between those +/// modes replaces the previous pending intent for that artifact. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ArtifactOverlay { + /// Structured edits to an authored node-definition artifact. + Slot { overlay: SlotOverlay }, + /// Whole-body edit to an asset or definition artifact. + Asset { overlay: AssetBodyOverlay }, +} + +impl ArtifactOverlay { + pub fn slot(overlay: SlotOverlay) -> Self { + Self::Slot { overlay } + } + + pub fn body(edit: AssetBodyOverlay) -> Self { + Self::Asset { overlay: edit } + } + + pub fn is_empty(&self) -> bool { + matches!(self, Self::Slot { overlay } if overlay.is_empty()) + } + + pub fn as_slot(&self) -> Option<&SlotOverlay> { + match self { + Self::Slot { overlay } => Some(overlay), + Self::Asset { .. } => None, + } + } + + pub fn as_body(&self) -> Option<&AssetBodyOverlay> { + match self { + Self::Slot { .. } => None, + Self::Asset { overlay: edit } => Some(edit), + } + } + + pub fn put_slot_edit(&mut self, edit: SlotEdit) -> bool { + match self { + Self::Slot { overlay } => overlay.put_edit(edit), + Self::Asset { .. } => { + let mut overlay = SlotOverlay::new(); + overlay.put_edit(edit); + *self = Self::Slot { overlay }; + true + } + } + } +} diff --git a/lp-core/lpc-model/src/project/overlay/asset_body_overlay.rs b/lp-core/lpc-model/src/project/overlay/asset_body_overlay.rs new file mode 100644 index 000000000..4e2c92862 --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay/asset_body_overlay.rs @@ -0,0 +1,14 @@ +use alloc::vec::Vec; + +/// Replace or delete an artifact body. +/// +/// Asset overlays are used for any whole-body artifact edit, including shader +/// source assets, fixture SVGs, and full node-definition artifact replacement. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AssetBodyOverlay { + /// Delete the artifact body from the effective project. + Delete, + /// Replace the effective artifact body with these bytes. + ReplaceBody(Vec), +} diff --git a/lp-core/lpc-model/src/project/overlay/mod.rs b/lp-core/lpc-model/src/project/overlay/mod.rs new file mode 100644 index 000000000..2f90cc0fa --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay/mod.rs @@ -0,0 +1,32 @@ +//! Pending project edit overlay. +//! +//! A [`ProjectOverlay`] holds uncommitted user-authored changes to project +//! artifacts. The registry applies an overlay over committed artifact bodies to +//! derive an effective [`crate::ProjectInventory`]. +//! +//! Overlay data is edit intent, not materialized artifact bytes for every +//! artifact. Slot overlays describe structured edits to node-definition TOML; +//! asset overlays replace or delete whole artifact bodies. +//! +//! Related modules: +//! +//! - [`crate::project::overlay_mutation`] defines command-shaped mutations that +//! update overlays. +//! - [`crate::project::inventory`] defines the effective read model produced +//! after overlay application. + +pub mod artifact_overlay; +pub mod asset_body_overlay; +pub mod project_overlay; +pub mod slot_edit; +pub mod slot_overlay; + +pub use crate::project::overlay_mutation::{ + MutationCmd, MutationCmdBatch, MutationCmdBatchResult, MutationCmdId, MutationCmdResult, + MutationCmdStatus, MutationEffect, MutationOp, MutationRejection, MutationRejectionReason, +}; +pub use artifact_overlay::ArtifactOverlay; +pub use asset_body_overlay::AssetBodyOverlay; +pub use project_overlay::ProjectOverlay; +pub use slot_edit::{SlotEdit, SlotEditOp}; +pub use slot_overlay::SlotOverlay; diff --git a/lp-core/lpc-model/src/project/overlay/project_overlay.rs b/lp-core/lpc-model/src/project/overlay/project_overlay.rs new file mode 100644 index 000000000..78154c409 --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay/project_overlay.rs @@ -0,0 +1,189 @@ +use lp_collection::VecMap; + +use crate::{ArtifactLocation, SlotPath}; + +use crate::project::overlay_mutation::MutationOp; + +use super::{ArtifactOverlay, AssetBodyOverlay, SlotEdit, SlotOverlay}; + +/// Canonical pending edits for a project. +/// +/// A project overlay is keyed by artifact location. Each artifact entry holds +/// the current pending intent for that artifact, either structured slot edits or +/// a whole-body asset edit. Empty artifact overlays are removed so the overlay +/// stays canonical. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ProjectOverlay { + /// Pending edits keyed by target artifact. + pub artifacts: VecMap, +} + +impl ProjectOverlay { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.artifacts.is_empty() + } + + pub fn contains_artifact(&self, artifact: &ArtifactLocation) -> bool { + self.artifacts.contains_key(artifact) + } + + pub fn artifact(&self, artifact: &ArtifactLocation) -> Option<&ArtifactOverlay> { + self.artifacts.get(artifact) + } + + pub fn iter(&self) -> impl Iterator + '_ { + self.artifacts + .iter() + .filter(|(_, overlay)| !overlay.is_empty()) + } + + pub fn put_slot_edit(&mut self, artifact: ArtifactLocation, edit: SlotEdit) -> bool { + let changed = match self.artifacts.get_mut(&artifact) { + Some(overlay) => overlay.put_slot_edit(edit), + None => { + let mut slot = SlotOverlay::new(); + slot.put_edit(edit); + self.artifacts + .insert(artifact.clone(), ArtifactOverlay::slot(slot)); + true + } + }; + self.remove_empty_artifact(&artifact); + changed + } + + pub fn remove_slot_edit(&mut self, artifact: &ArtifactLocation, path: &SlotPath) -> bool { + let changed = match self.artifacts.get_mut(artifact) { + Some(ArtifactOverlay::Slot { overlay }) => overlay.remove_edit(path), + Some(ArtifactOverlay::Asset { .. }) | None => false, + }; + self.remove_empty_artifact(artifact); + changed + } + + pub fn set_artifact_body( + &mut self, + artifact: ArtifactLocation, + edit: AssetBodyOverlay, + ) -> bool { + let next = ArtifactOverlay::body(edit); + if self.artifacts.get(&artifact) == Some(&next) { + return false; + } + self.artifacts.insert(artifact, next); + true + } + + pub fn clear_artifact(&mut self, artifact: &ArtifactLocation) -> bool { + self.artifacts.remove(artifact).is_some() + } + + pub fn clear(&mut self) -> bool { + let changed = !self.artifacts.is_empty(); + self.artifacts.clear(); + changed + } + + pub fn apply_mutation(&mut self, mutation: MutationOp) -> bool { + match mutation { + MutationOp::PutSlotEdit { artifact, edit } => self.put_slot_edit(artifact, edit), + MutationOp::RemoveSlotEdit { artifact, path } => { + self.remove_slot_edit(&artifact, &path) + } + MutationOp::SetArtifactBody { artifact, edit } => { + self.set_artifact_body(artifact, edit) + } + MutationOp::ClearArtifact { artifact } => self.clear_artifact(&artifact), + MutationOp::Clear => self.clear(), + } + } + + pub fn merge_from(&mut self, other: &ProjectOverlay) { + for (artifact, overlay) in other.iter() { + match overlay { + ArtifactOverlay::Slot { overlay } => { + for edit in overlay.to_apply_plan() { + self.put_slot_edit(artifact.clone(), edit); + } + } + ArtifactOverlay::Asset { overlay: edit } => { + self.set_artifact_body(artifact.clone(), edit.clone()); + } + } + } + } + + fn remove_empty_artifact(&mut self, artifact: &ArtifactLocation) { + if self + .artifacts + .get(artifact) + .is_some_and(ArtifactOverlay::is_empty) + { + self.artifacts.remove(artifact); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{LpValue, SlotEditOp}; + + #[test] + fn body_and_slot_overlays_are_exclusive() { + let mut overlay = ProjectOverlay::new(); + let path = ArtifactLocation::file("/shader.glsl"); + overlay.set_artifact_body( + path.clone(), + AssetBodyOverlay::ReplaceBody(b"body".to_vec()), + ); + assert!(matches!( + overlay.artifact(&path), + Some(ArtifactOverlay::Asset { .. }) + )); + + overlay.put_slot_edit( + path.clone(), + SlotEdit::ensure_present(SlotPath::parse("Shader").unwrap()), + ); + assert!(matches!( + overlay.artifact(&path), + Some(ArtifactOverlay::Slot { .. }) + )); + } + + #[test] + fn clear_empty_slot_overlay_removes_artifact() { + let mut overlay = ProjectOverlay::new(); + let artifact_path = ArtifactLocation::file("/project.toml"); + let slot_path = SlotPath::parse("nodes[clock]").unwrap(); + overlay.put_slot_edit( + artifact_path.clone(), + SlotEdit::assign_value(slot_path.clone(), LpValue::String("x".into())), + ); + + assert!(overlay.remove_slot_edit(&artifact_path, &slot_path)); + assert!(overlay.is_empty()); + } + + #[test] + fn apply_mutation_updates_canonical_overlay() { + let mut overlay = ProjectOverlay::new(); + let artifact_path = ArtifactLocation::file("/project.toml"); + let slot_path = SlotPath::parse("nodes[clock]").unwrap(); + + assert!(overlay.apply_mutation(MutationOp::PutSlotEdit { + artifact: artifact_path.clone(), + edit: SlotEdit::ensure_present(slot_path.clone()), + })); + + let Some(ArtifactOverlay::Slot { overlay: slot }) = overlay.artifact(&artifact_path) else { + panic!("expected slot overlay"); + }; + assert_eq!(slot.edits.get(&slot_path), Some(&SlotEditOp::EnsurePresent)); + } +} diff --git a/lp-core/lpc-model/src/project/overlay/slot_edit.rs b/lp-core/lpc-model/src/project/overlay/slot_edit.rs new file mode 100644 index 000000000..16960a1e7 --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay/slot_edit.rs @@ -0,0 +1,84 @@ +//! Structured slot edits within an authored node-definition artifact. +//! +//! Slot edits are the smallest overlay operation for TOML-shaped node +//! definitions. They use [`crate::SlotPath`] so callers can edit maps, options, +//! enum variants, and value leaves without replacing the whole artifact body. + +use crate::{LpValue, SlotPath}; + +/// One slot-tree edit within an authored artifact. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct SlotEdit { + /// Target slot path inside the artifact's node definition. + pub path: SlotPath, + /// Operation to apply at `path`. + pub op: SlotEditOp, +} + +/// Path-free slot operation. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SlotEditOp { + /// Default-construct the slot, map entry, option body, or enum variant at `path`. + EnsurePresent, + /// Assign a value leaf at `path`. + AssignValue(LpValue), + /// Remove optional/map presence at `path`. + Remove, +} + +impl SlotEdit { + pub fn ensure_present(path: SlotPath) -> Self { + Self { + path, + op: SlotEditOp::EnsurePresent, + } + } + + pub fn assign_value(path: SlotPath, value: LpValue) -> Self { + Self { + path, + op: SlotEditOp::AssignValue(value), + } + } + + pub fn remove(path: SlotPath) -> Self { + Self { + path, + op: SlotEditOp::Remove, + } + } + + pub fn op_name(&self) -> &'static str { + self.op.op_name() + } + + pub fn path(&self) -> &SlotPath { + &self.path + } +} + +impl SlotEditOp { + pub fn op_name(&self) -> &'static str { + match self { + Self::EnsurePresent => "ensure_present", + Self::AssignValue(_) => "assign_value", + Self::Remove => "remove", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constructors_split_path_from_op() { + let path = SlotPath::parse("controls.rate").unwrap(); + let edit = SlotEdit::assign_value(path.clone(), LpValue::F32(2.0)); + + assert_eq!(edit.path(), &path); + assert_eq!(edit.op_name(), "assign_value"); + assert_eq!(edit.op, SlotEditOp::AssignValue(LpValue::F32(2.0))); + } +} diff --git a/lp-core/lpc-model/src/project/overlay/slot_overlay.rs b/lp-core/lpc-model/src/project/overlay/slot_overlay.rs new file mode 100644 index 000000000..067e43095 --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay/slot_overlay.rs @@ -0,0 +1,229 @@ +use alloc::vec::Vec; +use lp_collection::VecMap; + +use crate::{NodeDef, SlotPath, SlotPathSegment}; + +use super::{SlotEdit, SlotEditOp}; + +/// Canonical pending slot edits for one authored artifact. +/// +/// A slot overlay keeps only the latest meaningful intent for each path. When a +/// structural edit makes descendant edits stale, those descendants are removed +/// so the overlay can be applied deterministically. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct SlotOverlay { + /// Pending slot operations keyed by target slot path. + pub edits: VecMap, +} + +impl SlotOverlay { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.edits.is_empty() + } + + pub fn contains_path(&self, path: &SlotPath) -> bool { + self.edits.contains_key(path) + } + + pub fn put_edit(&mut self, edit: SlotEdit) -> bool { + let before = self.clone(); + let target = edit.path.clone(); + let clear_scopes = structural_clear_scopes(&edit); + let clears_ancestor_remove = matches!( + edit.op, + SlotEditOp::EnsurePresent | SlotEditOp::AssignValue(_) + ); + + self.edits.retain(|existing_path, existing_op| { + if existing_path == &target { + return false; + } + if clear_scopes + .iter() + .any(|scope| is_strict_ancestor(scope, existing_path)) + { + return false; + } + if clears_ancestor_remove + && matches!(existing_op, SlotEditOp::Remove) + && is_strict_ancestor(existing_path, &target) + { + return false; + } + true + }); + + if matches!(edit.op, SlotEditOp::Remove) + && self.edits.iter().any(|(existing_path, existing_op)| { + matches!(existing_op, SlotEditOp::Remove) + && is_strict_ancestor(existing_path, &target) + }) + { + return *self != before; + } + + self.edits.insert(target, edit.op); + *self != before + } + + pub fn remove_edit(&mut self, path: &SlotPath) -> bool { + self.edits.remove(path).is_some() + } + + pub fn to_apply_plan(&self) -> Vec { + let mut edits: Vec<_> = self + .edits + .iter() + .map(|(path, op)| SlotEdit { + path: path.clone(), + op: op.clone(), + }) + .collect(); + edits.sort_by(|left, right| apply_order_key(left).cmp(&apply_order_key(right))); + edits + } +} + +fn apply_order_key(edit: &SlotEdit) -> (u8, usize, &SlotPath) { + let op_order = match edit.op { + SlotEditOp::EnsurePresent => 0, + SlotEditOp::AssignValue(_) => 1, + SlotEditOp::Remove => 2, + }; + (op_order, edit.path.segments().len(), &edit.path) +} + +fn structural_clear_scopes(edit: &SlotEdit) -> Vec { + match &edit.op { + SlotEditOp::Remove => alloc::vec![edit.path.clone()], + SlotEditOp::EnsurePresent => { + let mut scopes = alloc::vec![edit.path.clone()]; + if ensure_present_clears_parent_scope(&edit.path) { + scopes.push(parent_path(&edit.path)); + } + scopes + } + SlotEditOp::AssignValue(_) => Vec::new(), + } +} + +fn ensure_present_clears_parent_scope(path: &SlotPath) -> bool { + match path.segments() { + [SlotPathSegment::Field(name)] => NodeDef::is_variant_name(name.as_str()), + [.., SlotPathSegment::Field(_)] => true, + _ => false, + } +} + +fn parent_path(path: &SlotPath) -> SlotPath { + let Some((_, parent)) = path.segments().split_last() else { + return SlotPath::root(); + }; + SlotPath::from_segments(parent.to_vec()) +} + +fn is_strict_ancestor(ancestor: &SlotPath, descendant: &SlotPath) -> bool { + let ancestor = ancestor.segments(); + let descendant = descendant.segments(); + ancestor.len() < descendant.len() && descendant.starts_with(ancestor) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::LpValue; + + #[test] + fn same_path_upserts_to_latest_intent() { + let mut overlay = SlotOverlay::new(); + let path = SlotPath::parse("controls.rate").unwrap(); + + assert!(overlay.put_edit(SlotEdit::assign_value(path.clone(), LpValue::F32(1.0)))); + assert!(overlay.put_edit(SlotEdit::assign_value(path.clone(), LpValue::F32(2.0)))); + + assert_eq!(overlay.edits.len(), 1); + assert_eq!( + overlay.edits.get(&path), + Some(&SlotEditOp::AssignValue(LpValue::F32(2.0))) + ); + } + + #[test] + fn parent_remove_clears_pending_descendants() { + let mut overlay = SlotOverlay::new(); + overlay.put_edit(SlotEdit::assign_value( + SlotPath::parse("entries[0].node.controls.rate").unwrap(), + LpValue::F32(2.0), + )); + overlay.put_edit(SlotEdit::remove( + SlotPath::parse("entries[0].node").unwrap(), + )); + + assert_eq!(overlay.edits.len(), 1); + assert_eq!( + overlay + .edits + .get(&SlotPath::parse("entries[0].node").unwrap()), + Some(&SlotEditOp::Remove) + ); + } + + #[test] + fn descendant_assign_clears_ancestor_remove() { + let mut overlay = SlotOverlay::new(); + overlay.put_edit(SlotEdit::remove( + SlotPath::parse("entries[0].node").unwrap(), + )); + overlay.put_edit(SlotEdit::assign_value( + SlotPath::parse("entries[0].node.controls.rate").unwrap(), + LpValue::F32(2.0), + )); + + assert_eq!(overlay.edits.len(), 1); + assert!( + overlay + .edits + .contains_key(&SlotPath::parse("entries[0].node.controls.rate").unwrap()) + ); + } + + #[test] + fn structural_ensure_clears_stale_descendants() { + let mut overlay = SlotOverlay::new(); + overlay.put_edit(SlotEdit::assign_value( + SlotPath::parse("entries[0].node.controls.rate").unwrap(), + LpValue::F32(2.0), + )); + overlay.put_edit(SlotEdit::ensure_present( + SlotPath::parse("entries[0].node.Shader").unwrap(), + )); + + assert_eq!(overlay.edits.len(), 1); + assert!( + overlay + .edits + .contains_key(&SlotPath::parse("entries[0].node.Shader").unwrap()) + ); + } + + #[test] + fn apply_plan_places_structural_ensures_before_assignments() { + let mut overlay = SlotOverlay::new(); + overlay.put_edit(SlotEdit::ensure_present( + SlotPath::parse("entries[0].node.Shader").unwrap(), + )); + overlay.put_edit(SlotEdit::assign_value( + SlotPath::parse("entries[0].node.source.path").unwrap(), + LpValue::String(alloc::string::String::from("./shader.glsl")), + )); + + let plan = overlay.to_apply_plan(); + + assert!(matches!(plan[0].op, SlotEditOp::EnsurePresent)); + assert!(matches!(plan[1].op, SlotEditOp::AssignValue(_))); + } +} diff --git a/lp-core/lpc-model/src/project/overlay_commit/artifact_change_summary.rs b/lp-core/lpc-model/src/project/overlay_commit/artifact_change_summary.rs new file mode 100644 index 000000000..2e4e075e0 --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay_commit/artifact_change_summary.rs @@ -0,0 +1,6 @@ +//! Persistence-level artifact changes. + +use crate::{ArtifactLocation, ChangeSummary}; + +/// Artifact writes/deletes performed against durable storage. +pub type ArtifactChangeSummary = ChangeSummary; diff --git a/lp-core/lpc-model/src/project/overlay_commit/commit_result.rs b/lp-core/lpc-model/src/project/overlay_commit/commit_result.rs new file mode 100644 index 000000000..e94ab46c0 --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay_commit/commit_result.rs @@ -0,0 +1,18 @@ +use crate::ArtifactChangeSummary; + +/// Result from committing pending [`crate::ProjectOverlay`] edits to artifacts. +/// +/// Commit is the point where overlay intent is persisted back to artifact +/// storage. This reports both filesystem-level artifact changes and effective +/// project inventory changes observed after the commit. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CommitResult { + /// Artifact bodies that were added, changed, or removed on durable storage. + pub artifact_changes: ArtifactChangeSummary, +} + +impl CommitResult { + pub fn is_empty(&self) -> bool { + self.artifact_changes.is_empty() + } +} diff --git a/lp-core/lpc-model/src/project/overlay_commit/mod.rs b/lp-core/lpc-model/src/project/overlay_commit/mod.rs new file mode 100644 index 000000000..b141faae5 --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay_commit/mod.rs @@ -0,0 +1,2 @@ +pub mod artifact_change_summary; +pub mod commit_result; diff --git a/lp-core/lpc-model/src/project/overlay_mutation/asset_change_summary.rs b/lp-core/lpc-model/src/project/overlay_mutation/asset_change_summary.rs new file mode 100644 index 000000000..8913ee9cf --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay_mutation/asset_change_summary.rs @@ -0,0 +1,36 @@ +//! Effective asset inventory changes. +//! +//! These changes are derived by comparing two effective asset inventories. They +//! tell consumers which asset identities entered, left, or changed state. + +use crate::{AssetLocation, ChangeSummary}; + +/// Effective asset changes visible to runtime/project consumers. +pub type AssetChangeSummary = ChangeSummary; + +/// One changed asset and its coarse runtime-facing classification. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct AssetChange { + /// Changed asset identity. + pub location: AssetLocation, + /// Coarse classification of the change. + pub kind: AssetChangeKind, +} + +impl AssetChange { + pub fn new(location: AssetLocation, kind: AssetChangeKind) -> Self { + Self { location, kind } + } +} + +/// Runtime-facing asset change classification. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AssetChangeKind { + /// Asset body changed while remaining available. + Body, + /// Asset moved from available state into an error state. + EnteredError, + /// Asset moved from an error state into available state. + LeftError, +} diff --git a/lp-core/lpc-model/src/project/overlay_mutation/mod.rs b/lp-core/lpc-model/src/project/overlay_mutation/mod.rs new file mode 100644 index 000000000..30c1b7b19 --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay_mutation/mod.rs @@ -0,0 +1,26 @@ +//! Command and result types for mutating a project overlay. +//! +//! Overlay mutations are message-shaped operations. They update +//! [`crate::ProjectOverlay`] and report resulting effective project changes, +//! but they do not themselves read files or apply edits to artifacts. The +//! registry owns that execution. +//! +//! Related modules: +//! +//! - [`crate::project::overlay`] stores canonical pending edit intent. +//! - [`crate::project::inventory`] stores the effective project state used to +//! compute change summaries. + +pub mod asset_change_summary; +mod mutation_cmd; +mod mutation_op; +pub mod mutation_result; +pub mod node_def_change_summary; +pub mod node_use_change_summary; +pub mod project_change_summary; + +pub use mutation_cmd::{ + MutationCmd, MutationCmdBatch, MutationCmdBatchResult, MutationCmdId, MutationCmdResult, + MutationCmdStatus, MutationEffect, MutationRejection, MutationRejectionReason, +}; +pub use mutation_op::MutationOp; diff --git a/lp-core/lpc-model/src/project/overlay_mutation/mutation_cmd.rs b/lp-core/lpc-model/src/project/overlay_mutation/mutation_cmd.rs new file mode 100644 index 000000000..64d08e123 --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay_mutation/mutation_cmd.rs @@ -0,0 +1,136 @@ +use alloc::string::String; +use alloc::vec::Vec; + +use super::MutationOp; + +/// Ordered overlay mutation command batch. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct MutationCmdBatch { + /// Commands to apply in order. + pub commands: Vec, +} + +impl MutationCmdBatch { + pub fn new(commands: Vec) -> Self { + Self { commands } + } +} + +/// Ordered result for an [`MutationCmdBatch`]. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct MutationCmdBatchResult { + /// Per-command results in command order. + pub results: Vec, +} + +impl MutationCmdBatchResult { + pub fn new(results: Vec) -> Self { + Self { results } + } +} + +/// Client-visible id for one overlay mutation command. +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Eq, + Hash, + Ord, + PartialOrd, + serde::Serialize, + serde::Deserialize, +)] +#[serde(transparent)] +pub struct MutationCmdId(pub u64); + +impl MutationCmdId { + pub const fn new(id: u64) -> Self { + Self(id) + } + + pub const fn id(self) -> u64 { + self.0 + } +} + +/// One overlay mutation command with client correlation id. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct MutationCmd { + /// Client-supplied command id for result correlation. + pub id: MutationCmdId, + /// Mutation operation to apply. + pub mutation: MutationOp, +} + +/// Result for one overlay mutation command. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct MutationCmdResult { + /// Command id copied from the input command. + pub id: MutationCmdId, + /// Accepted or rejected status for the command. + pub status: MutationCmdStatus, +} + +impl MutationCmdResult { + pub fn accepted(id: MutationCmdId, effect: MutationEffect) -> Self { + Self { + id, + status: MutationCmdStatus::Accepted { effect }, + } + } + + pub fn rejected(id: MutationCmdId, rejection: MutationRejection) -> Self { + Self { + id, + status: MutationCmdStatus::Rejected { rejection }, + } + } +} + +/// Accepted or rejected overlay mutation status. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MutationCmdStatus { + /// Mutation was accepted and applied to the overlay. + Accepted { effect: MutationEffect }, + /// Mutation was rejected without changing the overlay. + Rejected { rejection: MutationRejection }, +} + +/// Observable effect of an accepted overlay mutation. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MutationEffect { + /// Whether the accepted mutation changed canonical overlay state. + OverlayChanged { changed: bool }, +} + +/// Stable reason for a rejected overlay mutation command. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MutationRejectionReason { + /// Mutation referenced an invalid slot or artifact path. + InvalidPath, + /// Mutation was well-formed but edit application failed. + EditFailed, + /// Mutation is not supported by the current registry implementation. + Unsupported, +} + +/// Stable rejection for an overlay mutation command. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MutationRejection { + /// Stable rejection category. + pub reason: MutationRejectionReason, + /// Human-readable rejection detail. + pub message: String, +} + +impl MutationRejection { + pub fn new(reason: MutationRejectionReason, message: String) -> Self { + Self { reason, message } + } +} diff --git a/lp-core/lpc-model/src/project/overlay_mutation/mutation_op.rs b/lp-core/lpc-model/src/project/overlay_mutation/mutation_op.rs new file mode 100644 index 000000000..517b6dbf7 --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay_mutation/mutation_op.rs @@ -0,0 +1,39 @@ +use crate::{ArtifactLocation, AssetBodyOverlay, SlotEdit, SlotPath}; + +/// One ordered mutation to the canonical project overlay. +/// +/// A mutation operation is the command payload without client correlation id or +/// result status. It is intentionally close to [`crate::ProjectOverlay`]'s CRUD +/// operations. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MutationOp { + /// Add or replace one pending slot edit for an artifact. + PutSlotEdit { + /// Artifact whose slot overlay should be changed. + artifact: ArtifactLocation, + /// Slot edit to insert into the artifact overlay. + edit: SlotEdit, + }, + /// Remove one pending slot edit from an artifact. + RemoveSlotEdit { + /// Artifact whose slot overlay should be changed. + artifact: ArtifactLocation, + /// Slot path whose pending edit should be removed. + path: SlotPath, + }, + /// Set whole-body pending intent for one artifact. + SetArtifactBody { + /// Artifact whose body should be replaced or deleted. + artifact: ArtifactLocation, + /// Whole-body asset edit. + edit: AssetBodyOverlay, + }, + /// Remove all pending intent for one artifact. + ClearArtifact { + /// Artifact to clear from the overlay. + artifact: ArtifactLocation, + }, + /// Remove all pending intent from the overlay. + Clear, +} diff --git a/lp-core/lpc-model/src/project/overlay_mutation/mutation_result.rs b/lp-core/lpc-model/src/project/overlay_mutation/mutation_result.rs new file mode 100644 index 000000000..83caa9d1e --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay_mutation/mutation_result.rs @@ -0,0 +1,56 @@ +use crate::{ProjectChangeSummary, Revision}; + +use super::MutationCmdBatchResult; + +/// Result from applying an ordered batch of overlay mutations. +/// +/// This carries the per-command acceptance/rejection results plus the aggregate +/// effective project change summary produced by the accepted commands. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct MutationBatchResults { + /// Per-command acceptance/rejection results. + pub commands: MutationCmdBatchResult, + /// Revision at which the overlay was last changed. + pub overlay_revision: Revision, + /// Effective project changes produced by the batch. + pub changes: ProjectChangeSummary, +} + +impl MutationBatchResults { + pub fn new( + commands: MutationCmdBatchResult, + overlay_revision: Revision, + changes: ProjectChangeSummary, + ) -> Self { + Self { + commands, + overlay_revision, + changes, + } + } +} + +/// Result from applying one or more overlay mutations. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MutationResult { + /// Revision at which the overlay was last changed. + pub overlay_revision: Revision, + /// Whether the operation changed canonical overlay state. + pub overlay_changed: bool, + /// Effective project changes produced by the operation. + pub changes: ProjectChangeSummary, +} + +impl MutationResult { + pub fn new( + overlay_revision: Revision, + overlay_changed: bool, + changes: ProjectChangeSummary, + ) -> Self { + Self { + overlay_revision, + overlay_changed, + changes, + } + } +} diff --git a/lp-core/lpc-model/src/project/overlay_mutation/node_def_change_summary.rs b/lp-core/lpc-model/src/project/overlay_mutation/node_def_change_summary.rs new file mode 100644 index 000000000..28c3dcfdb --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay_mutation/node_def_change_summary.rs @@ -0,0 +1,39 @@ +//! Effective node definition inventory changes. +//! +//! These changes are derived by comparing two effective definition inventories. +//! They tell consumers which definition identities entered, left, or changed +//! state. + +use crate::{ChangeSummary, NodeDefLocation, NodeKind}; + +/// Effective node definition changes visible to runtime/project consumers. +pub type NodeDefChangeSummary = ChangeSummary; + +/// One changed node definition and its coarse runtime-facing classification. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct NodeDefChange { + /// Changed definition identity. + pub location: NodeDefLocation, + /// Coarse classification of the change. + pub kind: NodeDefChangeKind, +} + +impl NodeDefChange { + pub fn new(location: NodeDefLocation, kind: NodeDefChangeKind) -> Self { + Self { location, kind } + } +} + +/// Runtime-facing node definition change classification. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NodeDefChangeKind { + /// Definition content changed without changing node kind. + Body, + /// Definition changed from one node kind to another. + KindChanged { from: NodeKind, to: NodeKind }, + /// Definition moved from loaded state to an error state. + EnteredError, + /// Definition moved from an error state to loaded state. + LeftError, +} diff --git a/lp-core/lpc-model/src/project/overlay_mutation/node_use_change_summary.rs b/lp-core/lpc-model/src/project/overlay_mutation/node_use_change_summary.rs new file mode 100644 index 000000000..23e7635af --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay_mutation/node_use_change_summary.rs @@ -0,0 +1,42 @@ +//! Effective node-use inventory changes. +//! +//! These changes are derived by comparing two effective project trees. They +//! describe topology-facing changes to node uses, not authored value changes +//! inside the referenced definitions. + +use crate::{ChangeSummary, NodeDefLocation, NodeUseLocation}; + +/// Effective node-use changes visible to runtime/project consumers. +pub type NodeUseChangeSummary = ChangeSummary; + +/// One changed node use and its coarse runtime-facing classification. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct NodeUseChange { + /// Changed node-use identity. + pub location: NodeUseLocation, + /// Coarse classification of the change. + pub kind: NodeUseChangeKind, +} + +impl NodeUseChange { + pub fn new(location: NodeUseLocation, kind: NodeUseChangeKind) -> Self { + Self { location, kind } + } +} + +/// Runtime-facing node-use change classification. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NodeUseChangeKind { + /// The use now points at a different node definition. + DefinitionChanged { + /// Previously resolved definition. + from: NodeDefLocation, + /// Currently resolved definition. + to: NodeDefLocation, + }, + /// The use moved under a different parent. + ParentChanged, + /// The authored slot or placement that produced this use changed. + OriginChanged, +} diff --git a/lp-core/lpc-model/src/project/overlay_mutation/project_change_summary.rs b/lp-core/lpc-model/src/project/overlay_mutation/project_change_summary.rs new file mode 100644 index 000000000..d8d2d964c --- /dev/null +++ b/lp-core/lpc-model/src/project/overlay_mutation/project_change_summary.rs @@ -0,0 +1,22 @@ +use crate::{AssetChangeSummary, NodeDefChangeSummary, NodeUseChangeSummary}; + +/// Runtime-facing summary of changes from one effective project inventory to another. +/// +/// A project change summary is a compact description of how one effective +/// [`crate::ProjectInventory`] differs from another. It is intended for runtime +/// projection and client refresh decisions, not for reconstructing an overlay. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ProjectChangeSummary { + /// Node definition additions, removals, and changes. + pub defs: NodeDefChangeSummary, + /// Asset additions, removals, and changes. + pub assets: AssetChangeSummary, + /// Node-use additions, removals, and structural changes. + pub uses: NodeUseChangeSummary, +} + +impl ProjectChangeSummary { + pub fn is_empty(&self) -> bool { + self.defs.is_empty() && self.assets.is_empty() && self.uses.is_empty() + } +} diff --git a/lp-core/lpc-model/src/slot/mod.rs b/lp-core/lpc-model/src/slot/mod.rs index 39f53ad5c..31180ab3a 100644 --- a/lp-core/lpc-model/src/slot/mod.rs +++ b/lp-core/lpc-model/src/slot/mod.rs @@ -62,8 +62,9 @@ pub use slot_mut_access::{ SlotValueMutAccess as SlotValueMut, }; pub use slot_mutation::{ - insert_slot_map_entry_default, set_slot_option_some_default, set_slot_value, - set_slot_variant_default, slot_data_revision, + ensure_slot_present, insert_slot_map_entry_default, remove_slot_map_entry, + set_slot_option_none, set_slot_option_some_default, set_slot_value, set_slot_variant_default, + slot_data_revision, }; pub use slot_name::{SlotName, SlotNameError}; pub use slot_owner::SlotOwner; @@ -103,10 +104,10 @@ pub use static_slot_shape::{ }; pub use crate::slots::{ - Affine2d, Affine2dSlot, ArtifactPath, ArtifactPathSlot, ColorOrderSlot, ColorOrderValue, - ControlProductSlot, Dim2u, Dim2uSlot, PositiveF32, PositiveF32Slot, Ratio, RatioSlot, - RelativeNodeRefSlot, RenderOrder, RenderOrderSlot, ResourceRefSlot, SourcePath, SourcePathSlot, - VisualProductSlot, Xy, XySlot, + Affine2d, Affine2dSlot, ArtifactPath, ArtifactPathSlot, AssetSlot, AssetSlotValue, + ColorOrderSlot, ColorOrderValue, ControlProductSlot, Dim2u, Dim2uSlot, PositiveF32, + PositiveF32Slot, Ratio, RatioSlot, RelativeNodeRefSlot, RenderOrder, RenderOrderSlot, + ResourceRefSlot, VisualProductSlot, Xy, XySlot, }; pub use value_ref::ValueRef; pub use value_slot::{MapSlot, MapSlotKeyLike, OptionSlot, SlotMapValueAccess, ValueSlot}; diff --git a/lp-core/lpc-model/src/slot/slot_data.rs b/lp-core/lpc-model/src/slot/slot_data.rs index 88986154a..506c734f0 100644 --- a/lp-core/lpc-model/src/slot/slot_data.rs +++ b/lp-core/lpc-model/src/slot/slot_data.rs @@ -1,8 +1,8 @@ use crate::{LpValue, Revision, SlotName, WithRevision, current_revision}; use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; /// Owned dynamic data for a slot-accessible value tree. /// @@ -51,15 +51,15 @@ impl SlotRecord { #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] pub struct SlotMapDyn { pub keys_revision: Revision, - pub entries: BTreeMap, + pub entries: VecMap, } impl SlotMapDyn { - pub fn new(entries: BTreeMap) -> Self { + pub fn new(entries: VecMap) -> Self { Self::with_revision(current_revision(), entries) } - pub fn with_revision(keys_revision: Revision, entries: BTreeMap) -> Self { + pub fn with_revision(keys_revision: Revision, entries: VecMap) -> Self { Self { keys_revision, entries, @@ -134,13 +134,13 @@ impl SlotOptionDyn { #[cfg(test)] mod tests { use super::*; - use alloc::collections::BTreeMap; use alloc::string::ToString; use alloc::vec; + use lp_collection::VecMap; #[test] fn slot_map_key_orders_stable_key_domains() { - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); entries.insert( SlotMapKey::U32(2), SlotData::Record(SlotRecord::new(vec![])), diff --git a/lp-core/lpc-model/src/slot/slot_factory.rs b/lp-core/lpc-model/src/slot/slot_factory.rs index c9f257c74..b7ca7fc65 100644 --- a/lp-core/lpc-model/src/slot/slot_factory.rs +++ b/lp-core/lpc-model/src/slot/slot_factory.rs @@ -7,10 +7,10 @@ use crate::{ WithRevision, current_revision, }; use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; +use lp_collection::VecMap; pub type SlotFactoryFn = fn(&SlotShapeRegistry, SlotShapeId) -> Result, SlotFactoryError>; @@ -207,7 +207,7 @@ fn create_dynamic_slot_data_for_root( } SlotShape::Map { .. } => Ok(SlotData::Map(SlotMapDyn::with_revision( current_revision(), - BTreeMap::new(), + VecMap::new(), ))), SlotShape::Enum { variants, .. } => { let variant = variants diff --git a/lp-core/lpc-model/src/slot/slot_mut_access.rs b/lp-core/lpc-model/src/slot/slot_mut_access.rs index 47e164831..22f89d00f 100644 --- a/lp-core/lpc-model/src/slot/slot_mut_access.rs +++ b/lp-core/lpc-model/src/slot/slot_mut_access.rs @@ -57,6 +57,11 @@ pub trait MapSlotMutAccess { registry: &SlotShapeRegistry, value_shape: &SlotShape, ) -> Result<(), SlotMutationError>; + fn remove_entry( + &mut self, + revision: Revision, + key: &SlotMapKey, + ) -> Result<(), SlotMutationError>; } /// Mutable access to an enum slot with one active variant. @@ -96,6 +101,7 @@ pub trait SlotOptionMutAccess { registry: &SlotShapeRegistry, some_shape: &SlotShape, ) -> Result<(), SlotMutationError>; + fn clear_presence(&mut self, revision: Revision) -> Result<(), SlotMutationError>; } /// Mutable access to a custom-coded slot subtree. @@ -276,6 +282,20 @@ impl MapSlotMutAccess for SlotMapDyn { self.keys_revision = revision; Ok(()) } + + fn remove_entry( + &mut self, + revision: Revision, + key: &SlotMapKey, + ) -> Result<(), SlotMutationError> { + if self.entries.remove(key).is_none() { + return Err(SlotMutationError::unknown_path(format!( + "map has no key {key:?}" + ))); + } + self.keys_revision = revision; + Ok(()) + } } impl SlotEnumMutAccess for SlotEnum { @@ -347,6 +367,12 @@ impl SlotOptionMutAccess for SlotOptionDyn { self.data = Some(Box::new(data)); Ok(()) } + + fn clear_presence(&mut self, revision: Revision) -> Result<(), SlotMutationError> { + self.data = None; + self.presence_revision = revision; + Ok(()) + } } impl SlotMapValueMutAccess for T @@ -389,6 +415,23 @@ where self.keys_revision = revision; Ok(()) } + + fn remove_entry( + &mut self, + revision: Revision, + key: &SlotMapKey, + ) -> Result<(), SlotMutationError> { + let typed_key = K::from_slot_map_key(key).ok_or_else(|| { + SlotMutationError::wrong_type(format!("invalid map key for typed map: {key:?}")) + })?; + if self.entries.remove(&typed_key).is_none() { + return Err(SlotMutationError::unknown_path(format!( + "map has no key {key:?}" + ))); + } + self.keys_revision = revision; + Ok(()) + } } impl FieldSlotMut for super::MapSlot @@ -424,6 +467,12 @@ where self.data = Some(T::default()); Ok(()) } + + fn clear_presence(&mut self, revision: Revision) -> Result<(), SlotMutationError> { + self.data = None; + self.presence_revision = revision; + Ok(()) + } } impl FieldSlotMut for super::OptionSlot @@ -439,7 +488,7 @@ where mod tests { use super::*; use crate::{MapSlot, ValueSlot}; - use alloc::collections::BTreeMap; + use lp_collection::VecMap; #[test] fn slot_mut_value_sets_lp_value() { @@ -465,7 +514,7 @@ mod tests { #[test] fn slot_mut_map_accesses_existing_key() { - let mut map = MapSlot::new(BTreeMap::from([( + let mut map = MapSlot::new(VecMap::from([( String::from("speed"), ValueSlot::with_version(Revision::new(1), 3.0_f32), )])); diff --git a/lp-core/lpc-model/src/slot/slot_mutation.rs b/lp-core/lpc-model/src/slot/slot_mutation.rs index a00f9e345..1ba390ce9 100644 --- a/lp-core/lpc-model/src/slot/slot_mutation.rs +++ b/lp-core/lpc-model/src/slot/slot_mutation.rs @@ -27,6 +27,20 @@ pub fn set_slot_value( ) } +/// Ensure every structural segment in `path` exists. +/// +/// Missing map entries are default-constructed, `some` option bodies are +/// default-constructed, and enum variant segments select the requested variant. +pub fn ensure_slot_present( + root: &mut dyn SlotMutAccess, + registry: &SlotShapeRegistry, + path: &SlotPath, + revision: Revision, +) -> Result<(), SlotMutationError> { + let shape = root_shape(root, registry)?; + ensure_slot_present_in_shape(root.data_mut(), shape, registry, path.segments(), revision) +} + /// Switch an enum slot to a default-constructed variant. pub fn set_slot_variant_default( root: &mut dyn SlotMutAccess, @@ -65,6 +79,36 @@ pub fn insert_slot_map_entry_default( ) } +/// Set an option slot to `none`. +pub fn set_slot_option_none( + root: &mut dyn SlotMutAccess, + registry: &SlotShapeRegistry, + path: &SlotPath, + revision: Revision, +) -> Result<(), SlotMutationError> { + let shape = root_shape(root, registry)?; + set_slot_option_none_in_shape(root.data_mut(), shape, registry, path.segments(), revision) +} + +/// Remove one map entry by path. +pub fn remove_slot_map_entry( + root: &mut dyn SlotMutAccess, + registry: &SlotShapeRegistry, + path: &SlotPath, + revision: Revision, + key: &SlotMapKey, +) -> Result<(), SlotMutationError> { + let shape = root_shape(root, registry)?; + remove_slot_map_entry_in_shape( + root.data_mut(), + shape, + registry, + path.segments(), + revision, + key, + ) +} + /// Set an option slot to `some(default)`. pub fn set_slot_option_some_default( root: &mut dyn SlotMutAccess, @@ -94,6 +138,96 @@ pub fn slot_data_revision( Ok(revision_for_data(data)) } +fn ensure_slot_present_in_shape( + data: SlotDataMutAccess<'_>, + shape: SlotShapeView<'_>, + registry: &SlotShapeRegistry, + segments: &[SlotPathSegment], + revision: Revision, +) -> Result<(), SlotMutationError> { + let shape = resolve_ref_shape(shape, registry)?; + let Some((head, tail)) = segments.split_first() else { + return match (shape.option_some(), data) { + (Some(some), SlotDataMutAccess::Option(option)) => { + if option.data_mut().is_none() { + let owned_some = some.to_owned_shape(); + option.set_some_default(revision, registry, &owned_some)?; + } + Ok(()) + } + _ => Ok(()), + }; + }; + + match (data, head) { + (SlotDataMutAccess::Record(record), SlotPathSegment::Field(name)) + if shape.record_fields_len().is_some() => + { + let (index, field) = shape.record_field_by_name(name).ok_or_else(|| { + SlotMutationError::unknown_path(format!("record has no field {name}")) + })?; + let field_data = record.field_mut(index).ok_or_else(|| { + SlotMutationError::unknown_path(format!("record field {name} has no data")) + })?; + ensure_slot_present_in_shape(field_data, field.shape(), registry, tail, revision) + } + (SlotDataMutAccess::Map(map), SlotPathSegment::Key(key)) if shape.map_value().is_some() => { + let value_shape = shape.map_value().expect("map value shape"); + if map.get_mut(key).is_none() { + let owned_value = value_shape.to_owned_shape(); + map.insert_default(revision, key, registry, &owned_value)?; + } + let item_data = map.get_mut(key).ok_or_else(|| { + SlotMutationError::unknown_path(format!("map has no key {}", display_key(key))) + })?; + ensure_slot_present_in_shape(item_data, value_shape, registry, tail, revision) + } + (SlotDataMutAccess::Option(option), SlotPathSegment::Field(name)) + if name.as_str() == "some" && shape.option_some().is_some() => + { + let some_shape = shape.option_some().expect("option some shape"); + if option.data_mut().is_none() { + let owned_some = some_shape.to_owned_shape(); + option.set_some_default(revision, registry, &owned_some)?; + } + let data = option + .data_mut() + .ok_or_else(|| SlotMutationError::unknown_path("option slot is none"))?; + ensure_slot_present_in_shape(data, some_shape, registry, tail, revision) + } + (SlotDataMutAccess::Enum(en), SlotPathSegment::Field(name)) if shape.is_enum() => { + if let Some(variant) = shape.enum_variant_by_name(name) { + if en.variant() != name.as_str() { + let owned_shape = shape.to_owned_shape(); + let SlotShape::Enum { variants, .. } = owned_shape else { + unreachable!("enum shape checked above"); + }; + en.set_variant_default_with_shape( + revision, + name.as_str(), + registry, + &variants, + )?; + } + let data = en.data_mut(); + ensure_slot_present_in_shape(data, variant.shape(), registry, tail, revision) + } else { + let active = String::from(en.variant()); + let variant = enum_variant_by_str(shape, &active)?; + let data = en.data_mut(); + ensure_slot_present_in_shape(data, variant, registry, segments, revision) + } + } + (_, SlotPathSegment::Field(name)) => Err(SlotMutationError::unknown_path(format!( + "slot path field {name} cannot descend into current slot shape" + ))), + (_, SlotPathSegment::Key(key)) => Err(SlotMutationError::unknown_path(format!( + "slot path key {} cannot descend into current slot shape", + display_key(key) + ))), + } +} + fn set_slot_value_in_shape( data: SlotDataMutAccess<'_>, shape: SlotShapeView<'_>, @@ -104,6 +238,9 @@ fn set_slot_value_in_shape( ) -> Result<(), SlotMutationError> { let shape = resolve_ref_shape(shape, registry)?; let Some((head, tail)) = segments.split_first() else { + if let Some(codec) = shape.custom_codec() { + return set_custom_slot_value(data, codec, revision, value); + } return match (shape.value_shape(), data) { (Some(shape), SlotDataMutAccess::Value(value_slot)) => { let ty = shape.ty_owned(); @@ -189,6 +326,35 @@ fn set_slot_value_in_shape( } } +fn set_custom_slot_value( + data: SlotDataMutAccess<'_>, + codec: crate::SlotShapeId, + revision: Revision, + value: LpValue, +) -> Result<(), SlotMutationError> { + let SlotDataMutAccess::Custom(custom) = data else { + return Err(SlotMutationError::unsupported_target( + "slot path resolves to custom shape but not custom data", + )); + }; + if codec == crate::slots::ASSET_SLOT_CODEC_ID { + let Some(slot) = custom + .as_any_mut() + .downcast_mut::() + else { + return Err(SlotMutationError::unsupported_target( + "asset slot codec expected AssetSlot data", + )); + }; + return slot + .set_from_lp_value(revision, value) + .map_err(SlotMutationError::wrong_type); + } + Err(SlotMutationError::unsupported_target(format!( + "set value mutation does not support custom slot codec {codec}" + ))) +} + fn set_slot_variant_default_in_shape( data: SlotDataMutAccess<'_>, shape: SlotShapeView<'_>, @@ -387,6 +553,161 @@ fn insert_slot_map_entry_default_in_shape( } } +fn set_slot_option_none_in_shape( + data: SlotDataMutAccess<'_>, + shape: SlotShapeView<'_>, + registry: &SlotShapeRegistry, + segments: &[SlotPathSegment], + revision: Revision, +) -> Result<(), SlotMutationError> { + let shape = resolve_ref_shape(shape, registry)?; + let Some((head, tail)) = segments.split_first() else { + return match (shape.option_some(), data) { + (_, SlotDataMutAccess::Option(option)) => option.clear_presence(revision), + _ => Err(SlotMutationError::unsupported_target( + "set option none requires an option slot", + )), + }; + }; + + match (data, head) { + (SlotDataMutAccess::Record(record), SlotPathSegment::Field(name)) + if shape.record_fields_len().is_some() => + { + let (index, field) = shape.record_field_by_name(name).ok_or_else(|| { + SlotMutationError::unknown_path(format!("record has no field {name}")) + })?; + let field_data = record.field_mut(index).ok_or_else(|| { + SlotMutationError::unknown_path(format!("record field {name} has no data")) + })?; + set_slot_option_none_in_shape(field_data, field.shape(), registry, tail, revision) + } + (SlotDataMutAccess::Map(map), SlotPathSegment::Key(key)) if shape.map_value().is_some() => { + let item_data = map.get_mut(key).ok_or_else(|| { + SlotMutationError::unknown_path(format!("map has no key {}", display_key(key))) + })?; + set_slot_option_none_in_shape( + item_data, + shape.map_value().expect("map value shape"), + registry, + tail, + revision, + ) + } + (SlotDataMutAccess::Option(option), SlotPathSegment::Field(name)) + if name.as_str() == "some" && shape.option_some().is_some() => + { + let data = option + .data_mut() + .ok_or_else(|| SlotMutationError::unknown_path("option slot is none"))?; + set_slot_option_none_in_shape( + data, + shape.option_some().expect("option some shape"), + registry, + tail, + revision, + ) + } + (SlotDataMutAccess::Enum(en), SlotPathSegment::Field(name)) if shape.is_enum() => { + let active = String::from(en.variant()); + let variant = enum_variant_by_str(shape, &active)?; + let data = en.data_mut(); + if name.as_str() == active { + set_slot_option_none_in_shape(data, variant, registry, tail, revision) + } else { + set_slot_option_none_in_shape(data, variant, registry, segments, revision) + } + } + (_, SlotPathSegment::Field(name)) => Err(SlotMutationError::unknown_path(format!( + "slot path field {name} cannot descend into current slot shape" + ))), + (_, SlotPathSegment::Key(key)) => Err(SlotMutationError::unknown_path(format!( + "slot path key {} cannot descend into current slot shape", + display_key(key) + ))), + } +} + +fn remove_slot_map_entry_in_shape( + data: SlotDataMutAccess<'_>, + shape: SlotShapeView<'_>, + registry: &SlotShapeRegistry, + segments: &[SlotPathSegment], + revision: Revision, + key: &SlotMapKey, +) -> Result<(), SlotMutationError> { + let shape = resolve_ref_shape(shape, registry)?; + let Some((head, tail)) = segments.split_first() else { + return match (shape.map_value(), data) { + (_, SlotDataMutAccess::Map(map)) => map.remove_entry(revision, key), + _ => Err(SlotMutationError::unsupported_target( + "remove map entry requires a map slot", + )), + }; + }; + + match (data, head) { + (SlotDataMutAccess::Record(record), SlotPathSegment::Field(name)) + if shape.record_fields_len().is_some() => + { + let (index, field) = shape.record_field_by_name(name).ok_or_else(|| { + SlotMutationError::unknown_path(format!("record has no field {name}")) + })?; + let field_data = record.field_mut(index).ok_or_else(|| { + SlotMutationError::unknown_path(format!("record field {name} has no data")) + })?; + remove_slot_map_entry_in_shape(field_data, field.shape(), registry, tail, revision, key) + } + (SlotDataMutAccess::Map(map), SlotPathSegment::Key(map_key)) + if shape.map_value().is_some() => + { + let item_data = map.get_mut(map_key).ok_or_else(|| { + SlotMutationError::unknown_path(format!("map has no key {}", display_key(map_key))) + })?; + remove_slot_map_entry_in_shape( + item_data, + shape.map_value().expect("map value shape"), + registry, + tail, + revision, + key, + ) + } + (SlotDataMutAccess::Option(option), SlotPathSegment::Field(name)) + if name.as_str() == "some" && shape.option_some().is_some() => + { + let data = option + .data_mut() + .ok_or_else(|| SlotMutationError::unknown_path("option slot is none"))?; + remove_slot_map_entry_in_shape( + data, + shape.option_some().expect("option some shape"), + registry, + tail, + revision, + key, + ) + } + (SlotDataMutAccess::Enum(en), SlotPathSegment::Field(name)) if shape.is_enum() => { + let active = String::from(en.variant()); + let variant = enum_variant_by_str(shape, &active)?; + let data = en.data_mut(); + if name.as_str() == active { + remove_slot_map_entry_in_shape(data, variant, registry, tail, revision, key) + } else { + remove_slot_map_entry_in_shape(data, variant, registry, segments, revision, key) + } + } + (_, SlotPathSegment::Field(name)) => Err(SlotMutationError::unknown_path(format!( + "slot path field {name} cannot descend into current slot shape" + ))), + (_, SlotPathSegment::Key(key)) => Err(SlotMutationError::unknown_path(format!( + "slot path key {} cannot descend into current slot shape", + display_key(key) + ))), + } +} + fn set_slot_option_some_default_in_shape( data: SlotDataMutAccess<'_>, shape: SlotShapeView<'_>, @@ -598,13 +919,13 @@ fn lp_value_matches_type(value: &LpValue, ty: &LpType) -> bool { mod tests { use super::*; use crate::{ - EnumSlot, MapSlot, OptionSlot, SlotDataAccess, SlotEnumShape, SlotMapValueAccess, - SlotMapValueMutAccess, SlotMeta, SlotRecordAccess, SlotRecordMutAccess, SlotShapeId, - SlotShapeRegistry, SlottedEnum, SlottedEnumMut, StaticSlotShape, ValueSlot, + AssetSlot, EnumSlot, MapSlot, OptionSlot, SlotDataAccess, SlotEnumShape, + SlotMapValueAccess, SlotMapValueMutAccess, SlotMeta, SlotRecordAccess, SlotRecordMutAccess, + SlotShapeId, SlotShapeRegistry, SlottedEnum, SlottedEnumMut, StaticSlotShape, ValueSlot, }; use alloc::boxed::Box; - use alloc::collections::BTreeMap; use alloc::vec; + use lp_collection::VecMap; #[derive(crate::Slotted)] struct MutRoot { @@ -615,6 +936,11 @@ mod tests { pub mode: EnumSlot, } + #[derive(crate::Slotted)] + struct AssetRoot { + pub source: AssetSlot, + } + enum TestEnum { A { value: ValueSlot }, B { other: ValueSlot }, @@ -742,6 +1068,36 @@ mod tests { assert_eq!(root.gain.revision(), Revision::new(5)); } + #[test] + fn slot_mutation_sets_asset_slot_from_string_snapshot() { + let mut root = AssetRoot { + source: AssetSlot::path("old.glsl"), + }; + let mut registry = SlotShapeRegistry::default(); + registry + .ensure_shape_named( + AssetRoot::SHAPE_ID, + AssetRoot::shape_name().expect("shape name"), + AssetRoot::slot_shape(), + ) + .unwrap(); + + set_slot_value( + &mut root, + ®istry, + &SlotPath::parse("source").unwrap(), + Revision::new(11), + LpValue::String(String::from("new.glsl")), + ) + .unwrap(); + + assert_eq!( + root.source.artifact_value().unwrap().to_string(), + "new.glsl" + ); + assert_eq!(root.source.revision(), Revision::new(11)); + } + #[test] fn slot_mutation_accepts_raw_enum_value_leaf() { let mut root = test_root(); @@ -830,6 +1186,38 @@ mod tests { assert!(!root.params.entries.contains_key("gain")); } + #[test] + fn ensure_slot_present_inserts_missing_map_key_without_replacing_existing() { + let mut root = test_root(); + let registry = registry(); + + ensure_slot_present( + &mut root, + ®istry, + &SlotPath::parse("params[gain]").unwrap(), + Revision::new(7), + ) + .unwrap(); + set_slot_value( + &mut root, + ®istry, + &SlotPath::parse("params[gain]").unwrap(), + Revision::new(8), + LpValue::F32(9.0), + ) + .unwrap(); + ensure_slot_present( + &mut root, + ®istry, + &SlotPath::parse("params[gain]").unwrap(), + Revision::new(9), + ) + .unwrap(); + + assert_eq!(root.params.keys_revision, Revision::new(7)); + assert_eq!(root.params.entries["gain"].value(), &9.0); + } + #[test] fn slot_mutation_sets_option_some_value_leaf() { let mut root = test_root(); @@ -875,6 +1263,26 @@ mod tests { assert_eq!(root.enabled.data.as_ref().unwrap().value(), &false); } + #[test] + fn ensure_slot_present_sets_option_some_default() { + let mut root = MutRoot { + enabled: OptionSlot::none(), + ..test_root() + }; + let registry = registry(); + + ensure_slot_present( + &mut root, + ®istry, + &SlotPath::parse("enabled.some").unwrap(), + Revision::new(7), + ) + .unwrap(); + + assert_eq!(root.enabled.presence_revision, Revision::new(7)); + assert_eq!(root.enabled.data.as_ref().unwrap().value(), &false); + } + #[test] fn slot_mutation_sets_active_enum_payload_leaf() { let mut root = test_root(); @@ -924,6 +1332,26 @@ mod tests { assert_eq!(other.value(), &5.0); } + #[test] + fn ensure_slot_present_selects_enum_variant() { + let mut root = test_root(); + let registry = registry(); + + ensure_slot_present( + &mut root, + ®istry, + &SlotPath::parse("mode.b").unwrap(), + Revision::new(9), + ) + .unwrap(); + + let TestEnum::B { other } = root.mode.value() else { + panic!("expected b"); + }; + assert_eq!(root.mode.variant_revision(), Revision::new(9)); + assert_eq!(other.value(), &0.0); + } + #[test] fn slot_mutation_switches_dynamic_enum_to_default_variant() { use crate::{DynamicSlotObject, SlotData, SlotEnum, SlotMeta}; @@ -980,7 +1408,7 @@ mod tests { fn test_root() -> MutRoot { MutRoot { gain: ValueSlot::new(1.0), - params: MapSlot::new(BTreeMap::from([( + params: MapSlot::new(VecMap::from([( String::from("exposure"), ValueSlot::new(1.0), )])), diff --git a/lp-core/lpc-model/src/slot/slot_shape.rs b/lp-core/lpc-model/src/slot/slot_shape.rs index da770a415..8fdde8247 100644 --- a/lp-core/lpc-model/src/slot/slot_shape.rs +++ b/lp-core/lpc-model/src/slot/slot_shape.rs @@ -12,7 +12,7 @@ use super::{SlotMeta, stable_hash::fnv1a_32}; /// shapes provide named or keyed structure around those leaves. #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "snake_case", tag = "kind")] +#[serde(rename_all = "snake_case")] pub enum SlotShape { Ref { id: SlotShapeId, @@ -127,7 +127,7 @@ impl core::error::Error for SlotShapeIdError {} /// runtime data model is always one active variant plus that variant's payload. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "snake_case", tag = "kind")] +#[serde(rename_all = "snake_case")] pub enum SlotEnumEncoding { /// Store the active variant in a discriminator field, such as /// `kind = "Variant"`, with the payload flattened beside it. @@ -361,7 +361,7 @@ mod tests { #[test] fn enum_encoding_defaults_to_tagged_kind() { - let json = r#"{"kind":"enum","variants":[]}"#; + let json = r#"{"enum":{"variants":[]}}"#; let shape: SlotShape = serde_json::from_str(json).unwrap(); 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 a5087c3ce..987ecd697 100644 --- a/lp-core/lpc-model/src/slot/slot_shape_registry.rs +++ b/lp-core/lpc-model/src/slot/slot_shape_registry.rs @@ -9,19 +9,19 @@ use crate::{ SlotShapeLookup, SlotShapeView, current_revision, }; use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::string::String; use core::ops::Bound::{Excluded, Unbounded}; +use lp_collection::VecMap; /// Registry of id-addressed slot shapes. #[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] pub struct SlotShapeRegistry { pub ids_revision: Revision, - shapes: BTreeMap, + shapes: VecMap, #[serde(skip)] #[cfg_attr(feature = "schema-gen", schemars(skip))] - factories: BTreeMap, + factories: VecMap, } /// Versioned registry entry for one slot shape. @@ -490,7 +490,7 @@ impl SlotShapeRegistry { after: Option, limit: usize, ) -> (SlotShapeRegistrySnapshot, Option) { - let mut shapes = BTreeMap::new(); + let mut shapes = VecMap::new(); let mut last_included = None; let mut next = None; let limit = limit.max(1); @@ -520,7 +520,7 @@ impl SlotShapeRegistry { after: Option, limit: usize, ) -> (SlotShapeRegistrySnapshot, Option) { - let mut shapes = BTreeMap::new(); + let mut shapes = VecMap::new(); let mut cursor = after; let mut last_included = None; let mut next = None; @@ -732,7 +732,7 @@ fn min_shape_id(left: Option, right: Option) -> Option #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] pub struct SlotShapeRegistrySnapshot { pub ids_revision: Revision, - pub shapes: BTreeMap, + pub shapes: VecMap, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/lp-core/lpc-model/src/slot/slot_value.rs b/lp-core/lpc-model/src/slot/slot_value.rs index d927cf3f6..ab85de133 100644 --- a/lp-core/lpc-model/src/slot/slot_value.rs +++ b/lp-core/lpc-model/src/slot/slot_value.rs @@ -57,7 +57,7 @@ impl SlotValueShape { /// Editor hint for a slot value leaf. #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "snake_case", tag = "kind")] +#[serde(rename_all = "snake_case")] pub enum ValueEditorHint { #[default] Plain, diff --git a/lp-core/lpc-model/src/slot/static_slot_shape.rs b/lp-core/lpc-model/src/slot/static_slot_shape.rs index 2fdc8352c..c415c08cd 100644 --- a/lp-core/lpc-model/src/slot/static_slot_shape.rs +++ b/lp-core/lpc-model/src/slot/static_slot_shape.rs @@ -15,7 +15,7 @@ use alloc::vec::Vec; /// Borrowed static shape of a slot tree. #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)] -#[serde(rename_all = "snake_case", tag = "kind")] +#[serde(rename_all = "snake_case")] pub enum StaticSlotShapeDescriptor { Ref { id: SlotShapeId, @@ -313,7 +313,7 @@ impl StaticModelEnumVariant { /// Borrowed editor hint for static value shapes. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case", tag = "kind")] +#[serde(rename_all = "snake_case")] pub enum StaticValueEditorHint { #[default] Plain, @@ -388,7 +388,7 @@ impl StaticSlotEnumOption { /// Borrowed enum syntax for a static enum slot. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case", tag = "kind")] +#[serde(rename_all = "snake_case")] pub enum StaticSlotEnumEncoding { Tagged { field: &'static str }, External, diff --git a/lp-core/lpc-model/src/slot/value_slot.rs b/lp-core/lpc-model/src/slot/value_slot.rs index 1a65c12ea..b1868067b 100644 --- a/lp-core/lpc-model/src/slot/value_slot.rs +++ b/lp-core/lpc-model/src/slot/value_slot.rs @@ -2,11 +2,11 @@ use crate::{ LpValue, Revision, SlotMapKeyShape, SlotShape, StaticSlotMeta, StaticSlotShapeDescriptor, WithRevision, current_revision, }; -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; +use lp_collection::VecMap; use serde::{ Deserialize, Deserializer, Serialize, Serializer, de::{MapAccess, Visitor}, @@ -167,15 +167,15 @@ pub trait MapSlotKeyLike: Clone + Ord { #[derive(Clone, Debug, PartialEq, Eq)] pub struct MapSlot { pub keys_revision: Revision, - pub entries: BTreeMap, + pub entries: VecMap, } impl MapSlot { - pub fn new(entries: BTreeMap) -> Self { + pub fn new(entries: VecMap) -> Self { Self::with_version(current_revision(), entries) } - pub fn with_version(keys_revision: Revision, entries: BTreeMap) -> Self { + pub fn with_version(keys_revision: Revision, entries: VecMap) -> Self { Self { keys_revision, entries, @@ -210,7 +210,7 @@ impl MapSlot { impl Default for MapSlot { fn default() -> Self { - Self::new(BTreeMap::new()) + Self::new(VecMap::new()) } } @@ -237,15 +237,15 @@ where V: schemars::JsonSchema, { fn schema_name() -> alloc::borrow::Cow<'static, str> { - as schemars::JsonSchema>::schema_name() + as schemars::JsonSchema>::schema_name() } fn schema_id() -> alloc::borrow::Cow<'static, str> { - as schemars::JsonSchema>::schema_id() + as schemars::JsonSchema>::schema_id() } fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - as schemars::JsonSchema>::json_schema(generator) + as schemars::JsonSchema>::json_schema(generator) } } @@ -277,7 +277,7 @@ where where A: MapAccess<'de>, { - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); while let Some((key, value)) = access.next_entry::()? { let key = K::from_authored_key(&key).map_err(serde::de::Error::custom)?; entries.insert(key, value); @@ -595,7 +595,7 @@ mod tests { } } - let mut map = MapSlot::new(BTreeMap::::new()); + let mut map = MapSlot::new(VecMap::::new()); map.insert_with_version( Revision::new(3), String::from("a"), @@ -621,7 +621,7 @@ mod tests { #[test] fn map_slot_serializes_as_authored_map_and_stamps_key_version() { - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); entries.insert( String::from("speed"), ValueSlot::with_version(Revision::new(2), 7_u32), @@ -641,7 +641,7 @@ mod tests { #[test] fn map_slot_round_trips_numeric_authored_keys() { - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); entries.insert(7_u32, ValueSlot::new(String::from("seven"))); let map = MapSlot::new(entries); diff --git a/lp-core/lpc-model/src/slot_codec/custom_slot_codec.rs b/lp-core/lpc-model/src/slot_codec/custom_slot_codec.rs index 1fe0146d9..b9260198f 100644 --- a/lp-core/lpc-model/src/slot_codec/custom_slot_codec.rs +++ b/lp-core/lpc-model/src/slot_codec/custom_slot_codec.rs @@ -13,25 +13,22 @@ use super::{ pub(crate) fn read_custom_slot( codec: SlotShapeId, data: &mut dyn SlotCustomMutAccess, - registry: &SlotShapeRegistry, + _registry: &SlotShapeRegistry, value: ValueReader<'_, '_, S>, ) -> Result<(), SyntaxError> where S: SyntaxEventSource, { - if codec == crate::node::node_invocation::NODE_INVOCATION_CODEC_ID { - let Some(invocation) = data - .as_any_mut() - .downcast_mut::() - else { + if codec == crate::slots::ASSET_SLOT_CODEC_ID { + let Some(slot) = data.as_any_mut().downcast_mut::() else { value.skip_value()?; return Err(SyntaxError::new( "", None, - "node def ref codec expected NodeInvocation data", + "asset slot codec expected AssetSlot data", )); }; - return invocation.read_invocation_slot(registry, value); + return slot.read_slot(value); } value.skip_value()?; @@ -45,22 +42,19 @@ where pub(crate) fn write_custom_slot_json( codec: SlotShapeId, data: &dyn SlotCustomAccess, - registry: &SlotShapeRegistry, + _registry: &SlotShapeRegistry, value: SlotValueWriter<'_, W>, ) -> Result<(), SlotWriteError> where W: SlotWrite, { - if codec == crate::node::node_invocation::NODE_INVOCATION_CODEC_ID { - let Some(invocation) = data - .as_any() - .downcast_ref::() - else { + if codec == crate::slots::ASSET_SLOT_CODEC_ID { + let Some(slot) = data.as_any().downcast_ref::() else { return Err(SlotWriteError::InvalidSlotData( - "node def ref codec expected NodeInvocation data".into(), + "asset slot codec expected AssetSlot data".into(), )); }; - return invocation.write_invocation_slot_json(registry, value); + return slot.write_slot_json(value); } Err(SlotWriteError::InvalidSlotData(format!( @@ -71,18 +65,15 @@ where pub(crate) fn write_custom_slot_toml( codec: SlotShapeId, data: &dyn SlotCustomAccess, - registry: &SlotShapeRegistry, + _registry: &SlotShapeRegistry, ) -> Result { - if codec == crate::node::node_invocation::NODE_INVOCATION_CODEC_ID { - let Some(invocation) = data - .as_any() - .downcast_ref::() - else { + if codec == crate::slots::ASSET_SLOT_CODEC_ID { + let Some(slot) = data.as_any().downcast_ref::() else { return Err(SlotDataWriteError::ShapeDataMismatch { - message: "node def ref codec expected NodeInvocation data".into(), + message: "asset slot codec expected AssetSlot data".into(), }); }; - return invocation.write_invocation_slot_toml(registry); + return slot.write_slot_toml(); } Err(SlotDataWriteError::ShapeDataMismatch { @@ -94,14 +85,11 @@ pub(crate) fn snapshot_custom_slot_data<'a>( codec: SlotShapeId, data: &'a dyn SlotCustomAccess, ) -> Result, String> { - if codec == crate::node::node_invocation::NODE_INVOCATION_CODEC_ID { - let Some(invocation) = data - .as_any() - .downcast_ref::() - else { - return Err("node def ref codec expected NodeInvocation data".into()); + if codec == crate::slots::ASSET_SLOT_CODEC_ID { + let Some(slot) = data.as_any().downcast_ref::() else { + return Err("asset slot codec expected AssetSlot data".into()); }; - return Ok(SlotDataAccess::Record(invocation)); + return Ok(SlotDataAccess::Value(slot)); } Err(format!("unknown custom slot codec {codec}")) diff --git a/lp-core/lpc-model/src/slot_codec/dynamic_slot_writer.rs b/lp-core/lpc-model/src/slot_codec/dynamic_slot_writer.rs index 56117e0de..d70da36cf 100644 --- a/lp-core/lpc-model/src/slot_codec/dynamic_slot_writer.rs +++ b/lp-core/lpc-model/src/slot_codec/dynamic_slot_writer.rs @@ -875,8 +875,8 @@ mod tests { slot::shape::{enum_external, field, map, option, record, unit, value}, }; use alloc::boxed::Box; - use alloc::collections::BTreeMap; use alloc::vec; + use lp_collection::VecMap; #[test] fn dynamic_slot_writer_writes_records_to_json() { @@ -900,7 +900,7 @@ mod tests { ) .unwrap(); let data = SlotData::Record(SlotRecord::new(vec![SlotData::Map(SlotMapDyn::new( - BTreeMap::from([ + VecMap::from([ ( SlotMapKey::String("a".to_string()), SlotData::Value(WithRevision::new(Revision::default(), LpValue::U32(1))), diff --git a/lp-core/lpc-model/src/slot_codec/mod.rs b/lp-core/lpc-model/src/slot_codec/mod.rs index 0d3ace6b1..4aea4682a 100644 --- a/lp-core/lpc-model/src/slot_codec/mod.rs +++ b/lp-core/lpc-model/src/slot_codec/mod.rs @@ -288,7 +288,7 @@ name = "aux" #[test] fn json_writer_writes_string_maps_and_fixed_f32_arrays() { - let mut values = alloc::collections::BTreeMap::new(); + let mut values = lp_collection::VecMap::new(); values.insert("white_point".to_string(), [0.9, 1.0, 1.0]); let mut out = Vec::new(); diff --git a/lp-core/lpc-model/src/slot_codec/slot_reader.rs b/lp-core/lpc-model/src/slot_codec/slot_reader.rs index 385e35905..f9e3e8944 100644 --- a/lp-core/lpc-model/src/slot_codec/slot_reader.rs +++ b/lp-core/lpc-model/src/slot_codec/slot_reader.rs @@ -1,7 +1,7 @@ -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; +use lp_collection::VecMap; use base64::Engine; @@ -131,6 +131,18 @@ where self.replay = Some(event); } + pub(crate) fn peek_event(&mut self) -> Result, SyntaxError> { + if let Some(event) = self.replay.take() { + self.replay = Some(event.clone()); + return Ok(Some(event)); + } + let event = self.source.next_event()?; + if let Some(ref evt) = event { + self.replay = Some(evt.clone()); + } + Ok(event) + } + fn skip_value(&mut self) -> Result<(), SyntaxError> { let Some(event) = self.next_event()? else { return Err(self.error("expected value to skip, found end of input")); @@ -446,8 +458,8 @@ where pub fn string_key_map( self, mut read_value: impl FnMut(ValueReader<'_, 'a, S>) -> Result, - ) -> Result, SyntaxError> { - let mut entries = BTreeMap::new(); + ) -> Result, SyntaxError> { + let mut entries = VecMap::new(); let mut object = self.object()?; while let Some(mut prop) = object.next_prop()? { let key = prop.name().to_string(); @@ -460,8 +472,8 @@ where pub fn u32_key_map( self, mut read_value: impl FnMut(ValueReader<'_, 'a, S>) -> Result, - ) -> Result, SyntaxError> { - let mut entries = BTreeMap::new(); + ) -> Result, SyntaxError> { + let mut entries = VecMap::new(); let mut object = self.object()?; while let Some(mut prop) = object.next_prop()? { let key = match prop.name().parse::() { @@ -514,6 +526,11 @@ where } } + pub fn is_string_scalar(&mut self) -> Result { + let event = self.reader.peek_event()?; + Ok(matches!(event, Some(SyntaxEvent::StringChunk { .. }))) + } + pub fn string(self) -> Result { let Some(event) = self.reader.next_event()? else { return Err(self.reader.error("expected string, found end of input")); diff --git a/lp-core/lpc-model/src/slot_codec/slot_value_codec.rs b/lp-core/lpc-model/src/slot_codec/slot_value_codec.rs index ae4f74577..0879b8ee3 100644 --- a/lp-core/lpc-model/src/slot_codec/slot_value_codec.rs +++ b/lp-core/lpc-model/src/slot_codec/slot_value_codec.rs @@ -1,8 +1,8 @@ use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::string::{String, ToString}; use alloc::vec; use alloc::vec::Vec; +use lp_collection::VecMap; use crate::{ ControlExtent, ControlProduct, LpType, LpValue, ModelEnumVariant, ModelStructMember, NodeId, @@ -585,7 +585,7 @@ fn write_lp_struct( where W: SlotWrite, { - let values: BTreeMap<&str, &LpValue> = values + let values: VecMap<&str, &LpValue> = values .iter() .map(|(name, value)| (name.as_str(), value)) .collect(); diff --git a/lp-core/lpc-model/src/slot_codec/slot_writer.rs b/lp-core/lpc-model/src/slot_codec/slot_writer.rs index 6fba926a8..d25d810d5 100644 --- a/lp-core/lpc-model/src/slot_codec/slot_writer.rs +++ b/lp-core/lpc-model/src/slot_codec/slot_writer.rs @@ -1,10 +1,10 @@ use alloc::{ - collections::BTreeMap, string::{String, ToString}, vec::Vec, }; use core::convert::Infallible; use core::fmt; +use lp_collection::VecMap; use base64::Engine; @@ -278,7 +278,7 @@ where pub fn string_key_map( self, - map: &BTreeMap, + map: &VecMap, mut write_value: impl FnMut(SlotValueWriter<'_, W>, &T) -> Result<(), SlotWriteError>, ) -> Result<(), SlotWriteError> { let mut object = self.object()?; @@ -290,7 +290,7 @@ where pub fn u32_key_map( self, - map: &BTreeMap, + map: &VecMap, mut write_value: impl FnMut(SlotValueWriter<'_, W>, &T) -> Result<(), SlotWriteError>, ) -> Result<(), SlotWriteError> { let mut object = self.object()?; diff --git a/lp-core/lpc-model/src/slot_sync_codec/mod.rs b/lp-core/lpc-model/src/slot_sync_codec/mod.rs index d2c7c9897..1766f51c0 100644 --- a/lp-core/lpc-model/src/slot_sync_codec/mod.rs +++ b/lp-core/lpc-model/src/slot_sync_codec/mod.rs @@ -21,11 +21,11 @@ mod tests { SlotMapKeyShape, SlotOptionDyn, SlotRecord, SlotShape, SlotShapeId, SlotShapeRegistry, WithRevision, }; - use alloc::collections::BTreeMap; use alloc::string::String; use alloc::string::ToString; use alloc::vec; use alloc::vec::Vec; + use lp_collection::VecMap; #[test] fn sync_snapshot_preserves_revisions_and_map_keys() { @@ -95,7 +95,7 @@ mod tests { SlotData::Value(WithRevision::new(Revision::new(3), LpValue::Bool(true))), SlotData::Map(SlotMapDyn::with_revision( Revision::new(5), - BTreeMap::from([( + VecMap::from([( SlotMapKey::String(String::from("gain")), SlotData::Value(WithRevision::new(Revision::new(8), LpValue::U32(42))), )]), diff --git a/lp-core/lpc-model/src/slot_sync_codec/snapshot_reader.rs b/lp-core/lpc-model/src/slot_sync_codec/snapshot_reader.rs index c66993577..8860dd266 100644 --- a/lp-core/lpc-model/src/slot_sync_codec/snapshot_reader.rs +++ b/lp-core/lpc-model/src/slot_sync_codec/snapshot_reader.rs @@ -1,7 +1,7 @@ -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; +use lp_collection::VecMap; use crate::slot_codec::{JsonSyntaxSource, SyntaxError, SyntaxEventSource, ValueReader}; use crate::{ @@ -203,11 +203,11 @@ fn read_entries( value: ValueReader<'_, '_, S>, key_shape: SlotMapKeyShape, value_shape: SlotShapeView<'_>, -) -> Result, SyntaxError> +) -> Result, SyntaxError> where S: SyntaxEventSource, { - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); let mut array = value.array()?; while let Some(item) = array.next_item()? { let (key, data) = read_entry(registry, item, key_shape, value_shape)?; diff --git a/lp-core/lpc-model/src/slots/asset_slot.rs b/lp-core/lpc-model/src/slots/asset_slot.rs new file mode 100644 index 000000000..a95565f0a --- /dev/null +++ b/lp-core/lpc-model/src/slots/asset_slot.rs @@ -0,0 +1,670 @@ +//! Authored artifact-or-inline asset slot. + +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use crate::slot::shape; +use crate::slot_codec::{ + SlotDataWriteError, SlotValueWriter, SlotWrite, SlotWriteError, SyntaxError, SyntaxEventSource, + ValueReader, +}; +use crate::{ + ArtifactSpec, FieldSlot, FieldSlotMut, LpPathBuf, LpType, LpValue, Revision, SlotCustomAccess, + SlotCustomMutAccess, SlotDataAccess, SlotDataMutAccess, SlotMapValueMutAccess, SlotMeta, + SlotRecordAccess, SlotRecordMutAccess, SlotShape, SlotShapeId, SlotValueAccess, SlotValueShape, + StaticLpType, StaticSlotMeta, StaticSlotShapeDescriptor, StaticSlotValueShape, ValueEditorHint, + current_revision, +}; + +pub(crate) const ASSET_SLOT_CODEC_ID: SlotShapeId = + SlotShapeId::from_static_name("lp::slots::AssetSlotCodec"); +const ASSET_SLOT_SNAPSHOT_SHAPE_ID: SlotShapeId = + SlotShapeId::from_static_name("lp::slots::AssetSlotSnapshot"); + +const PATH_KEY: &str = "path"; +const LEGACY_PATH_KEY: &str = "$path"; +const BYTES_KEY: &str = "bytes"; +const EXTENSION_KEY: &str = "extension"; + +/// Authored asset slot value. +/// +/// An artifact reference round-trips as a bare authored path string +/// (`"shader.glsl"`); inline bodies are tables keyed by `text`/`bytes`. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub enum AssetSlotValue { + /// Asset body lives in another artifact. + Artifact(ArtifactSpec), + /// UTF-8 asset body embedded in the owning artifact. + InlineText { + extension: Option, + text: String, + }, + /// Raw asset bytes embedded in the owning artifact. + InlineBytes { + extension: Option, + bytes: Vec, + }, +} + +// Hand-written, streaming serde for the compact authored form. This is +// deliberately NOT `#[serde(untagged)]`: untagged (like internally-tagged) +// buffers the whole input into serde's `Content` tree and re-parses, which +// monomorphizes the heavy `Content` machinery into the deserialize graph — the +// exact flash cost the externally-tagged-enum work removed. A `Visitor` +// dispatches on the input shape (string vs map) in a single streaming pass. +impl serde::Serialize for AssetSlotValue { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeMap; + match self { + Self::Artifact(spec) => spec.serialize(serializer), + Self::InlineText { extension, text } => { + let mut map = serializer.serialize_map(None)?; + if let Some(extension) = extension { + map.serialize_entry(EXTENSION_KEY, extension)?; + } + map.serialize_entry("text", text)?; + map.end() + } + Self::InlineBytes { extension, bytes } => { + let mut map = serializer.serialize_map(None)?; + if let Some(extension) = extension { + map.serialize_entry(EXTENSION_KEY, extension)?; + } + map.serialize_entry(BYTES_KEY, bytes)?; + map.end() + } + } + } +} + +impl<'de> serde::Deserialize<'de> for AssetSlotValue { + fn deserialize>(deserializer: D) -> Result { + struct AssetSlotValueVisitor; + + impl<'de> serde::de::Visitor<'de> for AssetSlotValueVisitor { + type Value = AssetSlotValue; + + fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("an artifact path string or an inline asset table") + } + + fn visit_str(self, value: &str) -> Result { + ArtifactSpec::parse(value) + .map(AssetSlotValue::Artifact) + .map_err(serde::de::Error::custom) + } + + fn visit_string(self, value: String) -> Result { + self.visit_str(&value) + } + + fn visit_map>( + self, + mut map: M, + ) -> Result { + let mut extension: Option> = None; + let mut text: Option = None; + let mut bytes: Option> = None; + while let Some(key) = map.next_key::()? { + match key.as_str() { + EXTENSION_KEY => extension = Some(map.next_value()?), + "text" => text = Some(map.next_value()?), + BYTES_KEY => bytes = Some(map.next_value()?), + _ => { + map.next_value::()?; + } + } + } + let extension = extension.unwrap_or(None); + match (text, bytes) { + (Some(text), None) => Ok(AssetSlotValue::InlineText { extension, text }), + (None, Some(bytes)) => Ok(AssetSlotValue::InlineBytes { extension, bytes }), + (None, None) => Err(serde::de::Error::custom( + "inline asset table requires a `text` or `bytes` field", + )), + (Some(_), Some(_)) => Err(serde::de::Error::custom( + "inline asset table has both `text` and `bytes`", + )), + } + } + } + + deserializer.deserialize_any(AssetSlotValueVisitor) + } +} + +/// Authored artifact-or-inline asset reference. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct AssetSlot { + value: AssetSlotValue, + revision: Revision, +} + +// Like the other semantic slot wrappers (see `ValueSlot`), an asset slot +// serializes as its bare authored value and stamps the ambient revision on +// deserialize, rather than exposing the internal `revision` field on the wire. +impl serde::Serialize for AssetSlot { + fn serialize(&self, serializer: S) -> Result { + self.value.serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for AssetSlot { + fn deserialize>(deserializer: D) -> Result { + Ok(Self { + value: AssetSlotValue::deserialize(deserializer)?, + revision: current_revision(), + }) + } +} + +impl Default for AssetSlot { + fn default() -> Self { + Self { + value: AssetSlotValue::Artifact(ArtifactSpec::path("")), + revision: Revision::default(), + } + } +} + +impl AssetSlot { + pub fn artifact(spec: ArtifactSpec) -> Self { + Self { + value: AssetSlotValue::Artifact(spec), + revision: current_revision(), + } + } + + pub fn path(path: impl Into) -> Self { + Self::artifact(ArtifactSpec::path(path)) + } + + pub fn inline_text(extension: impl Into, text: impl Into) -> Self { + Self { + value: AssetSlotValue::InlineText { + extension: Some(extension.into()), + text: text.into(), + }, + revision: current_revision(), + } + } + + pub fn inline_bytes(extension: Option, bytes: Vec) -> Self { + Self { + value: AssetSlotValue::InlineBytes { extension, bytes }, + revision: current_revision(), + } + } + + pub fn revision(&self) -> Revision { + self.revision + } + + pub fn value(&self) -> &AssetSlotValue { + &self.value + } + + pub fn artifact_value(&self) -> Option<&ArtifactSpec> { + match &self.value { + AssetSlotValue::Artifact(spec) => Some(spec), + AssetSlotValue::InlineText { .. } | AssetSlotValue::InlineBytes { .. } => None, + } + } + + pub fn inline_text_value(&self) -> Option<(Option<&str>, &str)> { + match &self.value { + AssetSlotValue::InlineText { extension, text } => { + Some((extension.as_deref(), text.as_str())) + } + AssetSlotValue::Artifact(_) | AssetSlotValue::InlineBytes { .. } => None, + } + } + + pub fn inline_bytes_value(&self) -> Option<(Option<&str>, &[u8])> { + match &self.value { + AssetSlotValue::InlineBytes { extension, bytes } => { + Some((extension.as_deref(), bytes.as_slice())) + } + AssetSlotValue::Artifact(_) | AssetSlotValue::InlineText { .. } => None, + } + } + + fn snapshot_text(&self) -> String { + match &self.value { + AssetSlotValue::Artifact(spec) => spec.to_string(), + AssetSlotValue::InlineText { extension, text } => match extension { + Some(extension) => format!("inline text .{extension}: {text}"), + None => format!("inline text: {text}"), + }, + AssetSlotValue::InlineBytes { extension, bytes } => match extension { + Some(extension) => format!("inline bytes .{extension}: {} bytes", bytes.len()), + None => format!("inline bytes: {} bytes", bytes.len()), + }, + } + } + + pub(crate) fn set_value(&mut self, value: AssetSlotValue) { + self.set_value_with_revision(current_revision(), value); + } + + pub(crate) fn set_value_with_revision(&mut self, revision: Revision, value: AssetSlotValue) { + self.value = value; + self.revision = revision; + } + + pub(crate) fn set_from_lp_value( + &mut self, + revision: Revision, + value: LpValue, + ) -> Result<(), String> { + let LpValue::String(value) = value else { + return Err(format!( + "asset slot assignment expects string, got {value:?}" + )); + }; + let value = read_artifact_spec(value).map_err(|err| err.to_string())?; + self.set_value_with_revision(revision, value); + Ok(()) + } + + pub(crate) fn read_slot(&mut self, value: ValueReader<'_, '_, S>) -> Result<(), SyntaxError> + where + S: SyntaxEventSource, + { + let value = read_value(value)?; + self.set_value(value); + Ok(()) + } + + pub(crate) fn write_slot_json( + &self, + value: SlotValueWriter<'_, W>, + ) -> Result<(), SlotWriteError> + where + W: SlotWrite, + { + write_value_json(&self.value, value) + } + + pub(crate) fn write_slot_toml(&self) -> Result { + write_value_toml(&self.value) + } +} + +fn read_value(mut value: ValueReader<'_, '_, S>) -> Result +where + S: SyntaxEventSource, +{ + if value.is_string_scalar()? { + return read_artifact_spec(value.string()?); + } + + let mut object = value.object()?; + let Some(first) = object.peek_prop_name()? else { + return Err(object.missing_required_field(PATH_KEY)); + }; + + if first == PATH_KEY || first == LEGACY_PATH_KEY { + let Some(mut prop) = object.next_prop()? else { + return Err(object.missing_required_field(PATH_KEY)); + }; + let spec = prop.value().string()?; + drop(prop); + object.finish()?; + return read_artifact_spec(spec); + } + + if first == BYTES_KEY || first == EXTENSION_KEY { + return read_inline_bytes(object); + } + + let mut inline_key = None; + let mut inline_text = None; + while let Some(mut prop) = object.next_prop()? { + let name = prop.name().to_string(); + if name == PATH_KEY || name == LEGACY_PATH_KEY || name == BYTES_KEY || name == EXTENSION_KEY + { + return Err(prop.unknown_field(&name, &["inline text extension key"])); + } + if inline_key.is_some() { + return Err(prop.unknown_field(&name, &[inline_key.as_deref().unwrap_or("inline")])); + } + inline_key = Some(name); + inline_text = Some(prop.value().string()?); + } + + let Some(extension) = inline_key else { + return Err(object.missing_required_field("inline extension key")); + }; + Ok(AssetSlotValue::InlineText { + extension: Some(extension), + text: inline_text.unwrap_or_default(), + }) +} + +fn read_artifact_spec(value: String) -> Result { + ArtifactSpec::parse(&value) + .map(AssetSlotValue::Artifact) + .map_err(|err| SyntaxError::new("", None, format!("invalid artifact spec: {err}"))) +} + +fn read_inline_bytes( + mut object: crate::slot_codec::ObjectReader<'_, '_, S>, +) -> Result +where + S: SyntaxEventSource, +{ + let mut extension = None; + let mut bytes = None; + while let Some(mut prop) = object.next_prop()? { + match prop.name() { + BYTES_KEY => bytes = Some(read_byte_array(prop.value())?), + EXTENSION_KEY => extension = Some(prop.value().string()?), + name => return Err(prop.unknown_field(name, &[BYTES_KEY, EXTENSION_KEY])), + } + } + + Ok(AssetSlotValue::InlineBytes { + extension, + bytes: bytes.unwrap_or_default(), + }) +} + +fn read_byte_array(value: ValueReader<'_, '_, S>) -> Result, SyntaxError> +where + S: SyntaxEventSource, +{ + let mut bytes = Vec::new(); + let mut array = value.array()?; + while let Some(item) = array.next_item()? { + let byte = item.u32()?; + let Ok(byte) = u8::try_from(byte) else { + return Err(SyntaxError::new( + "", + None, + "expected byte value in range 0..=255", + )); + }; + bytes.push(byte); + } + Ok(bytes) +} + +fn write_value_json( + slot_value: &AssetSlotValue, + writer: SlotValueWriter<'_, W>, +) -> Result<(), SlotWriteError> +where + W: SlotWrite, +{ + match slot_value { + AssetSlotValue::Artifact(spec) => writer.string(&spec.to_string()), + AssetSlotValue::InlineText { extension, text } => { + let mut object = writer.object()?; + object + .prop(extension.as_deref().unwrap_or("text"))? + .string(text)?; + object.finish() + } + AssetSlotValue::InlineBytes { extension, bytes } => { + write_inline_bytes_json(writer, extension.as_deref(), bytes.as_slice()) + } + } +} + +fn write_inline_bytes_json( + value: SlotValueWriter<'_, W>, + extension: Option<&str>, + bytes: &[u8], +) -> Result<(), SlotWriteError> +where + W: SlotWrite, +{ + let mut object = value.object()?; + if let Some(extension) = extension { + object.prop(EXTENSION_KEY)?.string(extension)?; + } + let mut array = object.prop(BYTES_KEY)?.array()?; + for byte in bytes { + array.item()?.u32(u32::from(*byte))?; + } + array.finish()?; + object.finish() +} + +fn write_value_toml(value: &AssetSlotValue) -> Result { + match value { + AssetSlotValue::Artifact(spec) => Ok(toml::Value::String(spec.to_string())), + AssetSlotValue::InlineText { extension, text } => { + let mut table = toml::map::Map::new(); + table.insert( + extension.clone().unwrap_or_else(|| String::from("text")), + toml::Value::String(text.clone()), + ); + Ok(toml::Value::Table(table)) + } + AssetSlotValue::InlineBytes { extension, bytes } => { + let mut table = toml::map::Map::new(); + if let Some(extension) = extension { + table.insert( + String::from(EXTENSION_KEY), + toml::Value::String(extension.clone()), + ); + } + table.insert( + String::from(BYTES_KEY), + toml::Value::Array( + bytes + .iter() + .map(|byte| toml::Value::Integer(i64::from(*byte))) + .collect(), + ), + ); + Ok(toml::Value::Table(table)) + } + } +} + +impl FieldSlot for AssetSlot { + const STATIC_SLOT_FIELD_SHAPE_DESCRIPTOR: Option<&'static StaticSlotShapeDescriptor> = + Some(&StaticSlotShapeDescriptor::Custom { + meta: StaticSlotMeta::EMPTY, + codec: ASSET_SLOT_CODEC_ID, + shape: &StaticSlotShapeDescriptor::Value { + shape: StaticSlotValueShape::new( + ASSET_SLOT_SNAPSHOT_SHAPE_ID, + StaticLpType::String, + ), + }, + refs: &[], + }); + + fn slot_field_shape() -> SlotShape { + shape::custom(ASSET_SLOT_CODEC_ID, asset_slot_snapshot_shape(), Vec::new()) + } + + fn slot_field_data(&self) -> SlotDataAccess<'_> { + SlotDataAccess::Custom(self) + } +} + +impl FieldSlotMut for AssetSlot { + fn slot_field_data_mut(&mut self) -> SlotDataMutAccess<'_> { + SlotDataMutAccess::Custom(self) + } +} + +impl SlotMapValueMutAccess for AssetSlot { + fn slot_data_mut(&mut self) -> SlotDataMutAccess<'_> { + SlotDataMutAccess::Custom(self) + } +} + +impl SlotRecordAccess for AssetSlot { + fn fields_revision(&self) -> Revision { + self.revision + } + + fn field(&self, index: usize) -> Option> { + if index == 0 { + Some(SlotDataAccess::Custom(self)) + } else { + None + } + } +} + +impl SlotRecordMutAccess for AssetSlot { + fn fields_revision(&self) -> Revision { + self.revision + } + + fn field_mut(&mut self, index: usize) -> Option> { + if index == 0 { + Some(SlotDataMutAccess::Custom(self)) + } else { + None + } + } +} + +impl SlotCustomAccess for AssetSlot { + fn custom_codec_id(&self) -> SlotShapeId { + ASSET_SLOT_CODEC_ID + } + + fn custom_revision(&self) -> Revision { + self.revision + } + + fn as_any(&self) -> &dyn core::any::Any { + self + } +} + +impl SlotCustomMutAccess for AssetSlot { + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { + self + } +} + +fn asset_slot_snapshot_shape() -> SlotShape { + SlotShape::Value { + shape: SlotValueShape { + id: ASSET_SLOT_SNAPSHOT_SHAPE_ID, + ty: LpType::String, + meta: SlotMeta::empty(), + editor: ValueEditorHint::Plain, + }, + } +} + +impl SlotValueAccess for AssetSlot { + fn changed_at(&self) -> Revision { + self.revision + } + + fn value(&self) -> LpValue { + LpValue::String(self.snapshot_text()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SlotShapeRegistry; + use crate::slot_codec::{SlotReader, TomlSyntaxSource, apply_reader_to_slot}; + + fn read_asset(text: &str) -> AssetSlot { + let registry = SlotShapeRegistry::default(); + let wrapper = toml::from_str::(text).expect("toml"); + let value = wrapper.get("source").unwrap_or(&wrapper); + let mut reader = SlotReader::new(TomlSyntaxSource::new(value).expect("syntax"), ®istry); + let mut slot = AssetSlot::default(); + apply_reader_to_slot( + slot.slot_field_data_mut(), + &AssetSlot::slot_field_shape(), + ®istry, + reader.value(), + ) + .expect("read asset"); + slot + } + + #[test] + fn parses_path_shorthand() { + let slot = read_asset( + r#" +source = "./shader.glsl" +"#, + ); + assert_eq!(slot.artifact_value().unwrap().to_string(), "shader.glsl"); + } + + #[test] + fn parses_path_table() { + let slot = read_asset( + r#" +path = "./shader.glsl" +"#, + ); + assert_eq!(slot.artifact_value().unwrap().to_string(), "shader.glsl"); + } + + #[test] + fn parses_legacy_dollar_path_table() { + let slot = read_asset( + r#" +"$path" = "./shader.glsl" +"#, + ); + assert_eq!(slot.artifact_value().unwrap().to_string(), "shader.glsl"); + } + + #[test] + fn parses_inline_glsl_table() { + let slot = read_asset( + r#" +glsl = "void main() {}" +"#, + ); + let (ext, text) = slot.inline_text_value().unwrap(); + assert_eq!(ext, Some("glsl")); + assert!(text.contains("main")); + } + + #[test] + fn parses_inline_svg_table() { + let slot = read_asset( + r#" +svg = "" +"#, + ); + let (ext, text) = slot.inline_text_value().unwrap(); + assert_eq!(ext, Some("svg")); + assert_eq!(text, ""); + } + + #[test] + fn parses_inline_bytes_table() { + let slot = read_asset( + r#" +extension = "png" +bytes = [137, 80, 78, 71] +"#, + ); + let (ext, bytes) = slot.inline_bytes_value().unwrap(); + assert_eq!(ext, Some("png")); + assert_eq!(bytes, &[137, 80, 78, 71]); + } + + #[test] + fn round_trips_artifact_shorthand_toml() { + let slot = AssetSlot::path("./a.glsl"); + let value = slot.write_slot_toml().expect("write"); + assert_eq!(value.as_str(), Some("a.glsl")); + } +} diff --git a/lp-core/lpc-model/src/slots/mod.rs b/lp-core/lpc-model/src/slots/mod.rs index 61ba762ed..d94f89a75 100644 --- a/lp-core/lpc-model/src/slots/mod.rs +++ b/lp-core/lpc-model/src/slots/mod.rs @@ -6,21 +6,24 @@ mod affine2d; mod artifact_path; +mod asset_slot; mod color_order; mod control_product; mod dim2u; +pub mod node_invocation_slot; mod positive_f32; mod ratio; mod relative_node_ref; mod render_order; mod resource_ref; -mod source_path; mod u32_list; mod visual_product; mod xy; pub use affine2d::{Affine2d, Affine2dSlot}; pub use artifact_path::{ArtifactPath, ArtifactPathSlot}; +pub(crate) use asset_slot::ASSET_SLOT_CODEC_ID; +pub use asset_slot::{AssetSlot, AssetSlotValue}; pub use color_order::{ColorOrderSlot, ColorOrderValue}; pub use control_product::ControlProductSlot; pub use dim2u::{Dim2u, Dim2uSlot}; @@ -29,7 +32,6 @@ pub use ratio::{Ratio, RatioSlot}; pub use relative_node_ref::RelativeNodeRefSlot; pub use render_order::{RenderOrder, RenderOrderSlot}; pub use resource_ref::ResourceRefSlot; -pub use source_path::{SourcePath, SourcePathSlot}; pub use visual_product::VisualProductSlot; pub use xy::{Xy, XySlot}; @@ -45,7 +47,7 @@ mod tests { positive: PositiveF32Slot, render_order: RenderOrderSlot, xy: XySlot, - source_path: SourcePathSlot, + asset: AssetSlot, artifact_path: ArtifactPathSlot, dim: Dim2uSlot, transform: Affine2dSlot, @@ -62,7 +64,7 @@ mod tests { positive: PositiveF32Slot::new(PositiveF32(2.0)), render_order: RenderOrderSlot::new(RenderOrder(10)), xy: XySlot::new(Xy([1.0, 2.0])), - source_path: SourcePathSlot::new(SourcePath(String::from("shader.glsl"))), + asset: AssetSlot::path("shader.glsl"), artifact_path: ArtifactPathSlot::new(ArtifactPath(String::from("./shader.toml"))), dim: Dim2uSlot::new(Dim2u { width: 64, @@ -76,7 +78,7 @@ mod tests { let authored = toml::to_string_pretty(&slots).unwrap(); assert!(authored.contains("ratio = 0.75")); - assert!(authored.contains("source_path = \"shader.glsl\"")); + assert!(authored.contains("asset = \"shader.glsl\"")); assert!(authored.contains("color_order = \"grb\"")); assert!(authored.contains("texture_loc = \"..texture\"")); diff --git a/lp-core/lpc-model/src/slots/node_invocation_slot.rs b/lp-core/lpc-model/src/slots/node_invocation_slot.rs new file mode 100644 index 000000000..c552ebf42 --- /dev/null +++ b/lp-core/lpc-model/src/slots/node_invocation_slot.rs @@ -0,0 +1,261 @@ +//! Parent-owned child node invocation. +//! +//! A [`NodeInvocation`] is the authored value stored by a parent when it owns a +//! child node position. It can be unset, reference another node artifact, or +//! carry an inline [`NodeDef`]. +//! +//! A [`NodeInvocationSlot`] is the slot wrapper used by slotted node +//! definitions. Prefer the slot alias for fields in authored model structs, and +//! use [`NodeInvocation`] for the value after reading or unwrapping the slot. + +use alloc::string::ToString; + +use crate::artifact::artifact_spec::ArtifactSpec; +use crate::nodes::node_def::{NodeArtifact, NodeDef}; +use crate::{ + ArtifactPath, ArtifactPathSlot, EnumSlot, FieldSlot, FieldSlotMut, SlotDataAccess, + SlotDataMutAccess, SlotShape, Slotted, StaticSlotShape, StaticSlotShapeDescriptor, +}; + +/// Slot wrapper for an authored child node invocation. +pub type NodeInvocationSlot = EnumSlot; + +/// Authored value for one parent-owned child node position. +#[derive(Clone, Debug, PartialEq, Slotted)] +#[slot(enum_encoding = "external", rename_all = "snake_case")] +pub enum NodeInvocation { + /// Reserved map entry with no wiring yet (valid while editing). + #[default] + Unset, + Ref(ArtifactPathSlot), + Def(NodeInvocationBody), +} + +/// Inline node definition body referenced by shape id to avoid static descriptor cycles. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct NodeInvocationBody(pub NodeArtifact); + +impl NodeInvocationBody { + pub fn new(def: NodeDef) -> Self { + Self(NodeArtifact::new(def)) + } + + pub fn value(&self) -> &NodeDef { + self.0.node_def() + } +} + +impl FieldSlot for NodeInvocationBody { + const STATIC_SLOT_FIELD_SHAPE_DESCRIPTOR: Option<&'static StaticSlotShapeDescriptor> = + Some(&StaticSlotShapeDescriptor::Ref { + id: NodeArtifact::SHAPE_ID, + }); + + fn slot_field_shape() -> SlotShape { + SlotShape::reference(::SHAPE_ID) + } + + fn slot_field_data(&self) -> SlotDataAccess<'_> { + self.0.slot_field_data() + } +} + +impl FieldSlotMut for NodeInvocationBody { + fn slot_field_data_mut(&mut self) -> SlotDataMutAccess<'_> { + self.0.slot_field_data_mut() + } +} + +impl NodeInvocation { + /// Construct a path-backed invocation. + #[must_use] + pub fn new(specifier: ArtifactSpec) -> Self { + Self::path(specifier) + } + + #[must_use] + pub fn path(specifier: ArtifactSpec) -> Self { + Self::Ref(ArtifactPathSlot::new(ArtifactPath(specifier.to_string()))) + } + + #[must_use] + pub fn inline(def: NodeDef) -> Self { + Self::Def(NodeInvocationBody::new(def)) + } + + pub fn ref_specifier(&self) -> Option { + match self { + Self::Unset | Self::Def(_) => None, + Self::Ref(path) => { + let text = path.value().as_str(); + if text.is_empty() { + None + } else { + ArtifactSpec::parse(text).ok() + } + } + } + } + + pub fn inline_def(&self) -> Option<&NodeDef> { + match self { + Self::Unset | Self::Ref(_) => None, + Self::Def(body) => Some(body.value()), + } + } + + pub fn is_unset(&self) -> bool { + matches!(self, Self::Unset) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EnumSlot, FieldSlotMut, NodeDef, SlotEnumShape, SlotShapeRegistry}; + + #[test] + fn node_invocation_default_is_unset() { + assert!(NodeInvocation::default().is_unset()); + } + + #[test] + fn node_invocation_toml_unset_form_loads() { + let invocation = read_invocation( + r#" +unset = {} +"#, + ); + assert!(invocation.is_unset()); + } + + #[test] + fn node_invocation_toml_ref_form_loads() { + let invocation = read_invocation( + r#" +ref = "./texture.toml" +"#, + ); + + assert_eq!( + invocation.ref_specifier().unwrap(), + ArtifactSpec::path("./texture.toml") + ); + } + + #[test] + fn node_invocation_rejects_legacy_def_path_form() { + let err = read_invocation_err( + r#" +def = { path = "./texture.toml" } +"#, + ); + + assert!(err.to_string().contains("def") || err.to_string().contains("unknown")); + } + + #[test] + fn node_invocation_rejects_legacy_artifact_field() { + let err = read_invocation_err( + r#" +artifact = "./texture.toml" +"#, + ); + + assert!(err.to_string().contains("artifact") || err.to_string().contains("unknown")); + } + + #[test] + fn node_invocation_toml_inline_def_form_loads() { + let invocation = read_invocation( + r#" +[def] +kind = "Clock" +"#, + ); + + assert!(matches!(invocation.inline_def(), Some(NodeDef::Clock(_)))); + } + + #[test] + fn node_invocation_rejects_ref_plus_inline_def() { + let err = read_invocation_err( + r#" +ref = "./clock.toml" + +[def] +kind = "Clock" +"#, + ); + + assert!(err.to_string().contains("def") || err.to_string().contains("unknown")); + } + + #[test] + fn node_invocation_round_trips_unset_form() { + let text = r#" +kind = "Project" + +[nodes.placeholder] +unset = {} +"#; + round_trip_project_fragment(text); + } + + #[test] + fn node_invocation_round_trips_ref_form() { + let text = r#" +kind = "Project" + +[nodes.shader] +ref = "./shader.toml" +"#; + round_trip_project_fragment(text); + } + + #[test] + fn node_invocation_round_trips_inline_def_form() { + let text = r#" +kind = "Project" + +[nodes.clock.def] +kind = "Clock" +"#; + round_trip_project_fragment(text); + } + + fn round_trip_project_fragment(text: &str) { + let registry = SlotShapeRegistry::default(); + let def = NodeDef::read_toml(®istry, text).unwrap(); + let written = NodeDef::write_toml(&def, ®istry).unwrap(); + let again = NodeDef::read_toml(®istry, &written).unwrap(); + assert_eq!(def, again); + } + + fn read_invocation(text: &str) -> NodeInvocation { + read_invocation_result(text).unwrap() + } + + fn read_invocation_err(text: &str) -> crate::slot_codec::SyntaxError { + read_invocation_result(text).unwrap_err() + } + + fn read_invocation_result( + text: &str, + ) -> Result { + let registry = SlotShapeRegistry::default(); + let value = toml::from_str::(text).unwrap(); + let mut reader = crate::slot_codec::SlotReader::new( + crate::slot_codec::TomlSyntaxSource::new(&value).unwrap(), + ®istry, + ); + let mut invocation = EnumSlot::new(NodeInvocation::default()); + crate::slot_codec::apply_reader_to_slot( + invocation.slot_field_data_mut(), + &NodeInvocation::slot_enum_shape(), + ®istry, + reader.value(), + )?; + Ok(invocation.into_inner()) + } +} diff --git a/lp-core/lpc-model/src/slots/source_path.rs b/lp-core/lpc-model/src/slots/source_path.rs deleted file mode 100644 index 5ec5a09aa..000000000 --- a/lp-core/lpc-model/src/slots/source_path.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::{SlotValue, ValueSlot}; -use alloc::string::String; -use serde::{Deserialize, Serialize}; - -/// Path to an authored source file. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, SlotValue)] -#[slot_value(editor = path)] -pub struct SourcePath(pub String); - -impl SourcePath { - pub fn as_str(&self) -> &str { - &self.0 - } - - pub fn as_path_buf(&self) -> crate::LpPathBuf { - crate::AsLpPathBuf::as_path_buf(&self.0) - } -} - -impl From for SourcePath { - fn from(value: String) -> Self { - Self(value) - } -} - -impl From<&str> for SourcePath { - fn from(value: &str) -> Self { - Self(String::from(value)) - } -} - -pub type SourcePathSlot = ValueSlot; diff --git a/lp-core/lpc-model/src/sync/current_revision.rs b/lp-core/lpc-model/src/sync/current_revision.rs index 6fef109f0..b8d04c3f9 100644 --- a/lp-core/lpc-model/src/sync/current_revision.rs +++ b/lp-core/lpc-model/src/sync/current_revision.rs @@ -1,7 +1,18 @@ use crate::Revision; -use core::sync::atomic::{AtomicI32, Ordering}; -static CURRENT_REVISION: AtomicI32 = AtomicI32::new(0); +// Production: a process-wide atomic, advanced by the single frame-orchestration +// owner. Test builds: a per-thread cell, so parallel tests that set/advance the +// ambient revision are isolated and don't race through shared global state +// (libtest runs tests across many threads). `cfg(test)` is only set when +// compiling this crate's own tests on the host, never in the firmware +// (`no_std`) build, which keeps the atomic. +#[cfg(not(test))] +static CURRENT_REVISION: core::sync::atomic::AtomicI32 = core::sync::atomic::AtomicI32::new(0); + +#[cfg(test)] +std::thread_local! { + static CURRENT_REVISION: core::cell::Cell = core::cell::Cell::new(0); +} /// Current ambient synchronized-state revision. /// @@ -10,7 +21,11 @@ static CURRENT_REVISION: AtomicI32 = AtomicI32::new(0); /// the ambient revision advances; data containers should normally read it, not /// advance it themselves. pub fn current_revision() -> Revision { - Revision::new(CURRENT_REVISION.load(Ordering::Relaxed) as i64) + #[cfg(not(test))] + let raw = CURRENT_REVISION.load(core::sync::atomic::Ordering::Relaxed); + #[cfg(test)] + let raw = CURRENT_REVISION.with(core::cell::Cell::get); + Revision::new(raw as i64) } /// Set the ambient synchronized-state revision. @@ -19,12 +34,24 @@ pub fn current_revision() -> Revision { /// focused tests. Ordinary slot data mutation should stamp the current revision /// rather than setting it. pub fn set_current_revision(revision: Revision) { - CURRENT_REVISION.store(revision.as_i64() as i32, Ordering::Relaxed); + let raw = revision.as_i64() as i32; + #[cfg(not(test))] + CURRENT_REVISION.store(raw, core::sync::atomic::Ordering::Relaxed); + #[cfg(test)] + CURRENT_REVISION.with(|cell| cell.set(raw)); } /// Advance the ambient synchronized-state revision and return the new value. pub fn advance_revision() -> Revision { - Revision::new((CURRENT_REVISION.fetch_add(1, Ordering::Relaxed) + 1) as i64) + #[cfg(not(test))] + let next = CURRENT_REVISION.fetch_add(1, core::sync::atomic::Ordering::Relaxed) + 1; + #[cfg(test)] + let next = CURRENT_REVISION.with(|cell| { + let next = cell.get() + 1; + cell.set(next); + next + }); + Revision::new(next as i64) } #[cfg(test)] diff --git a/lp-core/lpc-registry/Cargo.toml b/lp-core/lpc-registry/Cargo.toml new file mode 100644 index 000000000..14e2f989c --- /dev/null +++ b/lp-core/lpc-registry/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "lpc-registry" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[features] +default = ["std", "diff"] +std = ["lpc-model/std", "lpfs/std"] +# Host/CI harness helpers. Omit on embedded. +diff = [] + +[dependencies] +lp-collection = { workspace = true, features = ["serde"] } +lpc-model = { path = "../lpc-model", default-features = false } +lpfs = { path = "../../lp-base/lpfs", default-features = false } +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +lpc-wire = { path = "../lpc-wire", default-features = false } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/lp-core/lpc-registry/src/artifact/artifact_entry.rs b/lp-core/lpc-registry/src/artifact/artifact_entry.rs new file mode 100644 index 000000000..7bc4f55e0 --- /dev/null +++ b/lp-core/lpc-registry/src/artifact/artifact_entry.rs @@ -0,0 +1,12 @@ +//! Single artifact record in [`super::ArtifactStore`]. + +use lpc_model::Revision; + +use super::{ArtifactLocation, ArtifactReadState}; + +/// One registered project artifact: location, content revision, read outcome. +pub struct ArtifactEntry { + pub location: ArtifactLocation, + pub revision: Revision, + pub read_state: ArtifactReadState, +} diff --git a/lp-core/lpc-registry/src/artifact/artifact_error.rs b/lp-core/lpc-registry/src/artifact/artifact_error.rs new file mode 100644 index 000000000..e1c33509c --- /dev/null +++ b/lp-core/lpc-registry/src/artifact/artifact_error.rs @@ -0,0 +1,27 @@ +//! Structured errors for artifact store operations. + +use alloc::string::String; + +use super::ArtifactReadFailure; +use lpc_model::{ArtifactLocation, ArtifactLocationError}; + +/// Errors returned by [`super::ArtifactStore`] and read operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArtifactError { + /// No catalog entry exists for this [`ArtifactLocation`]. + UnknownArtifact { location: ArtifactLocation }, + /// Locator resolution failed at acquire time (no entry created). + Resolution(String), + /// Transient read failed; see [`ArtifactReadFailure`] on the entry. + Read(ArtifactReadFailure), + /// Internal invariant violation (should not happen for file artifacts). + Internal(String), +} + +impl From for ArtifactError { + fn from(err: ArtifactLocationError) -> Self { + match err { + ArtifactLocationError::Resolution(message) => Self::Resolution(message), + } + } +} diff --git a/lp-core/lpc-registry/src/artifact/artifact_read_state.rs b/lp-core/lpc-registry/src/artifact/artifact_read_state.rs new file mode 100644 index 000000000..d9e4e1ab4 --- /dev/null +++ b/lp-core/lpc-registry/src/artifact/artifact_read_state.rs @@ -0,0 +1,39 @@ +//! Last read / fs-notify outcome for a held artifact (no bytes stored). + +use alloc::string::String; + +use lpfs::FsError; + +/// Outcome of the last materialization attempt or fs delete notification. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ArtifactReadState { + /// No read since the last [`super::ArtifactEntry::revision`] bump. + Unread, + /// Last transient read succeeded (bytes not retained on the entry). + ReadOk, + /// Read failed or fs notified delete while held. + Failed(ArtifactReadFailure), +} + +/// Structured read / availability failure (distinct from acquire-time resolution). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ArtifactReadFailure { + /// `FsChange::Delete` while entry held — watcher-sourced. + Deleted, + /// File not on disk at read time. + NotFound, + /// Filesystem or host I/O error. + Io { message: String }, + /// Invalid path at read time. + InvalidPath { message: String }, +} + +impl ArtifactReadFailure { + pub fn from_fs_error(err: FsError) -> Self { + match err { + FsError::NotFound(_msg) => Self::NotFound, + FsError::Filesystem(msg) => Self::Io { message: msg }, + FsError::InvalidPath(msg) => Self::InvalidPath { message: msg }, + } + } +} diff --git a/lp-core/lpc-registry/src/artifact/artifact_store.rs b/lp-core/lpc-registry/src/artifact/artifact_store.rs new file mode 100644 index 000000000..207f838a5 --- /dev/null +++ b/lp-core/lpc-registry/src/artifact/artifact_store.rs @@ -0,0 +1,296 @@ +//! Project artifact catalog: locations, freshness, transient reads. + +use alloc::vec::Vec; +use lp_collection::VecMap; + +use lpc_model::{ArtifactLocation, ArtifactSpec, Revision}; +use lpfs::{FsEvent, FsEventKind, LpFs, LpPath, LpPathBuf}; + +use super::{ArtifactEntry, ArtifactError, ArtifactReadFailure, ArtifactReadState}; + +/// Catalog of project file artifacts keyed by [`ArtifactLocation`]. +/// +/// An artifact remains registered until [`Self::unregister`]. Registration is +/// idempotent: [`Self::register_file`] returns the same location for the same path. +pub struct ArtifactStore { + by_location: VecMap, +} + +impl ArtifactStore { + pub fn new() -> Self { + Self { + by_location: VecMap::new(), + } + } + + /// Register `path` in the project catalog, or return the existing location. + pub fn register_file(&mut self, path: LpPathBuf, frame: Revision) -> ArtifactLocation { + self.register_location(ArtifactLocation::file(path), frame) + } + + /// Register a resolved location, or return the existing entry's location. + pub fn register_location( + &mut self, + location: ArtifactLocation, + frame: Revision, + ) -> ArtifactLocation { + if let Some(entry) = self.by_location.get(&location) { + return entry.location.clone(); + } + self.by_location.insert( + location.clone(), + ArtifactEntry { + location: location.clone(), + revision: frame, + read_state: ArtifactReadState::Unread, + }, + ); + location + } + + pub fn acquire_specifier( + &mut self, + specifier: &ArtifactSpec, + frame: Revision, + ) -> Result { + let location = ArtifactLocation::try_from_specifier(specifier)?; + let path = location.file_path().clone(); + Ok(self.register_file(path, frame)) + } + + /// Drop a registered artifact when nothing in the project references it. + pub fn unregister(&mut self, location: &ArtifactLocation) -> Result<(), ArtifactError> { + self.by_location + .remove(location) + .ok_or(ArtifactError::UnknownArtifact { + location: location.clone(), + })?; + Ok(()) + } + + pub fn location_for_path(&self, path: &LpPath) -> Option { + let location = ArtifactLocation::location_for_path(path); + self.by_location + .get(&location) + .map(|entry| entry.location.clone()) + } + + pub fn locations(&self) -> impl Iterator + '_ { + self.by_location + .values() + .map(|entry| entry.location.clone()) + } + + pub fn apply_fs_changes(&mut self, changes: &[FsEvent], frame: Revision) { + for change in changes { + self.apply_fs_change(change, frame); + } + } + + pub fn read_bytes( + &mut self, + location: &ArtifactLocation, + fs: &dyn LpFs, + ) -> Result, ArtifactError> { + let path = location.file_path().clone(); + + if self.entry(location).is_none() { + return Err(ArtifactError::UnknownArtifact { + location: location.clone(), + }); + } + + match fs.read_file(path.as_path()) { + Ok(bytes) => { + if let Some(entry) = self.by_location.get_mut(location) { + entry.read_state = ArtifactReadState::ReadOk; + } + Ok(bytes) + } + Err(err) => { + let failure = ArtifactReadFailure::from_fs_error(err); + if let Some(entry) = self.by_location.get_mut(location) { + entry.read_state = ArtifactReadState::Failed(failure.clone()); + } + Err(ArtifactError::Read(failure)) + } + } + } + + pub fn revision(&self, location: &ArtifactLocation) -> Option { + self.entry(location).map(|entry| entry.revision) + } + + pub fn entry(&self, location: &ArtifactLocation) -> Option<&ArtifactEntry> { + self.by_location.get(location) + } +} + +impl Default for ArtifactStore { + fn default() -> Self { + Self::new() + } +} + +impl ArtifactStore { + fn apply_fs_change(&mut self, change: &FsEvent, frame: Revision) { + for entry in self.by_location.values_mut() { + let path = entry.location.file_path(); + if path != &change.path { + continue; + } + entry.revision = frame; + entry.read_state = match change.kind { + FsEventKind::Delete => ArtifactReadState::Failed(ArtifactReadFailure::Deleted), + FsEventKind::Modify | FsEventKind::Create => ArtifactReadState::Unread, + }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lpfs::{FsEvent, FsEventKind, LpFsMemory}; + + fn fs_change(path: &str, kind: FsEventKind) -> FsEvent { + FsEvent { + path: LpPathBuf::from(path), + kind, + } + } + + fn project_path(name: &str) -> LpPathBuf { + LpPathBuf::from(alloc::format!("/{name}")) + } + + fn file_loc(path: &str) -> ArtifactLocation { + ArtifactLocation::file(path) + } + + #[test] + fn register_same_path_reuses_location() { + let mut store = ArtifactStore::new(); + let loc1 = store.register_file(LpPathBuf::from("/shader.glsl"), Revision::new(1)); + let loc2 = store.register_file(LpPathBuf::from("/shader.glsl"), Revision::new(2)); + assert_eq!(loc1, loc2); + assert_eq!(store.locations().count(), 1); + } + + #[test] + fn unregister_removes_entry() { + let mut store = ArtifactStore::new(); + let location = store.register_file(LpPathBuf::from("/a.toml"), Revision::new(1)); + store.unregister(&location).unwrap(); + assert!(store.entry(&location).is_none()); + assert!(store.location_for_path(LpPath::new("/a.toml")).is_none()); + } + + #[test] + fn fs_modify_bumps_revision_and_sets_unread() { + let mut store = ArtifactStore::new(); + let location = store.register_file(LpPathBuf::from("/b.glsl"), Revision::new(1)); + store.apply_fs_changes( + &[fs_change("/b.glsl", FsEventKind::Modify)], + Revision::new(5), + ); + assert_eq!(store.revision(&location), Some(Revision::new(5))); + assert_eq!( + store.entry(&location).unwrap().read_state, + ArtifactReadState::Unread + ); + } + + #[test] + fn fs_change_on_unregistered_path_is_noop() { + let mut store = ArtifactStore::new(); + store.apply_fs_changes( + &[fs_change("/missing.glsl", FsEventKind::Modify)], + Revision::new(9), + ); + let location = store.register_file(LpPathBuf::from("/missing.glsl"), Revision::new(2)); + assert_eq!(store.revision(&location), Some(Revision::new(2))); + } + + #[test] + fn fs_delete_sets_deleted_failure_while_registered() { + let mut store = ArtifactStore::new(); + let location = store.register_file(LpPathBuf::from("/c.svg"), Revision::new(1)); + store.apply_fs_changes( + &[fs_change("/c.svg", FsEventKind::Delete)], + Revision::new(3), + ); + assert_eq!(store.revision(&location), Some(Revision::new(3))); + assert_eq!( + store.entry(&location).unwrap().read_state, + ArtifactReadState::Failed(ArtifactReadFailure::Deleted) + ); + } + + #[test] + fn acquire_specifier_rejects_lib() { + let mut store = ArtifactStore::new(); + let specifier = ArtifactSpec::parse("lib:core/x").unwrap(); + let err = store + .acquire_specifier(&specifier, Revision::new(1)) + .unwrap_err(); + assert!(matches!(err, ArtifactError::Resolution(_))); + let location = store.register_file(LpPathBuf::from("/after.toml"), Revision::new(1)); + assert_eq!(location, file_loc("/after.toml")); + } + + #[test] + fn read_bytes_success_sets_read_ok() { + let mut fs = LpFsMemory::new(); + fs.write_file_mut(project_path("shader.glsl").as_path(), b"void main() {}") + .unwrap(); + + let mut store = ArtifactStore::new(); + let location = store.register_file(LpPathBuf::from("/shader.glsl"), Revision::new(1)); + let bytes = store.read_bytes(&location, &fs).unwrap(); + assert_eq!(bytes, b"void main() {}"); + assert_eq!( + store.entry(&location).unwrap().read_state, + ArtifactReadState::ReadOk + ); + } + + #[test] + fn read_bytes_missing_file_sets_not_found() { + let fs = LpFsMemory::new(); + let mut store = ArtifactStore::new(); + let location = store.register_file(LpPathBuf::from("/nope.glsl"), Revision::new(1)); + let err = store.read_bytes(&location, &fs).unwrap_err(); + assert!(matches!( + err, + ArtifactError::Read(ArtifactReadFailure::NotFound) + )); + assert_eq!( + store.entry(&location).unwrap().read_state, + ArtifactReadState::Failed(ArtifactReadFailure::NotFound) + ); + } + + #[test] + fn read_after_fs_modify_gets_new_content() { + let mut fs = LpFsMemory::new(); + fs.write_file_mut(project_path("x.glsl").as_path(), b"v1") + .unwrap(); + + let mut store = ArtifactStore::new(); + let location = store.register_file(LpPathBuf::from("/x.glsl"), Revision::new(1)); + assert_eq!(store.read_bytes(&location, &fs).unwrap(), b"v1"); + + fs.write_file_mut(project_path("x.glsl").as_path(), b"v2") + .unwrap(); + store.apply_fs_changes( + &[fs_change("/x.glsl", FsEventKind::Modify)], + Revision::new(2), + ); + assert_eq!( + store.entry(&location).unwrap().read_state, + ArtifactReadState::Unread + ); + assert_eq!(store.read_bytes(&location, &fs).unwrap(), b"v2"); + } +} diff --git a/lp-core/lpc-registry/src/artifact/mod.rs b/lp-core/lpc-registry/src/artifact/mod.rs new file mode 100644 index 000000000..cf2f59a2c --- /dev/null +++ b/lp-core/lpc-registry/src/artifact/mod.rs @@ -0,0 +1,12 @@ +//! Project artifact catalog: stable locations, freshness metadata, transient reads. + +mod artifact_entry; +mod artifact_error; +mod artifact_read_state; +mod artifact_store; + +pub use artifact_entry::ArtifactEntry; +pub use artifact_error::ArtifactError; +pub use artifact_read_state::{ArtifactReadFailure, ArtifactReadState}; +pub use artifact_store::ArtifactStore; +pub use lpc_model::ArtifactLocation; diff --git a/lp-core/lpc-registry/src/asset/asset_content.rs b/lp-core/lpc-registry/src/asset/asset_content.rs new file mode 100644 index 000000000..077a2c073 --- /dev/null +++ b/lp-core/lpc-registry/src/asset/asset_content.rs @@ -0,0 +1,38 @@ +//! Transient effective asset bodies. + +use alloc::string::String; +use alloc::vec::Vec; + +use lpc_model::{AssetContentType, AssetLocation, Revision}; + +/// Effective asset bytes read for compilation, diagnostics, or runtime load. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetBytes { + pub location: AssetLocation, + pub content_type: AssetContentType, + pub revision: Revision, + pub bytes: Vec, + pub diagnostic_name: String, +} + +impl AssetBytes { + pub fn changed_since(&self, revision: Revision) -> bool { + self.revision > revision + } +} + +/// Effective UTF-8 asset text. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetText { + pub location: AssetLocation, + pub content_type: AssetContentType, + pub revision: Revision, + pub text: String, + pub diagnostic_name: String, +} + +impl AssetText { + pub fn changed_since(&self, revision: Revision) -> bool { + self.revision > revision + } +} diff --git a/lp-core/lpc-registry/src/asset/asset_read_error.rs b/lp-core/lpc-registry/src/asset/asset_read_error.rs new file mode 100644 index 000000000..59b977ff9 --- /dev/null +++ b/lp-core/lpc-registry/src/asset/asset_read_error.rs @@ -0,0 +1,35 @@ +//! Errors from effective asset materialization. + +use alloc::string::String; + +use lpc_model::{AssetLocation, NodeDefLocation}; + +/// Failure reading the effective body of a referenced project asset. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AssetReadError { + UnreferencedAsset { + location: AssetLocation, + }, + NotFound { + location: AssetLocation, + }, + Deleted { + location: AssetLocation, + }, + ReadError { + location: AssetLocation, + message: String, + }, + Utf8 { + location: AssetLocation, + message: String, + }, + Unsupported { + location: AssetLocation, + message: String, + }, + OwnerDefUnavailable { + location: AssetLocation, + owner: NodeDefLocation, + }, +} diff --git a/lp-core/lpc-registry/src/asset/materialize_asset.rs b/lp-core/lpc-registry/src/asset/materialize_asset.rs new file mode 100644 index 000000000..c9c80d80c --- /dev/null +++ b/lp-core/lpc-registry/src/asset/materialize_asset.rs @@ -0,0 +1,197 @@ +//! Materialize effective project assets by [`lpc_model::AssetLocation`]. + +use alloc::format; +use alloc::string::{String, ToString}; + +use lpc_model::{ + ArtifactLocation, ArtifactOverlay, AssetBodyOverlay, AssetEntry, AssetLocation, NodeDefState, + ProjectInventory, ProjectOverlay, WithRevision, +}; +use lpfs::LpFs; + +use crate::{ArtifactError, ArtifactReadFailure, ArtifactStore}; + +use super::{AssetBytes, AssetReadError, AssetText}; + +pub fn materialize_asset( + artifacts: &mut ArtifactStore, + overlay: &WithRevision, + inventory: &ProjectInventory, + fs: &dyn LpFs, + source: &AssetLocation, +) -> Result { + let entry = inventory + .assets + .get(source) + .ok_or_else(|| AssetReadError::UnreferencedAsset { + location: source.clone(), + })?; + + match source { + AssetLocation::Artifact { location } => { + materialize_artifact_asset(artifacts, overlay, fs, source, location, entry) + } + AssetLocation::Inline { owner, path } => { + materialize_inline_asset(inventory, source, owner, path, entry) + } + } +} + +pub fn materialize_asset_text( + artifacts: &mut ArtifactStore, + overlay: &WithRevision, + inventory: &ProjectInventory, + fs: &dyn LpFs, + source: &AssetLocation, +) -> Result { + let materialized = materialize_asset(artifacts, overlay, inventory, fs, source)?; + let text = + String::from_utf8(materialized.bytes.clone()).map_err(|err| AssetReadError::Utf8 { + location: source.clone(), + message: err.to_string(), + })?; + + Ok(AssetText { + location: materialized.location, + content_type: materialized.content_type, + revision: materialized.revision, + text, + diagnostic_name: materialized.diagnostic_name, + }) +} + +fn materialize_artifact_asset( + artifacts: &mut ArtifactStore, + overlay: &WithRevision, + fs: &dyn LpFs, + source: &AssetLocation, + location: &ArtifactLocation, + entry: &AssetEntry, +) -> Result { + match overlay.get().artifact(location) { + Some(ArtifactOverlay::Asset { + overlay: AssetBodyOverlay::ReplaceBody(bytes), + }) => { + return Ok(AssetBytes { + location: source.clone(), + content_type: entry.content_type, + revision: overlay.changed_at(), + bytes: bytes.clone(), + diagnostic_name: artifact_diagnostic_name(location), + }); + } + Some(ArtifactOverlay::Asset { + overlay: AssetBodyOverlay::Delete, + }) => { + return Err(AssetReadError::Deleted { + location: source.clone(), + }); + } + Some(ArtifactOverlay::Slot { .. }) => { + return Err(AssetReadError::Unsupported { + location: source.clone(), + message: String::from("slot overlay cannot materialize as an asset body"), + }); + } + None => {} + } + + match artifacts.read_bytes(location, fs) { + Ok(bytes) => Ok(AssetBytes { + location: source.clone(), + content_type: entry.content_type, + revision: artifacts.revision(location).unwrap_or(entry.revision), + bytes, + diagnostic_name: artifact_diagnostic_name(location), + }), + Err(err) => Err(error_from_artifact(source, err)), + } +} + +fn materialize_inline_asset( + inventory: &ProjectInventory, + source: &AssetLocation, + owner: &lpc_model::NodeDefLocation, + path: &lpc_model::SlotPath, + entry: &AssetEntry, +) -> Result { + let Some(owner_entry) = inventory.defs.get(owner) else { + return Err(AssetReadError::OwnerDefUnavailable { + location: source.clone(), + owner: owner.clone(), + }); + }; + let NodeDefState::Loaded(def) = &owner_entry.state else { + return Err(AssetReadError::OwnerDefUnavailable { + location: source.clone(), + owner: owner.clone(), + }); + }; + + if let Some(text) = def.inline_asset_text(&owner.path, path) { + return Ok(AssetBytes { + location: source.clone(), + content_type: entry.content_type, + revision: entry.revision, + bytes: text.text.as_bytes().to_vec(), + diagnostic_name: inline_asset_diagnostic_name(owner, path, Some(text.extension)), + }); + } + + if let Some(bytes) = def.inline_asset_bytes(&owner.path, path) { + return Ok(AssetBytes { + location: source.clone(), + content_type: entry.content_type, + revision: entry.revision, + bytes: bytes.bytes.to_vec(), + diagnostic_name: inline_asset_diagnostic_name(owner, path, bytes.extension), + }); + } + + Err(AssetReadError::Unsupported { + location: source.clone(), + message: String::from("inline asset body is not supported by this node definition"), + }) +} + +fn inline_asset_diagnostic_name( + owner: &lpc_model::NodeDefLocation, + path: &lpc_model::SlotPath, + extension: Option<&str>, +) -> String { + match extension { + Some(extension) => format!( + "{}:{}.{}", + owner.artifact.file_path().as_str(), + path, + extension + ), + None => format!("{}:{}", owner.artifact.file_path().as_str(), path), + } +} + +fn error_from_artifact(source: &AssetLocation, err: ArtifactError) -> AssetReadError { + match err { + ArtifactError::Read(ArtifactReadFailure::NotFound) => AssetReadError::NotFound { + location: source.clone(), + }, + ArtifactError::Read(ArtifactReadFailure::Deleted) => AssetReadError::Deleted { + location: source.clone(), + }, + ArtifactError::Read(ArtifactReadFailure::Io { message }) + | ArtifactError::Read(ArtifactReadFailure::InvalidPath { message }) + | ArtifactError::Resolution(message) + | ArtifactError::Internal(message) => AssetReadError::ReadError { + location: source.clone(), + message, + }, + ArtifactError::UnknownArtifact { location } => AssetReadError::ReadError { + location: source.clone(), + message: format!("unknown artifact {}", location.to_uri()), + }, + } +} + +fn artifact_diagnostic_name(location: &ArtifactLocation) -> String { + location.file_path().as_str().to_string() +} diff --git a/lp-core/lpc-registry/src/asset/mod.rs b/lp-core/lpc-registry/src/asset/mod.rs new file mode 100644 index 000000000..f9926d93a --- /dev/null +++ b/lp-core/lpc-registry/src/asset/mod.rs @@ -0,0 +1,9 @@ +//! Effective project asset materialization. + +mod asset_content; +mod asset_read_error; +mod materialize_asset; + +pub use asset_content::{AssetBytes, AssetText}; +pub use asset_read_error::AssetReadError; +pub use materialize_asset::{materialize_asset, materialize_asset_text}; diff --git a/lp-core/lpc-registry/src/lib.rs b/lp-core/lpc-registry/src/lib.rs new file mode 100644 index 000000000..59ee2d730 --- /dev/null +++ b/lp-core/lpc-registry/src/lib.rs @@ -0,0 +1,35 @@ +//! Effective project registry built from artifacts plus a pending overlay. + +#![no_std] + +extern crate alloc; + +#[cfg(feature = "std")] +extern crate std; + +pub mod artifact; +pub mod asset; +pub(crate) mod overlay; + +pub mod registry; +#[cfg(any(test, feature = "diff"))] +pub mod test; + +pub use artifact::{ + ArtifactEntry, ArtifactError, ArtifactLocation, ArtifactReadFailure, ArtifactReadState, + ArtifactStore, +}; +pub use asset::{AssetBytes, AssetReadError, AssetText}; +pub use lpc_model::{ + ArtifactOverlay, AssetBodyOverlay, ProjectOverlay, SlotEdit, SlotEditOp, SlotOverlay, +}; +pub use overlay::{EditApplyError, serialize_slot_draft}; +pub use registry::commit_error::CommitError; +pub use registry::load_result::LoadResult; +pub use registry::parse_ctx::ParseCtx; +pub use registry::project_registry::ProjectRegistry; +pub use registry::registry_error::RegistryError; +#[cfg(feature = "diff")] +pub use test::snapshot_overlay::{ + ProjectSnapshot, SnapshotError, derive_overlay_between_snapshots, +}; diff --git a/lp-core/lpc-registry/src/overlay/apply_error.rs b/lp-core/lpc-registry/src/overlay/apply_error.rs new file mode 100644 index 000000000..b26c0c6ee --- /dev/null +++ b/lp-core/lpc-registry/src/overlay/apply_error.rs @@ -0,0 +1,30 @@ +//! Errors from applying edits to the slot overlay. + +use alloc::string::String; +use core::fmt; + +use crate::ArtifactLocation; + +/// Failure applying pending overlay edits. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditApplyError { + InvalidPath { message: String }, + UnknownArtifact { location: ArtifactLocation }, + Parse { message: String }, + SlotMutation { message: String }, + Serialize { message: String }, +} + +impl fmt::Display for EditApplyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPath { message } => write!(f, "invalid path: {message}"), + Self::UnknownArtifact { location } => { + write!(f, "unknown artifact {}", location.to_uri()) + } + Self::Parse { message } => write!(f, "parse error: {message}"), + Self::SlotMutation { message } => write!(f, "slot mutation error: {message}"), + Self::Serialize { message } => write!(f, "serialize error: {message}"), + } + } +} diff --git a/lp-core/lpc-registry/src/overlay/apply_slot.rs b/lp-core/lpc-registry/src/overlay/apply_slot.rs new file mode 100644 index 000000000..c88245ed8 --- /dev/null +++ b/lp-core/lpc-registry/src/overlay/apply_slot.rs @@ -0,0 +1,149 @@ +//! Apply slot-level artifact ops and serialize overlay drafts. + +use alloc::string::ToString; +use alloc::vec::Vec; + +use lpc_model::{ + NodeArtifact, NodeDef, Revision, SlotMutAccess, SlotName, SlotOverlay, SlotPath, + SlotPathSegment, ensure_slot_present, remove_slot_map_entry, set_slot_option_none, + set_slot_value, set_slot_variant_default, +}; + +use crate::ParseCtx; +use lpc_model::SlotEdit; + +use super::EditApplyError; + +pub fn serialize_slot_draft(def: &NodeDef, ctx: &ParseCtx<'_>) -> Result, EditApplyError> { + let text = NodeDef::write_toml(def, ctx.shapes).map_err(|err| EditApplyError::Serialize { + message: err.to_string(), + })?; + Ok(text.into_bytes()) +} + +pub(crate) fn parse_def_bytes(bytes: &[u8], ctx: &ParseCtx<'_>) -> Result { + let text = core::str::from_utf8(bytes).map_err(|err| EditApplyError::Parse { + message: err.to_string(), + })?; + NodeDef::read_toml(ctx.shapes, text).map_err(|err| EditApplyError::Parse { + message: err.to_string(), + }) +} + +pub(crate) fn apply_slot_overlay_to_def( + def: &mut NodeDef, + overlay: &SlotOverlay, + ctx: &ParseCtx<'_>, + frame: Revision, +) -> Result<(), EditApplyError> { + for edit in overlay.to_apply_plan() { + apply_op_to_def(def, &edit, ctx, frame)?; + } + Ok(()) +} + +pub(crate) fn apply_op_to_def( + def: &mut NodeDef, + op: &SlotEdit, + ctx: &ParseCtx<'_>, + frame: Revision, +) -> Result<(), EditApplyError> { + match &op.op { + lpc_model::SlotEditOp::EnsurePresent => { + apply_ensure_present(def, ctx, &op.path, frame).map(drop) + } + lpc_model::SlotEditOp::AssignValue(value) => { + let value_path = apply_ensure_present(def, ctx, &op.path, frame)?; + mutate_def(def, |root| { + set_slot_value(root, ctx.shapes, &value_path, frame, value.clone()) + }) + } + lpc_model::SlotEditOp::Remove => apply_remove(def, ctx, &op.path, frame), + } +} + +fn apply_ensure_present( + def: &mut NodeDef, + ctx: &ParseCtx<'_>, + path: &SlotPath, + frame: Revision, +) -> Result { + if let Some((variant, tail)) = split_root_variant(path) { + if def.variant_name() == variant.as_str() { + mutate_def(def, |root| { + ensure_slot_present(root, ctx.shapes, &tail, frame) + })?; + return Ok(tail); + } + + let mut artifact = NodeArtifact::new(def.clone()); + if set_slot_variant_default( + &mut artifact, + ctx.shapes, + &SlotPath::root(), + frame, + variant.as_str(), + ) + .is_ok() + { + let mut switched = artifact.into_node_def(); + mutate_def(&mut switched, |root| { + ensure_slot_present(root, ctx.shapes, &tail, frame) + })?; + *def = switched; + return Ok(tail); + } + } + mutate_def(def, |root| { + ensure_slot_present(root, ctx.shapes, path, frame) + })?; + Ok(path.clone()) +} + +fn split_root_variant(path: &SlotPath) -> Option<(&SlotName, SlotPath)> { + let (SlotPathSegment::Field(variant), tail) = path.segments().split_first()? else { + return None; + }; + if !NodeDef::is_variant_name(variant.as_str()) { + return None; + } + Some((variant, SlotPath::from_segments(tail.to_vec()))) +} + +fn apply_remove( + def: &mut NodeDef, + ctx: &ParseCtx<'_>, + path: &SlotPath, + frame: Revision, +) -> Result<(), EditApplyError> { + let Some((parent, terminal)) = split_parent(path) else { + return mutate_def(def, |root| { + set_slot_option_none(root, ctx.shapes, path, frame) + }); + }; + match terminal { + SlotPathSegment::Key(key) => mutate_def(def, |root| { + remove_slot_map_entry(root, ctx.shapes, &parent, frame, key) + }), + SlotPathSegment::Field(name) if name.as_str() == "some" => mutate_def(def, |root| { + set_slot_option_none(root, ctx.shapes, &parent, frame) + }), + SlotPathSegment::Field(_) => mutate_def(def, |root| { + set_slot_option_none(root, ctx.shapes, path, frame) + }), + } +} + +fn split_parent(path: &SlotPath) -> Option<(SlotPath, &SlotPathSegment)> { + let (terminal, parent) = path.segments().split_last()?; + Some((SlotPath::from_segments(parent.to_vec()), terminal)) +} + +fn mutate_def( + def: &mut NodeDef, + f: impl FnOnce(&mut dyn SlotMutAccess) -> Result<(), lpc_model::SlotMutationError>, +) -> Result<(), EditApplyError> { + f(def).map_err(|err| EditApplyError::SlotMutation { + message: err.to_string(), + }) +} diff --git a/lp-core/lpc-registry/src/overlay/inventory_change_summary.rs b/lp-core/lpc-registry/src/overlay/inventory_change_summary.rs new file mode 100644 index 000000000..186a6bdc9 --- /dev/null +++ b/lp-core/lpc-registry/src/overlay/inventory_change_summary.rs @@ -0,0 +1,176 @@ +//! Coarse project change summaries between effective inventories. + +use lpc_model::{ + AssetChange, AssetChangeKind, AssetChangeSummary, AssetEntry, AssetState, NodeDefChange, + NodeDefChangeKind, NodeDefEntry, NodeDefState, NodeUseChange, NodeUseChangeKind, + NodeUseChangeSummary, ProjectChangeSummary, ProjectInventory, ProjectNode, ProjectNodeOrigin, +}; + +pub(crate) fn change_summary_between( + before: &ProjectInventory, + after: &ProjectInventory, +) -> ProjectChangeSummary { + ProjectChangeSummary { + defs: node_def_changes(before, after), + assets: asset_changes(before, after), + uses: node_use_changes(before, after), + } +} + +fn node_use_changes(before: &ProjectInventory, after: &ProjectInventory) -> NodeUseChangeSummary { + let mut changes = NodeUseChangeSummary::default(); + + for location in after.tree.nodes.keys() { + if !before.tree.nodes.contains_key(location) { + changes.added.push(location.clone()); + } + } + for location in before.tree.nodes.keys() { + if !after.tree.nodes.contains_key(location) { + changes.removed.push(location.clone()); + } + } + for (location, before_node) in &before.tree.nodes { + let Some(after_node) = after.tree.nodes.get(location) else { + continue; + }; + if let Some(kind) = classify_node_use_change(before_node, after_node) { + changes + .changed + .push(NodeUseChange::new(location.clone(), kind)); + } + } + + changes +} + +fn classify_node_use_change( + before: &ProjectNode, + after: &ProjectNode, +) -> Option { + if before.parent != after.parent { + return Some(NodeUseChangeKind::ParentChanged); + } + if before.def_location != after.def_location { + return Some(NodeUseChangeKind::DefinitionChanged { + from: before.def_location.clone(), + to: after.def_location.clone(), + }); + } + if !same_node_use_origin(&before.origin, &after.origin) { + return Some(NodeUseChangeKind::OriginChanged); + } + None +} + +fn same_node_use_origin(before: &ProjectNodeOrigin, after: &ProjectNodeOrigin) -> bool { + match (before, after) { + (ProjectNodeOrigin::Root, ProjectNodeOrigin::Root) => true, + ( + ProjectNodeOrigin::Invocation { + slot: before_slot, + role: before_role, + .. + }, + ProjectNodeOrigin::Invocation { + slot: after_slot, + role: after_role, + .. + }, + ) => before_slot == after_slot && before_role == after_role, + _ => false, + } +} + +fn node_def_changes( + before: &ProjectInventory, + after: &ProjectInventory, +) -> lpc_model::NodeDefChangeSummary { + let mut changes = lpc_model::NodeDefChangeSummary::default(); + + for location in after.defs.keys() { + if !before.defs.contains_key(location) { + changes.added.push(location.clone()); + } + } + for location in before.defs.keys() { + if !after.defs.contains_key(location) { + changes.removed.push(location.clone()); + } + } + for (location, before_entry) in &before.defs { + let Some(after_entry) = after.defs.get(location) else { + continue; + }; + if let Some(kind) = classify_node_def_change(before_entry, after_entry) { + changes + .changed + .push(NodeDefChange::new(location.clone(), kind)); + } + } + + changes +} + +fn asset_changes(before: &ProjectInventory, after: &ProjectInventory) -> AssetChangeSummary { + let mut changes = AssetChangeSummary::default(); + + for source in after.assets.keys() { + if !before.assets.contains_key(source) { + changes.added.push(source.clone()); + } + } + for source in before.assets.keys() { + if !after.assets.contains_key(source) { + changes.removed.push(source.clone()); + } + } + for (source, before_entry) in &before.assets { + let Some(after_entry) = after.assets.get(source) else { + continue; + }; + if let Some(kind) = classify_asset_change(before_entry, after_entry) { + changes.changed.push(AssetChange::new(source.clone(), kind)); + } + } + + changes +} + +fn classify_node_def_change( + before: &NodeDefEntry, + after: &NodeDefEntry, +) -> Option { + if before == after { + return None; + } + + match (&before.state, &after.state) { + (NodeDefState::Loaded(before_def), NodeDefState::Loaded(after_def)) => { + if before_def.kind() != after_def.kind() { + Some(NodeDefChangeKind::KindChanged { + from: before_def.kind(), + to: after_def.kind(), + }) + } else { + Some(NodeDefChangeKind::Body) + } + } + (NodeDefState::Loaded(_), _) => Some(NodeDefChangeKind::EnteredError), + (_, NodeDefState::Loaded(_)) => Some(NodeDefChangeKind::LeftError), + _ => Some(NodeDefChangeKind::EnteredError), + } +} + +fn classify_asset_change(before: &AssetEntry, after: &AssetEntry) -> Option { + if before == after { + return None; + } + + match (&before.state, &after.state) { + (AssetState::Available { .. }, AssetState::Available { .. }) => Some(AssetChangeKind::Body), + (AssetState::Available { .. }, _) => Some(AssetChangeKind::EnteredError), + (_, AssetState::Available { .. }) => Some(AssetChangeKind::LeftError), + _ => Some(AssetChangeKind::EnteredError), + } +} diff --git a/lp-core/lpc-registry/src/overlay/mod.rs b/lp-core/lpc-registry/src/overlay/mod.rs new file mode 100644 index 000000000..3e400aa83 --- /dev/null +++ b/lp-core/lpc-registry/src/overlay/mod.rs @@ -0,0 +1,10 @@ +//! Apply pending edit model operations to node definitions. + +mod apply_error; +mod apply_slot; +pub mod inventory_change_summary; +pub mod project_inventory_derivation; + +pub use apply_error::EditApplyError; +pub use apply_slot::serialize_slot_draft; +pub(crate) use apply_slot::{apply_slot_overlay_to_def, parse_def_bytes}; diff --git a/lp-core/lpc-registry/src/overlay/project_inventory_derivation.rs b/lp-core/lpc-registry/src/overlay/project_inventory_derivation.rs new file mode 100644 index 000000000..d5190bfcb --- /dev/null +++ b/lp-core/lpc-registry/src/overlay/project_inventory_derivation.rs @@ -0,0 +1,379 @@ +//! Effective inventory derivation by walking loaded node definitions. + +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use lpc_model::{ + ArtifactLocation, AssetBodyOrigin, AssetBodyOverlay, AssetContentType, AssetEntry, + AssetLocation, AssetState, NodeDefEntry, NodeDefLocation, NodeDefState, NodeInvocation, + NodeUseLocation, ProjectInventory, ProjectNode, ProjectNodeOrigin, ProjectOverlay, + ReferencedAsset, Revision, SlotPath, WithRevision, resolve_artifact_specifier, +}; +use lpfs::{LpFs, LpPath}; + +use crate::{ + ArtifactError, ArtifactReadFailure, ArtifactStore, ParseCtx, + overlay::{EditApplyError, apply_slot_overlay_to_def, parse_def_bytes}, +}; + +pub(crate) fn derive_effective_inventory( + artifacts: &mut ArtifactStore, + root: Option<&NodeDefLocation>, + overlay: &WithRevision, + fs: &dyn LpFs, + frame: Revision, + ctx: &ParseCtx<'_>, +) -> ProjectInventory { + let mut derivation = InventoryDerivation { + artifacts, + overlay, + fs, + frame, + ctx, + inventory: ProjectInventory::new(), + }; + + if let Some(root) = root { + let root_key = NodeUseLocation::root(); + derivation.inventory.tree.root = root_key.clone(); + derivation.walk_graph_node( + root_key.clone(), + None, + root.clone(), + ProjectNodeOrigin::Root, + &mut Vec::new(), + ); + } + + derivation.inventory +} + +struct InventoryDerivation<'a, 'ctx> { + artifacts: &'a mut ArtifactStore, + overlay: &'a WithRevision, + fs: &'a dyn LpFs, + frame: Revision, + ctx: &'a ParseCtx<'ctx>, + inventory: ProjectInventory, +} + +impl InventoryDerivation<'_, '_> { + fn walk_graph_node( + &mut self, + key: NodeUseLocation, + parent: Option, + location: NodeDefLocation, + origin: ProjectNodeOrigin, + ancestry: &mut Vec, + ) { + let (state, revision) = self.ensure_def_entry(location.clone()); + self.inventory.tree.insert_node(ProjectNode { + key: key.clone(), + parent, + def_location: location.clone(), + origin, + }); + + let NodeDefState::Loaded(def) = state else { + return; + }; + + if ancestry.contains(&location) { + return; + } + + ancestry.push(location.clone()); + self.walk_loaded_def(&key, &location, &def, revision, ancestry); + ancestry.pop(); + } + + fn ensure_def_entry(&mut self, location: NodeDefLocation) -> (NodeDefState, Revision) { + if let Some(entry) = self.inventory.defs.get(&location) { + return (entry.state.clone(), entry.revision); + } + + let revision = self.revision_for_artifact(&location.artifact); + let state = self.read_effective_def(&location.artifact); + self.inventory.defs.insert( + location.clone(), + NodeDefEntry::new(location, state.clone(), revision), + ); + (state, revision) + } + + fn walk_loaded_def( + &mut self, + key: &NodeUseLocation, + location: &NodeDefLocation, + def: &lpc_model::NodeDef, + revision: Revision, + ancestry: &mut Vec, + ) { + match def.referenced_assets( + location.artifact.file_path().as_path(), + location, + &location.path, + ) { + Ok(assets) => { + for asset in assets { + self.walk_asset(asset, revision, key); + } + } + Err(err) => { + let source = AssetLocation::artifact(ArtifactLocation::file(error_asset_path( + &location.artifact, + &location.path, + ))); + let state = AssetState::ReadError { + message: err.to_string(), + }; + self.inventory.assets.insert( + source.clone(), + AssetEntry::new( + source, + AssetContentType::Binary, + state, + self.overlay.changed_at(), + ), + ); + } + } + + for site in def.invocation_sites(&location.path) { + match &site.invocation { + NodeInvocation::Unset => {} + NodeInvocation::Def(body) => { + let child_location = NodeDefLocation { + artifact: location.artifact.clone(), + path: site.path.clone(), + }; + let child_def = body.value().clone(); + self.inventory.defs.insert( + child_location.clone(), + NodeDefEntry::new( + child_location.clone(), + NodeDefState::Loaded(child_def.clone()), + revision, + ), + ); + self.walk_graph_node( + key.child(site.path.clone()), + Some(key.clone()), + child_location, + ProjectNodeOrigin::Invocation { + slot: site.path, + role: site.role, + invocation: site.invocation, + }, + ancestry, + ); + } + NodeInvocation::Ref(_) => { + let child_location = self.resolve_ref_invocation( + location.artifact.file_path().as_path(), + &site.invocation, + ); + self.walk_graph_node( + key.child(site.path.clone()), + Some(key.clone()), + child_location, + ProjectNodeOrigin::Invocation { + slot: site.path, + role: site.role, + invocation: site.invocation, + }, + ancestry, + ); + } + } + } + } + + fn resolve_ref_invocation( + &mut self, + containing_file: &LpPath, + invocation: &NodeInvocation, + ) -> NodeDefLocation { + let Some(specifier) = invocation.ref_specifier() else { + let artifact = ArtifactLocation::file(format!( + "{}#unresolved-ref:", + containing_file.as_str() + )); + return NodeDefLocation::artifact_root(artifact); + }; + match resolve_artifact_specifier(containing_file, &specifier) { + Ok(path) => { + let artifact = self.artifacts.register_file(path, self.frame); + NodeDefLocation::artifact_root(artifact) + } + Err(_) => { + let artifact = ArtifactLocation::file(error_ref_path(containing_file, &specifier)); + NodeDefLocation::artifact_root(artifact) + } + } + } + + fn walk_asset( + &mut self, + asset: ReferencedAsset, + owner_revision: Revision, + consumer: &NodeUseLocation, + ) { + let revision = self.revision_for_asset(&asset.location, owner_revision); + let state = self.read_effective_asset(&asset.location); + self.inventory + .tree + .add_asset_consumer(asset.location.clone(), consumer.clone()); + self.inventory.assets.insert( + asset.location.clone(), + AssetEntry::new(asset.location, asset.content_type, state, revision), + ); + } + + fn read_effective_def(&mut self, location: &ArtifactLocation) -> NodeDefState { + let pending = self.overlay.get().artifact(location); + + if let Some(body) = pending.and_then(|overlay| overlay.as_body()) { + return match body { + AssetBodyOverlay::Delete => NodeDefState::Deleted, + AssetBodyOverlay::ReplaceBody(bytes) => match parse_def_bytes(bytes, self.ctx) { + Ok(def) => NodeDefState::Loaded(def), + Err(err) => NodeDefState::ParseError(parse_error(err)), + }, + }; + } + + let mut def = match self.artifacts.read_bytes(location, self.fs) { + Ok(bytes) => match parse_def_bytes(&bytes, self.ctx) { + Ok(def) => def, + Err(err) => return NodeDefState::ParseError(parse_error(err)), + }, + Err(_) if pending.and_then(|overlay| overlay.as_slot()).is_some() => { + lpc_model::NodeDef::default() + } + Err(err) => return node_def_state_for_read_error(err), + }; + + if let Some(slot_overlay) = pending.and_then(|overlay| overlay.as_slot()) { + if let Err(err) = apply_slot_overlay_to_def( + &mut def, + slot_overlay, + self.ctx, + self.overlay.changed_at(), + ) { + return NodeDefState::ParseError(parse_error(err)); + } + } + + NodeDefState::Loaded(def) + } + + fn read_effective_asset(&mut self, source: &AssetLocation) -> AssetState { + let location = match source { + AssetLocation::Artifact { location } => location, + AssetLocation::Inline { .. } => { + return AssetState::Available { + origin: AssetBodyOrigin::Inline, + }; + } + }; + + self.artifacts + .register_location(location.clone(), self.frame); + + match self + .overlay + .get() + .artifact(location) + .and_then(|overlay| overlay.as_body()) + { + Some(AssetBodyOverlay::Delete) => return AssetState::Deleted, + Some(AssetBodyOverlay::ReplaceBody(_)) => { + return AssetState::Available { + origin: AssetBodyOrigin::OverlayReplace, + }; + } + None => {} + } + + if self + .overlay + .get() + .artifact(location) + .and_then(|overlay| overlay.as_slot()) + .is_some() + { + return AssetState::ReadError { + message: String::from("slot overlay cannot apply to an asset artifact"), + }; + } + + match self.artifacts.read_bytes(location, self.fs) { + Ok(_) => AssetState::Available { + origin: AssetBodyOrigin::Committed, + }, + Err(ArtifactError::Read(ArtifactReadFailure::NotFound)) => AssetState::NotFound, + Err(ArtifactError::Read(ArtifactReadFailure::Deleted)) => AssetState::Deleted, + Err(err) => AssetState::ReadError { + message: artifact_error_message(&err), + }, + } + } + + fn revision_for_artifact(&self, location: &ArtifactLocation) -> Revision { + if self.overlay.get().contains_artifact(location) { + self.overlay.changed_at() + } else { + self.artifacts.revision(location).unwrap_or(self.frame) + } + } + + fn revision_for_asset(&self, source: &AssetLocation, owner_revision: Revision) -> Revision { + match source { + AssetLocation::Artifact { location } => self.revision_for_artifact(location), + AssetLocation::Inline { .. } => owner_revision, + } + } +} + +fn node_def_state_for_read_error(err: ArtifactError) -> NodeDefState { + match err { + ArtifactError::Read(ArtifactReadFailure::NotFound) => NodeDefState::NotFound, + ArtifactError::Read(ArtifactReadFailure::Deleted) => NodeDefState::Deleted, + other => NodeDefState::ReadError { + message: artifact_error_message(&other), + }, + } +} + +fn parse_error(err: EditApplyError) -> lpc_model::NodeDefParseError { + lpc_model::NodeDefParseError::Toml { + error: err.to_string(), + } +} + +fn artifact_error_message(err: &ArtifactError) -> String { + match err { + ArtifactError::UnknownArtifact { location } => { + format!("unknown artifact {}", location.to_uri()) + } + ArtifactError::Resolution(message) | ArtifactError::Internal(message) => message.clone(), + ArtifactError::Read(ArtifactReadFailure::Deleted) => String::from("artifact was deleted"), + ArtifactError::Read(ArtifactReadFailure::NotFound) => String::from("artifact not found"), + ArtifactError::Read(ArtifactReadFailure::Io { message }) + | ArtifactError::Read(ArtifactReadFailure::InvalidPath { message }) => message.clone(), + } +} + +fn error_ref_path(containing_file: &LpPath, specifier: &lpc_model::ArtifactSpec) -> String { + format!("{}#unresolved-ref:{specifier}", containing_file.as_str()) +} + +fn error_asset_path(artifact: &ArtifactLocation, base_path: &SlotPath) -> String { + format!( + "{}#asset-resolution-error:{}", + artifact.file_path().as_str(), + base_path + ) +} diff --git a/lp-core/lpc-registry/src/registry/commit_error.rs b/lp-core/lpc-registry/src/registry/commit_error.rs new file mode 100644 index 000000000..bba12c4a4 --- /dev/null +++ b/lp-core/lpc-registry/src/registry/commit_error.rs @@ -0,0 +1,18 @@ +//! Overlay commit failures. + +use alloc::string::String; + +use lpc_model::ArtifactLocation; + +/// Error while committing pending overlay edits to durable artifacts. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CommitError { + Filesystem { + location: ArtifactLocation, + message: String, + }, + Projection { + location: ArtifactLocation, + message: String, + }, +} diff --git a/lp-core/lpc-registry/src/registry/load_result.rs b/lp-core/lpc-registry/src/registry/load_result.rs new file mode 100644 index 000000000..acfc2da59 --- /dev/null +++ b/lp-core/lpc-registry/src/registry/load_result.rs @@ -0,0 +1,16 @@ +//! Result from loading the root project artifact. + +use lpc_model::{NodeDefLocation, ProjectChangeSummary}; + +/// Initial effective inventory changes after loading a root project artifact. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LoadResult { + pub root: NodeDefLocation, + pub changes: ProjectChangeSummary, +} + +impl LoadResult { + pub fn new(root: NodeDefLocation, changes: ProjectChangeSummary) -> Self { + Self { root, changes } + } +} diff --git a/lp-core/lpc-registry/src/registry/mod.rs b/lp-core/lpc-registry/src/registry/mod.rs new file mode 100644 index 000000000..cb3e4811e --- /dev/null +++ b/lp-core/lpc-registry/src/registry/mod.rs @@ -0,0 +1,5 @@ +pub mod commit_error; +pub mod load_result; +pub mod parse_ctx; +pub mod project_registry; +pub mod registry_error; diff --git a/lp-core/lpc-registry/src/registry/parse_ctx.rs b/lp-core/lpc-registry/src/registry/parse_ctx.rs new file mode 100644 index 000000000..3383c5ecd --- /dev/null +++ b/lp-core/lpc-registry/src/registry/parse_ctx.rs @@ -0,0 +1,9 @@ +//! Registry parsing context. + +use lpc_model::SlotShapeRegistry; + +/// Shared model parsing context for authored node definitions. +#[derive(Clone, Copy)] +pub struct ParseCtx<'a> { + pub shapes: &'a SlotShapeRegistry, +} diff --git a/lp-core/lpc-registry/src/registry/project_registry.rs b/lp-core/lpc-registry/src/registry/project_registry.rs new file mode 100644 index 000000000..bc710d43f --- /dev/null +++ b/lp-core/lpc-registry/src/registry/project_registry.rs @@ -0,0 +1,351 @@ +//! Effective project registry built from artifacts plus overlay. + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use lpc_model::{ + ArtifactChangeSummary, ArtifactLocation, ArtifactOverlay, AssetBodyOverlay, CommitResult, + MutationBatchResults, MutationCmdBatch, MutationCmdBatchResult, MutationCmdResult, + MutationEffect, MutationOp, MutationResult, NodeDefEntry, NodeDefLocation, NodeDefState, + ProjectInventory, ProjectOverlay, Revision, WithRevision, +}; +use lpfs::{FsEvent, FsEventKind, LpFs, LpPath}; + +use crate::overlay::inventory_change_summary::change_summary_between; +use crate::overlay::project_inventory_derivation::derive_effective_inventory; +use crate::{ + ArtifactStore, CommitError, LoadResult, ParseCtx, RegistryError, + asset::{AssetBytes, AssetReadError, AssetText}, + overlay::{EditApplyError, serialize_slot_draft}, +}; + +/// Canonical registry for a loaded project. +pub struct ProjectRegistry { + artifacts: ArtifactStore, + overlay: WithRevision, + inventory: ProjectInventory, + root: Option, +} + +impl ProjectRegistry { + pub fn new() -> Self { + Self { + artifacts: ArtifactStore::new(), + overlay: WithRevision::new(Revision::default(), ProjectOverlay::new()), + inventory: ProjectInventory::new(), + root: None, + } + } + + pub fn load_root( + &mut self, + fs: &dyn LpFs, + root_path: &LpPath, + frame: Revision, + ctx: &ParseCtx<'_>, + ) -> Result { + let artifact = self.artifacts.register_file(root_path.to_path_buf(), frame); + let root = NodeDefLocation::artifact_root(artifact); + let before = ProjectInventory::new(); + + self.root = Some(root.clone()); + let after = self.derive_inventory(fs, frame, ctx); + let changes = change_summary_between(&before, &after); + self.inventory = after; + + Ok(LoadResult::new(root, changes)) + } + + pub fn mutate( + &mut self, + fs: &dyn LpFs, + mutation: MutationOp, + frame: Revision, + ctx: &ParseCtx<'_>, + ) -> Result { + let before = self.inventory.clone(); + let overlay_changed = self.overlay.get_mut().apply_mutation(mutation); + if overlay_changed { + self.overlay.mark_updated(frame); + } + let after = self.derive_inventory(fs, frame, ctx); + let changes = change_summary_between(&before, &after); + self.inventory = after; + + Ok(MutationResult::new( + self.overlay.changed_at(), + overlay_changed, + changes, + )) + } + + pub fn mutate_batch( + &mut self, + fs: &dyn LpFs, + batch: MutationCmdBatch, + frame: Revision, + ctx: &ParseCtx<'_>, + ) -> MutationBatchResults { + let before = self.inventory.clone(); + let mut any_changed = false; + let mut results = Vec::new(); + + for command in batch.commands { + let changed = self.overlay.get_mut().apply_mutation(command.mutation); + any_changed |= changed; + results.push(MutationCmdResult::accepted( + command.id, + MutationEffect::OverlayChanged { changed }, + )); + } + if any_changed { + self.overlay.mark_updated(frame); + } + + let after = self.derive_inventory(fs, frame, ctx); + let changes = change_summary_between(&before, &after); + self.inventory = after; + + MutationBatchResults::new( + MutationCmdBatchResult::new(results), + self.overlay.changed_at(), + changes, + ) + } + + pub fn discard_overlay( + &mut self, + fs: &dyn LpFs, + frame: Revision, + ctx: &ParseCtx<'_>, + ) -> lpc_model::ProjectChangeSummary { + let before = self.inventory.clone(); + if self.overlay.get_mut().clear() { + self.overlay.mark_updated(frame); + } + let after = self.derive_inventory(fs, frame, ctx); + let changes = change_summary_between(&before, &after); + self.inventory = after; + changes + } + + pub fn refresh_artifacts( + &mut self, + fs: &dyn LpFs, + events: &[FsEvent], + frame: Revision, + ctx: &ParseCtx<'_>, + ) -> lpc_model::ProjectChangeSummary { + let before = self.inventory.clone(); + self.artifacts.apply_fs_changes(events, frame); + let after = self.derive_inventory(fs, frame, ctx); + let changes = change_summary_between(&before, &after); + self.inventory = after; + changes + } + + pub fn commit_overlay( + &mut self, + fs: &dyn LpFs, + frame: Revision, + ctx: &ParseCtx<'_>, + ) -> Result { + let overlay = self.overlay.get().clone(); + let mut artifact_changes = ArtifactChangeSummary::default(); + let mut fs_events = Vec::new(); + + for (location, overlay) in overlay.iter() { + self.artifacts.register_location(location.clone(), frame); + let existed = fs + .file_exists(location.file_path().as_path()) + .unwrap_or(false); + match overlay { + ArtifactOverlay::Asset { + overlay: AssetBodyOverlay::Delete, + } => { + if existed { + fs.delete_file(location.file_path().as_path()) + .map_err(|err| CommitError::Filesystem { + location: location.clone(), + message: err.to_string(), + })?; + } + artifact_changes.removed.push(location.clone()); + fs_events.push(FsEvent { + path: location.file_path().clone(), + kind: FsEventKind::Delete, + }); + } + ArtifactOverlay::Asset { + overlay: AssetBodyOverlay::ReplaceBody(bytes), + } => { + fs.write_file(location.file_path().as_path(), bytes) + .map_err(|err| CommitError::Filesystem { + location: location.clone(), + message: err.to_string(), + })?; + if existed { + artifact_changes.changed.push(location.clone()); + fs_events.push(FsEvent { + path: location.file_path().clone(), + kind: FsEventKind::Modify, + }); + } else { + artifact_changes.added.push(location.clone()); + fs_events.push(FsEvent { + path: location.file_path().clone(), + kind: FsEventKind::Create, + }); + } + } + ArtifactOverlay::Slot { .. } => { + let bytes = self.materialize_node_def_bytes_for_commit(location, ctx)?; + fs.write_file(location.file_path().as_path(), &bytes) + .map_err(|err| CommitError::Filesystem { + location: location.clone(), + message: err.to_string(), + })?; + if existed { + artifact_changes.changed.push(location.clone()); + fs_events.push(FsEvent { + path: location.file_path().clone(), + kind: FsEventKind::Modify, + }); + } else { + artifact_changes.added.push(location.clone()); + fs_events.push(FsEvent { + path: location.file_path().clone(), + kind: FsEventKind::Create, + }); + } + } + } + } + + self.overlay.set(frame, ProjectOverlay::new()); + self.artifacts.apply_fs_changes(&fs_events, frame); + let after = self.derive_inventory(fs, frame, ctx); + self.inventory = after; + + Ok(CommitResult { artifact_changes }) + } + + fn materialize_node_def_bytes_for_commit( + &self, + artifact: &ArtifactLocation, + ctx: &ParseCtx<'_>, + ) -> Result, CommitError> { + let location = NodeDefLocation::artifact_root(artifact.clone()); + let Some(entry) = self.inventory.defs.get(&location) else { + return Err(CommitError::Projection { + location: artifact.clone(), + message: String::from("slot overlay has no effective node definition"), + }); + }; + let NodeDefState::Loaded(def) = &entry.state else { + return Err(CommitError::Projection { + location: artifact.clone(), + message: String::from("slot overlay targets an errored node definition"), + }); + }; + + serialize_slot_draft(def, ctx).map_err(|err| CommitError::Projection { + location: artifact.clone(), + message: err.to_string(), + }) + } + + pub fn artifacts(&self) -> &ArtifactStore { + &self.artifacts + } + + pub fn inventory(&self) -> &ProjectInventory { + &self.inventory + } + + pub fn overlay(&self) -> &WithRevision { + &self.overlay + } + + pub fn root(&self) -> Option<&NodeDefLocation> { + self.root.as_ref() + } + + pub fn def(&self, location: &NodeDefLocation) -> Option<&NodeDefEntry> { + self.inventory.defs.get(location) + } + + pub fn asset(&self, source: &lpc_model::AssetLocation) -> Option<&lpc_model::AssetEntry> { + self.inventory.assets.get(source) + } + + pub fn materialize_asset( + &mut self, + fs: &dyn LpFs, + source: &lpc_model::AssetLocation, + ) -> Result { + crate::asset::materialize_asset( + &mut self.artifacts, + &self.overlay, + &self.inventory, + fs, + source, + ) + } + + pub fn read_asset_bytes_if_changed( + &mut self, + fs: &dyn LpFs, + location: &lpc_model::AssetLocation, + since: Revision, + ) -> Result, AssetReadError> { + let asset = self.materialize_asset(fs, location)?; + Ok(asset.changed_since(since).then_some(asset)) + } + + pub fn materialize_asset_text( + &mut self, + fs: &dyn LpFs, + source: &lpc_model::AssetLocation, + ) -> Result { + crate::asset::materialize_asset_text( + &mut self.artifacts, + &self.overlay, + &self.inventory, + fs, + source, + ) + } + + pub fn read_asset_text_if_changed( + &mut self, + fs: &dyn LpFs, + location: &lpc_model::AssetLocation, + since: Revision, + ) -> Result, AssetReadError> { + let asset = self.materialize_asset_text(fs, location)?; + Ok(asset.changed_since(since).then_some(asset)) + } + + pub(crate) fn derive_inventory( + &mut self, + fs: &dyn LpFs, + frame: Revision, + ctx: &ParseCtx<'_>, + ) -> ProjectInventory { + derive_effective_inventory( + &mut self.artifacts, + self.root.as_ref(), + &self.overlay, + fs, + frame, + ctx, + ) + } +} + +impl Default for ProjectRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/lp-core/lpc-registry/src/registry/registry_error.rs b/lp-core/lpc-registry/src/registry/registry_error.rs new file mode 100644 index 000000000..2046ea862 --- /dev/null +++ b/lp-core/lpc-registry/src/registry/registry_error.rs @@ -0,0 +1,10 @@ +//! Errors returned by project registry helpers. + +use alloc::string::String; + +/// Shared registry error for artifact/reference resolution during rebuild. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RegistryError { + InvalidPath { message: String }, + SpecifierResolution { message: String }, +} diff --git a/lp-core/lpc-registry/src/test/fixtures.rs b/lp-core/lpc-registry/src/test/fixtures.rs new file mode 100644 index 000000000..8e2a55d12 --- /dev/null +++ b/lp-core/lpc-registry/src/test/fixtures.rs @@ -0,0 +1,88 @@ +//! Shared TOML fixtures for fs-change scenario tests. + +use lpfs::{LpFsMemory, LpPath}; + +pub fn write_file(fs: &mut LpFsMemory, path: &str, contents: &str) { + fs.write_file_mut(LpPath::new(path), contents.as_bytes()) + .unwrap(); +} + +pub fn load_shader_project() -> LpFsMemory { + let mut fs = LpFsMemory::new(); + write_file( + &mut fs, + "/shader.toml", + r#" +kind = "Shader" +source = { path = "shader.glsl" } +render_order = 0 +"#, + ); + write_file( + &mut fs, + "/shader.glsl", + "void main() { gl_FragColor = vec4(1.0); }", + ); + fs +} + +pub fn load_fixture_project() -> LpFsMemory { + let mut fs = LpFsMemory::new(); + write_file( + &mut fs, + "/fixture.toml", + r#" +kind = "Fixture" +color_order = "rgb" +sampling = "direct" +transform = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + +[render_size] +width = 16 +height = 16 + +[mapping] +kind = "SvgPath" +source = "./mapping.svg" +sample_diameter = 2.0 +"#, + ); + write_file( + &mut fs, + "/mapping.svg", + r#""#, + ); + fs +} + +pub fn load_playlist_with_inline_child() -> LpFsMemory { + let mut fs = LpFsMemory::new(); + write_file( + &mut fs, + "/playlist.toml", + r#" +kind = "Playlist" + +[entries.2.node.def] +kind = "Shader" +source = { path = "a.glsl" } +"#, + ); + write_file(&mut fs, "/a.glsl", "void main() {}"); + fs +} + +pub fn load_clock() -> LpFsMemory { + let mut fs = LpFsMemory::new(); + write_file( + &mut fs, + "/clock.toml", + r#" +kind = "Clock" + +[controls] +rate = 1.0 +"#, + ); + fs +} diff --git a/lp-core/lpc-registry/src/test/mod.rs b/lp-core/lpc-registry/src/test/mod.rs new file mode 100644 index 000000000..0276f30ae --- /dev/null +++ b/lp-core/lpc-registry/src/test/mod.rs @@ -0,0 +1,5 @@ +//! Test-only fixtures and helpers. + +pub mod fixtures; +#[cfg(feature = "diff")] +pub mod snapshot_overlay; diff --git a/lp-core/lpc-registry/src/test/snapshot_overlay.rs b/lp-core/lpc-registry/src/test/snapshot_overlay.rs new file mode 100644 index 000000000..3f3ca5430 --- /dev/null +++ b/lp-core/lpc-registry/src/test/snapshot_overlay.rs @@ -0,0 +1,97 @@ +//! Snapshot-to-overlay helper for test/bootstrap workflows. + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use lp_collection::VecMap; + +use lpc_model::{ArtifactLocation, AssetBodyOverlay, ProjectOverlay}; +use lpfs::{LpFs, LpPath, LpPathBuf}; + +/// Raw project files keyed by absolute project path. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProjectSnapshot { + files: VecMap>, +} + +impl ProjectSnapshot { + pub fn empty() -> Self { + Self::default() + } + + pub fn from_fs(fs: &dyn LpFs) -> Result { + let paths = fs + .list_dir(LpPath::new("/"), true) + .map_err(snapshot_fs_error)?; + let mut files = VecMap::new(); + for path in paths { + if fs.is_dir(path.as_path()).map_err(snapshot_fs_error)? { + continue; + } + let bytes = fs.read_file(path.as_path()).map_err(snapshot_fs_error)?; + files.insert(path.as_str().to_string(), bytes); + } + Ok(Self { files }) + } + + pub fn insert(&mut self, path: LpPathBuf, bytes: Vec) { + self.files.insert(path.as_str().to_string(), bytes); + } + + pub fn get(&self, path: &str) -> Option<&[u8]> { + self.files.get(path).map(Vec::as_slice) + } + + pub fn iter(&self) -> impl Iterator { + self.files + .iter() + .map(|(path, bytes)| (path.as_str(), bytes.as_slice())) + } + + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } + + pub fn copy_to_memory_fs(&self) -> lpfs::LpFsMemory { + let mut fs = lpfs::LpFsMemory::new(); + for (path, bytes) in &self.files { + fs.write_file_mut(LpPath::new(path), bytes).expect("write"); + } + fs + } +} + +/// Build an artifact-body overlay that transforms `base` files into `target`. +pub fn derive_overlay_between_snapshots( + base: &ProjectSnapshot, + target: &ProjectSnapshot, +) -> ProjectOverlay { + let mut overlay = ProjectOverlay::new(); + + for (path, bytes) in target.iter() { + if base.get(path) != Some(bytes) { + overlay.set_artifact_body( + ArtifactLocation::file(path), + AssetBodyOverlay::ReplaceBody(bytes.to_vec()), + ); + } + } + for (path, _) in base.iter() { + if target.get(path).is_none() { + overlay.set_artifact_body(ArtifactLocation::file(path), AssetBodyOverlay::Delete); + } + } + + overlay +} + +/// Snapshot helper failure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SnapshotError { + Fs { message: String }, +} + +fn snapshot_fs_error(err: lpfs::FsError) -> SnapshotError { + SnapshotError::Fs { + message: err.to_string(), + } +} diff --git a/lp-core/lpc-registry/tests/apply.rs b/lp-core/lpc-registry/tests/apply.rs new file mode 100644 index 000000000..8e1eef8bd --- /dev/null +++ b/lp-core/lpc-registry/tests/apply.rs @@ -0,0 +1,299 @@ +use lpc_model::{ + ArtifactLocation, AssetBodyOrigin, AssetBodyOverlay, AssetChangeKind, AssetLocation, + AssetState, LpValue, MutationOp, NodeDefChangeKind, NodeDefLocation, NodeDefState, Revision, + SlotEdit, SlotPath, SlotShapeRegistry, +}; +use lpc_registry::{ParseCtx, ProjectRegistry}; +use lpfs::{FsEvent, FsEventKind, LpFs, LpFsMemory, LpPath, LpPathBuf}; + +fn parse_ctx<'a>(shapes: &'a SlotShapeRegistry) -> ParseCtx<'a> { + ParseCtx { shapes } +} + +fn write_file(fs: &mut LpFsMemory, path: &str, contents: &str) { + fs.write_file_mut(LpPath::new(path), contents.as_bytes()) + .unwrap(); +} + +fn shader_project() -> (LpFsMemory, SlotShapeRegistry, ProjectRegistry) { + let shapes = SlotShapeRegistry::default(); + let mut fs = LpFsMemory::new(); + write_file( + &mut fs, + "/project.toml", + r#" +kind = "Project" + +[nodes.shader] +ref = "./shader.toml" +"#, + ); + write_file( + &mut fs, + "/shader.toml", + r#" +kind = "Shader" +source = { path = "shader.glsl" } +"#, + ); + write_file(&mut fs, "/shader.glsl", "void main() {}"); + + let mut registry = ProjectRegistry::new(); + registry + .load_root( + &fs, + LpPath::new("/project.toml"), + Revision::new(1), + &parse_ctx(&shapes), + ) + .unwrap(); + (fs, shapes, registry) +} + +fn clock_project() -> (LpFsMemory, SlotShapeRegistry, ProjectRegistry) { + let shapes = SlotShapeRegistry::default(); + let mut fs = LpFsMemory::new(); + write_file( + &mut fs, + "/project.toml", + r#" +kind = "Project" + +[nodes.clock] +ref = "./clock.toml" +"#, + ); + write_file( + &mut fs, + "/clock.toml", + r#" +kind = "Clock" + +[controls] +rate = 1.0 +"#, + ); + + let mut registry = ProjectRegistry::new(); + registry + .load_root( + &fs, + LpPath::new("/project.toml"), + Revision::new(1), + &parse_ctx(&shapes), + ) + .unwrap(); + (fs, shapes, registry) +} + +#[test] +fn apply_body_overlay_changes_referenced_node_def_and_assets() { + let (fs, shapes, mut registry) = shader_project(); + let ctx = parse_ctx(&shapes); + let shader_location = ArtifactLocation::file("/shader.toml"); + + let result = registry + .mutate( + &fs, + MutationOp::SetArtifactBody { + artifact: shader_location.clone(), + edit: AssetBodyOverlay::ReplaceBody(br#"kind = "Clock""#.to_vec()), + }, + Revision::new(2), + &ctx, + ) + .unwrap(); + + let shader_def = NodeDefLocation::artifact_root(shader_location); + assert_eq!( + result.changes.defs.changed, + vec![lpc_model::NodeDefChange::new( + shader_def.clone(), + NodeDefChangeKind::KindChanged { + from: lpc_model::NodeKind::Shader, + to: lpc_model::NodeKind::Clock, + } + )] + ); + assert_eq!( + result.changes.assets.removed, + vec![AssetLocation::artifact(ArtifactLocation::file( + "/shader.glsl" + ))] + ); + assert!(matches!( + registry.def(&shader_def).unwrap().state, + NodeDefState::Loaded(lpc_model::NodeDef::Clock(_)) + )); +} + +#[test] +fn apply_asset_overlay_changes_referenced_asset() { + let (fs, shapes, mut registry) = shader_project(); + let ctx = parse_ctx(&shapes); + let asset = ArtifactLocation::file("/shader.glsl"); + let asset_source = AssetLocation::artifact(asset.clone()); + + let result = registry + .mutate( + &fs, + MutationOp::SetArtifactBody { + artifact: asset.clone(), + edit: AssetBodyOverlay::ReplaceBody( + b"void main() { gl_FragColor = vec4(1.0); }".to_vec(), + ), + }, + Revision::new(2), + &ctx, + ) + .unwrap(); + + assert_eq!( + result.changes.assets.changed, + vec![lpc_model::AssetChange::new( + asset_source.clone(), + AssetChangeKind::Body + )] + ); + assert_eq!( + registry.asset(&asset_source).unwrap().state, + AssetState::Available { + origin: AssetBodyOrigin::OverlayReplace + } + ); +} + +#[test] +fn discard_overlay_returns_inventory_to_committed_state() { + let (fs, shapes, mut registry) = shader_project(); + let ctx = parse_ctx(&shapes); + let asset = ArtifactLocation::file("/shader.glsl"); + let asset_source = AssetLocation::artifact(asset.clone()); + + registry + .mutate( + &fs, + MutationOp::SetArtifactBody { + artifact: asset.clone(), + edit: AssetBodyOverlay::Delete, + }, + Revision::new(2), + &ctx, + ) + .unwrap(); + assert_eq!( + registry.asset(&asset_source).unwrap().state, + AssetState::Deleted + ); + + let changes = registry.discard_overlay(&fs, Revision::new(3), &ctx); + + assert_eq!( + changes.assets.changed, + vec![lpc_model::AssetChange::new( + asset_source.clone(), + AssetChangeKind::LeftError + )] + ); + assert_eq!( + registry.asset(&asset_source).unwrap().state, + AssetState::Available { + origin: AssetBodyOrigin::Committed + } + ); +} + +#[test] +fn commit_overlay_writes_artifact_without_runtime_project_change() { + let (fs, shapes, mut registry) = shader_project(); + let ctx = parse_ctx(&shapes); + let asset = ArtifactLocation::file("/shader.glsl"); + let asset_source = AssetLocation::artifact(asset.clone()); + let body = b"void main() { gl_FragColor = vec4(0.5); }".to_vec(); + + registry + .mutate( + &fs, + MutationOp::SetArtifactBody { + artifact: asset.clone(), + edit: AssetBodyOverlay::ReplaceBody(body.clone()), + }, + Revision::new(2), + &ctx, + ) + .unwrap(); + + let result = registry + .commit_overlay(&fs, Revision::new(3), &ctx) + .unwrap(); + + assert_eq!(result.artifact_changes.changed, vec![asset.clone()]); + assert_eq!(fs.read_file(LpPath::new("/shader.glsl")).unwrap(), body); + assert_eq!( + registry.asset(&asset_source).unwrap().state, + AssetState::Available { + origin: AssetBodyOrigin::Committed + } + ); +} + +#[test] +fn commit_slot_overlay_writes_effective_node_def() { + let (fs, shapes, mut registry) = clock_project(); + let ctx = parse_ctx(&shapes); + let clock = ArtifactLocation::file("/clock.toml"); + + registry + .mutate( + &fs, + MutationOp::PutSlotEdit { + artifact: clock.clone(), + edit: SlotEdit::assign_value( + SlotPath::parse("controls.rate").unwrap(), + LpValue::F32(2.0), + ), + }, + Revision::new(2), + &ctx, + ) + .unwrap(); + + let result = registry + .commit_overlay(&fs, Revision::new(3), &ctx) + .unwrap(); + + let text = String::from_utf8(fs.read_file(LpPath::new("/clock.toml")).unwrap()).unwrap(); + assert_eq!(result.artifact_changes.changed, vec![clock]); + assert!(text.contains("rate = 2")); +} + +#[test] +fn refresh_artifacts_returns_runtime_asset_changes() { + let (mut fs, shapes, mut registry) = shader_project(); + let ctx = parse_ctx(&shapes); + let asset = ArtifactLocation::file("/shader.glsl"); + let asset_source = AssetLocation::artifact(asset.clone()); + write_file( + &mut fs, + "/shader.glsl", + "void main() { gl_FragColor = vec4(0.25); }", + ); + + let changes = registry.refresh_artifacts( + &fs, + &[FsEvent { + path: LpPathBuf::from("/shader.glsl"), + kind: FsEventKind::Modify, + }], + Revision::new(2), + &ctx, + ); + + assert_eq!( + changes.assets.changed, + vec![lpc_model::AssetChange::new( + asset_source, + AssetChangeKind::Body + )] + ); +} diff --git a/lp-core/lpc-registry/tests/asset_materialization.rs b/lp-core/lpc-registry/tests/asset_materialization.rs new file mode 100644 index 000000000..938782094 --- /dev/null +++ b/lp-core/lpc-registry/tests/asset_materialization.rs @@ -0,0 +1,103 @@ +mod support; + +use lpc_model::{ + ArtifactLocation, AssetBodyOverlay, AssetLocation, MutationOp, NodeDefLocation, SlotPath, +}; +use lpc_registry::AssetReadError; + +use support::{RegistryScenario, artifact, artifact_asset}; + +#[test] +fn materializes_committed_shader_source_and_fixture_assets() { + let (mut scenario, _) = RegistryScenario::load_fixture("fyeah-sign"); + + let shader = scenario + .materialize_asset_text(&artifact_asset("/idle.glsl")) + .expect("shader source"); + assert!(shader.text.contains("vec4 render")); + assert_eq!(shader.diagnostic_name, "/idle.glsl"); + + let fixture = scenario + .materialize_asset(&artifact_asset("/fyeah-mapping.svg")) + .expect("fixture svg"); + assert!(fixture.bytes.starts_with(b"(shapes: &'a SlotShapeRegistry) -> ParseCtx<'a> { + ParseCtx { shapes } +} + +fn write_file(fs: &mut LpFsMemory, path: &str, contents: &str) { + fs.write_file_mut(LpPath::new(path), contents.as_bytes()) + .unwrap(); +} + +#[test] +fn load_root_discovers_root_external_inline_and_asset_entries() { + let shapes = SlotShapeRegistry::default(); + let ctx = parse_ctx(&shapes); + let mut fs = LpFsMemory::new(); + write_file( + &mut fs, + "/project.toml", + r#" +kind = "Project" + +[nodes.shader] +ref = "./shader.toml" + +[nodes.clock.def] +kind = "Clock" +"#, + ); + write_file( + &mut fs, + "/shader.toml", + r#" +kind = "Shader" +source = { path = "shader.glsl" } +render_order = 0 +"#, + ); + write_file(&mut fs, "/shader.glsl", "void main() {}"); + + let mut registry = ProjectRegistry::new(); + let result = registry + .load_root(&fs, LpPath::new("/project.toml"), Revision::new(1), &ctx) + .unwrap(); + + let root = NodeDefLocation::artifact_root(ArtifactLocation::file("/project.toml")); + let shader = NodeDefLocation::artifact_root(ArtifactLocation::file("/shader.toml")); + let inline_clock = NodeDefLocation { + artifact: ArtifactLocation::file("/project.toml"), + path: SlotPath::parse("nodes[clock]").unwrap(), + }; + let shader_asset = AssetLocation::artifact(ArtifactLocation::file("/shader.glsl")); + + assert_eq!(result.root, root); + assert!(result.changes.assets.changed.is_empty()); + assert!(result.changes.assets.removed.is_empty()); + assert_eq!(registry.inventory().defs.len(), 3); + assert!(matches!( + registry.def(&root).unwrap().state, + NodeDefState::Loaded(lpc_model::NodeDef::Project(_)) + )); + assert!(matches!( + registry.def(&shader).unwrap().state, + NodeDefState::Loaded(lpc_model::NodeDef::Shader(_)) + )); + assert!(matches!( + registry.def(&inline_clock).unwrap().state, + NodeDefState::Loaded(lpc_model::NodeDef::Clock(_)) + )); + assert_eq!( + registry.asset(&shader_asset).unwrap().state, + AssetState::Available { + origin: AssetBodyOrigin::Committed + } + ); + assert_eq!(result.changes.defs.added.len(), 3); + assert_eq!(result.changes.assets.added, vec![shader_asset]); +} + +#[test] +fn load_root_discovers_inline_source_asset() { + let shapes = SlotShapeRegistry::default(); + let ctx = parse_ctx(&shapes); + let mut fs = LpFsMemory::new(); + write_file( + &mut fs, + "/project.toml", + r#" +kind = "Project" + +[nodes.shader.def] +kind = "Shader" +source = { glsl = "void main() {}" } +"#, + ); + + let mut registry = ProjectRegistry::new(); + registry + .load_root(&fs, LpPath::new("/project.toml"), Revision::new(1), &ctx) + .unwrap(); + + let source = AssetLocation::inline( + NodeDefLocation { + artifact: ArtifactLocation::file("/project.toml"), + path: SlotPath::parse("nodes[shader]").unwrap(), + }, + SlotPath::parse("nodes[shader].source").unwrap(), + ); + let entry = registry.asset(&source).expect("inline source asset"); + + assert_eq!(entry.content_type, AssetContentType::ShaderSource); + assert_eq!( + entry.state, + AssetState::Available { + origin: AssetBodyOrigin::Inline + } + ); +} + +#[test] +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.toml", + r#" +kind = "Project" + +[nodes.shader] +ref = "./missing.toml" +"#, + ); + + let mut registry = ProjectRegistry::new(); + registry + .load_root(&fs, LpPath::new("/project.toml"), Revision::new(1), &ctx) + .unwrap(); + + let missing = NodeDefLocation::artifact_root(ArtifactLocation::file("/missing.toml")); + assert_eq!( + registry.def(&missing).map(|entry| &entry.state), + Some(&NodeDefState::NotFound) + ); +} + +#[test] +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.toml", + r#" +kind = "Project" + +[nodes.shader] +ref = "./shader.toml" +"#, + ); + write_file( + &mut fs, + "/shader.toml", + r#" +kind = "Shader" +source = { path = "missing.glsl" } +"#, + ); + + let mut registry = ProjectRegistry::new(); + registry + .load_root(&fs, LpPath::new("/project.toml"), Revision::new(1), &ctx) + .unwrap(); + + let missing = AssetLocation::artifact(ArtifactLocation::file("/missing.glsl")); + assert_eq!( + registry.asset(&missing).map(|entry| &entry.state), + Some(&AssetState::NotFound) + ); +} diff --git a/lp-core/lpc-registry/tests/project_bootstrap.rs b/lp-core/lpc-registry/tests/project_bootstrap.rs new file mode 100644 index 000000000..2f4c15891 --- /dev/null +++ b/lp-core/lpc-registry/tests/project_bootstrap.rs @@ -0,0 +1,53 @@ +mod support; + +use lpc_model::{AssetContentType, NodeKind}; +use lpfs::{LpFs, LpPath}; +use support::{ + RegistryScenario, TestProject, assert_artifact_asset_content_types, assert_loaded_def_kinds, +}; + +#[test] +fn can_create_fyeah_sign_project_from_empty_fs_with_artifact_body_mutations() { + let fixture = TestProject::load("fyeah-sign"); + let mut scenario = RegistryScenario::empty(); + + let apply = scenario.apply_batch(fixture.replace_body_batch()); + assert_eq!(apply.commands.results.len(), fixture.file_count()); + assert!(apply.changes.is_empty()); + + let commit = scenario.commit(); + assert_eq!(commit.artifact_changes.added.len(), fixture.file_count()); + assert!( + scenario + .fs() + .file_exists(LpPath::new("/project.toml")) + .unwrap() + ); + + let load = scenario.load_root("/project.toml"); + assert_eq!(load.changes.defs.added.len(), 9); + assert_eq!(load.changes.assets.added.len(), 3); + + assert_loaded_def_kinds( + scenario.registry(), + &[ + ("/project.toml", NodeKind::Project), + ("/blast.toml", NodeKind::Shader), + ("/button.toml", NodeKind::Button), + ("/clock.toml", NodeKind::Clock), + ("/fixture.toml", NodeKind::Fixture), + ("/idle.toml", NodeKind::Shader), + ("/output.toml", NodeKind::Output), + ("/playlist.toml", NodeKind::Playlist), + ("/radio.toml", NodeKind::ControlRadio), + ], + ); + assert_artifact_asset_content_types( + scenario.registry(), + &[ + ("/blast.glsl", AssetContentType::ShaderSource), + ("/fyeah-mapping.svg", AssetContentType::FixtureSvg), + ("/idle.glsl", AssetContentType::ShaderSource), + ], + ); +} diff --git a/lp-core/lpc-registry/tests/project_change_sets.rs b/lp-core/lpc-registry/tests/project_change_sets.rs new file mode 100644 index 000000000..0fae61a88 --- /dev/null +++ b/lp-core/lpc-registry/tests/project_change_sets.rs @@ -0,0 +1,299 @@ +mod support; + +use lpc_model::{ + AssetBodyOverlay, AssetChange, AssetChangeKind, MutationOp, NodeDefChange, NodeDefChangeKind, + NodeDefState, NodeKind, NodeUseChange, NodeUseChangeKind, NodeUseLocation, SlotPath, +}; +use support::{RegistryScenario, artifact, artifact_asset, root_def}; + +#[test] +fn shader_source_file_refresh_reports_one_asset_body_change() { + let (mut scenario, _) = RegistryScenario::load_fixture("fyeah-sign"); + + let changes = scenario.replace_file_and_refresh("/idle.glsl", b"void main() { }"); + + assert!(changes.defs.is_empty()); + assert_eq!( + changes.assets.changed, + vec![AssetChange::new( + artifact_asset("/idle.glsl"), + AssetChangeKind::Body, + )] + ); + assert!(changes.assets.added.is_empty()); + assert!(changes.assets.removed.is_empty()); + assert!(changes.uses.is_empty()); +} + +#[test] +fn unreferenced_file_refresh_does_not_change_effective_project() { + let (mut scenario, _) = RegistryScenario::load_fixture("fyeah-sign"); + + let changes = scenario.replace_file_and_refresh( + "/not-referenced.toml", + br#" +kind = "Clock" +"#, + ); + + assert!(changes.is_empty()); + assert!( + scenario + .registry() + .def(&root_def("/not-referenced.toml")) + .is_none() + ); + assert!( + scenario + .registry() + .asset(&artifact_asset("/not-referenced.glsl")) + .is_none() + ); +} + +#[test] +fn changed_registered_def_discovers_newly_referenced_file() { + let mut scenario = RegistryScenario::empty(); + scenario.write_file( + "/project.toml", + br#" +kind = "Project" +"#, + ); + scenario.write_file( + "/clock.toml", + br#" +kind = "Clock" +"#, + ); + scenario.load_root("/project.toml"); + + let changes = scenario.replace_file_and_refresh( + "/project.toml", + br#" +kind = "Project" + +[nodes.clock] +ref = "./clock.toml" +"#, + ); + + assert_eq!(changes.defs.added, vec![root_def("/clock.toml")]); + assert_eq!( + changes.defs.changed, + vec![NodeDefChange::new( + root_def("/project.toml"), + NodeDefChangeKind::Body, + )] + ); + assert_eq!( + changes.uses.added, + vec![NodeUseLocation::root().child(SlotPath::parse("nodes[clock]").unwrap())] + ); +} + +#[test] +fn missing_referenced_def_recovers_when_file_is_created() { + let mut scenario = RegistryScenario::empty(); + scenario.write_file( + "/project.toml", + br#" +kind = "Project" + +[nodes.clock] +ref = "./clock.toml" +"#, + ); + scenario.load_root("/project.toml"); + + assert_eq!( + scenario + .registry() + .def(&root_def("/clock.toml")) + .map(|entry| &entry.state), + Some(&NodeDefState::NotFound) + ); + + let changes = scenario.replace_file_and_refresh( + "/clock.toml", + br#" +kind = "Clock" +"#, + ); + + assert_eq!( + changes.defs.changed, + vec![NodeDefChange::new( + root_def("/clock.toml"), + NodeDefChangeKind::LeftError, + )] + ); + assert!(changes.uses.is_empty()); + assert!(matches!( + scenario + .registry() + .def(&root_def("/clock.toml")) + .map(|entry| &entry.state), + Some(NodeDefState::Loaded(_)) + )); +} + +#[test] +fn changing_shader_def_kind_removes_its_referenced_source_asset() { + let (mut scenario, _) = RegistryScenario::load_fixture("fyeah-sign"); + + let result = scenario.apply(MutationOp::SetArtifactBody { + artifact: artifact("/idle.toml"), + edit: AssetBodyOverlay::ReplaceBody(br#"kind = "Clock""#.to_vec()), + }); + + assert_eq!( + result.changes.defs.changed, + vec![NodeDefChange::new( + root_def("/idle.toml"), + NodeDefChangeKind::KindChanged { + from: NodeKind::Shader, + to: NodeKind::Clock, + }, + )] + ); + assert_eq!( + result.changes.assets.removed, + vec![artifact_asset("/idle.glsl")] + ); + assert!(result.changes.assets.changed.is_empty()); + assert!(result.changes.uses.is_empty()); +} + +#[test] +fn deleting_referenced_fixture_svg_reports_asset_entered_error() { + let (mut scenario, _) = RegistryScenario::load_fixture("fyeah-sign"); + + let changes = scenario.delete_file_and_refresh("/fyeah-mapping.svg"); + + assert!(changes.defs.is_empty()); + assert_eq!( + changes.assets.changed, + vec![AssetChange::new( + artifact_asset("/fyeah-mapping.svg"), + AssetChangeKind::EnteredError, + )] + ); + assert!(changes.uses.is_empty()); +} + +#[test] +fn removing_playlist_reference_removes_child_def_and_its_asset() { + let (mut scenario, _) = RegistryScenario::load_fixture("fyeah-sign"); + + let changes = scenario.replace_file_and_refresh( + "/playlist.toml", + br#" +kind = "Playlist" +idle_entry = 1 +default_fade = 0.35 + +[bindings.time] +source = "bus#time.seconds" + +[entries.1] +name = "idle" +fade_after = 0.12 +node = { ref = "./idle.toml" } +"#, + ); + + assert_eq!(changes.defs.removed, vec![root_def("/blast.toml")]); + assert_eq!( + changes.defs.changed, + vec![NodeDefChange::new( + root_def("/playlist.toml"), + NodeDefChangeKind::Body, + )] + ); + assert_eq!(changes.assets.removed, vec![artifact_asset("/blast.glsl")]); + assert!(changes.assets.changed.is_empty()); + assert_eq!( + changes.uses.removed, + vec![ + NodeUseLocation::root() + .child(SlotPath::parse("nodes[playlist]").unwrap()) + .child(SlotPath::parse("entries[2].node").unwrap()) + ] + ); + assert!(changes.uses.changed.is_empty()); +} + +#[test] +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.toml", + br#" +kind = "Project" +name = "fyeah-sign" + +[nodes.output] +ref = "./output.toml" + +[nodes.clock] +ref = "./idle.toml" + +[nodes.button] +ref = "./button.toml" + +[nodes.radio] +ref = "./radio.toml" + +[nodes.playlist] +ref = "./playlist.toml" + +[nodes.fixture] +ref = "./fixture.toml" +"#, + ); + + assert_eq!( + changes.uses.changed, + vec![NodeUseChange::new( + NodeUseLocation::root().child(SlotPath::parse("nodes[clock]").unwrap()), + NodeUseChangeKind::DefinitionChanged { + from: root_def("/clock.toml"), + to: root_def("/idle.toml"), + }, + )] + ); +} + +#[test] +fn same_kind_body_value_edit_does_not_report_node_use_change() { + let (mut scenario, _) = RegistryScenario::load_fixture("fyeah-sign"); + + let changes = scenario.replace_file_and_refresh( + "/output.toml", + br#" +kind = "Output" +endpoint = "ws281x:rmt:D10" + +[bindings.input] +source = "bus#control.out" + +[options] +white_point = [0.9, 1.0, 1.0] +brightness = 0.25 +interpolation_enabled = true +dithering_enabled = false +lut_enabled = true +"#, + ); + + assert_eq!( + changes.defs.changed, + vec![NodeDefChange::new( + root_def("/output.toml"), + NodeDefChangeKind::Body, + )] + ); + assert!(changes.uses.is_empty()); +} diff --git a/lp-core/lpc-registry/tests/project_discovery.rs b/lp-core/lpc-registry/tests/project_discovery.rs new file mode 100644 index 000000000..8080e087a --- /dev/null +++ b/lp-core/lpc-registry/tests/project_discovery.rs @@ -0,0 +1,44 @@ +mod support; + +use lpc_model::{AssetContentType, NodeKind}; + +use support::{RegistryScenario, assert_artifact_asset_content_types, assert_loaded_def_kinds}; + +#[test] +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.toml"))); + assert!(load.changes.defs.changed.is_empty()); + assert!(load.changes.defs.removed.is_empty()); + assert!(load.changes.assets.changed.is_empty()); + assert!(load.changes.assets.removed.is_empty()); + + assert_loaded_def_kinds( + registry, + &[ + ("/project.toml", NodeKind::Project), + ("/blast.toml", NodeKind::Shader), + ("/button.toml", NodeKind::Button), + ("/clock.toml", NodeKind::Clock), + ("/fixture.toml", NodeKind::Fixture), + ("/idle.toml", NodeKind::Shader), + ("/output.toml", NodeKind::Output), + ("/playlist.toml", NodeKind::Playlist), + ("/radio.toml", NodeKind::ControlRadio), + ], + ); + + assert_artifact_asset_content_types( + registry, + &[ + ("/blast.glsl", AssetContentType::ShaderSource), + ("/fyeah-mapping.svg", AssetContentType::FixtureSvg), + ("/idle.glsl", AssetContentType::ShaderSource), + ], + ); + + assert_eq!(load.changes.defs.added.len(), 9); + assert_eq!(load.changes.assets.added.len(), 3); +} diff --git a/lp-core/lpc-registry/tests/project_graph.rs b/lp-core/lpc-registry/tests/project_graph.rs new file mode 100644 index 000000000..ee40d79ec --- /dev/null +++ b/lp-core/lpc-registry/tests/project_graph.rs @@ -0,0 +1,194 @@ +mod support; + +use lpc_model::{ + NodeDefLocation, NodeDefState, NodeUseLocation, ProjectNodeOrigin, ProjectNodePlacement, + SlotPath, +}; +use lpc_registry::{ParseCtx, ProjectRegistry}; +use lpfs::{LpFsMemory, LpPath}; + +use support::{RegistryScenario, artifact, artifact_asset, root_def}; + +#[test] +fn fyeah_sign_graph_contains_project_children_playlist_entries_and_asset_consumers() { + let (scenario, _) = RegistryScenario::load_fixture("fyeah-sign"); + let graph = &scenario.registry().inventory().tree; + + let root = NodeUseLocation::root(); + let playlist = root.child(SlotPath::parse("nodes[playlist]").unwrap()); + let idle = playlist.child(SlotPath::parse("entries[1].node").unwrap()); + let blast = playlist.child(SlotPath::parse("entries[2].node").unwrap()); + + assert_eq!(graph.root, root); + assert_eq!(graph.nodes.len(), 9); + assert_eq!(graph.nodes[&root].def_location, root_def("/project.toml")); + assert_project_child( + graph.nodes.get(&playlist).unwrap(), + "playlist", + "/playlist.toml", + ); + assert_playlist_entry( + graph.nodes.get(&idle).unwrap(), + 1, + Some("idle"), + "/idle.toml", + ); + assert_playlist_entry( + graph.nodes.get(&blast).unwrap(), + 2, + Some("blast"), + "/blast.toml", + ); + + assert_eq!( + graph + .asset_consumers + .get(&artifact_asset("/idle.glsl")) + .unwrap(), + &vec![idle] + ); + assert_eq!( + graph + .asset_consumers + .get(&artifact_asset("/blast.glsl")) + .unwrap(), + &vec![blast] + ); + assert_eq!( + graph + .asset_consumers + .get(&artifact_asset("/fyeah-mapping.svg")) + .unwrap(), + &vec![root.child(SlotPath::parse("nodes[fixture]").unwrap())] + ); +} + +#[test] +fn duplicate_external_refs_share_def_entry_but_create_distinct_graph_nodes() { + let (registry, _) = load_inline_project( + r#" +kind = "Project" + +[nodes.a] +ref = "./shader.toml" + +[nodes.b] +ref = "./shader.toml" +"#, + &[( + "/shader.toml", + r#" +kind = "Shader" +source = { path = "shader.glsl" } +"#, + )], + &[("/shader.glsl", b"void main() {}".as_slice())], + ); + let graph = ®istry.inventory().tree; + let shader = root_def("/shader.toml"); + let a = NodeUseLocation::root().child(SlotPath::parse("nodes[a]").unwrap()); + let b = NodeUseLocation::root().child(SlotPath::parse("nodes[b]").unwrap()); + + assert_eq!(registry.inventory().defs.len(), 2); + assert_eq!(graph.def_instances.get(&shader).unwrap(), &vec![a, b]); + assert_eq!( + graph + .asset_consumers + .get(&artifact_asset("/shader.glsl")) + .unwrap() + .len(), + 2 + ); +} + +#[test] +fn inline_and_missing_children_are_graph_nodes() { + let (registry, _) = load_inline_project( + r#" +kind = "Project" + +[nodes.clock.def] +kind = "Clock" + +[nodes.missing] +ref = "./missing.toml" +"#, + &[], + &[], + ); + let graph = ®istry.inventory().tree; + let inline_clock = NodeDefLocation { + artifact: artifact("/project.toml"), + path: SlotPath::parse("nodes[clock]").unwrap(), + }; + let missing = root_def("/missing.toml"); + + assert!(graph.def_instances.contains_key(&inline_clock)); + assert!(graph.def_instances.contains_key(&missing)); + assert_eq!( + registry.def(&missing).map(|entry| &entry.state), + Some(&NodeDefState::NotFound) + ); +} + +fn assert_project_child(entry: &lpc_model::ProjectNode, name: &str, expected_def_path: &str) { + assert_eq!(entry.def_location, root_def(expected_def_path)); + let ProjectNodeOrigin::Invocation { role, .. } = &entry.origin else { + panic!("expected invocation origin"); + }; + assert_eq!( + role, + &ProjectNodePlacement::ProjectChild { + name: name.to_string() + } + ); +} + +fn assert_playlist_entry( + entry: &lpc_model::ProjectNode, + key: u32, + name: Option<&str>, + expected_def_path: &str, +) { + assert_eq!(entry.def_location, root_def(expected_def_path)); + let ProjectNodeOrigin::Invocation { role, .. } = &entry.origin else { + panic!("expected invocation origin"); + }; + assert_eq!( + role, + &ProjectNodePlacement::PlaylistEntry { + entry: key, + name: name.map(str::to_string) + } + ); +} + +fn load_inline_project( + project: &str, + toml_files: &[(&str, &str)], + byte_files: &[(&str, &[u8])], +) -> (ProjectRegistry, LpFsMemory) { + let shapes = lpc_model::SlotShapeRegistry::default(); + let ctx = ParseCtx { shapes: &shapes }; + let mut fs = LpFsMemory::new(); + fs.write_file_mut(LpPath::new("/project.toml"), project.as_bytes()) + .unwrap(); + for (path, contents) in toml_files { + fs.write_file_mut(LpPath::new(path), contents.as_bytes()) + .unwrap(); + } + for (path, bytes) in byte_files { + fs.write_file_mut(LpPath::new(path), bytes).unwrap(); + } + + let mut registry = ProjectRegistry::new(); + registry + .load_root( + &fs, + LpPath::new("/project.toml"), + lpc_model::Revision::new(1), + &ctx, + ) + .unwrap(); + (registry, fs) +} diff --git a/lp-core/lpc-registry/tests/runtime_harness.rs b/lp-core/lpc-registry/tests/runtime_harness.rs new file mode 100644 index 000000000..b5219351a --- /dev/null +++ b/lp-core/lpc-registry/tests/runtime_harness.rs @@ -0,0 +1,145 @@ +use std::collections::BTreeMap; + +use lpc_model::{ + ArtifactLocation, AssetBodyOverlay, AssetLocation, AssetState, MutationOp, NodeDefLocation, + Revision, SlotShapeRegistry, +}; +use lpc_registry::{ParseCtx, ProjectRegistry}; +use lpfs::{LpFsMemory, LpPath}; + +fn parse_ctx<'a>(shapes: &'a SlotShapeRegistry) -> ParseCtx<'a> { + ParseCtx { shapes } +} + +fn write_file(fs: &mut LpFsMemory, path: &str, contents: &str) { + fs.write_file_mut(LpPath::new(path), contents.as_bytes()) + .unwrap(); +} + +#[derive(Default)] +struct FakeRuntime { + nodes: BTreeMap, + assets: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq)] +struct RuntimeNodeState { + revision: Revision, + loaded: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RuntimeAssetState { + revision: Revision, + available: bool, +} + +impl FakeRuntime { + fn apply(&mut self, registry: &ProjectRegistry, changes: &lpc_model::ProjectChangeSummary) { + for location in &changes.defs.removed { + self.nodes.remove(location); + } + for location in &changes.assets.removed { + self.assets.remove(location); + } + for location in &changes.defs.added { + self.load_node(registry, location); + } + for change in &changes.defs.changed { + self.load_node(registry, &change.location); + } + for location in &changes.assets.added { + self.load_asset(registry, location); + } + for change in &changes.assets.changed { + self.load_asset(registry, &change.location); + } + } + + fn load_node(&mut self, registry: &ProjectRegistry, location: &NodeDefLocation) { + let entry = registry.def(location).expect("node entry"); + self.nodes.insert( + location.clone(), + RuntimeNodeState { + revision: entry.revision, + loaded: entry.state.is_loaded(), + }, + ); + } + + fn load_asset(&mut self, registry: &ProjectRegistry, source: &AssetLocation) { + let entry = registry.asset(source).expect("asset entry"); + self.assets.insert( + source.clone(), + RuntimeAssetState { + revision: entry.revision, + available: entry.state.is_available(), + }, + ); + } +} + +#[test] +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.toml", + r#" +kind = "Project" + +[nodes.shader] +ref = "./shader.toml" +"#, + ); + write_file( + &mut fs, + "/shader.toml", + r#" +kind = "Shader" +source = { path = "shader.glsl" } +"#, + ); + write_file(&mut fs, "/shader.glsl", "void main() {}"); + + let mut registry = ProjectRegistry::new(); + let load = registry + .load_root(&fs, LpPath::new("/project.toml"), Revision::new(1), &ctx) + .unwrap(); + let mut runtime = FakeRuntime::default(); + runtime.apply(®istry, &load.changes); + assert_eq!(runtime.nodes.len(), 2); + assert_eq!(runtime.assets.len(), 1); + + let asset = ArtifactLocation::file("/shader.glsl"); + let asset_source = AssetLocation::artifact(asset.clone()); + let apply = registry + .mutate( + &fs, + MutationOp::SetArtifactBody { + artifact: asset.clone(), + edit: AssetBodyOverlay::ReplaceBody(b"void main() { }".to_vec()), + }, + Revision::new(2), + &ctx, + ) + .unwrap(); + runtime.apply(®istry, &apply.changes); + assert_eq!( + runtime.assets.get(&asset_source).unwrap().revision, + Revision::new(2) + ); + assert_eq!( + registry.asset(&asset_source).unwrap().state, + AssetState::Available { + origin: lpc_model::AssetBodyOrigin::OverlayReplace + } + ); + + let commit = registry + .commit_overlay(&fs, Revision::new(3), &ctx) + .unwrap(); + assert_eq!(commit.artifact_changes.changed, vec![asset]); +} diff --git a/lp-core/lpc-registry/tests/snapshot_overlay.rs b/lp-core/lpc-registry/tests/snapshot_overlay.rs new file mode 100644 index 000000000..6c6e8f106 --- /dev/null +++ b/lp-core/lpc-registry/tests/snapshot_overlay.rs @@ -0,0 +1,54 @@ +use lpc_model::{ArtifactOverlay, MutationOp, Revision, SlotShapeRegistry}; +use lpc_registry::{ParseCtx, ProjectRegistry, ProjectSnapshot, derive_overlay_between_snapshots}; +use lpfs::{LpFsMemory, LpPath, LpPathBuf}; + +fn parse_ctx<'a>(shapes: &'a SlotShapeRegistry) -> ParseCtx<'a> { + ParseCtx { shapes } +} + +#[test] +fn snapshot_overlay_can_bootstrap_project_files() { + let shapes = SlotShapeRegistry::default(); + let ctx = parse_ctx(&shapes); + let base = ProjectSnapshot::empty(); + let mut target = ProjectSnapshot::empty(); + target.insert( + LpPathBuf::from("/project.toml"), + br#" +kind = "Project" + +[nodes.clock.def] +kind = "Clock" +"# + .to_vec(), + ); + + let overlay = derive_overlay_between_snapshots(&base, &target); + let fs = LpFsMemory::new(); + let mut registry = ProjectRegistry::new(); + for (artifact, artifact_overlay) in overlay.iter() { + let ArtifactOverlay::Asset { overlay: edit } = artifact_overlay else { + panic!("snapshot overlay should only emit body edits"); + }; + registry + .mutate( + &fs, + MutationOp::SetArtifactBody { + artifact: artifact.clone(), + edit: edit.clone(), + }, + Revision::new(1), + &ctx, + ) + .unwrap(); + } + registry + .commit_overlay(&fs, Revision::new(2), &ctx) + .unwrap(); + + let mut loaded = ProjectRegistry::new(); + loaded + .load_root(&fs, LpPath::new("/project.toml"), Revision::new(3), &ctx) + .unwrap(); + assert_eq!(loaded.inventory().defs.len(), 2); +} diff --git a/lp-core/lpc-registry/tests/support/assertions.rs b/lp-core/lpc-registry/tests/support/assertions.rs new file mode 100644 index 000000000..7c9e49db3 --- /dev/null +++ b/lp-core/lpc-registry/tests/support/assertions.rs @@ -0,0 +1,48 @@ +use lpc_model::{AssetContentType, NodeDefState, NodeKind}; +use lpc_registry::ProjectRegistry; + +use super::{artifact_asset, root_def}; + +pub fn assert_loaded_def_kinds(registry: &ProjectRegistry, expected: &[(&str, NodeKind)]) { + assert_eq!( + registry.inventory().defs.len(), + expected.len(), + "unexpected def inventory: {:#?}", + registry.inventory().defs + ); + + for (path, kind) in expected { + let location = root_def(path); + let entry = registry + .def(&location) + .unwrap_or_else(|| panic!("missing def {path}")); + let NodeDefState::Loaded(def) = &entry.state else { + panic!("def {path} was not loaded: {:?}", entry.state); + }; + assert_eq!(def.kind(), *kind, "wrong kind for {path}"); + } +} + +pub fn assert_artifact_asset_content_types( + registry: &ProjectRegistry, + expected: &[(&str, AssetContentType)], +) { + assert_eq!( + registry.inventory().assets.len(), + expected.len(), + "unexpected asset inventory: {:#?}", + registry.inventory().assets + ); + + for (path, content_type) in expected { + let source = artifact_asset(path); + let entry = registry + .asset(&source) + .unwrap_or_else(|| panic!("missing asset {path}")); + assert_eq!( + entry.content_type, *content_type, + "wrong asset content type for {path}" + ); + assert!(entry.state.is_available(), "asset {path} is not available"); + } +} diff --git a/lp-core/lpc-registry/tests/support/identifiers.rs b/lp-core/lpc-registry/tests/support/identifiers.rs new file mode 100644 index 000000000..9858d832c --- /dev/null +++ b/lp-core/lpc-registry/tests/support/identifiers.rs @@ -0,0 +1,13 @@ +use lpc_model::{ArtifactLocation, AssetLocation, NodeDefLocation}; + +pub fn artifact(path: &str) -> ArtifactLocation { + ArtifactLocation::file(path) +} + +pub fn artifact_asset(path: &str) -> AssetLocation { + AssetLocation::artifact(artifact(path)) +} + +pub fn root_def(path: &str) -> NodeDefLocation { + NodeDefLocation::artifact_root(artifact(path)) +} diff --git a/lp-core/lpc-registry/tests/support/mod.rs b/lp-core/lpc-registry/tests/support/mod.rs new file mode 100644 index 000000000..698126b52 --- /dev/null +++ b/lp-core/lpc-registry/tests/support/mod.rs @@ -0,0 +1,12 @@ +#![allow(dead_code, unused_imports)] + +pub mod assertions; +pub mod identifiers; +pub mod project_files; +pub mod scenario; +pub mod test_project; + +pub use assertions::{assert_artifact_asset_content_types, assert_loaded_def_kinds}; +pub use identifiers::{artifact, artifact_asset, root_def}; +pub use scenario::RegistryScenario; +pub use test_project::TestProject; diff --git a/lp-core/lpc-registry/tests/support/project_files.rs b/lp-core/lpc-registry/tests/support/project_files.rs new file mode 100644 index 000000000..3fdd55c97 --- /dev/null +++ b/lp-core/lpc-registry/tests/support/project_files.rs @@ -0,0 +1,36 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +pub fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("repo root") +} + +pub fn read_project_files(root: &Path) -> BTreeMap> { + let mut files = BTreeMap::new(); + read_project_files_recursive(root, root, &mut files); + files +} + +fn read_project_files_recursive(root: &Path, dir: &Path, files: &mut BTreeMap>) { + let mut entries = std::fs::read_dir(dir) + .unwrap_or_else(|err| panic!("read {}: {err}", dir.display())) + .map(|entry| entry.expect("directory entry").path()) + .collect::>(); + entries.sort(); + + for path in entries { + if path.is_dir() { + read_project_files_recursive(root, &path, files); + continue; + } + + let relative = path.strip_prefix(root).expect("project-relative path"); + let project_path = format!("/{}", relative.to_string_lossy()); + let bytes = + std::fs::read(&path).unwrap_or_else(|err| panic!("read {}: {err}", path.display())); + files.insert(project_path, bytes); + } +} diff --git a/lp-core/lpc-registry/tests/support/scenario.rs b/lp-core/lpc-registry/tests/support/scenario.rs new file mode 100644 index 000000000..f3edfe1af --- /dev/null +++ b/lp-core/lpc-registry/tests/support/scenario.rs @@ -0,0 +1,148 @@ +use lpc_model::{ + AssetLocation, CommitResult, MutationBatchResults, MutationCmdBatch, MutationOp, + MutationResult, Revision, SlotShapeRegistry, +}; +use lpc_registry::{AssetBytes, AssetReadError, AssetText, LoadResult, ParseCtx, ProjectRegistry}; +use lpfs::{FsEvent, FsEventKind, LpFsMemory, LpPath, LpPathBuf}; + +use super::TestProject; + +pub struct RegistryScenario { + fs: LpFsMemory, + registry: ProjectRegistry, + shapes: SlotShapeRegistry, + next_revision: i64, +} + +impl RegistryScenario { + pub fn empty() -> Self { + Self { + fs: LpFsMemory::new(), + registry: ProjectRegistry::new(), + shapes: SlotShapeRegistry::default(), + next_revision: 1, + } + } + + pub fn load_fixture(name: &str) -> (Self, LoadResult) { + let fixture = TestProject::load(name); + let mut scenario = Self { + fs: fixture.copy_to_memory_fs(), + registry: ProjectRegistry::new(), + shapes: SlotShapeRegistry::default(), + next_revision: 1, + }; + let load = scenario.load_root("/project.toml"); + (scenario, load) + } + + pub fn registry(&self) -> &ProjectRegistry { + &self.registry + } + + pub fn registry_mut(&mut self) -> &mut ProjectRegistry { + &mut self.registry + } + + pub fn fs(&self) -> &LpFsMemory { + &self.fs + } + + pub fn write_file(&mut self, path: &str, bytes: impl AsRef<[u8]>) { + self.fs + .write_file_mut(LpPath::new(path), bytes.as_ref()) + .expect("write file"); + } + + pub fn materialize_asset( + &mut self, + source: &AssetLocation, + ) -> Result { + self.registry.materialize_asset(&self.fs, source) + } + + pub fn materialize_asset_text( + &mut self, + source: &AssetLocation, + ) -> Result { + self.registry.materialize_asset_text(&self.fs, source) + } + + pub fn load_root(&mut self, root_path: &str) -> LoadResult { + let frame = self.next_revision(); + let ctx = ParseCtx { + shapes: &self.shapes, + }; + self.registry + .load_root(&self.fs, LpPath::new(root_path), frame, &ctx) + .expect("load project root") + } + + pub fn apply(&mut self, mutation: MutationOp) -> MutationResult { + let frame = self.next_revision(); + let ctx = ParseCtx { + shapes: &self.shapes, + }; + self.registry + .mutate(&self.fs, mutation, frame, &ctx) + .expect("apply overlay mutation") + } + + pub fn apply_batch(&mut self, batch: MutationCmdBatch) -> MutationBatchResults { + let frame = self.next_revision(); + let ctx = ParseCtx { + shapes: &self.shapes, + }; + self.registry.mutate_batch(&self.fs, batch, frame, &ctx) + } + + pub fn commit(&mut self) -> CommitResult { + let frame = self.next_revision(); + let ctx = ParseCtx { + shapes: &self.shapes, + }; + self.registry + .commit_overlay(&self.fs, frame, &ctx) + .expect("commit overlay") + } + + pub fn replace_file_and_refresh( + &mut self, + path: &str, + bytes: impl AsRef<[u8]>, + ) -> lpc_model::ProjectChangeSummary { + self.fs + .write_file_mut(LpPath::new(path), bytes.as_ref()) + .expect("replace file"); + self.refresh(path, FsEventKind::Modify) + } + + pub fn delete_file_and_refresh(&mut self, path: &str) -> lpc_model::ProjectChangeSummary { + self.fs + .delete_file_mut(LpPath::new(path)) + .expect("delete file"); + self.refresh(path, FsEventKind::Delete) + } + + fn refresh(&mut self, path: &str, kind: FsEventKind) -> lpc_model::ProjectChangeSummary { + let frame = self.next_revision(); + let ctx = ParseCtx { + shapes: &self.shapes, + }; + self.registry.refresh_artifacts( + &self.fs, + &[FsEvent { + path: LpPathBuf::from(path), + kind, + }], + frame, + &ctx, + ) + } + + fn next_revision(&mut self) -> Revision { + let revision = Revision::new(self.next_revision); + self.next_revision += 1; + revision + } +} diff --git a/lp-core/lpc-registry/tests/support/test_project.rs b/lp-core/lpc-registry/tests/support/test_project.rs new file mode 100644 index 000000000..8a36ffef5 --- /dev/null +++ b/lp-core/lpc-registry/tests/support/test_project.rs @@ -0,0 +1,52 @@ +use std::collections::BTreeMap; + +use lpc_model::{AssetBodyOverlay, MutationCmd, MutationCmdBatch, MutationCmdId, MutationOp}; +use lpfs::{LpFsMemory, LpPath}; + +use super::{artifact, project_files}; + +pub struct TestProject { + files: BTreeMap>, +} + +impl TestProject { + pub fn load(name: &str) -> Self { + let root = project_files::repo_root() + .join("projects") + .join("test") + .join(name); + assert!(root.is_dir(), "missing test project `{}`", root.display()); + + let files = project_files::read_project_files(&root); + Self { files } + } + + pub fn file_count(&self) -> usize { + self.files.len() + } + + pub fn copy_to_memory_fs(&self) -> LpFsMemory { + let mut fs = LpFsMemory::new(); + for (path, bytes) in &self.files { + fs.write_file_mut(LpPath::new(path), bytes) + .expect("copy fixture file to memory fs"); + } + fs + } + + pub fn replace_body_batch(&self) -> MutationCmdBatch { + MutationCmdBatch::new( + self.files + .iter() + .enumerate() + .map(|(index, (path, bytes))| MutationCmd { + id: MutationCmdId::new(index as u64 + 1), + mutation: MutationOp::SetArtifactBody { + artifact: artifact(path), + edit: AssetBodyOverlay::ReplaceBody(bytes.clone()), + }, + }) + .collect(), + ) + } +} diff --git a/lp-core/lpc-shared/Cargo.toml b/lp-core/lpc-shared/Cargo.toml index 9f1bdc469..7bb3edadd 100644 --- a/lp-core/lpc-shared/Cargo.toml +++ b/lp-core/lpc-shared/Cargo.toml @@ -8,17 +8,18 @@ rust-version.workspace = true [features] default = ["std"] -std = ["lpfs/std", "toml/std"] +std = ["lpc-hardware/std", "lpfs/std"] [dependencies] +lp-collection = { workspace = true, features = ["serde"] } libm = "0.2" log = { workspace = true, default-features = false } +lpc-hardware = { path = "../lpc-hardware", default-features = false } lpc-model = { path = "../lpc-model", default-features = false } lpc-wire = { path = "../lpc-wire", default-features = false } lpfs = { path = "../../lp-base/lpfs", default-features = false } lps-shared = { path = "../../lp-shader/lps-shared", default-features = false } serde = { workspace = true, features = ["derive"] } -toml = { version = "0.9", default-features = false, features = ["parse", "serde", "display"] } [lints] workspace = true diff --git a/lp-core/lpc-shared/src/display_pipeline/options.rs b/lp-core/lpc-shared/src/display_pipeline/options.rs index 03ad0dc6f..e580c3ff6 100644 --- a/lp-core/lpc-shared/src/display_pipeline/options.rs +++ b/lp-core/lpc-shared/src/display_pipeline/options.rs @@ -1,6 +1,10 @@ -//! Display pipeline options +//! Display pipeline options. -/// Options for display pipeline (LUT, dithering, interpolation) +/// Color and temporal-processing options for [`super::DisplayPipeline`]. +/// +/// These options belong with the display pipeline rather than `lpc-hardware`: +/// hardware outputs receive already-rendered bytes, while the pipeline decides +/// how 16-bit engine samples become those bytes. #[derive(Debug, Clone)] pub struct DisplayPipelineOptions { /// RGB white point balance diff --git a/lp-core/lpc-shared/src/error.rs b/lp-core/lpc-shared/src/error.rs index a0f506a3e..6f0c686ef 100644 --- a/lp-core/lpc-shared/src/error.rs +++ b/lp-core/lpc-shared/src/error.rs @@ -3,8 +3,6 @@ use alloc::string::String; use core::fmt; -use crate::hardware::HardwareError; - /// Texture error type #[derive(Debug, Clone)] pub enum TextureError { @@ -27,51 +25,6 @@ impl fmt::Display for TextureError { } } -/// Output provider error type -#[derive(Debug, Clone)] -pub enum OutputError { - /// Pin is already open - PinAlreadyOpen { pin: u32 }, - /// Hardware resource claim failed - Hardware { error: HardwareError }, - /// Invalid handle - InvalidHandle { handle: i32 }, - /// Invalid configuration - InvalidConfig { reason: String }, - /// Data length mismatch - DataLengthMismatch { expected: u32, actual: usize }, - /// Other error - Other { message: String }, -} - -impl fmt::Display for OutputError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - OutputError::PinAlreadyOpen { pin } => { - write!(f, "Pin {pin} is already open") - } - OutputError::Hardware { error } => { - write!(f, "Hardware error: {error}") - } - OutputError::InvalidHandle { handle } => { - write!(f, "Invalid handle: {handle}") - } - OutputError::InvalidConfig { reason } => { - write!(f, "Invalid config: {reason}") - } - OutputError::DataLengthMismatch { expected, actual } => { - write!( - f, - "Data length {actual} doesn't match expected byte_count {expected}" - ) - } - OutputError::Other { message } => { - write!(f, "Error: {message}") - } - } - } -} - /// Display pipeline error type #[derive(Debug, Clone)] pub enum DisplayPipelineError { diff --git a/lp-core/lpc-shared/src/hardware/button_driver.rs b/lp-core/lpc-shared/src/hardware/button_driver.rs deleted file mode 100644 index 2e7f12f37..000000000 --- a/lp-core/lpc-shared/src/hardware/button_driver.rs +++ /dev/null @@ -1,43 +0,0 @@ -use alloc::boxed::Box; - -use super::{ - ButtonDebouncer, ButtonEvent, HardwareAddress, HardwareDriver, HardwareEndpoint, - HardwareEndpointError, HardwareEndpointId, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ButtonConfig { - stable_ms: u64, -} - -impl ButtonConfig { - pub fn new(stable_ms: u64) -> Self { - Self { stable_ms } - } - - pub fn stable_ms(&self) -> u64 { - self.stable_ms - } -} - -impl Default for ButtonConfig { - fn default() -> Self { - Self::new(ButtonDebouncer::DEFAULT_STABLE_MS) - } -} - -pub trait ButtonInput { - fn source(&self) -> &HardwareAddress; - - fn poll(&mut self, now_ms: u64) -> Option; -} - -pub trait ButtonDriver: HardwareDriver { - fn endpoints(&self) -> alloc::vec::Vec; - - fn open( - &self, - endpoint_id: &HardwareEndpointId, - config: ButtonConfig, - ) -> Result, HardwareEndpointError>; -} diff --git a/lp-core/lpc-shared/src/hardware/hardware_capability.rs b/lp-core/lpc-shared/src/hardware/hardware_capability.rs deleted file mode 100644 index f91da6ce2..000000000 --- a/lp-core/lpc-shared/src/hardware/hardware_capability.rs +++ /dev/null @@ -1,11 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum HardwareCapability { - GpioOutput, - GpioInput, - Ws281xOutput, - Rmt, - Radio, -} diff --git a/lp-core/lpc-shared/src/hardware/hardware_claim.rs b/lp-core/lpc-shared/src/hardware/hardware_claim.rs deleted file mode 100644 index b21b35050..000000000 --- a/lp-core/lpc-shared/src/hardware/hardware_claim.rs +++ /dev/null @@ -1,27 +0,0 @@ -use alloc::string::String; -use alloc::vec::Vec; - -use super::HardwareAddress; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HardwareClaim { - claimant: String, - addresses: Vec, -} - -impl HardwareClaim { - pub fn new(claimant: impl Into, addresses: impl Into>) -> Self { - Self { - claimant: claimant.into(), - addresses: addresses.into(), - } - } - - pub fn claimant(&self) -> &str { - &self.claimant - } - - pub fn addresses(&self) -> &[HardwareAddress] { - &self.addresses - } -} diff --git a/lp-core/lpc-shared/src/hardware/hardware_driver.rs b/lp-core/lpc-shared/src/hardware/hardware_driver.rs deleted file mode 100644 index b02457976..000000000 --- a/lp-core/lpc-shared/src/hardware/hardware_driver.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub trait HardwareDriver { - fn driver_id(&self) -> &str; - - fn display_label(&self) -> &str { - self.driver_id() - } -} diff --git a/lp-core/lpc-shared/src/hardware/hardware_endpoint.rs b/lp-core/lpc-shared/src/hardware/hardware_endpoint.rs deleted file mode 100644 index a6ec12ef8..000000000 --- a/lp-core/lpc-shared/src/hardware/hardware_endpoint.rs +++ /dev/null @@ -1,70 +0,0 @@ -use alloc::string::String; - -use lpc_model::HardwareEndpointSpec; - -use super::{HardwareAddress, HardwareEndpointId, HardwareEndpointKind, HardwareEndpointStatus}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HardwareEndpoint { - id: HardwareEndpointId, - spec: HardwareEndpointSpec, - kind: HardwareEndpointKind, - driver_id: String, - address: HardwareAddress, - display_label: String, - status: HardwareEndpointStatus, -} - -impl HardwareEndpoint { - pub fn new( - id: HardwareEndpointId, - spec: HardwareEndpointSpec, - kind: HardwareEndpointKind, - driver_id: impl Into, - address: HardwareAddress, - display_label: impl Into, - status: HardwareEndpointStatus, - ) -> Self { - Self { - id, - spec, - kind, - driver_id: driver_id.into(), - address, - display_label: display_label.into(), - status, - } - } - - pub fn id(&self) -> &HardwareEndpointId { - &self.id - } - - pub fn spec(&self) -> &HardwareEndpointSpec { - &self.spec - } - - pub fn kind(&self) -> HardwareEndpointKind { - self.kind - } - - pub fn driver_id(&self) -> &str { - &self.driver_id - } - - pub fn address(&self) -> &HardwareAddress { - &self.address - } - - pub fn display_label(&self) -> &str { - &self.display_label - } - - pub fn status(&self) -> &HardwareEndpointStatus { - &self.status - } - - pub fn is_available(&self) -> bool { - self.status.is_available() - } -} diff --git a/lp-core/lpc-shared/src/hardware/hardware_endpoint_id.rs b/lp-core/lpc-shared/src/hardware/hardware_endpoint_id.rs deleted file mode 100644 index df3257898..000000000 --- a/lp-core/lpc-shared/src/hardware/hardware_endpoint_id.rs +++ /dev/null @@ -1,33 +0,0 @@ -use alloc::format; -use alloc::string::String; -use core::fmt; - -use super::HardwareAddress; -use lpc_model::HardwareEndpointSpec; - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct HardwareEndpointId(String); - -impl HardwareEndpointId { - pub fn new(id: impl Into) -> Self { - Self(id.into()) - } - - pub fn for_driver_address(driver_id: &str, address: &HardwareAddress) -> Self { - Self(format!("{driver_id}:{}", address.as_str())) - } - - pub fn for_driver_spec(driver_id: &str, spec: &HardwareEndpointSpec) -> Self { - Self(format!("{driver_id}:{}", spec.as_str())) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for HardwareEndpointId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} diff --git a/lp-core/lpc-shared/src/hardware/hardware_registry.rs b/lp-core/lpc-shared/src/hardware/hardware_registry.rs deleted file mode 100644 index 08c1b4740..000000000 --- a/lp-core/lpc-shared/src/hardware/hardware_registry.rs +++ /dev/null @@ -1,298 +0,0 @@ -use alloc::collections::{BTreeMap, BTreeSet}; -use alloc::string::{String, ToString}; -use core::cell::RefCell; - -use super::{ - HardwareAddress, HardwareCapability, HardwareClaim, HardwareEndpointStatus, HardwareError, - HardwareLease, HardwareLeaseId, HardwareManifest, -}; - -#[derive(Debug, Clone)] -struct ActiveClaim { - claimant: String, -} - -#[derive(Debug, Clone)] -struct HardwareRegistryState { - next_lease_id: u64, - active_by_address: BTreeMap, - addresses_by_lease: BTreeMap>, -} - -#[derive(Debug)] -pub struct HardwareRegistry { - manifest: HardwareManifest, - state: RefCell, -} - -impl HardwareRegistry { - pub fn new(manifest: HardwareManifest) -> Self { - Self { - manifest, - state: RefCell::new(HardwareRegistryState { - next_lease_id: 1, - active_by_address: BTreeMap::new(), - addresses_by_lease: BTreeMap::new(), - }), - } - } - - pub fn manifest(&self) -> &HardwareManifest { - &self.manifest - } - - pub fn claim_bundle(&self, claim: HardwareClaim) -> Result { - self.validate_claim(&claim)?; - - let mut state = self.state.borrow_mut(); - let lease_id = HardwareLeaseId::new(state.next_lease_id); - state.next_lease_id += 1; - - let mut addresses = BTreeSet::new(); - for address in claim.addresses() { - state.active_by_address.insert( - address.clone(), - ActiveClaim { - claimant: claim.claimant().to_string(), - }, - ); - addresses.insert(address.clone()); - } - state.addresses_by_lease.insert(lease_id, addresses); - - Ok(HardwareLease::new( - lease_id, - claim.claimant().to_string(), - claim.addresses().to_vec(), - )) - } - - pub fn release(&self, lease: &HardwareLease) -> Result<(), HardwareError> { - let mut state = self.state.borrow_mut(); - let addresses = - state - .addresses_by_lease - .remove(&lease.id()) - .ok_or(HardwareError::UnknownLease { - lease_id: lease.id(), - })?; - - for address in addresses { - state.active_by_address.remove(&address); - } - Ok(()) - } - - pub fn is_claimed(&self, address: &HardwareAddress) -> bool { - self.state.borrow().active_by_address.contains_key(address) - } - - pub fn claimant_for(&self, address: &HardwareAddress) -> Option { - self.state - .borrow() - .active_by_address - .get(address) - .map(|claim| claim.claimant.clone()) - } - - pub fn endpoint_status_for(&self, address: &HardwareAddress) -> HardwareEndpointStatus { - match self.manifest.resource(address) { - Some(resource) => { - if let Some(reason) = resource.reserved_reason() { - HardwareEndpointStatus::Reserved { - reason: reason.into(), - } - } else if let Some(claimant) = self.claimant_for(address) { - HardwareEndpointStatus::InUse { claimant } - } else { - HardwareEndpointStatus::Available - } - } - None => HardwareEndpointStatus::Unavailable { - reason: alloc::format!("unknown hardware resource: {address}"), - }, - } - } - - pub fn ensure_capability( - &self, - address: &HardwareAddress, - capability: HardwareCapability, - ) -> Result<(), HardwareError> { - let resource = - self.manifest - .resource(address) - .ok_or_else(|| HardwareError::UnknownResource { - address: address.clone(), - })?; - if !resource.supports(capability) { - return Err(HardwareError::UnsupportedCapability { - address: address.clone(), - capability, - }); - } - Ok(()) - } - - fn validate_claim(&self, claim: &HardwareClaim) -> Result<(), HardwareError> { - if claim.addresses().is_empty() { - return Err(HardwareError::EmptyClaim); - } - - let mut seen = BTreeSet::new(); - let state = self.state.borrow(); - for address in claim.addresses() { - if !seen.insert(address.clone()) { - return Err(HardwareError::DuplicateAddressInClaim { - address: address.clone(), - }); - } - - let resource = - self.manifest - .resource(address) - .ok_or_else(|| HardwareError::UnknownResource { - address: address.clone(), - })?; - if let Some(reason) = resource.reserved_reason() { - return Err(HardwareError::ReservedResource { - address: address.clone(), - reason: reason.into(), - }); - } - - if let Some(active) = state.active_by_address.get(address) { - return Err(HardwareError::ResourceAlreadyClaimed { - address: address.clone(), - claimant: active.claimant.clone(), - }); - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::hardware::HardwareResource; - use alloc::vec; - - #[test] - fn claim_bundle_claims_and_releases_resources() { - let registry = registry(); - let lease = registry - .claim_bundle(HardwareClaim::new( - "output", - vec![HardwareAddress::gpio(18), HardwareAddress::rmt_ws281x(0)], - )) - .unwrap(); - - assert!(registry.is_claimed(&HardwareAddress::gpio(18))); - assert!(registry.is_claimed(&HardwareAddress::rmt_ws281x(0))); - - registry.release(&lease).unwrap(); - - assert!(!registry.is_claimed(&HardwareAddress::gpio(18))); - assert!(!registry.is_claimed(&HardwareAddress::rmt_ws281x(0))); - } - - #[test] - fn claim_bundle_is_atomic_when_later_resource_is_claimed() { - let registry = registry(); - let rmt_lease = registry - .claim_bundle(HardwareClaim::new( - "output-a", - vec![HardwareAddress::rmt_ws281x(0)], - )) - .unwrap(); - - let result = registry.claim_bundle(HardwareClaim::new( - "output-b", - vec![HardwareAddress::gpio(18), HardwareAddress::rmt_ws281x(0)], - )); - - assert!(matches!( - result, - Err(HardwareError::ResourceAlreadyClaimed { .. }) - )); - assert!(!registry.is_claimed(&HardwareAddress::gpio(18))); - assert!(registry.is_claimed(&HardwareAddress::rmt_ws281x(0))); - - registry.release(&rmt_lease).unwrap(); - } - - #[test] - fn duplicate_address_in_claim_fails() { - let registry = registry(); - let result = registry.claim_bundle(HardwareClaim::new( - "output", - vec![HardwareAddress::gpio(18), HardwareAddress::gpio(18)], - )); - - assert!(matches!( - result, - Err(HardwareError::DuplicateAddressInClaim { .. }) - )); - } - - #[test] - fn reserved_resource_fails() { - let manifest = HardwareManifest::new( - "board", - "Board", - [HardwareResource::new( - HardwareAddress::gpio(12), - [HardwareCapability::GpioOutput], - "GPIO12", - ) - .reserved("crashes during GPIO scan")], - ); - let registry = HardwareRegistry::new(manifest); - - let result = registry.claim_bundle(HardwareClaim::new( - "output", - vec![HardwareAddress::gpio(12)], - )); - - assert!(matches!( - result, - Err(HardwareError::ReservedResource { .. }) - )); - } - - #[test] - fn unsupported_capability_fails() { - let registry = registry(); - - let result = - registry.ensure_capability(&HardwareAddress::gpio(18), HardwareCapability::Radio); - - assert!(matches!( - result, - Err(HardwareError::UnsupportedCapability { .. }) - )); - } - - fn registry() -> HardwareRegistry { - HardwareRegistry::new(HardwareManifest::new( - "board", - "Board", - [ - HardwareResource::new( - HardwareAddress::gpio(18), - [ - HardwareCapability::GpioOutput, - HardwareCapability::GpioInput, - ], - "D6", - ), - HardwareResource::new( - HardwareAddress::rmt_ws281x(0), - [HardwareCapability::Rmt, HardwareCapability::Ws281xOutput], - "RMT0", - ), - ], - )) - } -} diff --git a/lp-core/lpc-shared/src/hardware/mod.rs b/lp-core/lpc-shared/src/hardware/mod.rs deleted file mode 100644 index 423341f3b..000000000 --- a/lp-core/lpc-shared/src/hardware/mod.rs +++ /dev/null @@ -1,66 +0,0 @@ -pub mod button_debouncer; -pub mod button_driver; -pub mod button_event; -pub mod default_manifests; -pub mod hardware_address; -pub mod hardware_capability; -pub mod hardware_claim; -pub mod hardware_driver; -pub mod hardware_endpoint; -pub mod hardware_endpoint_error; -pub mod hardware_endpoint_id; -pub mod hardware_endpoint_kind; -pub mod hardware_endpoint_status; -pub mod hardware_error; -pub mod hardware_lease; -pub mod hardware_manifest; -pub mod hardware_manifest_file; -pub mod hardware_registry; -pub mod hardware_resource; -pub mod hardware_system; -pub mod hardware_target; -pub mod radio_channel; -pub mod radio_driver; -pub mod radio_message; -pub mod virtual_button; -pub mod virtual_button_driver; -pub mod virtual_radio_driver; -pub mod virtual_ws281x_driver; -pub mod ws281x_driver; - -pub use button_debouncer::ButtonDebouncer; -pub use button_driver::{ButtonConfig, ButtonDriver, ButtonInput}; -pub use button_event::{ButtonEvent, ButtonEventKind}; -pub use default_manifests::{default_esp32c6_hardware_manifest, permissive_emu_hardware_manifest}; -pub use hardware_address::HardwareAddress; -pub use hardware_capability::HardwareCapability; -pub use hardware_claim::HardwareClaim; -pub use hardware_driver::HardwareDriver; -pub use hardware_endpoint::HardwareEndpoint; -pub use hardware_endpoint_error::HardwareEndpointError; -pub use hardware_endpoint_id::HardwareEndpointId; -pub use hardware_endpoint_kind::HardwareEndpointKind; -pub use hardware_endpoint_status::HardwareEndpointStatus; -pub use hardware_error::HardwareError; -pub use hardware_lease::{HardwareLease, HardwareLeaseId}; -pub use hardware_manifest::HardwareManifest; -pub use hardware_manifest_file::{ - HardwareBoardLabelFile, HardwareBoardLabelStatus, HardwareManifestFile, - HardwareManifestFileError, -}; -pub use hardware_registry::HardwareRegistry; -pub use hardware_resource::HardwareResource; -pub use hardware_system::HardwareSystem; -pub use hardware_target::HardwareTarget; -pub use lpc_model::HardwareEndpointSpec; -pub use radio_channel::{RadioChannelId, RadioDeviceId, RadioDrainReport, RadioEventId}; -pub use radio_driver::{RadioConfig, RadioDevice, RadioDriver}; -pub use radio_message::{ - RADIO_MAX_PACKET_LEN, RADIO_MAX_PAYLOAD_LEN, RADIO_WIRE_HEADER_LEN, RADIO_WIRE_MAGIC, - RADIO_WIRE_VERSION, RadioMessage, RadioMessageKind, RadioPacketError, -}; -pub use virtual_button::VirtualButton; -pub use virtual_button_driver::VirtualButtonDriver; -pub use virtual_radio_driver::VirtualRadioDriver; -pub use virtual_ws281x_driver::{VirtualWs281xDriver, VirtualWs281xOutput}; -pub use ws281x_driver::{Ws281xConfig, Ws281xDriver, Ws281xOutput}; diff --git a/lp-core/lpc-shared/src/hardware/virtual_button.rs b/lp-core/lpc-shared/src/hardware/virtual_button.rs deleted file mode 100644 index dceee2c8a..000000000 --- a/lp-core/lpc-shared/src/hardware/virtual_button.rs +++ /dev/null @@ -1,182 +0,0 @@ -use alloc::rc::Rc; -use alloc::vec; - -use super::{ - ButtonDebouncer, ButtonEvent, HardwareAddress, HardwareCapability, HardwareClaim, - HardwareError, HardwareLease, HardwareRegistry, -}; - -pub struct VirtualButton { - registry: Rc, - source: HardwareAddress, - lease: Option, - debouncer: ButtonDebouncer, -} - -impl VirtualButton { - pub fn open_gpio( - registry: Rc, - pin: u32, - stable_ms: u64, - ) -> Result { - let source = HardwareAddress::gpio(pin); - registry.ensure_capability(&source, HardwareCapability::GpioInput)?; - let lease = - registry.claim_bundle(HardwareClaim::new("virtual-button", vec![source.clone()]))?; - Ok(Self { - registry, - source: source.clone(), - lease: Some(lease), - debouncer: ButtonDebouncer::new(source, stable_ms), - }) - } - - pub fn source(&self) -> &HardwareAddress { - &self.source - } - - pub fn sample(&mut self, now_ms: u64, pressed: bool) -> Option { - self.debouncer.sample(now_ms, pressed) - } - - pub fn close(&mut self) -> Result<(), HardwareError> { - if let Some(lease) = self.lease.take() { - self.registry.release(&lease)?; - } - Ok(()) - } -} - -impl Drop for VirtualButton { - fn drop(&mut self) { - let _ = self.close(); - } -} - -#[cfg(test)] -mod tests { - use crate::output::{MemoryOutputProvider, OutputFormat, OutputProvider}; - - use super::*; - use crate::hardware::{HardwareEndpointSpec, HardwareManifest, HardwareResource}; - - #[test] - fn button_claim_blocks_output_on_same_gpio() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); - let _button = VirtualButton::open_gpio(Rc::clone(®istry), 4, 30).unwrap(); - let output = MemoryOutputProvider::with_hardware_registry(registry); - let endpoint = endpoint("ws281x:rmt:GPIO4"); - - let result = output.open(&endpoint, 3, OutputFormat::Ws2811, None); - - assert!(matches!( - result, - Err(crate::OutputError::Hardware { - error: HardwareError::ResourceAlreadyClaimed { .. } - }) - )); - } - - #[test] - fn output_claim_blocks_button_on_same_gpio() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); - let output = MemoryOutputProvider::with_hardware_registry(Rc::clone(®istry)); - let handle = output - .open(&endpoint("ws281x:rmt:GPIO4"), 3, OutputFormat::Ws2811, None) - .unwrap(); - - let result = VirtualButton::open_gpio(registry, 4, 30); - - assert!(matches!( - result, - Err(HardwareError::ResourceAlreadyClaimed { .. }) - )); - output.close(handle).unwrap(); - } - - #[test] - fn output_and_button_can_use_different_resources() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); - let output = MemoryOutputProvider::with_hardware_registry(Rc::clone(®istry)); - let handle = output - .open( - &endpoint("ws281x:rmt:GPIO18"), - 3, - OutputFormat::Ws2811, - None, - ) - .unwrap(); - - let button = VirtualButton::open_gpio(Rc::clone(®istry), 4, 30).unwrap(); - - assert_eq!(button.source(), &HardwareAddress::gpio(4)); - assert!(registry.is_claimed(&HardwareAddress::gpio(18))); - assert!(registry.is_claimed(&HardwareAddress::gpio(4))); - output.close(handle).unwrap(); - } - - #[test] - fn reserved_gpio_cannot_be_claimed_for_button() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); - - let result = VirtualButton::open_gpio(registry, 12, 30); - - assert!(matches!( - result, - Err(HardwareError::ReservedResource { .. }) - )); - } - - #[test] - fn close_releases_button_gpio() { - let registry = Rc::new(HardwareRegistry::new(test_manifest())); - let mut button = VirtualButton::open_gpio(Rc::clone(®istry), 4, 30).unwrap(); - - button.close().unwrap(); - - assert!(!registry.is_claimed(&HardwareAddress::gpio(4))); - } - - fn test_manifest() -> HardwareManifest { - HardwareManifest::new( - "test", - "Test Board", - [ - HardwareResource::new( - HardwareAddress::gpio(4), - [ - HardwareCapability::GpioOutput, - HardwareCapability::GpioInput, - ], - "GPIO4", - ), - HardwareResource::new( - HardwareAddress::gpio(12), - [ - HardwareCapability::GpioOutput, - HardwareCapability::GpioInput, - ], - "GPIO12", - ) - .reserved("reserved for test"), - HardwareResource::new( - HardwareAddress::gpio(18), - [ - HardwareCapability::GpioOutput, - HardwareCapability::GpioInput, - ], - "GPIO18", - ), - HardwareResource::new( - HardwareAddress::rmt_ws281x(0), - [HardwareCapability::Rmt, HardwareCapability::Ws281xOutput], - "RMT WS281x 0", - ), - ], - ) - } - - fn endpoint(spec: &'static str) -> HardwareEndpointSpec { - HardwareEndpointSpec::from_static(spec) - } -} diff --git a/lp-core/lpc-shared/src/hardware/ws281x_driver.rs b/lp-core/lpc-shared/src/hardware/ws281x_driver.rs deleted file mode 100644 index 04754ee4e..000000000 --- a/lp-core/lpc-shared/src/hardware/ws281x_driver.rs +++ /dev/null @@ -1,49 +0,0 @@ -use alloc::boxed::Box; - -use crate::DisplayPipelineOptions; -use crate::error::OutputError; - -use super::{HardwareDriver, HardwareEndpoint, HardwareEndpointError, HardwareEndpointId}; - -#[derive(Debug, Clone)] -pub struct Ws281xConfig { - byte_count: u32, - display_options: Option, -} - -impl Ws281xConfig { - pub fn new(byte_count: u32, display_options: Option) -> Self { - Self { - byte_count, - display_options, - } - } - - pub fn byte_count(&self) -> u32 { - self.byte_count - } - - pub fn display_options(&self) -> Option<&DisplayPipelineOptions> { - self.display_options.as_ref() - } - - pub fn display_options_cloned(&self) -> Option { - self.display_options.clone() - } -} - -pub trait Ws281xOutput { - fn write(&mut self, data: &[u16]) -> Result<(), OutputError>; - - fn resize(&mut self, config: Ws281xConfig) -> Result<(), OutputError>; -} - -pub trait Ws281xDriver: HardwareDriver { - fn endpoints(&self) -> alloc::vec::Vec; - - fn open( - &self, - endpoint_id: &HardwareEndpointId, - config: Ws281xConfig, - ) -> Result, HardwareEndpointError>; -} diff --git a/lp-core/lpc-shared/src/lib.rs b/lp-core/lpc-shared/src/lib.rs index 90e2cc817..bf2d6c873 100644 --- a/lp-core/lpc-shared/src/lib.rs +++ b/lp-core/lpc-shared/src/lib.rs @@ -16,7 +16,6 @@ extern crate std; pub mod backtrace; pub mod error; pub mod fps; -pub mod hardware; pub mod stats; pub mod util; // Temporarily enabled for Texture @@ -27,7 +26,8 @@ pub mod time; pub mod transport; pub use display_pipeline::{DisplayPipeline, DisplayPipelineOptions}; -pub use error::{DisplayPipelineError, OutputError, TextureError}; +pub use error::{DisplayPipelineError, TextureError}; +pub use lpc_hardware::OutputError; // Re-export TransportError from lp-model for convenience pub use lpc_wire::TransportError; pub use project::ProjectBuilder; diff --git a/lp-core/lpc-shared/src/output/memory.rs b/lp-core/lpc-shared/src/output/memory.rs index 9803d2469..483f12ec3 100644 --- a/lp-core/lpc-shared/src/output/memory.rs +++ b/lp-core/lpc-shared/src/output/memory.rs @@ -1,19 +1,19 @@ -use crate::error::OutputError; -use crate::hardware::{ - HardwareAddress, HardwareEndpointError, HardwareEndpointSpec, HardwareManifest, - HardwareRegistry, HardwareSystem, Ws281xConfig, Ws281xOutput, -}; use crate::output::provider::{ OutputChannelHandle, OutputDriverOptions, OutputFormat, OutputProvider, }; use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::format; use alloc::rc::Rc; use alloc::string::{String, ToString}; use alloc::vec; use alloc::vec::Vec; use core::cell::RefCell; +use lp_collection::VecMap; +use lpc_hardware::OutputError; +use lpc_hardware::{ + HardwareEndpointError, HardwareSystem, HwAddress, HwEndpointSpec, HwManifest, HwRegistry, + Ws281xConfig, Ws281xOutput, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum EndpointValidation { @@ -23,7 +23,7 @@ enum EndpointValidation { /// Channel state for in-memory provider struct ChannelState { - endpoint: HardwareEndpointSpec, + endpoint: HwEndpointSpec, #[allow( dead_code, reason = "Stored for validation; may be used for protocol-specific handling" @@ -37,7 +37,7 @@ struct ChannelState { /// Internal state for memory provider (wrapped in RefCell for interior mutability) struct MemoryOutputProviderState { - channels: BTreeMap, + channels: VecMap, next_handle: i32, } @@ -54,7 +54,7 @@ pub struct MemoryOutputProvider { impl MemoryOutputProvider { /// Create a new memory output provider pub fn new() -> Self { - Self::with_hardware_manifest(HardwareManifest::virtual_single_rmt_gpio_board()) + Self::with_hardware_manifest(HwManifest::virtual_single_rmt_gpio_board()) } /// Create a memory provider that accepts any authored hardware endpoint. @@ -65,17 +65,17 @@ impl MemoryOutputProvider { pub fn new_permissive() -> Self { Self::with_validation( Rc::new(HardwareSystem::with_virtual_drivers(Rc::new( - HardwareRegistry::new(HardwareManifest::virtual_single_rmt_gpio_board()), + HwRegistry::new(HwManifest::virtual_single_rmt_gpio_board()), ))), EndpointValidation::Permissive, ) } - pub fn with_hardware_manifest(manifest: HardwareManifest) -> Self { - Self::with_hardware_registry(Rc::new(HardwareRegistry::new(manifest))) + pub fn with_hardware_manifest(manifest: HwManifest) -> Self { + Self::with_hardware_registry(Rc::new(HwRegistry::new(manifest))) } - pub fn with_hardware_registry(hardware_registry: Rc) -> Self { + pub fn with_hardware_registry(hardware_registry: Rc) -> Self { Self::with_hardware_system(Rc::new(HardwareSystem::with_virtual_drivers( hardware_registry, ))) @@ -93,7 +93,7 @@ impl MemoryOutputProvider { hardware_system, endpoint_validation, state: RefCell::new(MemoryOutputProviderState { - channels: BTreeMap::new(), + channels: VecMap::new(), next_handle: 0, }), } @@ -117,18 +117,18 @@ impl MemoryOutputProvider { pub fn is_pin_open(&self, pin: u32) -> bool { self.hardware_system .registry() - .is_claimed(&HardwareAddress::gpio(pin)) + .is_claimed(&HwAddress::gpio(pin)) } /// Check if an endpoint is currently opened. - pub fn is_endpoint_open(&self, endpoint: &HardwareEndpointSpec) -> bool { + pub fn is_endpoint_open(&self, endpoint: &HwEndpointSpec) -> bool { self.get_handle_for_endpoint(endpoint).is_some() } /// Get the handle for a given endpoint (for testing) pub fn get_handle_for_endpoint( &self, - endpoint: &HardwareEndpointSpec, + endpoint: &HwEndpointSpec, ) -> Option { let state = self.state.borrow(); for (handle, channel_state) in state.channels.iter() { @@ -148,13 +148,11 @@ impl MemoryOutputProvider { impl OutputProvider for MemoryOutputProvider { fn open( &self, - endpoint: &HardwareEndpointSpec, + endpoint: &HwEndpointSpec, byte_count: u32, format: OutputFormat, options: Option, ) -> Result { - let _ = options; - // Validate byte_count if byte_count == 0 { return Err(OutputError::InvalidConfig { @@ -215,7 +213,7 @@ impl OutputProvider for MemoryOutputProvider { channel_state.byte_count = new_len as u32; channel_state .output - .resize(Ws281xConfig::new(channel_state.byte_count, None))?; + .resize(Ws281xConfig::new(channel_state.byte_count))?; } else if data.len() < expected_len { return Err(OutputError::DataLengthMismatch { expected: expected_len as u32, @@ -223,7 +221,9 @@ impl OutputProvider for MemoryOutputProvider { }); } - channel_state.output.write(data)?; + let mut raw = Vec::with_capacity(channel_state.data.len()); + render_rgb8(data, channel_state.data.len(), &mut raw); + channel_state.output.write(&raw)?; // Store data let len = channel_state.data.len(); @@ -251,17 +251,18 @@ impl OutputProvider for MemoryOutputProvider { impl MemoryOutputProvider { fn open_ws281x_output( &self, - endpoint: &HardwareEndpointSpec, + endpoint: &HwEndpointSpec, byte_count: u32, options: Option, ) -> Result, OutputError> { + let _ = options; match self.endpoint_validation { EndpointValidation::HardwareSystem => self .hardware_system - .open_ws281x_by_spec(endpoint, Ws281xConfig::new(byte_count, options)) + .open_ws281x_by_spec(endpoint, Ws281xConfig::new(byte_count)) .map_err(endpoint_error_to_output_error), EndpointValidation::Permissive => { - let _ = (endpoint, options); + let _ = endpoint; validate_ws281x_byte_count(byte_count)?; Ok(Box::new(MemoryWs281xOutput::new(byte_count))) } @@ -271,20 +272,20 @@ impl MemoryOutputProvider { struct MemoryWs281xOutput { byte_count: u32, - data: Vec, + data: Vec, } impl MemoryWs281xOutput { fn new(byte_count: u32) -> Self { Self { byte_count, - data: vec![0; u16_len_for_byte_count(byte_count)], + data: vec![0; byte_len_for_byte_count(byte_count)], } } } impl Ws281xOutput for MemoryWs281xOutput { - fn write(&mut self, data: &[u16]) -> Result<(), OutputError> { + fn write(&mut self, data: &[u8]) -> Result<(), OutputError> { let expected_len = self.data.len(); if data.len() > expected_len { let new_len = (data.len() / 3) * 3; @@ -305,7 +306,8 @@ impl Ws281xOutput for MemoryWs281xOutput { fn resize(&mut self, config: Ws281xConfig) -> Result<(), OutputError> { validate_ws281x_byte_count(config.byte_count())?; self.byte_count = config.byte_count(); - self.data.resize(u16_len_for_byte_count(self.byte_count), 0); + self.data + .resize(byte_len_for_byte_count(self.byte_count), 0); Ok(()) } } @@ -319,10 +321,15 @@ fn validate_ws281x_byte_count(byte_count: u32) -> Result<(), OutputError> { Ok(()) } -fn u16_len_for_byte_count(byte_count: u32) -> usize { +fn byte_len_for_byte_count(byte_count: u32) -> usize { ((byte_count / 3) as usize) * 3 } +fn render_rgb8(data: &[u16], len: usize, out: &mut Vec) { + out.clear(); + out.extend(data[..len].iter().map(|sample| (sample >> 8) as u8)); +} + fn endpoint_error_to_output_error(error: HardwareEndpointError) -> OutputError { match error { HardwareEndpointError::Hardware { error } => OutputError::Hardware { error }, @@ -336,8 +343,8 @@ fn endpoint_error_to_output_error(error: HardwareEndpointError) -> OutputError { mod tests { use super::*; - fn endpoint(spec: &'static str) -> HardwareEndpointSpec { - HardwareEndpointSpec::from_static(spec) + fn endpoint(spec: &'static str) -> HwEndpointSpec { + HwEndpointSpec::from_static(spec) } #[test] diff --git a/lp-core/lpc-shared/src/output/provider.rs b/lp-core/lpc-shared/src/output/provider.rs index 15a1c642a..786d811fc 100644 --- a/lp-core/lpc-shared/src/output/provider.rs +++ b/lp-core/lpc-shared/src/output/provider.rs @@ -1,6 +1,6 @@ use crate::display_pipeline::DisplayPipelineOptions; -use crate::error::OutputError; -use crate::hardware::HardwareEndpointSpec; +use lpc_hardware::HwEndpointSpec; +use lpc_hardware::OutputError; /// Options for output driver (DisplayPipeline). Alias for DisplayPipelineOptions. pub type OutputDriverOptions = DisplayPipelineOptions; @@ -49,7 +49,7 @@ pub trait OutputProvider { /// - Hardware initialization failed fn open( &self, - endpoint: &HardwareEndpointSpec, + endpoint: &HwEndpointSpec, byte_count: u32, format: OutputFormat, options: Option, diff --git a/lp-core/lpc-shared/src/project/builder.rs b/lp-core/lpc-shared/src/project/builder.rs index a29da9220..166acb949 100644 --- a/lp-core/lpc-shared/src/project/builder.rs +++ b/lp-core/lpc-shared/src/project/builder.rs @@ -1,17 +1,18 @@ //! Project builder for creating artifact-authored test projects with a fluent API. -use alloc::{collections::BTreeMap, format, rc::Rc, string::String, vec, vec::Vec}; +use alloc::{format, rc::Rc, string::String, vec, vec::Vec}; use core::cell::RefCell; +use lp_collection::VecMap; use lpc_model::GlslOpts; use lpc_model::nodes::fixture::{ColorOrder, FixtureDef, MappingConfig, PathSpec, RingOrder}; use lpc_model::nodes::output::{OutputDef, OutputDriverOptionsConfig}; -use lpc_model::nodes::shader::{ShaderDef, ShaderSlotDef, ShaderSource}; +use lpc_model::nodes::shader::{ShaderDef, ShaderSlotDef}; use lpc_model::nodes::texture::TextureDef; use lpc_model::{ - Affine2d, Affine2dSlot, ArtifactLocator, AsLpPath, BindingDef, BindingDefs, BindingRef, + Affine2d, Affine2dSlot, ArtifactSpec, AsLpPath, AssetSlot, BindingDef, BindingDefs, BindingRef, BusSlotRef, Dim2u, Dim2uSlot, EnumSlot, FixtureDiagnosticMode, FixtureSamplingConfig, - HardwareEndpointSpec, MapSlot, NodeDef, NodeInvocation, OptionSlot, ProjectDef, Ratio, - RatioSlot, RenderOrder, RenderOrderSlot, SlotPath, SlotShapeRegistry, ValueSlot, + HwEndpointSpec, MapSlot, NodeDef, NodeInvocation, NodeInvocationSlot, OptionSlot, ProjectDef, + Ratio, RatioSlot, RenderOrder, RenderOrderSlot, SlotPath, SlotShapeRegistry, ValueSlot, }; use lpfs::LpFs; use lpfs::lp_path::LpPathBuf; @@ -56,7 +57,7 @@ pub struct ShaderBuilder { /// Builder for output nodes pub struct OutputBuilder { - endpoint: HardwareEndpointSpec, + endpoint: HwEndpointSpec, options: OutputDriverOptionsConfig, } @@ -176,12 +177,14 @@ impl ProjectBuilder { /// Build completes - writes project.toml and all node artifact files. pub fn build(self) { let registry = slot_shape_registry(); - let mut nodes = BTreeMap::new(); + let mut nodes = VecMap::new(); for (name, path) in &self.nodes { let relative_path = path.as_str().trim_start_matches('/'); nodes.insert( name.clone(), - NodeInvocation::new(ArtifactLocator::path(format!("./{relative_path}"))), + NodeInvocationSlot::new(NodeInvocation::new(ArtifactSpec::path(format!( + "./{relative_path}" + )))), ); } let project = ProjectDef { @@ -271,7 +274,7 @@ impl ShaderBuilder { let source_file = format!("{node_name}.glsl"); let config = ShaderDef { - source: EnumSlot::new(ShaderSource::path(source_file)), + source: AssetSlot::path(source_file), render_order: RenderOrderSlot::new(RenderOrder(self.render_order)), bindings: bus_output_binding_defs("visual.out"), glsl_opts: GlslOpts::default(), @@ -296,14 +299,14 @@ impl ShaderBuilder { impl OutputBuilder { /// Set the hardware endpoint spec. - pub fn endpoint(mut self, endpoint: HardwareEndpointSpec) -> Self { + pub fn endpoint(mut self, endpoint: HwEndpointSpec) -> Self { self.endpoint = endpoint; self } /// Set the hardware endpoint spec from text. pub fn endpoint_str(mut self, endpoint: &str) -> Self { - self.endpoint = HardwareEndpointSpec::parse(endpoint).expect("valid output endpoint spec"); + self.endpoint = HwEndpointSpec::parse(endpoint).expect("valid output endpoint spec"); self } @@ -423,7 +426,7 @@ fn bus_output_binding_defs(slot: &str) -> BindingDefs { } fn default_visual_consumed_slots() -> MapSlot { - let mut slots = BTreeMap::new(); + let mut slots = VecMap::new(); slots.insert( String::from("time"), ShaderSlotDef::value_f32("Time", "Project clock time in seconds", 0.0, None), @@ -432,7 +435,7 @@ fn default_visual_consumed_slots() -> MapSlot { } fn fixture_binding_defs() -> BindingDefs { - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); entries.insert( String::from("input"), BindingDef::source(BindingRef::Bus(BusSlotRef::new( @@ -449,13 +452,13 @@ fn fixture_binding_defs() -> BindingDefs { } fn single_binding_defs(slot: &str, binding: BindingDef) -> BindingDefs { - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); entries.insert(String::from(slot), binding); BindingDefs::new(entries) } fn default_mapping() -> MappingConfig { - let mut ring_lamp_counts = BTreeMap::new(); + let mut ring_lamp_counts = VecMap::new(); ring_lamp_counts.insert(0, ValueSlot::new(1)); MappingConfig::path_points_vec( @@ -486,6 +489,7 @@ fn affine2d_from_matrix(matrix: [[f32; 4]; 4]) -> Affine2d { #[cfg(test)] mod tests { use super::*; + use lp_collection::VecMap; use lpc_model::NodeDef; use lpfs::LpFsMemory; diff --git a/lp-core/lpc-shared/src/transport/server.rs b/lp-core/lpc-shared/src/transport/server.rs index b2696b932..d685119cd 100644 --- a/lp-core/lpc-shared/src/transport/server.rs +++ b/lp-core/lpc-shared/src/transport/server.rs @@ -14,8 +14,7 @@ use lpc_wire::json::json_write::JsonWrite; use lpc_wire::json::json_writer::{JsonWriter, JsonWriterError}; use lpc_wire::{ ProjectProbeRequest, ProjectReadQuery, ProjectReadRequest, ProjectReadResponse, TransportError, - WireProjectHandle, WireServerMessage, WireSlotMutationRequest, WireSlotMutationResponse, - messages::ClientMessage, + WireProjectHandle, WireServerMessage, messages::ClientMessage, }; /// Source that can write a project-read response to JSON without requiring the @@ -23,11 +22,6 @@ use lpc_wire::{ pub trait ProjectReadJsonSource { fn project_read_revision(&self) -> Revision; - fn apply_project_mutations( - &mut self, - mutations: Vec, - ) -> Vec; - fn write_project_read_result_json( &mut self, since: Option, @@ -53,7 +47,6 @@ pub trait ProjectReadJsonSource { where W: JsonWrite, { - let mutation_responses = self.apply_project_mutations(request.mutations); let mut writer = JsonWriter::new(out); writer.write_raw(b"{\"revision\":")?; writer.serde(&self.project_read_revision())?; @@ -77,14 +70,6 @@ pub trait ProjectReadJsonSource { writer = JsonWriter::new(out); } - writer.write_raw(b"],\"mutations\":[")?; - for (index, mutation) in mutation_responses.into_iter().enumerate() { - if index > 0 { - writer.write_raw(b",")?; - } - writer.serde(&mutation)?; - } - writer.write_raw(b"]}")?; Ok(writer.into_inner()) } diff --git a/lp-core/lpc-slot-macros/src/slotted_record.rs b/lp-core/lpc-slot-macros/src/slotted_record.rs index 6b9655bc2..87cb2fc33 100644 --- a/lp-core/lpc-slot-macros/src/slotted_record.rs +++ b/lp-core/lpc-slot-macros/src/slotted_record.rs @@ -176,6 +176,14 @@ pub(crate) fn derive_record( } impl ::lpc_model::FieldSlot for #ident { + // When a record type appears inside another shape (a struct field or + // enum variant payload) it is emitted as a reference to its own + // catalog entry rather than re-inlining the full record. The + // canonical shape (`slot_shape`/`STATIC_SLOT_SHAPE_DESCRIPTOR`) keeps + // the full record; every embedding site resolves the id through the + // registry. This keeps the shape registry de-duplicated: each record + // is described once instead of once per embedding. Use + // `#[slot(record)]` on a field to force the old inline behavior. const STATIC_SLOT_FIELD_SHAPE_DESCRIPTOR: Option<&'static ::lpc_model::StaticSlotShapeDescriptor> = Some(&::lpc_model::StaticSlotShapeDescriptor::Ref { id: ::SHAPE_ID, diff --git a/lp-core/lpc-slot-mockup/Cargo.toml b/lp-core/lpc-slot-mockup/Cargo.toml deleted file mode 100644 index aab86acaf..000000000 --- a/lp-core/lpc-slot-mockup/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "lpc-slot-mockup" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true - -[dependencies] -lpc-model = { path = "../lpc-model", features = ["std", "derive"] } -lpc-view = { path = "../lpc-view", features = ["std"] } -lpc-wire = { path = "../lpc-wire", features = ["std"] } -toml = { workspace = true, features = ["display"] } - -[build-dependencies] -lpc-slot-codegen = { path = "../lpc-slot-codegen" } - -[lints] -workspace = true diff --git a/lp-core/lpc-slot-mockup/build.rs b/lp-core/lpc-slot-mockup/build.rs deleted file mode 100644 index 6408274e5..000000000 --- a/lp-core/lpc-slot-mockup/build.rs +++ /dev/null @@ -1,15 +0,0 @@ -use std::{env, path::PathBuf}; - -fn main() { - println!("cargo:rerun-if-changed=src"); - - let crate_root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest dir")); - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("out dir")); - let out_file = out_dir.join("slot_shapes.rs"); - - lpc_slot_codegen::generate_slot_shapes(lpc_slot_codegen::SlotShapeCodegenConfig { - crate_root, - out_file, - }) - .expect("generate slot shape bootstrap"); -} diff --git a/lp-core/lpc-slot-mockup/src/engine/fixture_node.rs b/lp-core/lpc-slot-mockup/src/engine/fixture_node.rs deleted file mode 100644 index 673d58734..000000000 --- a/lp-core/lpc-slot-mockup/src/engine/fixture_node.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::collections::BTreeMap; - -use crate::source::MappingConfig; -use lpc_model::{EnumSlot, MapSlot, PositiveF32, PositiveF32Slot, Slotted, Xy, XySlot}; - -#[derive(Slotted)] -pub struct FixtureNode { - pub touches: MapSlot, - pub mapping_preview: EnumSlot, -} - -#[derive(Slotted)] -pub struct TouchState { - pub position: XySlot, - pub pressure: PositiveF32Slot, -} - -impl FixtureNode { - pub fn new() -> Self { - let mut touches = BTreeMap::new(); - touches.insert(1, TouchState::new([0.2, 0.3], 0.7)); - touches.insert(2, TouchState::new([0.8, 0.4], 0.4)); - - Self { - touches: MapSlot::new(touches), - mapping_preview: EnumSlot::new(MappingConfig::path_points_default()), - } - } - pub fn switch_mapping_preview(&mut self) { - self.mapping_preview = EnumSlot::new(MappingConfig::square()); - } - - pub fn disable_mapping_preview(&mut self) { - self.mapping_preview = EnumSlot::new(MappingConfig::disabled()); - } - - pub fn remove_touch(&mut self, id: u32) { - self.touches.remove(&id); - } -} - -impl Default for FixtureNode { - fn default() -> Self { - Self::new() - } -} - -impl TouchState { - fn new(position: [f32; 2], pressure: f32) -> Self { - Self { - position: XySlot::new(Xy(position)), - pressure: PositiveF32Slot::new(PositiveF32(pressure)), - } - } -} - -impl Default for TouchState { - fn default() -> Self { - Self::new([0.0, 0.0], 1.0) - } -} diff --git a/lp-core/lpc-slot-mockup/src/engine/mod.rs b/lp-core/lpc-slot-mockup/src/engine/mod.rs deleted file mode 100644 index 54bab5b9d..000000000 --- a/lp-core/lpc-slot-mockup/src/engine/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod fixture_node; -mod output_node; -mod runtime; -mod shader_node; - -pub use fixture_node::{FixtureNode, TouchState}; -pub use output_node::OutputNode; -pub use runtime::MockRuntime; -pub use shader_node::ShaderNode; diff --git a/lp-core/lpc-slot-mockup/src/engine/output_node.rs b/lp-core/lpc-slot-mockup/src/engine/output_node.rs deleted file mode 100644 index 7f73966bd..000000000 --- a/lp-core/lpc-slot-mockup/src/engine/output_node.rs +++ /dev/null @@ -1,20 +0,0 @@ -use lpc_model::{Slotted, ValueSlot}; - -#[derive(Slotted)] -pub struct OutputNode { - pub frames_sent: ValueSlot, -} - -impl OutputNode { - pub fn new() -> Self { - Self { - frames_sent: ValueSlot::new(0), - } - } -} - -impl Default for OutputNode { - fn default() -> Self { - Self::new() - } -} diff --git a/lp-core/lpc-slot-mockup/src/engine/runtime.rs b/lp-core/lpc-slot-mockup/src/engine/runtime.rs deleted file mode 100644 index 1e054c8cd..000000000 --- a/lp-core/lpc-slot-mockup/src/engine/runtime.rs +++ /dev/null @@ -1,263 +0,0 @@ -use lpc_model::{ - Revision, SlotAccess, SlotMutAccess, SlotMutationError, SlotPath, SlotShapeId, - SlotShapeRegistry, current_revision, set_current_revision, set_slot_value, slot_data_revision, -}; -use lpc_wire::{ - WireSlotMutationOp, WireSlotMutationRejection, WireSlotMutationRequest, - WireSlotMutationResponse, WireSlotMutationResult, -}; - -use crate::source::{FixtureDef, OutputDef, ProjectDef, ShaderDef, TextureDef}; - -use super::{FixtureNode, OutputNode, ShaderNode}; - -pub struct MockRuntime { - pub registry: SlotShapeRegistry, - pub project: ProjectDef, - pub shader_def: ShaderDef, - pub fixture_def: FixtureDef, - pub output_def: OutputDef, - pub texture_def: TextureDef, - pub shader_node: ShaderNode, - pub fixture_node: FixtureNode, - pub output_node: OutputNode, -} - -impl MockRuntime { - pub fn new() -> Self { - set_current_revision(Revision::new(1)); - - let mut registry = SlotShapeRegistry::default(); - crate::model::register_shapes(&mut registry).unwrap(); - - let shader_def = ShaderDef::new(); - let shader_node = ShaderNode::from_def(&shader_def); - // Shader runtime params are dynamic: the shape is owned by this loaded - // node/artifact instance, not by the Rust `ShaderNode` type. - registry - .register_shape(shader_node.shape_id(), shader_node.shape()) - .unwrap(); - - Self { - registry, - project: ProjectDef::new(), - shader_node, - fixture_def: FixtureDef::new(), - output_def: OutputDef::default(), - texture_def: TextureDef::new(), - fixture_node: FixtureNode::new(), - output_node: OutputNode::new(), - shader_def, - } - } - - pub fn roots(&self) -> Vec<(&str, &dyn SlotAccess)> { - vec![ - ("source.project", &self.project), - ("source.shader", &self.shader_def), - ("source.fixture", &self.fixture_def), - ("source.output", &self.output_def), - ("source.texture", &self.texture_def), - ("engine.shader_node", &self.shader_node), - ("engine.fixture_node", &self.fixture_node), - ("engine.output_node", &self.output_node), - ] - } - - pub fn add_shader_param_def(&mut self, frame: Revision, name: &str, default: f32) { - set_current_revision(frame); - self.shader_def.add_param_def(name, default); - } - - pub fn set_shader_param(&mut self, frame: Revision, name: &str, value: f32) { - set_current_revision(frame); - self.shader_node.set_param(name, value); - } - - pub fn change_shader_param_to_vec3( - &mut self, - frame: Revision, - name: &str, - param_value: [f32; 3], - ) { - set_current_revision(frame); - self.shader_def.set_param_value_type(name, "vec3"); - self.shader_node.set_param_vec3(name, param_value); - self.refresh_shader_node_shape(); - } - - pub fn remove_shader_param(&mut self, frame: Revision, name: &str) { - set_current_revision(frame); - self.shader_node.remove_param(name); - self.refresh_shader_node_shape(); - } - - pub fn clear_compile_error(&mut self, frame: Revision) { - set_current_revision(frame); - self.shader_node.clear_compile_error(); - } - - pub fn switch_fixture_mapping(&mut self, frame: Revision) { - set_current_revision(frame); - self.fixture_def.switch_mapping_to_square(); - self.fixture_node.switch_mapping_preview(); - } - - pub fn disable_fixture_mapping(&mut self, frame: Revision) { - set_current_revision(frame); - self.fixture_def.disable_mapping(); - self.fixture_node.disable_mapping_preview(); - } - - pub fn clear_fixture_brightness(&mut self, frame: Revision) { - set_current_revision(frame); - self.fixture_def.clear_brightness(); - } - - pub fn set_fixture_ring_lamp_counts(&mut self, frame: Revision, counts: Vec) { - set_current_revision(frame); - assert!( - self.fixture_def.set_ring_lamp_counts(counts), - "fixture mapping must be path_points/ring_array in the mockup" - ); - } - - pub fn remove_touch(&mut self, frame: Revision, id: u32) { - set_current_revision(frame); - self.fixture_node.remove_touch(id); - } - - pub fn apply_slot_mutation( - &mut self, - frame: Revision, - request: WireSlotMutationRequest, - ) -> WireSlotMutationResponse { - set_current_revision(frame); - let result = self.apply_slot_mutation_result(&request); - WireSlotMutationResponse { - id: request.id, - result, - } - } - - fn refresh_shader_node_shape(&mut self) { - self.registry - .replace_shape(self.shader_node.shape_id(), self.shader_node.shape()); - } - - fn apply_slot_mutation_result( - &mut self, - request: &WireSlotMutationRequest, - ) -> WireSlotMutationResult { - let info = match self.mutation_target_info(&request.root, &request.path) { - Ok(info) => info, - Err(rejection) => return WireSlotMutationResult::Rejected(rejection), - }; - - if info.shape_version != request.expected_shape_version { - return WireSlotMutationResult::Rejected(WireSlotMutationRejection::ShapeConflict { - current_version: info.shape_version, - }); - } - if info.data_version != request.expected_data_version { - return WireSlotMutationResult::Rejected(WireSlotMutationRejection::DataConflict { - current_version: info.data_version, - }); - } - - match &request.op { - WireSlotMutationOp::SetValue(value) => { - let registry = self.registry.clone(); - let root = match self.root_mut(&request.root) { - Ok(root) => root, - Err(rejection) => return WireSlotMutationResult::Rejected(rejection), - }; - match set_slot_value( - root, - ®istry, - &request.path, - current_revision(), - value.clone(), - ) { - Ok(()) => WireSlotMutationResult::Accepted, - Err(error) => { - WireSlotMutationResult::Rejected(mutation_error_to_rejection(error)) - } - } - } - } - } - - fn mutation_target_info( - &self, - root: &str, - path: &SlotPath, - ) -> Result { - let root = self.root(root)?; - Ok(MutationTargetInfo { - shape_version: self.root_shape_version(root.shape_id())?, - data_version: slot_data_revision(root, &self.registry, path) - .map_err(mutation_error_to_rejection)?, - }) - } - - fn root_shape_version( - &self, - shape_id: SlotShapeId, - ) -> Result { - self.registry - .entry(&shape_id) - .map(|entry| entry.changed_at()) - .ok_or(WireSlotMutationRejection::UnknownRoot) - } - - fn root(&self, root: &str) -> Result<&dyn SlotAccess, WireSlotMutationRejection> { - match root { - "source.project" => Ok(&self.project), - "source.shader" => Ok(&self.shader_def), - "source.fixture" => Ok(&self.fixture_def), - "source.output" => Ok(&self.output_def), - "source.texture" => Ok(&self.texture_def), - "engine.shader_node" => Ok(&self.shader_node), - "engine.fixture_node" => Ok(&self.fixture_node), - "engine.output_node" => Ok(&self.output_node), - _ => Err(WireSlotMutationRejection::UnknownRoot), - } - } - - fn root_mut( - &mut self, - root: &str, - ) -> Result<&mut dyn SlotMutAccess, WireSlotMutationRejection> { - match root { - "source.project" => Ok(&mut self.project), - "source.shader" => Ok(&mut self.shader_def), - "source.fixture" => Ok(&mut self.fixture_def), - "source.output" => Ok(&mut self.output_def), - "source.texture" => Ok(&mut self.texture_def), - "engine.shader_node" => Ok(&mut self.shader_node), - _ => Err(WireSlotMutationRejection::UnknownRoot), - } - } -} - -impl Default for MockRuntime { - fn default() -> Self { - Self::new() - } -} - -struct MutationTargetInfo { - shape_version: Revision, - data_version: Revision, -} - -fn mutation_error_to_rejection(error: SlotMutationError) -> WireSlotMutationRejection { - match error { - SlotMutationError::WrongType { .. } => WireSlotMutationRejection::WrongType, - SlotMutationError::UnknownVariant { .. } | SlotMutationError::UnknownPath { .. } => { - WireSlotMutationRejection::UnknownPath - } - SlotMutationError::UnsupportedTarget { .. } => WireSlotMutationRejection::UnsupportedTarget, - } -} diff --git a/lp-core/lpc-slot-mockup/src/engine/shader_node.rs b/lp-core/lpc-slot-mockup/src/engine/shader_node.rs deleted file mode 100644 index 514aaff62..000000000 --- a/lp-core/lpc-slot-mockup/src/engine/shader_node.rs +++ /dev/null @@ -1,224 +0,0 @@ -use crate::source::ShaderDef; -use lpc_model::{ - __private::Box, - LpType, LpValue, ModelStructMember, Revision, SlotAccess, SlotData, SlotDataAccess, - SlotDataMutAccess, SlotMutAccess, SlotName, SlotOptionDyn, SlotRecord, SlotRecordAccess, - SlotRecordMutAccess, SlotShape, SlotShapeId, WithRevision, current_revision, - slot::shape::{field, option, record, value}, -}; - -pub struct ShaderNode { - shape_id: SlotShapeId, - param_names: Vec, - params: SlotRecord, - compile_error: SlotOptionDyn, -} - -impl ShaderNode { - pub const SHAPE_ID: SlotShapeId = SlotShapeId::from_static_name("engine.shader_node"); - - pub fn from_def(def: &ShaderDef) -> Self { - Self::from_def_with_shape_id(def, Self::SHAPE_ID) - } - - pub fn from_def_with_shape_id(def: &ShaderDef, shape_id: SlotShapeId) -> Self { - let param_names = def - .param_defs - .entries - .keys() - .map(|name| SlotName::parse(name).expect("shader param name")) - .collect::>(); - let params = def - .param_defs - .entries - .values() - .map(|param_def| { - SlotData::Value(WithRevision::new( - current_revision(), - param_def.default_value(), - )) - }) - .collect::>(); - - Self { - shape_id, - param_names, - params: SlotRecord::new(params), - compile_error: SlotOptionDyn::some_with_version( - current_revision(), - SlotData::Value(WithRevision::new( - current_revision(), - LpValue::String(String::from("initial compile warning")), - )), - ), - } - } - - pub fn shape(&self) -> SlotShape { - record(vec![ - field( - "params", - record( - self.param_names - .iter() - .zip(self.params.fields.iter()) - .map(|(name, data)| field(name.as_str(), value(lp_type_for_data(data)))) - .collect(), - ), - ), - field("compile_error", option(value(LpType::String))), - ]) - } - - pub fn set_param(&mut self, name: &str, value: f32) { - self.set_param_value(name, LpValue::F32(value)); - } - - pub fn set_param_vec3(&mut self, name: &str, value: [f32; 3]) { - self.set_param_value(name, LpValue::Vec3(value)); - } - - fn set_param_value(&mut self, name: &str, value: LpValue) { - let index = self.param_index(name); - let Some(SlotData::Value(param)) = self.params.fields.get_mut(index) else { - panic!("shader param exists"); - }; - param.set(current_revision(), value); - } - - pub fn remove_param(&mut self, name: &str) { - let index = self.param_index(name); - self.param_names.remove(index); - self.params.fields.remove(index); - self.params.fields_revision = current_revision(); - } - - pub fn param_revision(&self, name: &str) -> Option { - let index = self - .param_names - .iter() - .position(|param_name| param_name.as_str() == name)?; - let SlotData::Value(value) = self.params.fields.get(index)? else { - return None; - }; - Some(value.changed_at()) - } - - pub fn param_lp_type(&self, name: &str) -> Option { - let index = self - .param_names - .iter() - .position(|param_name| param_name.as_str() == name)?; - self.params.fields.get(index).map(lp_type_for_data) - } - - pub fn clear_compile_error(&mut self) { - self.compile_error = SlotOptionDyn::none(); - } - - fn param_index(&self, name: &str) -> usize { - self.param_names - .iter() - .position(|param_name| param_name.as_str() == name) - .expect("shader param exists") - } -} - -impl SlotAccess for ShaderNode { - fn shape_id(&self) -> SlotShapeId { - self.shape_id - } - - fn data(&self) -> SlotDataAccess<'_> { - SlotDataAccess::Record(self) - } - - fn as_any(&self) -> &dyn core::any::Any { - self - } - - fn into_any(self: Box) -> Box { - self - } -} - -impl SlotRecordAccess for ShaderNode { - fn field(&self, index: usize) -> Option> { - match index { - 0 => Some(SlotDataAccess::Record(&self.params)), - 1 => Some(SlotDataAccess::Option(&self.compile_error)), - _ => None, - } - } -} - -impl SlotMutAccess for ShaderNode { - fn data_mut(&mut self) -> SlotDataMutAccess<'_> { - SlotDataMutAccess::Record(self) - } -} - -impl SlotRecordMutAccess for ShaderNode { - fn field_mut(&mut self, index: usize) -> Option> { - match index { - 0 => Some(SlotDataMutAccess::Record(&mut self.params)), - 1 => Some(SlotDataMutAccess::Option(&mut self.compile_error)), - _ => None, - } - } -} - -fn lp_type_for_data(data: &SlotData) -> LpType { - let SlotData::Value(value) = data else { - panic!("shader param value must be a value slot"); - }; - lp_type_for_value(value.value()) -} - -fn lp_type_for_value(value: &LpValue) -> LpType { - match value { - LpValue::Unset => panic!("unset shader param values need an explicit type"), - LpValue::String(_) => LpType::String, - LpValue::I32(_) => LpType::I32, - LpValue::U32(_) => LpType::U32, - LpValue::F32(_) => LpType::F32, - LpValue::Bool(_) => LpType::Bool, - LpValue::Vec2(_) => LpType::Vec2, - LpValue::Vec3(_) => LpType::Vec3, - LpValue::Vec4(_) => LpType::Vec4, - LpValue::IVec2(_) => LpType::IVec2, - LpValue::IVec3(_) => LpType::IVec3, - LpValue::IVec4(_) => LpType::IVec4, - LpValue::UVec2(_) => LpType::UVec2, - LpValue::UVec3(_) => LpType::UVec3, - LpValue::UVec4(_) => LpType::UVec4, - LpValue::BVec2(_) => LpType::BVec2, - LpValue::BVec3(_) => LpType::BVec3, - LpValue::BVec4(_) => LpType::BVec4, - LpValue::Mat2x2(_) => LpType::Mat2x2, - LpValue::Mat3x3(_) => LpType::Mat3x3, - LpValue::Mat4x4(_) => LpType::Mat4x4, - LpValue::Array(values) => { - let Some(first) = values.first() else { - panic!("empty shader param arrays need an explicit type"); - }; - LpType::Array(Box::new(lp_type_for_value(first)), values.len()) - } - LpValue::Struct { name, fields } => LpType::Struct { - name: name.clone(), - fields: fields - .iter() - .map(|(name, value)| ModelStructMember { - name: name.clone(), - ty: lp_type_for_value(value), - }) - .collect(), - }, - LpValue::Enum { .. } => panic!("shader param enum values need an explicit type"), - LpValue::Resource(_) => LpType::Resource, - LpValue::Product(product) => match product { - lpc_model::ProductRef::Visual(_) => LpType::Product(lpc_model::ProductKind::Visual), - lpc_model::ProductRef::Control(_) => LpType::Product(lpc_model::ProductKind::Control), - }, - } -} diff --git a/lp-core/lpc-slot-mockup/src/lib.rs b/lp-core/lpc-slot-mockup/src/lib.rs deleted file mode 100644 index 12fb16892..000000000 --- a/lp-core/lpc-slot-mockup/src/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Temporary slot-model pressure crate. -//! -//! This crate defines fake LightPlayer-ish domain objects and forces them -//! through the real slot APIs in `lpc-model`. - -pub mod engine; -pub mod model; -pub mod slot_shapes { - include!(concat!(env!("OUT_DIR"), "/slot_shapes.rs")); -} -pub mod source; -pub mod wire; - -#[cfg(test)] -mod tests; diff --git a/lp-core/lpc-slot-mockup/src/model/mod.rs b/lp-core/lpc-slot-mockup/src/model/mod.rs deleted file mode 100644 index 33838febd..000000000 --- a/lp-core/lpc-slot-mockup/src/model/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -pub fn register_shapes( - registry: &mut lpc_model::SlotShapeRegistry, -) -> Result<(), lpc_model::SlotShapeRegistryError> { - register_default_shape::(registry)?; - register_default_shape::(registry)?; - register_default_shape::(registry)?; - register_default_shape::(registry)?; - register_default_shape::(registry)?; - register_default_shape::(registry)?; - register_default_shape::(registry)?; - register_default_shape::(registry)?; - register_default_shape::(registry)?; - register_default_shape::(registry)?; - register_default_shape::(registry)?; - register_default_shape::(registry)?; - Ok(()) -} - -fn register_default_shape( - registry: &mut lpc_model::SlotShapeRegistry, -) -> Result<(), lpc_model::SlotShapeRegistryError> -where - T: lpc_model::StaticSlotShape + lpc_model::SlotMutAccess + Default + 'static, -{ - T::ensure_default_registered::(registry).map(|_| ()) -} diff --git a/lp-core/lpc-slot-mockup/src/source/fixture_def.rs b/lp-core/lpc-slot-mockup/src/source/fixture_def.rs deleted file mode 100644 index f3b301eea..000000000 --- a/lp-core/lpc-slot-mockup/src/source/fixture_def.rs +++ /dev/null @@ -1,154 +0,0 @@ -use lpc_model::{ - Affine2d, Affine2dSlot, BindingDefs, ColorOrderSlot, ColorOrderValue, Dim2u, Dim2uSlot, - EnumSlot, FromLpValue, LpType, LpValue, OptionSlot, SlotMeta, SlotShapeId, SlotValue, - SlotValueShape, Slotted, ToLpValue, ValueEditorHint, ValueRootError, ValueSlot, -}; - -use super::{MappingConfig, shader_def::ScalarHint}; - -#[derive(Default, Slotted)] -pub struct FixtureDef { - pub render_size: Dim2uSlot, - pub bindings: BindingDefs, - pub sampling: ValueSlot, - pub mapping: EnumSlot, - pub color_order: ColorOrderSlot, - pub transform: Affine2dSlot, - pub brightness: OptionSlot, - pub gamma_correction: OptionSlot>, -} - -impl FixtureDef { - pub const KIND: &'static str = "fixture"; - - pub fn new() -> Self { - Self { - render_size: default_render_size(), - bindings: BindingDefs::default(), - sampling: ValueSlot::new(FixtureSamplingConfig::TextureArea), - mapping: EnumSlot::new(MappingConfig::path_points_default()), - color_order: ColorOrderSlot::new(ColorOrderValue::Grb), - transform: Affine2dSlot::new(Affine2d::identity()), - brightness: OptionSlot::some(ScalarHint::mock(0.8)), - gamma_correction: default_gamma_correction(), - } - } - - pub fn switch_mapping_to_square(&mut self) { - self.mapping = EnumSlot::new(MappingConfig::square()); - } - - pub fn disable_mapping(&mut self) { - self.mapping = EnumSlot::new(MappingConfig::disabled()); - } - - pub fn clear_brightness(&mut self) { - self.brightness.set_none(); - } - - pub fn sampling(&self) -> FixtureSamplingConfig { - *self.sampling.value() - } - - pub fn render_size(&self) -> Dim2u { - *self.render_size.value() - } - - pub fn mapping(&self) -> &MappingConfig { - self.mapping.value() - } - - pub fn color_order(&self) -> ColorOrderValue { - *self.color_order.value() - } - - pub fn transform(&self) -> Affine2d { - *self.transform.value() - } - - pub fn brightness(&self) -> Option<&ScalarHint> { - self.brightness.data.as_ref() - } - - pub fn gamma_correction(&self) -> Option { - self.gamma_correction - .data - .as_ref() - .map(|value| *value.value()) - } - - pub fn set_ring_lamp_counts(&mut self, counts: Vec) -> bool { - self.mapping.value_mut().set_ring_lamp_counts(counts) - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum FixtureSamplingConfig { - #[default] - TextureArea, - Point, -} - -impl FixtureSamplingConfig { - pub fn point() -> Self { - Self::Point - } - - pub fn as_str(self) -> &'static str { - match self { - Self::TextureArea => "texture_area", - Self::Point => "point", - } - } - - pub fn parse(value: &str) -> Option { - match value { - "texture_area" => Some(Self::TextureArea), - "point" => Some(Self::Point), - _ => None, - } - } -} - -impl ToLpValue for FixtureSamplingConfig { - fn to_lp_value(&self) -> LpValue { - LpValue::String(self.as_str().to_string()) - } -} - -impl FromLpValue for FixtureSamplingConfig { - fn from_lp_value(value: &LpValue) -> Result { - match value { - LpValue::String(value) => { - Self::parse(value).ok_or_else(|| ValueRootError::new("expected fixture sampling")) - } - other => Err(ValueRootError::new(format!( - "expected String, got {other:?}" - ))), - } - } -} - -impl SlotValue for FixtureSamplingConfig { - const SHAPE_ID: SlotShapeId = SlotShapeId::from_static_name("FixtureSamplingConfig"); - - fn value_shape() -> SlotValueShape { - SlotValueShape { - id: Self::SHAPE_ID, - ty: LpType::String, - meta: SlotMeta::empty(), - editor: ValueEditorHint::Plain, - } - } -} - -fn default_render_size() -> Dim2uSlot { - Dim2uSlot::new(Dim2u { - width: 16, - height: 16, - }) -} - -fn default_gamma_correction() -> OptionSlot> { - OptionSlot::some(ValueSlot::new(true)) -} diff --git a/lp-core/lpc-slot-mockup/src/source/mapping.rs b/lp-core/lpc-slot-mockup/src/source/mapping.rs deleted file mode 100644 index 97902a794..000000000 --- a/lp-core/lpc-slot-mockup/src/source/mapping.rs +++ /dev/null @@ -1,264 +0,0 @@ -use std::collections::BTreeMap; - -use lpc_model::{ - EnumSlot, MapSlot, PositiveF32, PositiveF32Slot, SlotEnumOption, SlotMeta, SlotShapeId, - SlotValue, SlotValueShape, Slotted, ToLpValue, ValueEditorHint, ValueRootError, ValueSlot, Xy, - XySlot, -}; - -/// Fixture-to-texture mapping authored on a fixture definition. -#[derive(Clone, Debug, PartialEq, Slotted)] -pub enum MappingConfig { - #[default] - Disabled, - Square { - origin: XySlot, - size: XySlot, - }, - PathPoints { - paths: MapSlot>, - sample_diameter: PositiveF32Slot, - }, -} - -/// Specifies one path for a fixture. -#[derive(Clone, Debug, PartialEq, Slotted)] -pub enum PathSpec { - #[default] - RingArray { - center: XySlot, - diameter: PositiveF32Slot, - start_ring_inclusive: ValueSlot, - end_ring_exclusive: ValueSlot, - ring_lamp_counts: MapSlot>, - offset_angle: ValueSlot, - order: ValueSlot, - }, - Manual, -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum RingOrder { - #[default] - InnerFirst, - OuterFirst, -} - -impl MappingConfig { - pub fn disabled() -> Self { - Self::Disabled - } - - pub fn square() -> Self { - Self::Square { - origin: XySlot::new(Xy([0.1, 0.2])), - size: XySlot::new(Xy([0.8, 0.7])), - } - } - - pub fn path_points_default() -> Self { - let mut paths = BTreeMap::new(); - paths.insert( - 0, - EnumSlot::new(PathSpec::ring_array_counts( - [0.5, 0.5], - 1.0, - 0, - 2, - &[1, 96], - 0.0, - RingOrder::InnerFirst, - )), - ); - Self::path_points(MapSlot::new(paths), 2.0) - } - - pub fn path_points(paths: MapSlot>, sample_diameter: f32) -> Self { - Self::PathPoints { - paths, - sample_diameter: PositiveF32Slot::new(PositiveF32(sample_diameter)), - } - } - - pub fn set_ring_lamp_counts(&mut self, counts: Vec) -> bool { - let Self::PathPoints { paths, .. } = self else { - return false; - }; - let Some(path) = paths.entries.get_mut(&0) else { - return false; - }; - path.value_mut().set_ring_lamp_counts(counts) - } - - pub fn square_fields(&self) -> Option<([f32; 2], [f32; 2])> { - let Self::Square { origin, size, .. } = self else { - return None; - }; - Some((origin.value().0, size.value().0)) - } - - pub fn path_points_fields(&self) -> Option<(&MapSlot>, f32)> { - let Self::PathPoints { - paths, - sample_diameter, - .. - } = self - else { - return None; - }; - Some((paths, sample_diameter.value().0)) - } -} - -impl PathSpec { - pub fn ring_array( - center: [f32; 2], - diameter: f32, - start_ring_inclusive: u32, - end_ring_exclusive: u32, - ring_lamp_counts: MapSlot>, - offset_angle: f32, - order: RingOrder, - ) -> Self { - Self::RingArray { - center: XySlot::new(Xy(center)), - diameter: PositiveF32Slot::new(PositiveF32(diameter)), - start_ring_inclusive: ValueSlot::new(start_ring_inclusive), - end_ring_exclusive: ValueSlot::new(end_ring_exclusive), - ring_lamp_counts, - offset_angle: ValueSlot::new(offset_angle), - order: ValueSlot::new(order), - } - } - - pub fn ring_array_counts( - center: [f32; 2], - diameter: f32, - start_ring_inclusive: u32, - end_ring_exclusive: u32, - ring_lamp_counts: &[u32], - offset_angle: f32, - order: RingOrder, - ) -> Self { - let mut counts = BTreeMap::new(); - for (index, count) in ring_lamp_counts.iter().copied().enumerate() { - counts.insert(index as u32, ValueSlot::new(count)); - } - Self::ring_array( - center, - diameter, - start_ring_inclusive, - end_ring_exclusive, - MapSlot::new(counts), - offset_angle, - order, - ) - } - - pub fn manual() -> Self { - Self::Manual - } - - fn set_ring_lamp_counts(&mut self, counts: Vec) -> bool { - let Self::RingArray { - ring_lamp_counts, .. - } = self - else { - return false; - }; - let entries = counts - .into_iter() - .enumerate() - .map(|(index, count)| (index as u32, ValueSlot::new(count))) - .collect(); - *ring_lamp_counts = MapSlot::new(entries); - true - } - - pub fn ring_array_fields( - &self, - ) -> Option<( - [f32; 2], - f32, - u32, - u32, - &MapSlot>, - f32, - RingOrder, - )> { - let Self::RingArray { - center, - diameter, - start_ring_inclusive, - end_ring_exclusive, - ring_lamp_counts, - offset_angle, - order, - .. - } = self - else { - return None; - }; - Some(( - center.value().0, - diameter.value().0, - *start_ring_inclusive.value(), - *end_ring_exclusive.value(), - ring_lamp_counts, - *offset_angle.value(), - *order.value(), - )) - } -} - -impl RingOrder { - pub fn as_str(self) -> &'static str { - match self { - Self::InnerFirst => "inner_first", - Self::OuterFirst => "outer_first", - } - } - - pub fn parse(value: &str) -> Result { - match value { - "inner_first" => Ok(Self::InnerFirst), - "outer_first" => Ok(Self::OuterFirst), - other => Err(ValueRootError::new(format!("unknown ring order {other:?}"))), - } - } -} - -impl ToLpValue for RingOrder { - fn to_lp_value(&self) -> lpc_model::LpValue { - lpc_model::LpValue::String(self.as_str().to_string()) - } -} - -impl lpc_model::FromLpValue for RingOrder { - fn from_lp_value(value: &lpc_model::LpValue) -> Result { - match value { - lpc_model::LpValue::String(value) => Self::parse(value), - other => Err(ValueRootError::new(format!( - "expected String, got {other:?}" - ))), - } - } -} - -impl SlotValue for RingOrder { - const SHAPE_ID: SlotShapeId = SlotShapeId::from_static_name("RingOrder"); - - fn value_shape() -> SlotValueShape { - SlotValueShape { - id: Self::SHAPE_ID, - ty: lpc_model::LpType::String, - meta: SlotMeta::empty(), - editor: ValueEditorHint::Dropdown { - options: vec![ - SlotEnumOption::new("inner_first", "Inner first"), - SlotEnumOption::new("outer_first", "Outer first"), - ], - }, - } - } -} diff --git a/lp-core/lpc-slot-mockup/src/source/mod.rs b/lp-core/lpc-slot-mockup/src/source/mod.rs deleted file mode 100644 index f7c618b62..000000000 --- a/lp-core/lpc-slot-mockup/src/source/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -mod fixture_def; -mod mapping; -mod node_def; -mod output_def; -mod project_def; -mod ring_lamp_counts; -mod shader_def; -mod texture_def; - -pub use fixture_def::{FixtureDef, FixtureSamplingConfig}; -pub use mapping::{MappingConfig, PathSpec, RingOrder}; -pub use node_def::NodeDef; -pub use output_def::{OutputDef, OutputDriverOptionsConfig}; -pub use project_def::{NodeInvocationDef, ProjectDef}; -pub use ring_lamp_counts::RingLampCounts; -pub use shader_def::{ScalarHint, ShaderDef, ShaderParamDef}; -pub use texture_def::TextureDef; diff --git a/lp-core/lpc-slot-mockup/src/source/node_def.rs b/lp-core/lpc-slot-mockup/src/source/node_def.rs deleted file mode 100644 index 653d6d804..000000000 --- a/lp-core/lpc-slot-mockup/src/source/node_def.rs +++ /dev/null @@ -1,92 +0,0 @@ -use lpc_model::{__private::Box, SlotAccess, SlotDataAccess, SlotShapeId}; - -use super::{FixtureDef, OutputDef, ProjectDef, ShaderDef, TextureDef}; - -/// Mock authored node-definition wrapper. -/// -/// This mirrors the real domain's closed authored node set and gives the -/// mockup a real place to prove discriminator-based slot codec dispatch. -pub enum NodeDef { - Project(ProjectDef), - Output(OutputDef), - Texture(TextureDef), - Fixture(FixtureDef), - Shader(ShaderDef), -} - -impl NodeDef { - pub fn kind_name(&self) -> &'static str { - match self { - Self::Project(_) => ProjectDef::KIND, - Self::Output(_) => OutputDef::KIND, - Self::Texture(_) => TextureDef::KIND, - Self::Fixture(_) => FixtureDef::KIND, - Self::Shader(_) => ShaderDef::KIND, - } - } - - pub fn as_project(&self) -> Option<&ProjectDef> { - match self { - Self::Project(def) => Some(def), - _ => None, - } - } - - pub fn as_output(&self) -> Option<&OutputDef> { - match self { - Self::Output(def) => Some(def), - _ => None, - } - } - - pub fn as_texture(&self) -> Option<&TextureDef> { - match self { - Self::Texture(def) => Some(def), - _ => None, - } - } - - pub fn as_fixture(&self) -> Option<&FixtureDef> { - match self { - Self::Fixture(def) => Some(def), - _ => None, - } - } - - pub fn as_shader(&self) -> Option<&ShaderDef> { - match self { - Self::Shader(def) => Some(def), - _ => None, - } - } -} - -impl SlotAccess for NodeDef { - fn shape_id(&self) -> SlotShapeId { - match self { - Self::Project(def) => def.shape_id(), - Self::Output(def) => def.shape_id(), - Self::Texture(def) => def.shape_id(), - Self::Fixture(def) => def.shape_id(), - Self::Shader(def) => def.shape_id(), - } - } - - fn data(&self) -> SlotDataAccess<'_> { - match self { - Self::Project(def) => def.data(), - Self::Output(def) => def.data(), - Self::Texture(def) => def.data(), - Self::Fixture(def) => def.data(), - Self::Shader(def) => def.data(), - } - } - - fn as_any(&self) -> &dyn core::any::Any { - self - } - - fn into_any(self: Box) -> Box { - self - } -} diff --git a/lp-core/lpc-slot-mockup/src/source/output_def.rs b/lp-core/lpc-slot-mockup/src/source/output_def.rs deleted file mode 100644 index 9f2ce3a1f..000000000 --- a/lp-core/lpc-slot-mockup/src/source/output_def.rs +++ /dev/null @@ -1,83 +0,0 @@ -use lpc_model::{BindingDefs, OptionSlot, Ratio, RatioSlot, Slotted, ValueSlot}; - -#[derive(Default, Slotted)] -pub struct OutputDef { - pub pin: ValueSlot, - pub bindings: BindingDefs, - pub options: OptionSlot, -} - -#[derive(Clone, Debug, PartialEq, Slotted)] -pub struct OutputDriverOptionsConfig { - pub white_point: ValueSlot<[f32; 3]>, - pub brightness: RatioSlot, - pub interpolation_enabled: ValueSlot, - pub dithering_enabled: ValueSlot, - pub lut_enabled: ValueSlot, -} - -impl OutputDef { - pub const KIND: &'static str = "output"; - - pub fn new() -> Self { - Self { - pin: ValueSlot::new(18), - bindings: BindingDefs::default(), - options: OptionSlot::some(OutputDriverOptionsConfig::default()), - } - } - - pub fn pin(&self) -> u32 { - *self.pin.value() - } - - pub fn options(&self) -> Option<&OutputDriverOptionsConfig> { - self.options.data.as_ref() - } -} - -impl Default for OutputDriverOptionsConfig { - fn default() -> Self { - Self { - white_point: default_white_point_slot(), - brightness: default_brightness_slot(), - interpolation_enabled: default_true_slot(), - dithering_enabled: default_true_slot(), - lut_enabled: default_true_slot(), - } - } -} - -impl OutputDriverOptionsConfig { - pub fn white_point(&self) -> [f32; 3] { - *self.white_point.value() - } - - pub fn brightness(&self) -> f32 { - self.brightness.value().0 - } - - pub fn interpolation_enabled(&self) -> bool { - *self.interpolation_enabled.value() - } - - pub fn dithering_enabled(&self) -> bool { - *self.dithering_enabled.value() - } - - pub fn lut_enabled(&self) -> bool { - *self.lut_enabled.value() - } -} - -fn default_white_point_slot() -> ValueSlot<[f32; 3]> { - ValueSlot::new([0.9, 1.0, 1.0]) -} - -fn default_brightness_slot() -> RatioSlot { - RatioSlot::new(Ratio(1.0)) -} - -fn default_true_slot() -> ValueSlot { - ValueSlot::new(true) -} diff --git a/lp-core/lpc-slot-mockup/src/source/project_def.rs b/lp-core/lpc-slot-mockup/src/source/project_def.rs deleted file mode 100644 index cb5546579..000000000 --- a/lp-core/lpc-slot-mockup/src/source/project_def.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::collections::BTreeMap; - -use lpc_model::{ - ArtifactPath, ArtifactPathSlot, EnumSlot, MapSlot, OptionSlot, Slotted, ValueSlot, -}; - -#[derive(Default, Slotted)] -pub struct ProjectDef { - pub name: OptionSlot>, - pub nodes: MapSlot, -} - -#[derive(Default, Slotted)] -pub struct NodeInvocationDef { - pub def: EnumSlot, -} - -#[derive(Slotted)] -#[slot(enum_encoding = "external", rename_all = "snake_case")] -pub enum NodeDefRef { - Path(ArtifactPathSlot), -} - -impl ProjectDef { - pub const KIND: &'static str = "project"; - - pub fn new() -> Self { - let mut nodes = BTreeMap::new(); - nodes.insert( - String::from("output"), - NodeInvocationDef::new("./output.toml"), - ); - nodes.insert( - String::from("texture"), - NodeInvocationDef::new("./texture.toml"), - ); - nodes.insert( - String::from("fixture"), - NodeInvocationDef::new("./fixture.toml"), - ); - nodes.insert( - String::from("shader"), - NodeInvocationDef::new("./shader.toml"), - ); - - Self { - name: OptionSlot::some(ValueSlot::new("basic".to_string())), - nodes: MapSlot::new(nodes), - } - } -} - -impl NodeInvocationDef { - pub fn new(path: &str) -> Self { - Self { - def: EnumSlot::new(NodeDefRef::Path(ArtifactPathSlot::new(ArtifactPath( - path.to_string(), - )))), - } - } - - pub fn def_path(&self) -> &str { - match self.def.value() { - NodeDefRef::Path(path) => path.value().as_str(), - } - } -} diff --git a/lp-core/lpc-slot-mockup/src/source/ring_lamp_counts.rs b/lp-core/lpc-slot-mockup/src/source/ring_lamp_counts.rs deleted file mode 100644 index 4c6415849..000000000 --- a/lp-core/lpc-slot-mockup/src/source/ring_lamp_counts.rs +++ /dev/null @@ -1,66 +0,0 @@ -use lpc_model::{ - FromLpValue, LpType, LpValue, SlotMeta, SlotShapeId, SlotValue, SlotValueShape, ToLpValue, - ValueEditorHint, ValueRootError, -}; - -/// One logical value containing the per-ring lamp counts for a generated path. -/// -/// The list is editable and inspectable as value structure, but it is not a map -/// of independently versioned slots. Changing one count produces a new complete -/// value for the `ring_lamp_counts` slot. -#[derive(Clone, Debug, PartialEq)] -pub struct RingLampCounts(pub Vec); - -impl RingLampCounts { - pub fn new(counts: impl Into>) -> Self { - Self(counts.into()) - } -} - -impl ToLpValue for RingLampCounts { - fn to_lp_value(&self) -> LpValue { - self.0.to_lp_value() - } -} - -impl FromLpValue for RingLampCounts { - fn from_lp_value(value: &LpValue) -> Result { - Vec::::from_lp_value(value).map(Self) - } -} - -impl SlotValue for RingLampCounts { - const SHAPE_ID: SlotShapeId = SlotShapeId::from_static_name("mock.source.ring_lamp_counts"); - - fn value_shape() -> SlotValueShape { - SlotValueShape { - id: Self::SHAPE_ID, - ty: LpType::List(Box::new(LpType::U32)), - meta: SlotMeta::empty(), - editor: ValueEditorHint::Plain, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ring_lamp_counts_are_one_array_value() { - let counts = RingLampCounts::new(vec![1, 8, 12]); - - assert_eq!( - counts.to_lp_value(), - LpValue::Array(vec![LpValue::U32(1), LpValue::U32(8), LpValue::U32(12)]) - ); - assert_eq!( - RingLampCounts::from_lp_value(&counts.to_lp_value()).unwrap(), - counts - ); - assert_eq!( - RingLampCounts::value_shape().ty, - LpType::List(Box::new(LpType::U32)) - ); - } -} diff --git a/lp-core/lpc-slot-mockup/src/source/shader_def.rs b/lp-core/lpc-slot-mockup/src/source/shader_def.rs deleted file mode 100644 index 17a0ef1e3..000000000 --- a/lp-core/lpc-slot-mockup/src/source/shader_def.rs +++ /dev/null @@ -1,184 +0,0 @@ -use std::collections::BTreeMap; - -use lpc_model::{ - AddSubMode, BindingDefs, DivMode, EnumSlot, GlslOpts, LpValue, MapSlot, MulMode, OptionSlot, - PositiveF32, PositiveF32Slot, Ratio, RatioSlot, RenderOrder, RenderOrderSlot, Revision, - ShaderSource, Slotted, ValueSlot, -}; - -#[derive(Default, Slotted)] -pub struct ShaderDef { - pub source: EnumSlot, - pub render_order: RenderOrderSlot, - pub bindings: BindingDefs, - pub glsl_opts: GlslOpts, - pub param_defs: MapSlot, -} - -#[derive(Clone, Debug, Default, PartialEq, Slotted)] -pub struct ShaderParamDef { - pub label: ValueSlot, - pub description: ValueSlot, - pub value_type: ValueSlot, - pub default: RatioSlot, - pub min: OptionSlot, -} - -#[derive(Clone, Debug, Default, PartialEq, Slotted)] -pub struct ScalarHint { - pub value: PositiveF32Slot, -} - -impl ShaderDef { - pub const KIND: &'static str = "shader"; - - pub fn new() -> Self { - let mut param_defs = BTreeMap::new(); - param_defs.insert( - String::from("exposure"), - ShaderParamDef::new("Exposure", "Output exposure multiplier", 1.0, Some(0.0)), - ); - param_defs.insert( - String::from("speed"), - ShaderParamDef::new("Speed", "Animation speed", 0.25, Some(0.0)), - ); - - Self { - source: EnumSlot::new(ShaderSource::path("main.glsl")), - render_order: RenderOrderSlot::new(RenderOrder(0)), - bindings: BindingDefs::default(), - glsl_opts: GlslOpts { - add_sub: ValueSlot::new(AddSubMode::Wrapping), - mul: ValueSlot::new(MulMode::Wrapping), - div: ValueSlot::new(DivMode::Reciprocal), - }, - param_defs: MapSlot::new(param_defs), - } - } - - pub fn source_path(&self) -> &str { - self.source - .value() - .path_value() - .expect("mock shader source path") - .as_str() - } - - pub fn render_order(&self) -> i32 { - self.render_order.value().0 - } - - pub fn glsl_opts(&self) -> &GlslOpts { - &self.glsl_opts - } - - pub fn bindings(&self) -> &BindingDefs { - &self.bindings - } - - pub fn add_param_def(&mut self, name: &str, default: f32) { - self.param_defs.insert( - name.to_string(), - ShaderParamDef::new(name, "Dynamically authored shader parameter", default, None), - ); - } - - pub fn set_param_value_type(&mut self, name: &str, value_type: &str) { - let param = self.param_defs.entries.get_mut(name).expect("param def"); - param.set_value_type(value_type); - } - - pub fn set_param_label(&mut self, name: &str, label: &str) { - let param = self.param_defs.entries.get_mut(name).expect("param def"); - param.set_label(label); - } - - pub fn param_label_revision(&self, name: &str) -> Option { - self.param_defs - .entries - .get(name) - .map(ShaderParamDef::label_revision) - } - - pub fn param_default_revision(&self, name: &str) -> Option { - self.param_defs - .entries - .get(name) - .map(ShaderParamDef::default_revision) - } -} - -impl ShaderParamDef { - pub fn new(label: &str, description: &str, default: f32, min: Option) -> Self { - Self { - label: ValueSlot::new(label.to_string()), - description: ValueSlot::new(description.to_string()), - value_type: ValueSlot::new(String::from("f32")), - default: RatioSlot::new(Ratio(default)), - min: match min { - Some(value) => OptionSlot::some(ScalarHint::new(value)), - None => OptionSlot::none(), - }, - } - } - - pub fn default_value(&self) -> LpValue { - LpValue::F32(self.default.value().0) - } - - pub fn label(&self) -> &str { - self.label.value() - } - - pub fn description(&self) -> &str { - self.description.value() - } - - pub fn value_type(&self) -> &str { - self.value_type.value() - } - - pub fn default_scalar(&self) -> f32 { - self.default.value().0 - } - - pub fn min(&self) -> Option<&ScalarHint> { - self.min.data.as_ref() - } - - fn set_value_type(&mut self, value_type: &str) { - self.value_type.set(value_type.to_string()); - } - - pub fn set_value_type_for_codec(&mut self, value_type: &str) { - self.set_value_type(value_type); - } - - fn set_label(&mut self, label: &str) { - self.label.set(label.to_string()); - } - - fn label_revision(&self) -> Revision { - self.label.revision() - } - - fn default_revision(&self) -> Revision { - self.default.revision() - } -} - -impl ScalarHint { - pub fn new(value: f32) -> Self { - Self { - value: PositiveF32Slot::new(PositiveF32(value)), - } - } - - pub fn mock(value: f32) -> Self { - Self::new(value) - } - - pub fn value(&self) -> f32 { - self.value.value().0 - } -} diff --git a/lp-core/lpc-slot-mockup/src/source/texture_def.rs b/lp-core/lpc-slot-mockup/src/source/texture_def.rs deleted file mode 100644 index 41f81f3ce..000000000 --- a/lp-core/lpc-slot-mockup/src/source/texture_def.rs +++ /dev/null @@ -1,29 +0,0 @@ -use lpc_model::{BindingDefs, Dim2u, Dim2uSlot, Slotted}; - -#[derive(Default, Slotted)] -pub struct TextureDef { - pub size: Dim2uSlot, - pub bindings: BindingDefs, -} - -impl TextureDef { - pub const KIND: &'static str = "texture"; - - pub fn new() -> Self { - Self { - size: Dim2uSlot::new(Dim2u { - width: 64, - height: 32, - }), - bindings: BindingDefs::default(), - } - } - - pub fn size(&self) -> Dim2u { - *self.size.value() - } - - pub fn bindings(&self) -> &BindingDefs { - &self.bindings - } -} diff --git a/lp-core/lpc-slot-mockup/src/tests/client_tree_walk.rs b/lp-core/lpc-slot-mockup/src/tests/client_tree_walk.rs deleted file mode 100644 index 37e37ec47..000000000 --- a/lp-core/lpc-slot-mockup/src/tests/client_tree_walk.rs +++ /dev/null @@ -1,15 +0,0 @@ -use super::fixture::Harness; - -#[test] -fn client_tree_walk_prints_synced_roots() { - let mut harness = Harness::new(); - - harness.sync_full(); - harness.print_client_tree("source.project"); - harness.print_client_tree("source.shader"); - harness.print_client_tree("source.fixture"); - harness.print_client_tree("engine.fixture_node"); - - assert!(harness.client.roots.contains_key("source.project")); - assert!(harness.client.roots.contains_key("engine.fixture_node")); -} diff --git a/lp-core/lpc-slot-mockup/src/tests/dynamic_param_shape.rs b/lp-core/lpc-slot-mockup/src/tests/dynamic_param_shape.rs deleted file mode 100644 index f68e834df..000000000 --- a/lp-core/lpc-slot-mockup/src/tests/dynamic_param_shape.rs +++ /dev/null @@ -1,151 +0,0 @@ -use lpc_model::{ - LpValue, Revision, SlotAccess, SlotData, SlotShapeId, SlotShapeRegistry, set_current_revision, -}; -use lpc_view::SlotMirrorView; -use lpc_wire::build_slot_full_sync; - -use crate::engine::ShaderNode; -use crate::source::ShaderDef; -use crate::wire::print_data_root; - -use super::fixture::{ - Harness, assert_shader_param, assert_shader_param_def_type, log_guard, print_lines, -}; - -#[test] -fn shader_param_type_change_syncs_registry_and_dynamic_value() { - let mut harness = Harness::new(); - harness.sync_full(); - - println!("initial dynamic shader node shape"); - harness.print_client_shape(ShaderNode::SHAPE_ID); - harness.print_client_tree("source.shader"); - harness.print_client_tree("engine.shader_node"); - - println!("server updating source.shader#param_defs[exposure].value to vec3"); - println!("server updating engine.shader_node params record shape"); - println!("server updating engine.shader_node#params.exposure to Vec3([0.25, 0.5, 0.75])"); - harness - .runtime - .change_shader_param_to_vec3(Revision::new(2), "exposure", [0.25, 0.5, 0.75]); - - harness.print_server_tree("source.shader"); - harness.print_server_tree("engine.shader_node"); - - harness.sync_registry(); - harness.print_client_shape(ShaderNode::SHAPE_ID); - - harness.sync_diff("source.shader", Revision::new(1)); - harness.print_client_tree("source.shader"); - assert_shader_param_def_type( - harness.client.roots.get("source.shader").unwrap(), - "exposure", - "vec3", - ); - - harness.sync_diff("engine.shader_node", Revision::new(1)); - harness.print_client_tree("engine.shader_node"); - assert_shader_param( - harness.client.roots.get("engine.shader_node").unwrap(), - "exposure", - LpValue::Vec3([0.25, 0.5, 0.75]), - ); -} - -#[test] -fn two_shader_instances_can_have_distinct_dynamic_param_shapes() { - let _log_guard = log_guard(); - set_current_revision(Revision::new(1)); - - let primary_shape_id = SlotShapeId::from_static_name("engine.shader_node.primary"); - let secondary_shape_id = SlotShapeId::from_static_name("engine.shader_node.secondary"); - - let primary_def = ShaderDef::new(); - let mut secondary_def = ShaderDef::new(); - secondary_def.add_param_def("gain", 0.5); - - let primary_node = ShaderNode::from_def_with_shape_id(&primary_def, primary_shape_id); - let secondary_node = ShaderNode::from_def_with_shape_id(&secondary_def, secondary_shape_id); - - let mut registry = SlotShapeRegistry::default(); - registry - .register_shape(primary_node.shape_id(), primary_node.shape()) - .unwrap(); - registry - .register_shape(secondary_node.shape_id(), secondary_node.shape()) - .unwrap(); - - println!("server loaded two shader node instances"); - println!( - "primary shader shape={} params=exposure,speed", - primary_node.shape_id() - ); - println!( - "secondary shader shape={} params=exposure,gain,speed", - secondary_node.shape_id() - ); - assert_ne!( - registry.get(&primary_shape_id), - registry.get(&secondary_shape_id) - ); - - let sync = build_slot_full_sync( - ®istry, - vec![ - ("engine.shader_primary", &primary_node as &dyn SlotAccess), - ( - "engine.shader_secondary", - &secondary_node as &dyn SlotAccess, - ), - ], - ); - let mut client = SlotMirrorView::default(); - client.apply_full_sync(sync).unwrap(); - - println!("client tree: engine.shader_primary"); - let primary_lines = print_data_root( - client.root_shapes.get("engine.shader_primary").unwrap(), - client.roots.get("engine.shader_primary").unwrap(), - &client.registry, - ); - print_lines(primary_lines.clone()); - - println!("client tree: engine.shader_secondary"); - let secondary_lines = print_data_root( - client.root_shapes.get("engine.shader_secondary").unwrap(), - client.roots.get("engine.shader_secondary").unwrap(), - &client.registry, - ); - print_lines(secondary_lines.clone()); - - assert_eq!( - client.root_shapes.get("engine.shader_primary"), - Some(&primary_shape_id) - ); - assert_eq!( - client.root_shapes.get("engine.shader_secondary"), - Some(&secondary_shape_id) - ); - assert_shader_param_count(client.roots.get("engine.shader_primary").unwrap(), 2); - assert_shader_param_count(client.roots.get("engine.shader_secondary").unwrap(), 3); - assert!( - !primary_lines - .iter() - .any(|line| line.contains(".params.gain")) - ); - assert!( - secondary_lines - .iter() - .any(|line| line.contains(".params.gain")) - ); -} - -fn assert_shader_param_count(data: &SlotData, expected: usize) { - let SlotData::Record(shader_node) = data else { - panic!("shader node record"); - }; - let SlotData::Record(params) = &shader_node.fields[0] else { - panic!("shader params record"); - }; - assert_eq!(params.fields.len(), expected); -} diff --git a/lp-core/lpc-slot-mockup/src/tests/dynamic_slot_codec.rs b/lp-core/lpc-slot-mockup/src/tests/dynamic_slot_codec.rs deleted file mode 100644 index ac6a68225..000000000 --- a/lp-core/lpc-slot-mockup/src/tests/dynamic_slot_codec.rs +++ /dev/null @@ -1,326 +0,0 @@ -use lpc_model::{ - LpValue, SlotAccess, SlotDataAccess, StaticSlotShape, slot_codec::JsonSyntaxSource, -}; - -use crate::{ - engine::ShaderNode, - source::{FixtureDef, ProjectDef, ShaderDef}, -}; - -#[test] -fn dynamic_slot_codec_reads_project_json_through_registry() { - let registry = registry(); - - let object = registry - .read_slot_json( - ProjectDef::SHAPE_ID, - r#"{"name":"basic","nodes":{"shader":{"def":{"path":"./shader.toml"}}}}"#, - ) - .unwrap(); - let Ok(project) = object.into_any().downcast::() else { - panic!("expected ProjectDef"); - }; - - assert_eq!(project.name.data.as_ref().unwrap().value(), "basic"); - assert_eq!( - project.nodes.entries.get("shader").unwrap().def_path(), - "./shader.toml" - ); -} - -#[test] -fn dynamic_slot_codec_writes_project_json_through_registry() { - let registry = registry(); - let project = ProjectDef::new(); - - let json = registry.write_slot_json(&project, Vec::new()).unwrap(); - let json = std::str::from_utf8(&json).unwrap(); - - assert!(json.contains(r#""name":"basic""#)); - assert!(json.contains(r#""shader":{"def":{"path":"./shader.toml"}}"#)); -} - -#[test] -fn dynamic_slot_codec_round_trips_project_json_through_registry() { - let registry = registry(); - let project = ProjectDef::new(); - - let json = registry.write_slot_json(&project, Vec::new()).unwrap(); - let decoded = registry - .read_slot_json(ProjectDef::SHAPE_ID, std::str::from_utf8(&json).unwrap()) - .unwrap(); - let Ok(decoded) = decoded.into_any().downcast::() else { - panic!("expected ProjectDef"); - }; - - assert_project_matches_default(&decoded); -} - -#[test] -fn dynamic_slot_codec_reads_project_toml_through_registry() { - let registry = registry(); - let toml: toml::Value = toml::from_str( - r#" -name = "basic" - -[nodes.shader] -def = { path = "./shader.toml" } -"#, - ) - .unwrap(); - - let object = registry - .read_slot_toml(ProjectDef::SHAPE_ID, &toml) - .unwrap(); - let Ok(project) = object.into_any().downcast::() else { - panic!("expected ProjectDef"); - }; - - assert_eq!(project.name.data.as_ref().unwrap().value(), "basic"); - assert_eq!( - project.nodes.entries.get("shader").unwrap().def_path(), - "./shader.toml" - ); -} - -#[test] -fn dynamic_slot_codec_writes_project_toml_through_registry() { - let registry = registry(); - let project = ProjectDef::new(); - - let value = registry.write_slot_toml(&project).unwrap(); - - assert_eq!(value["name"].as_str(), Some("basic")); - assert_eq!( - value["nodes"]["shader"]["def"]["path"].as_str(), - Some("./shader.toml") - ); -} - -#[test] -fn dynamic_slot_codec_round_trips_project_toml_through_registry() { - let registry = registry(); - let project = ProjectDef::new(); - - let value = registry.write_slot_toml(&project).unwrap(); - let decoded = registry - .read_slot_toml(ProjectDef::SHAPE_ID, &value) - .unwrap(); - let Ok(decoded) = decoded.into_any().downcast::() else { - panic!("expected ProjectDef"); - }; - - assert_project_matches_default(&decoded); -} - -#[test] -fn dynamic_slot_codec_reads_json_event_sources() { - let registry = registry(); - let object = registry - .read_slot_from( - ProjectDef::SHAPE_ID, - JsonSyntaxSource::new(r#"{"nodes":{"shader":{"def":{"path":"./shader.toml"}}}}"#) - .unwrap(), - ) - .unwrap(); - let Ok(project) = object.into_any().downcast::() else { - panic!("expected ProjectDef"); - }; - - assert_eq!( - project.nodes.entries.get("shader").unwrap().def_path(), - "./shader.toml" - ); -} - -#[test] -fn dynamic_slot_codec_reads_fixture_enum_payloads() { - let registry = registry(); - - let object = registry - .read_slot_json( - FixtureDef::SHAPE_ID, - r#"{"mapping":{"kind":"Square","origin":[0.25,0.75],"size":[0.5,0.25]}}"#, - ) - .unwrap(); - let Ok(fixture) = object.into_any().downcast::() else { - panic!("expected FixtureDef"); - }; - - assert_eq!( - fixture.mapping().square_fields(), - Some(([0.25, 0.75], [0.5, 0.25])) - ); -} - -#[test] -fn dynamic_slot_codec_round_trips_fixture_enum_payload_json() { - let registry = registry(); - let mut fixture = FixtureDef::new(); - fixture.switch_mapping_to_square(); - - let json = registry.write_slot_json(&fixture, Vec::new()).unwrap(); - let json = std::str::from_utf8(&json).unwrap(); - let decoded = registry.read_slot_json(FixtureDef::SHAPE_ID, json).unwrap(); - let Ok(decoded) = decoded.into_any().downcast::() else { - panic!("expected FixtureDef"); - }; - - assert_eq!( - decoded.mapping().square_fields(), - Some(([0.1, 0.2], [0.8, 0.7])) - ); -} - -#[test] -fn dynamic_slot_codec_round_trips_fixture_enum_payload_toml() { - let registry = registry(); - let mut fixture = FixtureDef::new(); - fixture.switch_mapping_to_square(); - - let value = registry.write_slot_toml(&fixture).unwrap(); - let decoded = registry - .read_slot_toml(FixtureDef::SHAPE_ID, &value) - .unwrap(); - let Ok(decoded) = decoded.into_any().downcast::() else { - panic!("expected FixtureDef"); - }; - - assert_eq!( - decoded.mapping().square_fields(), - Some(([0.1, 0.2], [0.8, 0.7])) - ); -} - -#[test] -fn dynamic_slot_codec_reads_registered_dynamic_shapes() { - let shader_def = ShaderDef::new(); - let shader_node = ShaderNode::from_def(&shader_def); - let mut registry = registry(); - registry - .register_dynamic_shape(shader_node.shape_id(), shader_node.shape()) - .unwrap(); - - let object = registry - .read_slot_json( - shader_node.shape_id(), - r#"{"params":{"exposure":1.25},"compile_error":"warning"}"#, - ) - .unwrap(); - - let SlotDataAccess::Record(shader_node_data) = object.data() else { - panic!("expected shader node record"); - }; - let SlotDataAccess::Record(params) = shader_node_data.field(0).unwrap() else { - panic!("expected params record"); - }; - assert_eq!(record_value(params, 0), LpValue::F32(1.25)); - assert_eq!( - option_value(shader_node_data.field(1).unwrap()), - Some(LpValue::String("warning".into())) - ); -} - -#[test] -fn dynamic_slot_codec_writes_registered_dynamic_shapes() { - let shader_def = ShaderDef::new(); - let shader_node = ShaderNode::from_def(&shader_def); - let mut registry = registry(); - registry - .register_dynamic_shape(shader_node.shape_id(), shader_node.shape()) - .unwrap(); - - let json = registry.write_slot_json(&shader_node, Vec::new()).unwrap(); - let json = std::str::from_utf8(&json).unwrap(); - - assert!(json.contains(r#""params":{"exposure":1"#)); - assert!(json.contains(r#""compile_error":"initial compile warning""#)); - - let value = registry.write_slot_toml(&shader_node).unwrap(); - assert_eq!(value["params"]["exposure"].as_float(), Some(1.0)); - assert_eq!( - value["compile_error"].as_str(), - Some("initial compile warning") - ); -} - -#[test] -fn dynamic_slot_codec_rejects_unknown_fields() { - let registry = registry(); - - let Err(error) = registry.read_slot_json(ProjectDef::SHAPE_ID, r#"{"surprise":true}"#) else { - panic!("expected unknown field error"); - }; - - assert!(error.message().contains("surprise")); - assert!(error.message().contains("nodes")); -} - -#[test] -fn dynamic_slot_codec_rejects_invalid_discriminators() { - let registry = registry(); - - let Err(error) = - registry.read_slot_json(FixtureDef::SHAPE_ID, r#"{"mapping":{"kind":"hex_grid"}}"#) - else { - panic!("expected discriminator error"); - }; - - assert!(error.message().contains("hex_grid")); - assert!(error.message().contains("Disabled")); - assert!(error.message().contains("Square")); - assert!(error.message().contains("PathPoints")); -} - -fn registry() -> lpc_model::SlotShapeRegistry { - let mut registry = lpc_model::SlotShapeRegistry::default(); - crate::model::register_shapes(&mut registry).unwrap(); - registry -} - -fn assert_project_matches_default(project: &ProjectDef) { - assert_eq!( - project.name.data.as_ref().map(|name| name.value().as_str()), - Some("basic") - ); - assert_eq!(project.nodes.entries.len(), 4); - assert_eq!(project.nodes.entries["output"].def_path(), "./output.toml"); - assert_eq!( - project.nodes.entries["texture"].def_path(), - "./texture.toml" - ); - assert_eq!( - project.nodes.entries["fixture"].def_path(), - "./fixture.toml" - ); - assert_eq!(project.nodes.entries["shader"].def_path(), "./shader.toml"); -} - -fn record_value(record: &dyn lpc_model::SlotRecordAccess, index: usize) -> LpValue { - match record.field(index).unwrap() { - SlotDataAccess::Value(value) => value.value(), - other => panic!("expected value, got {}", data_kind(other)), - } -} - -fn option_value(data: SlotDataAccess<'_>) -> Option { - let SlotDataAccess::Option(option) = data else { - panic!("expected option"); - }; - option.data().map(|data| match data { - SlotDataAccess::Value(value) => value.value(), - other => panic!("expected option value, got {}", data_kind(other)), - }) -} - -fn data_kind(data: SlotDataAccess<'_>) -> &'static str { - match data { - SlotDataAccess::Unit(_) => "unit", - SlotDataAccess::Value(_) => "value", - SlotDataAccess::Record(_) => "record", - SlotDataAccess::Map(_) => "map", - SlotDataAccess::Enum(_) => "enum", - SlotDataAccess::Option(_) => "option", - SlotDataAccess::Custom(_) => "custom", - } -} diff --git a/lp-core/lpc-slot-mockup/src/tests/fixture.rs b/lp-core/lpc-slot-mockup/src/tests/fixture.rs deleted file mode 100644 index 783cb4cf7..000000000 --- a/lp-core/lpc-slot-mockup/src/tests/fixture.rs +++ /dev/null @@ -1,269 +0,0 @@ -use lpc_model::{ - Revision, SlotAccess, SlotData, SlotMapKey, SlotPath, SlotPathSegment, SlotShapeId, -}; -use lpc_view::SlotMirrorView; -use lpc_wire::{WireSlotChange, WireSlotPatch}; -use std::sync::{Mutex, MutexGuard}; - -use crate::{ - engine::MockRuntime, - wire::{collect_diff, full_sync, print_data_root, print_root}, -}; - -pub struct Harness { - _log_guard: MutexGuard<'static, ()>, - pub runtime: MockRuntime, - pub client: SlotMirrorView, -} - -impl Harness { - pub fn new() -> Self { - let log_guard = log_guard(); - - println!("server loaded"); - Self { - _log_guard: log_guard, - runtime: MockRuntime::new(), - client: SlotMirrorView::default(), - } - } - - pub fn sync_full(&mut self) { - println!("syncing full state to client"); - let sync = full_sync(&self.runtime); - println!("full sync roots:"); - for root in &sync.roots { - println!(" {} shape={}", root.name, root.shape); - } - self.client.apply_full_sync(sync).unwrap(); - println!("client full sync applied"); - } - - pub fn sync_diff(&mut self, root_name: &str, since: Revision) -> Vec { - println!( - "syncing diff for {root_name} since frame {}", - since.as_i64() - ); - let root = self.server_root(root_name); - let patches = collect_diff(root_name, root, &self.runtime.registry, since); - print_patches(&patches); - self.client.apply_patches(&patches).unwrap(); - println!("client diff applied"); - patches - } - - pub fn sync_registry(&mut self) { - println!("syncing shape registry to client"); - let snapshot = self.runtime.registry.snapshot(); - println!( - "registry frame={} shapes={}", - snapshot.ids_revision.as_i64(), - snapshot.shapes.len() - ); - for (shape_id, shape) in &snapshot.shapes { - println!( - " shape {shape_id} revision={} node={:?}", - shape.changed_at().as_i64(), - shape.value() - ); - } - self.client.apply_registry_snapshot(snapshot); - println!("client registry applied"); - } - - pub fn print_client_shape(&self, shape_id: SlotShapeId) { - let shape = self.client.registry.entry(&shape_id).expect("client shape"); - println!( - "client shape {shape_id} revision={} node={:?}", - shape.changed_at().as_i64(), - shape.value() - ); - } - - pub fn print_server_tree(&self, root_name: &str) { - println!("server tree: {root_name}"); - print_lines(print_root( - self.server_root(root_name), - &self.runtime.registry, - )); - } - - pub fn print_client_tree(&self, root_name: &str) { - println!("client tree: {root_name}"); - let shape = self - .client - .root_shapes - .get(root_name) - .expect("client shape"); - let data = self.client.roots.get(root_name).expect("client root"); - print_lines(print_data_root(shape, data, &self.client.registry)); - } - - pub fn server_root(&self, root_name: &str) -> &dyn SlotAccess { - self.runtime - .roots() - .into_iter() - .find(|(name, _)| *name == root_name) - .map(|(_, root)| root) - .expect("server root") - } -} - -pub fn log_guard() -> MutexGuard<'static, ()> { - static TEST_LOG_LOCK: Mutex<()> = Mutex::new(()); - TEST_LOG_LOCK - .lock() - .unwrap_or_else(|poison| poison.into_inner()) -} - -pub fn print_lines(lines: Vec) { - for line in lines { - println!(" {line}"); - } -} - -pub fn print_patches(patches: &[WireSlotPatch]) { - println!("diff:"); - if patches.is_empty() { - println!(" "); - } - for patch in patches { - println!( - " {} {} -> {}", - patch.root, - patch.path, - describe_change(patch) - ); - } -} - -pub fn describe_change(patch: &WireSlotPatch) -> String { - match &patch.change { - WireSlotChange::Replace(data) => format!("replace {}", data.get()), - } -} - -pub fn assert_shader_param(data: &SlotData, name: &str, expected: lpc_model::LpValue) { - let SlotData::Record(shader_node) = data else { - panic!("shader node record"); - }; - let SlotData::Record(params) = &shader_node.fields[0] else { - panic!("shader params record"); - }; - let SlotData::Value(value) = ¶ms.fields[shader_param_index(name)] else { - panic!("shader param value"); - }; - assert_eq!(value.value(), &expected); -} - -pub fn assert_shader_param_lacks(data: &SlotData, name: &str) { - let SlotData::Record(shader_node) = data else { - panic!("shader node record"); - }; - let SlotData::Record(params) = &shader_node.fields[0] else { - panic!("shader params record"); - }; - assert!(shader_param_index(name) >= params.fields.len()); -} - -fn shader_param_index(name: &str) -> usize { - match name { - "exposure" => 0, - "speed" => 1, - _ => panic!("unknown shader param {name}"), - } -} - -pub fn assert_shader_param_def_type(data: &SlotData, name: &str, expected: &str) { - let selected = select(data, &format!("param_defs[{name}]")); - let SlotData::Record(param_def) = selected else { - panic!("shader param def record"); - }; - let SlotData::Value(value_shape) = ¶m_def.fields[2] else { - panic!("shader param def value_type"); - }; - assert_eq!( - value_shape.value(), - &lpc_model::LpValue::String(expected.to_string()) - ); -} - -pub fn assert_shader_param_def_label(data: &SlotData, name: &str, expected: &str) { - let selected = select(data, &format!("param_defs[{name}]")); - let SlotData::Record(param_def) = selected else { - panic!("shader param def record"); - }; - let SlotData::Value(label) = ¶m_def.fields[0] else { - panic!("shader param def label"); - }; - assert_eq!( - label.value(), - &lpc_model::LpValue::String(expected.to_string()) - ); -} - -pub fn assert_map_has_key(data: &SlotData, path: &str, key: SlotMapKey) { - let selected = select(data, path); - let SlotData::Map(map) = selected else { - panic!("map at {path}"); - }; - assert!(map.entries.contains_key(&key)); -} - -pub fn select<'a>(data: &'a SlotData, path: &str) -> &'a SlotData { - let mut current = data; - if path.is_empty() { - return current; - } - for segment in SlotPath::parse(path).unwrap().segments() { - current = match current { - SlotData::Record(record) => { - let SlotPathSegment::Field(segment) = segment else { - panic!("expected record field segment {segment:?}"); - }; - let index = match segment.as_str() { - "source.shader.param_defs" | "param_defs" => 4, - "engine.shader_node.params" | "params" => 0, - "engine.fixture_node.touches" | "touches" => 0, - "mapping" => 3, - "paths" => 0, - "sample_diameter" => 1, - "center" => 0, - "diameter" => 1, - "start_ring_inclusive" => 2, - "end_ring_exclusive" => 3, - "ring_lamp_counts" => 4, - "offset_angle" => 5, - "order" => 6, - "transform" => 5, - "brightness" => 6, - _ => panic!("unknown test record segment {segment}"), - }; - &record.fields[index] - } - SlotData::Map(map) => { - let SlotPathSegment::Key(segment) = segment else { - panic!("expected map key segment {segment:?}"); - }; - map.entries.get(segment).expect("map entry") - } - SlotData::Enum(en) => { - let SlotPathSegment::Field(segment) = segment else { - panic!("expected enum variant segment {segment:?}"); - }; - assert_eq!(en.variant.as_str(), segment.as_str()); - &en.data - } - SlotData::Option(option) => { - let SlotPathSegment::Field(segment) = segment else { - panic!("expected option segment {segment:?}"); - }; - assert_eq!(segment.as_str(), "some"); - option.data.as_deref().expect("option some") - } - SlotData::Unit { .. } => panic!("cannot select through unit"), - SlotData::Value(_) => panic!("cannot select through value"), - }; - } - current -} diff --git a/lp-core/lpc-slot-mockup/src/tests/full_sync.rs b/lp-core/lpc-slot-mockup/src/tests/full_sync.rs deleted file mode 100644 index c4fced411..000000000 --- a/lp-core/lpc-slot-mockup/src/tests/full_sync.rs +++ /dev/null @@ -1,45 +0,0 @@ -use lpc_model::{SlotData, SlotMapKey}; - -use super::fixture::{Harness, assert_map_has_key, select}; - -#[test] -fn full_sync_copies_server_roots_to_client() { - let mut harness = Harness::new(); - - harness.print_server_tree("source.shader"); - harness.print_server_tree("source.fixture"); - harness.sync_full(); - harness.print_client_tree("source.shader"); - harness.print_client_tree("source.fixture"); - - let shader = harness.client.roots.get("source.shader").unwrap(); - assert_map_has_key( - shader, - "param_defs", - SlotMapKey::String("exposure".to_string()), - ); - assert_map_has_key( - shader, - "param_defs", - SlotMapKey::String("speed".to_string()), - ); - - let fixture = harness.client.roots.get("source.fixture").unwrap(); - let ring_lamp_counts = select( - fixture, - "mapping.PathPoints.paths[0].RingArray.ring_lamp_counts", - ); - assert_map_has_key(ring_lamp_counts, "", SlotMapKey::U32(0)); - assert_map_has_key(ring_lamp_counts, "", SlotMapKey::U32(1)); - let count_zero = select(ring_lamp_counts, "[0]"); - let SlotData::Value(value) = count_zero else { - panic!("ring_lamp_counts[0] should be one slot value"); - }; - assert_eq!(value.value(), &lpc_model::LpValue::U32(1)); - - let count_one = select(ring_lamp_counts, "[1]"); - let SlotData::Value(value) = count_one else { - panic!("ring_lamp_counts[1] should be one slot value"); - }; - assert_eq!(value.value(), &lpc_model::LpValue::U32(96)); -} diff --git a/lp-core/lpc-slot-mockup/src/tests/incremental_change.rs b/lp-core/lpc-slot-mockup/src/tests/incremental_change.rs deleted file mode 100644 index b6cd567ff..000000000 --- a/lp-core/lpc-slot-mockup/src/tests/incremental_change.rs +++ /dev/null @@ -1,95 +0,0 @@ -use lpc_model::{LpValue, Revision, SlotData, SlotMapKey}; - -use super::fixture::{ - Harness, assert_map_has_key, assert_shader_param, assert_shader_param_lacks, select, -}; - -#[test] -fn incremental_changes_patch_client_state() { - let mut harness = Harness::new(); - harness.sync_full(); - harness.print_client_tree("engine.shader_node"); - - println!( - "server updating source.fixture#mapping.PathPoints.paths[0].RingArray.ring_lamp_counts" - ); - harness - .runtime - .set_fixture_ring_lamp_counts(Revision::new(2), vec![1, 8, 12, 16]); - harness.print_server_tree("source.fixture"); - harness.sync_diff("source.fixture", Revision::new(1)); - harness.print_client_tree("source.fixture"); - let ring_lamp_counts = select( - harness.client.roots.get("source.fixture").unwrap(), - "mapping.PathPoints.paths[0].RingArray.ring_lamp_counts", - ); - assert_map_has_key(ring_lamp_counts, "", SlotMapKey::U32(3)); - assert_eq!( - select(ring_lamp_counts, "[3]"), - &SlotData::Value(lpc_model::WithRevision::new( - Revision::new(2), - LpValue::U32(16), - )) - ); - - println!("server updating source.shader#param_defs[gain].default to 0.5"); - harness - .runtime - .add_shader_param_def(Revision::new(3), "gain", 0.5); - harness.print_server_tree("source.shader"); - harness.sync_diff("source.shader", Revision::new(2)); - harness.print_client_tree("source.shader"); - assert_map_has_key( - harness.client.roots.get("source.shader").unwrap(), - "param_defs", - SlotMapKey::String("gain".to_string()), - ); - - println!("server updating engine.shader_node#params.exposure to 2.5"); - harness - .runtime - .set_shader_param(Revision::new(4), "exposure", 2.5); - harness.print_server_tree("engine.shader_node"); - harness.sync_diff("engine.shader_node", Revision::new(3)); - harness.print_client_tree("engine.shader_node"); - assert_shader_param( - harness.client.roots.get("engine.shader_node").unwrap(), - "exposure", - LpValue::F32(2.5), - ); - - println!("server removing engine.shader_node#params.speed"); - harness - .runtime - .remove_shader_param(Revision::new(5), "speed"); - harness.print_server_tree("engine.shader_node"); - harness.sync_registry(); - harness.sync_diff("engine.shader_node", Revision::new(4)); - harness.print_client_tree("engine.shader_node"); - assert_shader_param_lacks( - harness.client.roots.get("engine.shader_node").unwrap(), - "speed", - ); - - println!("server updating source.fixture#mapping to square and brightness to none"); - harness.runtime.switch_fixture_mapping(Revision::new(6)); - harness.runtime.clear_fixture_brightness(Revision::new(7)); - harness.print_server_tree("source.fixture"); - harness.sync_diff("source.fixture", Revision::new(5)); - harness.print_client_tree("source.fixture"); - - println!("server updating source.fixture#mapping to disabled unit variant"); - harness.runtime.disable_fixture_mapping(Revision::new(8)); - harness.print_server_tree("source.fixture"); - harness.sync_diff("source.fixture", Revision::new(7)); - harness.print_client_tree("source.fixture"); - assert_eq!( - select( - harness.client.roots.get("source.fixture").unwrap(), - "mapping.Disabled", - ), - &lpc_model::SlotData::Unit { - revision: Revision::new(8), - }, - ); -} diff --git a/lp-core/lpc-slot-mockup/src/tests/mod.rs b/lp-core/lpc-slot-mockup/src/tests/mod.rs deleted file mode 100644 index 7524d99b3..000000000 --- a/lp-core/lpc-slot-mockup/src/tests/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -mod client_tree_walk; -mod dynamic_param_shape; -mod dynamic_slot_codec; -mod fixture; -mod full_sync; -mod incremental_change; -mod mutation; -mod server_tree_walk; -mod shape_codegen; -mod shape_factory; -mod storage_codec; diff --git a/lp-core/lpc-slot-mockup/src/tests/mutation.rs b/lp-core/lpc-slot-mockup/src/tests/mutation.rs deleted file mode 100644 index 6ab588ef2..000000000 --- a/lp-core/lpc-slot-mockup/src/tests/mutation.rs +++ /dev/null @@ -1,202 +0,0 @@ -use lpc_model::{LpValue, Revision, SlotPath}; -use lpc_wire::{ - WireSlotMutationId, WireSlotMutationOp, WireSlotMutationRejection, WireSlotMutationRequest, - WireSlotMutationResponse, WireSlotMutationResult, -}; - -use super::fixture::{Harness, assert_shader_param, assert_shader_param_def_label}; - -#[test] -fn client_mutation_accepts_runtime_value_without_optimistic_write() { - let mut harness = Harness::new(); - harness.sync_full(); - harness.print_client_tree("engine.shader_node"); - - println!("client requesting engine.shader_node#params.exposure = 2.0"); - let mutation_id = WireSlotMutationId::new(1); - let request = harness - .client - .prepare_set_value( - mutation_id, - "engine.shader_node", - SlotPath::parse("params.exposure").unwrap(), - LpValue::F32(2.0), - ) - .unwrap(); - assert!(harness.client.is_pending(mutation_id)); - assert_shader_param( - harness.client.roots.get("engine.shader_node").unwrap(), - "exposure", - LpValue::F32(1.0), - ); - - println!("server applying mutation"); - let response = harness - .runtime - .apply_slot_mutation(Revision::new(2), request); - assert_accepted(&response); - harness.client.apply_mutation_response(response); - assert!(!harness.client.is_pending(mutation_id)); - - println!("syncing accepted mutation result back to client"); - harness.sync_diff("engine.shader_node", Revision::new(1)); - harness.print_client_tree("engine.shader_node"); - assert_shader_param( - harness.client.roots.get("engine.shader_node").unwrap(), - "exposure", - LpValue::F32(2.0), - ); -} - -#[test] -fn client_mutation_accepts_source_value() { - let mut harness = Harness::new(); - harness.sync_full(); - - println!("client requesting source.shader#param_defs[exposure].label = Brightness"); - let mutation_id = WireSlotMutationId::new(2); - let request = harness - .client - .prepare_set_value( - mutation_id, - "source.shader", - SlotPath::parse("param_defs[exposure].label").unwrap(), - LpValue::String("Brightness".to_string()), - ) - .unwrap(); - let response = harness - .runtime - .apply_slot_mutation(Revision::new(2), request); - assert_accepted(&response); - harness.client.apply_mutation_response(response); - - harness.sync_diff("source.shader", Revision::new(1)); - harness.print_client_tree("source.shader"); - assert_shader_param_def_label( - harness.client.roots.get("source.shader").unwrap(), - "exposure", - "Brightness", - ); -} - -#[test] -fn client_mutation_rejects_stale_data_version() { - let mut harness = Harness::new(); - harness.sync_full(); - - let request = harness - .client - .prepare_set_value( - WireSlotMutationId::new(3), - "engine.shader_node", - SlotPath::parse("params.exposure").unwrap(), - LpValue::F32(2.0), - ) - .unwrap(); - println!("server independently updates engine.shader_node#params.exposure"); - harness - .runtime - .set_shader_param(Revision::new(2), "exposure", 3.0); - - let response = harness - .runtime - .apply_slot_mutation(Revision::new(3), request); - assert_rejected( - &response, - WireSlotMutationRejection::DataConflict { - current_version: Revision::new(2), - }, - ); - harness.client.apply_mutation_response(response); - assert_eq!( - harness.client.error(WireSlotMutationId::new(3)), - Some(&WireSlotMutationRejection::DataConflict { - current_version: Revision::new(2) - }) - ); -} - -#[test] -fn client_mutation_rejects_stale_shape_version() { - let mut harness = Harness::new(); - harness.sync_full(); - - let request = harness - .client - .prepare_set_value( - WireSlotMutationId::new(4), - "engine.shader_node", - SlotPath::parse("params.exposure").unwrap(), - LpValue::F32(2.0), - ) - .unwrap(); - println!("server changes engine.shader_node param shape before mutation arrives"); - harness - .runtime - .change_shader_param_to_vec3(Revision::new(2), "exposure", [0.1, 0.2, 0.3]); - - let response = harness - .runtime - .apply_slot_mutation(Revision::new(3), request); - assert_rejected( - &response, - WireSlotMutationRejection::ShapeConflict { - current_version: Revision::new(2), - }, - ); -} - -#[test] -fn client_mutation_rejects_wrong_type_unknown_path_and_unsupported_target() { - let mut harness = Harness::new(); - harness.sync_full(); - - let mut wrong_type = harness - .client - .prepare_set_value( - WireSlotMutationId::new(5), - "engine.shader_node", - SlotPath::parse("params.exposure").unwrap(), - LpValue::F32(2.0), - ) - .unwrap(); - wrong_type.op = WireSlotMutationOp::SetValue(LpValue::Vec3([1.0, 2.0, 3.0])); - let response = harness - .runtime - .apply_slot_mutation(Revision::new(2), wrong_type); - assert_rejected(&response, WireSlotMutationRejection::WrongType); - - let unknown_path = WireSlotMutationRequest { - id: WireSlotMutationId::new(6), - root: "engine.shader_node".to_string(), - path: SlotPath::parse("params.missing").unwrap(), - expected_shape_version: Revision::new(1), - expected_data_version: Revision::new(1), - op: WireSlotMutationOp::SetValue(LpValue::F32(2.0)), - }; - let response = harness - .runtime - .apply_slot_mutation(Revision::new(2), unknown_path); - assert_rejected(&response, WireSlotMutationRejection::UnknownPath); - - let unsupported = WireSlotMutationRequest { - id: WireSlotMutationId::new(7), - root: "engine.shader_node".to_string(), - path: SlotPath::parse("params").unwrap(), - expected_shape_version: Revision::new(1), - expected_data_version: Revision::new(1), - op: WireSlotMutationOp::SetValue(LpValue::F32(1.0)), - }; - let response = harness - .runtime - .apply_slot_mutation(Revision::new(2), unsupported); - assert_rejected(&response, WireSlotMutationRejection::UnsupportedTarget); -} - -fn assert_accepted(response: &WireSlotMutationResponse) { - assert_eq!(response.result, WireSlotMutationResult::Accepted); -} - -fn assert_rejected(response: &WireSlotMutationResponse, expected: WireSlotMutationRejection) { - assert_eq!(response.result, WireSlotMutationResult::Rejected(expected)); -} diff --git a/lp-core/lpc-slot-mockup/src/tests/server_tree_walk.rs b/lp-core/lpc-slot-mockup/src/tests/server_tree_walk.rs deleted file mode 100644 index 2cef89f94..000000000 --- a/lp-core/lpc-slot-mockup/src/tests/server_tree_walk.rs +++ /dev/null @@ -1,37 +0,0 @@ -use super::fixture::Harness; - -#[test] -fn server_tree_walk_prints_runtime_and_source_roots() { - let harness = Harness::new(); - - harness.print_server_tree("source.project"); - harness.print_server_tree("source.shader"); - harness.print_server_tree("source.fixture"); - harness.print_server_tree("engine.fixture_node"); - - let shader_lines = crate::wire::print_root( - harness.server_root("source.shader"), - &harness.runtime.registry, - ); - assert!( - shader_lines - .iter() - .any(|line| line.contains("param_defs[exposure].default")) - ); - - let fixture_lines = crate::wire::print_root( - harness.server_root("source.fixture"), - &harness.runtime.registry, - ); - assert!(fixture_lines.iter().any(|line| { - line.contains("mapping.PathPoints.paths[0].RingArray.ring_lamp_counts: map") - })); - assert!(fixture_lines.iter().any(|line| { - line.contains("mapping.PathPoints.paths[0].RingArray.ring_lamp_counts[0]: U32") - })); - assert!( - fixture_lines - .iter() - .any(|line| line.contains("mapping.PathPoints.sample_diameter: F32")) - ); -} diff --git a/lp-core/lpc-slot-mockup/src/tests/shape_codegen.rs b/lp-core/lpc-slot-mockup/src/tests/shape_codegen.rs deleted file mode 100644 index 183eeb6f0..000000000 --- a/lp-core/lpc-slot-mockup/src/tests/shape_codegen.rs +++ /dev/null @@ -1,35 +0,0 @@ -use crate::{ - engine::{FixtureNode, OutputNode, ShaderNode}, - source::{FixtureDef, OutputDef, ProjectDef, ShaderDef, TextureDef}, -}; -use lpc_model::{SlotShapeRegistry, StaticSlotShape}; - -#[test] -fn generated_catalog_covers_static_shapes() { - let mut registry = SlotShapeRegistry::default(); - - crate::model::register_shapes(&mut registry).unwrap(); - - assert_static_shape::(®istry); - assert_static_shape::(®istry); - assert_static_shape::(®istry); - assert_static_shape::(®istry); - assert_static_shape::(®istry); - assert_static_shape::(®istry); - assert_static_shape::(®istry); - assert!(!registry.contains(&ShaderNode::SHAPE_ID)); -} - -#[test] -fn model_catalog_registration_is_idempotent() { - let mut registry = SlotShapeRegistry::default(); - - crate::model::register_shapes(&mut registry).unwrap(); - crate::model::register_shapes(&mut registry).unwrap(); - - assert_static_shape::(®istry); -} - -fn assert_static_shape(registry: &SlotShapeRegistry) { - assert!(registry.contains(&T::SHAPE_ID)); -} diff --git a/lp-core/lpc-slot-mockup/src/tests/shape_factory.rs b/lp-core/lpc-slot-mockup/src/tests/shape_factory.rs deleted file mode 100644 index 42c9b550b..000000000 --- a/lp-core/lpc-slot-mockup/src/tests/shape_factory.rs +++ /dev/null @@ -1,141 +0,0 @@ -use lpc_model::{ - LpType, LpValue, SlotAccess, SlotDataAccess, SlotFactoryError, SlotMapKey, SlotPath, - SlotShapeId, StaticSlotShape, insert_slot_map_entry_default, set_slot_value, - set_slot_variant_default, -}; -use std::string::String; - -use crate::engine::ShaderNode; -use crate::source::{FixtureDef, ProjectDef, ShaderDef}; - -#[test] -fn shape_factory_creates_static_project_def() { - let registry = registry(); - - let object = registry - .create_default(ProjectDef::SHAPE_ID) - .expect("project default object"); - - assert_eq!(object.shape_id(), ProjectDef::SHAPE_ID); - let SlotDataAccess::Record(record) = object.data() else { - panic!("expected project record"); - }; - assert!(record.field(0).is_some(), "project name field"); - assert!(record.field(1).is_some(), "project nodes field"); -} - -#[test] -fn shape_factory_static_defaults_are_empty_slot_defaults() { - let registry = registry(); - - let object = registry - .create_default(ShaderDef::SHAPE_ID) - .expect("shader default object"); - - let SlotDataAccess::Record(record) = object.data() else { - panic!("expected shader record"); - }; - let SlotDataAccess::Enum(source) = record.field(0).expect("source") else { - panic!("expected source enum"); - }; - assert_eq!(source.variant(), "path"); - let SlotDataAccess::Value(path) = source.data() else { - panic!("expected source path value"); - }; - - assert_eq!(path.value(), LpValue::String(String::new())); -} - -#[test] -fn shape_factory_creates_dynamic_shader_node_object() { - let shader_def = ShaderDef::new(); - let shader_node = ShaderNode::from_def(&shader_def); - let mut registry = registry(); - registry - .register_dynamic_shape(shader_node.shape_id(), shader_node.shape()) - .expect("dynamic shader node shape"); - - let object = registry - .create_default(shader_node.shape_id()) - .expect("dynamic shader node object"); - - assert_eq!(object.shape_id(), shader_node.shape_id()); - let SlotDataAccess::Record(record) = object.data() else { - panic!("expected shader node record"); - }; - let SlotDataAccess::Record(params) = record.field(0).expect("params") else { - panic!("expected params record"); - }; - assert!(params.field(0).is_some()); -} - -#[test] -fn shape_factory_created_object_can_insert_map_entry_then_mutate() { - let registry = registry(); - let mut object = registry - .create_default(ProjectDef::SHAPE_ID) - .expect("project default object"); - - insert_slot_map_entry_default( - object.as_mut(), - ®istry, - &SlotPath::parse("nodes").unwrap(), - lpc_model::Revision::new(20), - &SlotMapKey::String(String::from("extra")), - ) - .unwrap(); - set_slot_value( - object.as_mut(), - ®istry, - &SlotPath::parse("nodes[extra].def.path").unwrap(), - lpc_model::Revision::new(21), - LpValue::String(String::from("./extra.toml")), - ) - .unwrap(); -} - -#[test] -fn shape_factory_created_object_can_switch_enum_then_mutate_payload() { - let registry = registry(); - let mut object = registry - .create_default(FixtureDef::SHAPE_ID) - .expect("fixture default object"); - - set_slot_variant_default( - object.as_mut(), - ®istry, - &SlotPath::parse("mapping").unwrap(), - lpc_model::Revision::new(30), - "Square", - ) - .unwrap(); - set_slot_value( - object.as_mut(), - ®istry, - &SlotPath::parse("mapping.origin").unwrap(), - lpc_model::Revision::new(31), - LpValue::Vec2([0.25, 0.75]), - ) - .unwrap(); -} - -#[test] -fn shape_factory_reports_explicitly_uncreatable_shapes() { - let mut registry = registry(); - let shape_id = SlotShapeId::from_static_name("mockup.uncreatable_shape"); - registry - .register_uncreatable_shape(shape_id, lpc_model::slot::shape::value(LpType::Bool)) - .unwrap(); - - let Err(error) = registry.create_default(shape_id) else { - panic!("expected uncreatable shape error"); - }; - - assert_eq!(error, SlotFactoryError::UnsupportedFactory(shape_id)); -} - -fn registry() -> lpc_model::SlotShapeRegistry { - let mut registry = lpc_model::SlotShapeRegistry::default(); - crate::model::register_shapes(&mut registry).unwrap(); - registry -} diff --git a/lp-core/lpc-slot-mockup/src/tests/storage_codec.rs b/lp-core/lpc-slot-mockup/src/tests/storage_codec.rs deleted file mode 100644 index cba7ec721..000000000 --- a/lp-core/lpc-slot-mockup/src/tests/storage_codec.rs +++ /dev/null @@ -1,176 +0,0 @@ -use lpc_model::{ - Revision, SlotAccess, SlotData, SlotEnum, SlotMapDyn, SlotOptionDyn, SlotRecord, WithRevision, - slot_codec::SlotWriter, -}; -use lpc_wire::snapshot_slot_root; - -use crate::engine::MockRuntime; - -#[test] -fn mock_disk_toml_roots_decode_through_slot_shapes() { - let runtime = MockRuntime::new(); - - for (name, root) in persisted_roots(&runtime) { - let encoded = runtime - .registry - .write_slot_toml_data(root.shape_id(), root.data()) - .unwrap(); - - let decoded = runtime - .registry - .read_slot_toml(root.shape_id(), &encoded) - .unwrap(); - let expected = snapshot_slot_root(&root.shape_id(), root.data(), &runtime.registry); - let actual = snapshot_slot_root(&decoded.shape_id(), decoded.data(), &runtime.registry); - - assert_eq!( - normalize_revisions(actual), - normalize_revisions(expected), - "decoded TOML root {name}" - ); - } -} - -#[test] -fn mock_wire_json_roots_use_direct_slot_writer_shape() { - let runtime = MockRuntime::new(); - - for (name, root) in persisted_roots(&runtime) { - let json = wrap_direct_json_data(&runtime, root); - let json = std::str::from_utf8(&json).unwrap(); - assert!(json.contains(r#""data""#), "direct JSON root {name}"); - } -} - -#[test] -fn mock_toml_rejects_unknown_domain_fields() { - let runtime = MockRuntime::new(); - let root = &runtime.shader_def as &dyn SlotAccess; - let mut encoded = runtime - .registry - .write_slot_toml_data(root.shape_id(), root.data()) - .unwrap(); - encoded.as_table_mut().expect("root table").insert( - "surprise".to_string(), - toml::Value::String("nope".to_string()), - ); - - let error = runtime - .registry - .read_slot_toml(root.shape_id(), &encoded) - .expect_err_without_debug(); - - assert!(error.message().contains("surprise")); -} - -#[test] -fn mock_toml_requires_enum_discriminators() { - let runtime = MockRuntime::new(); - let root = &runtime.fixture_def as &dyn SlotAccess; - let mut encoded = runtime - .registry - .write_slot_toml_data(root.shape_id(), root.data()) - .unwrap(); - encoded["mapping"] - .as_table_mut() - .expect("mapping table") - .remove("kind"); - - let error = runtime - .registry - .read_slot_toml(root.shape_id(), &encoded) - .expect_err_without_debug(); - - assert!(error.message().contains("kind")); -} - -#[test] -fn mock_toml_rejects_unknown_enum_discriminators() { - let runtime = MockRuntime::new(); - let root = &runtime.fixture_def as &dyn SlotAccess; - let mut encoded = runtime - .registry - .write_slot_toml_data(root.shape_id(), root.data()) - .unwrap(); - encoded["mapping"] - .as_table_mut() - .expect("mapping table") - .insert( - "kind".to_string(), - toml::Value::String("hex_grid".to_string()), - ); - - let error = runtime - .registry - .read_slot_toml(root.shape_id(), &encoded) - .expect_err_without_debug(); - - assert!(error.message().contains("hex_grid")); -} - -fn persisted_roots<'a>(runtime: &'a MockRuntime) -> Vec<(&'static str, &'a dyn SlotAccess)> { - vec![ - ("project", &runtime.project), - ("shader", &runtime.shader_def), - ("texture", &runtime.texture_def), - ("output", &runtime.output_def), - ("fixture", &runtime.fixture_def), - ] -} - -fn wrap_direct_json_data(runtime: &MockRuntime, root: &dyn SlotAccess) -> Vec { - let mut out = Vec::new(); - let mut writer = SlotWriter::new(&mut out); - let mut object = writer.object().unwrap(); - runtime - .registry - .write_slot_json_value(root.shape_id(), root.data(), object.prop("data").unwrap()) - .unwrap(); - object.finish().unwrap(); - out -} - -fn normalize_revisions(data: SlotData) -> SlotData { - match data { - SlotData::Unit { .. } => SlotData::Unit { - revision: Revision::default(), - }, - SlotData::Value(value) => SlotData::Value(WithRevision::new( - Revision::default(), - value.value().clone(), - )), - SlotData::Record(record) => SlotData::Record(SlotRecord::with_revision( - Revision::default(), - record.fields.into_iter().map(normalize_revisions).collect(), - )), - SlotData::Map(map) => SlotData::Map(SlotMapDyn::with_revision( - Revision::default(), - map.entries - .into_iter() - .map(|(key, value)| (key, normalize_revisions(value))) - .collect(), - )), - SlotData::Enum(en) => SlotData::Enum(SlotEnum::with_version( - Revision::default(), - en.variant, - normalize_revisions(*en.data), - )), - SlotData::Option(option) => SlotData::Option(SlotOptionDyn { - presence_revision: Revision::default(), - data: option.data.map(|data| Box::new(normalize_revisions(*data))), - }), - } -} - -trait ExpectErrWithoutDebug { - fn expect_err_without_debug(self) -> E; -} - -impl ExpectErrWithoutDebug for Result { - fn expect_err_without_debug(self) -> E { - match self { - Ok(_) => panic!("expected error"), - Err(error) => error, - } - } -} diff --git a/lp-core/lpc-slot-mockup/src/wire/debug.rs b/lp-core/lpc-slot-mockup/src/wire/debug.rs deleted file mode 100644 index 5e91895b5..000000000 --- a/lp-core/lpc-slot-mockup/src/wire/debug.rs +++ /dev/null @@ -1,121 +0,0 @@ -use lpc_model::{ - SlotAccess, SlotData, SlotDataAccess, SlotMapKey, SlotPath, SlotPathSegment, SlotShape, - SlotShapeId, SlotShapeRegistry, -}; - -pub fn print_root(root: &dyn SlotAccess, registry: &SlotShapeRegistry) -> Vec { - let mut lines = Vec::new(); - print_inner( - "".to_string(), - &root.shape_id(), - root.data(), - registry, - &mut lines, - ); - lines -} - -pub fn print_data_root( - shape_id: &SlotShapeId, - data: &SlotData, - registry: &SlotShapeRegistry, -) -> Vec { - let mut lines = Vec::new(); - print_inner( - "".to_string(), - shape_id, - data.access(), - registry, - &mut lines, - ); - lines -} - -fn print_inner( - path: String, - shape_id: &SlotShapeId, - data: SlotDataAccess<'_>, - registry: &SlotShapeRegistry, - lines: &mut Vec, -) { - let shape = registry.get(shape_id).expect("shape"); - print_shape(path, shape, data, registry, lines); -} - -fn print_shape( - path: String, - shape: &SlotShape, - data: SlotDataAccess<'_>, - registry: &SlotShapeRegistry, - lines: &mut Vec, -) { - match (shape, data) { - (SlotShape::Ref { id }, data) => print_inner(path, id, data, registry, lines), - (SlotShape::Unit { .. }, SlotDataAccess::Unit(_)) => { - lines.push(format!("{path}: unit")); - } - (SlotShape::Value { .. }, SlotDataAccess::Value(value)) => { - lines.push(format!("{path}: {:?}", value.value())); - } - (SlotShape::Record { fields, .. }, SlotDataAccess::Record(record)) => { - lines.push(format!("{path}: record")); - for (index, field) in fields.iter().enumerate() { - if let Some(child) = record.field(index) { - print_shape( - format!("{path}.{}", field.name), - &field.shape, - child, - registry, - lines, - ); - } - } - } - (SlotShape::Map { value, .. }, SlotDataAccess::Map(map)) => { - lines.push(format!("{path}: map")); - for key in map.keys() { - if let Some(child) = map.get(&key) { - print_shape( - format!("{path}{}", key_segment(&key)), - value, - child, - registry, - lines, - ); - } - } - } - (SlotShape::Enum { variants, .. }, SlotDataAccess::Enum(en)) => { - lines.push(format!("{path}: enum {}", en.variant())); - let variant = variants - .iter() - .find(|variant| variant.name.as_str() == en.variant()) - .expect("variant"); - print_shape( - format!("{path}.{}", en.variant()), - &variant.shape, - en.data(), - registry, - lines, - ); - } - (SlotShape::Option { some, .. }, SlotDataAccess::Option(option)) => { - lines.push(format!( - "{path}: option {}", - if option.data().is_some() { - "some" - } else { - "none" - } - )); - if let Some(child) = option.data() { - print_shape(format!("{path}.some"), some, child, registry, lines); - } - } - _ => panic!("shape/data mismatch"), - } -} - -fn key_segment(key: &SlotMapKey) -> String { - SlotPath::from_segments(vec![SlotPathSegment::Key(key.clone())]).to_string() -} diff --git a/lp-core/lpc-slot-mockup/src/wire/mod.rs b/lp-core/lpc-slot-mockup/src/wire/mod.rs deleted file mode 100644 index 64c64529d..000000000 --- a/lp-core/lpc-slot-mockup/src/wire/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod debug; -mod sync; - -pub use debug::print_data_root; -pub use debug::print_root; -pub use sync::{collect_diff, full_sync}; diff --git a/lp-core/lpc-slot-mockup/src/wire/sync.rs b/lp-core/lpc-slot-mockup/src/wire/sync.rs deleted file mode 100644 index 7fdc54a4b..000000000 --- a/lp-core/lpc-slot-mockup/src/wire/sync.rs +++ /dev/null @@ -1,9 +0,0 @@ -use lpc_wire::{WireSlotFullSync, build_slot_full_sync}; - -use crate::engine::MockRuntime; - -pub use lpc_wire::collect_slot_diff as collect_diff; - -pub fn full_sync(runtime: &MockRuntime) -> WireSlotFullSync { - build_slot_full_sync(&runtime.registry, runtime.roots()) -} diff --git a/lp-core/lpc-view/src/lib.rs b/lp-core/lpc-view/src/lib.rs index b05684978..28c45e1c7 100644 --- a/lp-core/lpc-view/src/lib.rs +++ b/lp-core/lpc-view/src/lib.rs @@ -19,5 +19,5 @@ pub use project::{ ClientResourceCache, NodeEntryView, ProjectReadApplyError, ProjectView, StatusChangeView, apply_project_read_response, }; -pub use slot::{PendingSlotMutation, SlotMirrorError, SlotMirrorView}; +pub use slot::{SlotMirrorError, SlotMirrorView}; pub use tree::{ApplyError, NodeTreeView, TreeEntryView, apply_tree_delta, apply_tree_deltas}; diff --git a/lp-core/lpc-view/src/project/apply_project_read.rs b/lp-core/lpc-view/src/project/apply_project_read.rs index e306a8d00..5d056da41 100644 --- a/lp-core/lpc-view/src/project/apply_project_read.rs +++ b/lp-core/lpc-view/src/project/apply_project_read.rs @@ -67,9 +67,6 @@ pub fn apply_project_read_response( ProjectReadResult::Runtime(_) => {} } } - for mutation in response.mutations { - view.slots.apply_mutation_response(mutation); - } view.revision = revision; Ok(()) } @@ -80,8 +77,8 @@ mod tests { use alloc::vec; use lpc_model::{NodeId, Revision, TreePath}; use lpc_wire::{ - NodeReadResult, ProjectReadResponse, ReadLevel, ResourceReadResult, WireEntryState, - WireNodeStatus, WireTreeDelta, + NodeReadResult, NodeRuntimeStatus, ProjectReadResponse, ReadLevel, ResourceReadResult, + WireEntryState, WireTreeDelta, }; #[test] @@ -97,7 +94,7 @@ mod tests { parent: None, child_kind: None, children: vec![], - status: WireNodeStatus::Created, + status: NodeRuntimeStatus::Created, state: WireEntryState::Pending, created_frame: Revision::new(0), change_frame: Revision::new(0), @@ -106,7 +103,6 @@ mod tests { slots: None, })], probes: vec![], - mutations: vec![], }; apply_project_read_response(&mut view, response).unwrap(); @@ -126,7 +122,6 @@ mod tests { runtime_buffer_payloads: vec![], })], probes: vec![], - mutations: vec![], }; apply_project_read_response(&mut view, response).unwrap(); diff --git a/lp-core/lpc-view/src/project/project_view.rs b/lp-core/lpc-view/src/project/project_view.rs index 42c7d1f09..a18ec62ea 100644 --- a/lp-core/lpc-view/src/project/project_view.rs +++ b/lp-core/lpc-view/src/project/project_view.rs @@ -1,6 +1,6 @@ use lpc_model::NodeKind; use lpc_model::{LpPathBuf, Revision}; -use lpc_wire::WireNodeStatus; +use lpc_wire::NodeRuntimeStatus; use super::resource_cache::ClientResourceCache; use crate::slot::SlotMirrorView; @@ -12,9 +12,9 @@ pub struct StatusChangeView { /// Node path. pub path: LpPathBuf, /// Previous status. - pub old_status: WireNodeStatus, + pub old_status: NodeRuntimeStatus, /// New status. - pub new_status: WireNodeStatus, + pub new_status: NodeRuntimeStatus, } /// Node-centric client-side project mirror. @@ -33,7 +33,7 @@ pub struct ProjectView { pub struct NodeEntryView { pub path: LpPathBuf, pub kind: NodeKind, - pub status: WireNodeStatus, + pub status: NodeRuntimeStatus, pub status_ver: Revision, } diff --git a/lp-core/lpc-view/src/slot/apply.rs b/lp-core/lpc-view/src/slot/apply.rs index 37e827d2e..f34519a80 100644 --- a/lp-core/lpc-view/src/slot/apply.rs +++ b/lp-core/lpc-view/src/slot/apply.rs @@ -1,9 +1,9 @@ use alloc::collections::BTreeMap; use alloc::string::{String, ToString}; use lpc_model::{ - LpType, LpValue, ModelStructMember, Revision, SlotData, SlotDataAccess, SlotMapKey, - SlotMapKeyShape, SlotPath, SlotPathSegment, SlotShapeId, SlotShapeLookup, SlotShapeRegistry, - SlotShapeView, slot_sync_codec::read_slot_snapshot_shape_json, + LpType, LpValue, ModelStructMember, SlotData, SlotDataAccess, SlotMapKey, SlotMapKeyShape, + SlotPath, SlotPathSegment, SlotShapeId, SlotShapeLookup, SlotShapeRegistry, SlotShapeView, + slot_sync_codec::read_slot_snapshot_shape_json, }; use lpc_wire::{WireSlotChange, WireSlotPatch}; @@ -48,39 +48,6 @@ pub(super) fn apply_patch( apply_replace(data, shape_id, &patch.path, &patch.change, registry) } -pub(super) fn shape_version_for_root( - root: &str, - root_shapes: &BTreeMap, - registry: &SlotShapeRegistry, -) -> Result { - let shape_id = root_shapes.get(root).ok_or(SlotMirrorError::UnknownRoot)?; - if let Some(entry) = registry.entry(shape_id) { - return Ok(entry.changed_at()); - } - registry - .get_shape(*shape_id) - .map(|_| Revision::default()) - .ok_or(SlotMirrorError::MissingShape(*shape_id)) -} - -pub(super) fn data_version_at( - root: &SlotData, - shape_id: &SlotShapeId, - path: &SlotPath, - registry: &SlotShapeRegistry, -) -> Result { - let (data, _) = resolve_path(root, shape_id, path, registry)?; - match data { - SlotDataAccess::Unit(revision) => Ok(revision), - SlotDataAccess::Value(value) => Ok(value.changed_at()), - SlotDataAccess::Record(record) => Ok(record.fields_revision()), - SlotDataAccess::Map(map) => Ok(map.keys_revision()), - SlotDataAccess::Enum(en) => Ok(en.variant_revision()), - SlotDataAccess::Option(option) => Ok(option.presence_revision()), - SlotDataAccess::Custom(custom) => Ok(custom.custom_revision()), - } -} - pub(super) fn validate_value_at( root: &SlotData, shape_id: &SlotShapeId, diff --git a/lp-core/lpc-view/src/slot/mirror.rs b/lp-core/lpc-view/src/slot/mirror.rs index 107ab9cd1..f3bba3227 100644 --- a/lp-core/lpc-view/src/slot/mirror.rs +++ b/lp-core/lpc-view/src/slot/mirror.rs @@ -1,20 +1,13 @@ use alloc::collections::BTreeMap; use alloc::format; -use alloc::string::{String, ToString}; +use alloc::string::String; use lpc_model::{ LpValue, SlotData, SlotPath, SlotShapeId, SlotShapeRegistry, SlotShapeRegistrySnapshot, slot_sync_codec::read_slot_snapshot_json, }; -use lpc_wire::{ - WireSlotFullSync, WireSlotMutationId, WireSlotMutationOp, WireSlotMutationRejection, - WireSlotMutationRequest, WireSlotMutationResponse, WireSlotMutationResult, WireSlotPatch, - WireSlotRootSnapshot, WireSlotRootsSnapshot, -}; +use lpc_wire::{WireSlotFullSync, WireSlotPatch, WireSlotRootSnapshot, WireSlotRootsSnapshot}; -use super::apply::{ - SlotMirrorError, apply_patch, data_version_at, shape_version_for_root, validate_value_at, -}; -use super::pending::PendingSlotMutation; +use super::apply::{SlotMirrorError, apply_patch, validate_value_at}; /// Authoritative client-side mirror of synced generic slot data. #[derive(Clone, Debug, Default, PartialEq)] @@ -22,8 +15,6 @@ pub struct SlotMirrorView { pub registry: SlotShapeRegistry, pub root_shapes: BTreeMap, pub roots: BTreeMap, - pub pending: BTreeMap, - pub errors: BTreeMap, } impl SlotMirrorView { @@ -65,66 +56,21 @@ impl SlotMirrorView { Ok(()) } - pub fn prepare_set_value( - &mut self, - id: WireSlotMutationId, + pub fn validate_set_value( + &self, root: &str, - path: SlotPath, - value: LpValue, - ) -> Result { + path: &SlotPath, + value: &LpValue, + ) -> Result<(), SlotMirrorError> { validate_value_at( self.roots.get(root).ok_or(SlotMirrorError::UnknownRoot)?, self.root_shapes .get(root) .ok_or(SlotMirrorError::UnknownRoot)?, - &path, - &value, - &self.registry, - )?; - - let request = WireSlotMutationRequest { - id, - root: root.to_string(), - expected_shape_version: shape_version_for_root( - root, - &self.root_shapes, - &self.registry, - )?, - expected_data_version: data_version_at( - self.roots.get(root).ok_or(SlotMirrorError::UnknownRoot)?, - self.root_shapes - .get(root) - .ok_or(SlotMirrorError::UnknownRoot)?, - &path, - &self.registry, - )?, path, - op: WireSlotMutationOp::SetValue(value), - }; - self.pending - .insert(id, PendingSlotMutation::new(request.clone())); - self.errors.remove(&id); - Ok(request) - } - - pub fn apply_mutation_response(&mut self, response: WireSlotMutationResponse) { - self.pending.remove(&response.id); - match response.result { - WireSlotMutationResult::Accepted => { - self.errors.remove(&response.id); - } - WireSlotMutationResult::Rejected(rejection) => { - self.errors.insert(response.id, rejection); - } - } - } - - pub fn is_pending(&self, id: WireSlotMutationId) -> bool { - self.pending.contains_key(&id) - } - - pub fn error(&self, id: WireSlotMutationId) -> Option<&WireSlotMutationRejection> { - self.errors.get(&id) + value, + &self.registry, + ) } fn read_wire_slot_root( @@ -151,82 +97,6 @@ mod tests { WireSlotChange, WireSlotData, WireSlotRootSnapshot, wire_slot_data_from_slot_access, }; - #[test] - fn set_value_mutation_tracks_pending_without_local_write() { - let mut view = fixture(); - let id = WireSlotMutationId::new(1); - - let request = view - .prepare_set_value( - id, - "engine.shader_node", - SlotPath::parse("params.exposure").unwrap(), - LpValue::F32(2.0), - ) - .unwrap(); - - assert!(view.is_pending(id)); - assert_eq!(request.expected_shape_version, Revision::new(1)); - assert_eq!(request.expected_data_version, Revision::new(3)); - assert_eq!( - exposure_value(&view), - &WithRevision::new(Revision::new(3), LpValue::F32(1.0)) - ); - } - - #[test] - fn accepted_response_clears_pending_without_local_write() { - let mut view = fixture(); - let id = WireSlotMutationId::new(1); - view.prepare_set_value( - id, - "engine.shader_node", - SlotPath::parse("params.exposure").unwrap(), - LpValue::F32(2.0), - ) - .unwrap(); - - view.apply_mutation_response(WireSlotMutationResponse { - id, - result: WireSlotMutationResult::Accepted, - }); - - assert!(!view.is_pending(id)); - assert!(view.error(id).is_none()); - assert_eq!( - exposure_value(&view), - &WithRevision::new(Revision::new(3), LpValue::F32(1.0)) - ); - } - - #[test] - fn rejected_response_records_error() { - let mut view = fixture(); - let id = WireSlotMutationId::new(1); - view.prepare_set_value( - id, - "engine.shader_node", - SlotPath::parse("params.exposure").unwrap(), - LpValue::F32(2.0), - ) - .unwrap(); - - view.apply_mutation_response(WireSlotMutationResponse { - id, - result: WireSlotMutationResult::Rejected(WireSlotMutationRejection::DataConflict { - current_version: Revision::new(4), - }), - }); - - assert!(!view.is_pending(id)); - assert_eq!( - view.error(id), - Some(&WireSlotMutationRejection::DataConflict { - current_version: Revision::new(4) - }) - ); - } - #[test] fn patches_update_authoritative_mirror() { let mut view = fixture(); @@ -249,19 +119,17 @@ mod tests { } #[test] - fn wrong_type_is_rejected_before_pending() { - let mut view = fixture(); + fn wrong_type_is_rejected_by_validation() { + let view = fixture(); let err = view - .prepare_set_value( - WireSlotMutationId::new(1), + .validate_set_value( "engine.shader_node", - SlotPath::parse("params.exposure").unwrap(), - LpValue::Vec3([1.0, 2.0, 3.0]), + &SlotPath::parse("params.exposure").unwrap(), + &LpValue::Vec3([1.0, 2.0, 3.0]), ) .unwrap_err(); assert_eq!(err, SlotMirrorError::WrongType); - assert!(view.pending.is_empty()); } fn fixture() -> SlotMirrorView { diff --git a/lp-core/lpc-view/src/slot/mod.rs b/lp-core/lpc-view/src/slot/mod.rs index 5d90a4b5c..73ab2ecaf 100644 --- a/lp-core/lpc-view/src/slot/mod.rs +++ b/lp-core/lpc-view/src/slot/mod.rs @@ -1,9 +1,7 @@ -//! Client-side mirror for generic slot sync and mutation. +//! Client-side mirror for generic slot sync. mod apply; mod mirror; -mod pending; pub use apply::SlotMirrorError; pub use mirror::SlotMirrorView; -pub use pending::PendingSlotMutation; diff --git a/lp-core/lpc-view/src/slot/pending.rs b/lp-core/lpc-view/src/slot/pending.rs deleted file mode 100644 index 91f6a7d7c..000000000 --- a/lp-core/lpc-view/src/slot/pending.rs +++ /dev/null @@ -1,17 +0,0 @@ -use lpc_wire::{WireSlotMutationRequest, WireSlotMutationResponse}; - -/// Client-side pending mutation metadata. -#[derive(Clone, Debug, PartialEq)] -pub struct PendingSlotMutation { - pub request: WireSlotMutationRequest, -} - -impl PendingSlotMutation { - pub fn new(request: WireSlotMutationRequest) -> Self { - Self { request } - } - - pub fn matches_response(&self, response: &WireSlotMutationResponse) -> bool { - self.request.id == response.id - } -} diff --git a/lp-core/lpc-view/src/tree/apply.rs b/lp-core/lpc-view/src/tree/apply.rs index 861d90ff7..590eee52d 100644 --- a/lp-core/lpc-view/src/tree/apply.rs +++ b/lp-core/lpc-view/src/tree/apply.rs @@ -145,7 +145,9 @@ pub fn apply_tree_deltas( mod tests { use super::{NodeTreeView, TreeEntryView, apply_tree_delta}; use lpc_model::{NodeId, NodeName, Revision, TreePath}; - use lpc_wire::{WireChildKind, WireEntryState, WireNodeStatus, WireSlotIndex, WireTreeDelta}; + use lpc_wire::{ + NodeRuntimeStatus, WireChildKind, WireEntryState, WireSlotIndex, WireTreeDelta, + }; fn make_tree_with_root() -> NodeTreeView { let mut tree = NodeTreeView::new(); @@ -154,7 +156,7 @@ mod tests { TreePath::parse("/root.show").unwrap(), None, None, - WireNodeStatus::Created, + NodeRuntimeStatus::Created, WireEntryState::Pending, Revision::new(0), Revision::new(0), @@ -176,7 +178,7 @@ mod tests { source: WireSlotIndex(0), }), children: alloc::vec![], - status: WireNodeStatus::Created, + status: NodeRuntimeStatus::Created, state: WireEntryState::Pending, created_frame: Revision::new(1), change_frame: Revision::new(1), @@ -201,7 +203,7 @@ mod tests { Some(WireChildKind::Input { source: WireSlotIndex(0), }), - WireNodeStatus::Created, + NodeRuntimeStatus::Created, WireEntryState::Pending, Revision::new(1), Revision::new(1), @@ -212,7 +214,7 @@ mod tests { // Apply status change let delta = WireTreeDelta::EntryChanged { id: NodeId::new(1), - status: WireNodeStatus::Ok, + status: NodeRuntimeStatus::Ok, state: WireEntryState::Alive, change_frame: Revision::new(5), }; @@ -220,7 +222,7 @@ mod tests { apply_tree_delta(&mut tree, &delta, Revision::new(5)).unwrap(); let entry = tree.get(NodeId::new(1)).unwrap(); - assert!(matches!(entry.status, WireNodeStatus::Ok)); + assert!(matches!(entry.status, NodeRuntimeStatus::Ok)); assert!(matches!(entry.state, WireEntryState::Alive)); assert_eq!(entry.change_frame.0, 5); } @@ -237,7 +239,7 @@ mod tests { Some(WireChildKind::Input { source: WireSlotIndex(0), }), - WireNodeStatus::Created, + NodeRuntimeStatus::Created, WireEntryState::Pending, Revision::new(1), Revision::new(1), @@ -271,7 +273,7 @@ mod tests { Some(WireChildKind::Input { source: WireSlotIndex(0), }), - WireNodeStatus::Created, + NodeRuntimeStatus::Created, WireEntryState::Pending, Revision::new(1), Revision::new(1), @@ -284,7 +286,7 @@ mod tests { Some(WireChildKind::Input { source: WireSlotIndex(1), }), - WireNodeStatus::Created, + NodeRuntimeStatus::Created, WireEntryState::Pending, Revision::new(1), Revision::new(1), @@ -326,7 +328,7 @@ mod tests { Some(WireChildKind::Sidecar { name: NodeName::parse("parent").unwrap(), }), - WireNodeStatus::Created, + NodeRuntimeStatus::Created, WireEntryState::Pending, Revision::new(1), Revision::new(1), @@ -339,7 +341,7 @@ mod tests { Some(WireChildKind::Input { source: WireSlotIndex(0), }), - WireNodeStatus::Created, + NodeRuntimeStatus::Created, WireEntryState::Pending, Revision::new(2), Revision::new(2), @@ -379,7 +381,7 @@ mod tests { let delta = WireTreeDelta::EntryChanged { id: NodeId::new(99), // doesn't exist - status: WireNodeStatus::Ok, + status: NodeRuntimeStatus::Ok, state: WireEntryState::Alive, change_frame: Revision::new(5), }; diff --git a/lp-core/lpc-view/src/tree/tree_entry_view.rs b/lp-core/lpc-view/src/tree/tree_entry_view.rs index b9113b8e0..56d169dad 100644 --- a/lp-core/lpc-view/src/tree/tree_entry_view.rs +++ b/lp-core/lpc-view/src/tree/tree_entry_view.rs @@ -6,7 +6,7 @@ use alloc::vec::Vec; use lpc_model::{NodeId, Revision, TreePath}; -use lpc_wire::{WireChildKind, WireEntryState, WireNodeStatus}; +use lpc_wire::{NodeRuntimeStatus, WireChildKind, WireEntryState}; /// Mirror of wire/node tree metadata (`NodeEntry` on engine) without node payloads. /// @@ -20,7 +20,7 @@ pub struct TreeEntryView { pub child_kind: Option, pub children: Vec, - pub status: WireNodeStatus, + pub status: NodeRuntimeStatus, pub state: WireEntryState, pub created_frame: Revision, @@ -39,7 +39,7 @@ impl TreeEntryView { path: TreePath, parent: Option, child_kind: Option, - status: WireNodeStatus, + status: NodeRuntimeStatus, state: WireEntryState, created_frame: Revision, change_frame: Revision, diff --git a/lp-core/lpc-wire/Cargo.toml b/lp-core/lpc-wire/Cargo.toml index bdade3ec9..a9e1bd21e 100644 --- a/lp-core/lpc-wire/Cargo.toml +++ b/lp-core/lpc-wire/Cargo.toml @@ -14,6 +14,7 @@ schema-gen = ["std", "dep:schemars"] ser-write-json = ["dep:ser-write-json"] [dependencies] +lp-collection = { workspace = true, features = ["serde"] } base64 = { workspace = true } lpc-model = { path = "../lpc-model", default-features = false } serde = { workspace = true, features = ["derive"] } diff --git a/lp-core/lpc-wire/README.md b/lp-core/lpc-wire/README.md index 640673b61..572642651 100644 --- a/lp-core/lpc-wire/README.md +++ b/lp-core/lpc-wire/README.md @@ -15,7 +15,7 @@ surface, but it should not become a second slot/model crate. **Naming:** Envelope and directional types (`Message`, `ClientMessage`, `ClientRequest`, `ServerMessage`, `FsRequest`, …) already imply the wire contract. Use `Wire*` when a noun also exists in model/source/view/engine form -and needs disambiguation — for example `WireTreeDelta`, `WireNodeStatus`, +and needs disambiguation — for example `WireTreeDelta`, `LegacyWireNodeSpecifier`, `WireSlotIndex`. `no_std`, designed for embedded-compatible transports. It should not depend on diff --git a/lp-core/lpc-wire/src/lib.rs b/lp-core/lpc-wire/src/lib.rs index 1caf93c0f..cd3d67593 100644 --- a/lp-core/lpc-wire/src/lib.rs +++ b/lp-core/lpc-wire/src/lib.rs @@ -11,6 +11,9 @@ pub mod json; pub mod message; pub mod messages; pub mod project; +pub mod project_command; +pub mod project_inventory; +pub mod project_overlay; pub mod serde_base64; pub mod server; pub mod slot; @@ -29,21 +32,26 @@ pub use messages::{ write_server_message, }; pub use project::{ - WireChannelSampleFormat, WireColorLayout, WireNodeStatus, WireProjectHandle, + NodeRuntimeStatus, WireChannelSampleFormat, WireColorLayout, WireProjectHandle, WireResourceAvailability, WireResourceKindSummary, WireResourceMetadataSummary, WireResourceSummary, WireRuntimeBufferKind, WireRuntimeBufferMetadataPayload, WireRuntimeBufferPayload, WireTextureFormat, }; +pub use project_command::{WireProjectCommand, WireProjectCommandResponse}; +pub use project_inventory::{WireProjectInventoryReadRequest, WireProjectInventoryReadResponse}; +pub use project_overlay::{ + WireOverlayCommitRequest, WireOverlayCommitResponse, WireOverlayMutationRequest, + WireOverlayMutationResponse, WireOverlayReadRequest, WireOverlayReadResponse, +}; pub use server::{ AvailableProject, ClientMsgBody, FsRequest, FsResponse, LoadedProject, MemoryStats, SampleStats, ServerConfig, ServerMsgBody, }; pub use slot::{ - WireSlotChange, WireSlotData, WireSlotFullSync, WireSlotMutationId, WireSlotMutationOp, - WireSlotMutationRejection, WireSlotMutationRequest, WireSlotMutationResponse, - WireSlotMutationResult, WireSlotPatch, WireSlotRootSnapshot, WireSlotRootsSnapshot, - build_slot_full_sync, build_slot_roots_snapshot, collect_slot_diff, snapshot_slot_root, - snapshot_slot_shape, wire_slot_data_from_slot_access, write_slot_shape_registry_snapshot_json, + WireSlotChange, WireSlotData, WireSlotFullSync, WireSlotPatch, WireSlotRootSnapshot, + WireSlotRootsSnapshot, build_slot_full_sync, build_slot_roots_snapshot, collect_slot_diff, + snapshot_slot_root, snapshot_slot_shape, wire_slot_data_from_slot_access, + write_slot_shape_registry_snapshot_json, }; pub use transport_error::TransportError; pub use tree::{WireChildKind, WireEntryState, WireSlotIndex, WireTreeDelta}; diff --git a/lp-core/lpc-wire/src/message/client.rs b/lp-core/lpc-wire/src/message/client.rs index 4754b5fa2..d1a8553e2 100644 --- a/lp-core/lpc-wire/src/message/client.rs +++ b/lp-core/lpc-wire/src/message/client.rs @@ -2,6 +2,7 @@ use crate::messages::ProjectReadRequest; use crate::project::WireProjectHandle; +use crate::project_command::WireProjectCommand; use crate::server::FsRequest; use alloc::string::String; use serde::{Deserialize, Serialize}; @@ -28,6 +29,10 @@ pub enum ClientRequest { handle: WireProjectHandle, request: ProjectReadRequest, }, + ProjectCommand { + handle: WireProjectHandle, + command: WireProjectCommand, + }, ListAvailableProjects, ListLoadedProjects, StopAllProjects, @@ -106,6 +111,28 @@ mod tests { } } + #[test] + fn test_project_command() { + let req = ClientRequest::ProjectCommand { + handle: WireProjectHandle::new(1), + command: crate::WireProjectCommand::ReadOverlay { + request: crate::WireOverlayReadRequest, + }, + }; + let json = crate::json::to_string(&req).unwrap(); + let deserialized: ClientRequest = crate::json::from_str(&json).unwrap(); + match deserialized { + ClientRequest::ProjectCommand { handle, command } => { + assert_eq!(handle.id(), 1); + assert!(matches!( + command, + crate::WireProjectCommand::ReadOverlay { .. } + )); + } + _ => panic!("Wrong request type"), + } + } + #[test] fn test_list_available_projects_request() { let req = ClientRequest::ListAvailableProjects; diff --git a/lp-core/lpc-wire/src/messages/project_read/project_read_request.rs b/lp-core/lpc-wire/src/messages/project_read/project_read_request.rs index ed34af6a4..b831d2870 100644 --- a/lp-core/lpc-wire/src/messages/project_read/project_read_request.rs +++ b/lp-core/lpc-wire/src/messages/project_read/project_read_request.rs @@ -4,7 +4,6 @@ use super::{ NodeReadQuery, ProjectProbeRequest, ReadLevel, ResourcePayloadRead, ResourceReadQuery, RuntimeReadQuery, ShapeReadQuery, }; -use crate::slot::WireSlotMutationRequest; use alloc::vec::Vec; use lpc_model::Revision; @@ -21,8 +20,6 @@ pub struct ProjectReadRequest { pub queries: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub probes: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub mutations: Vec, } impl ProjectReadRequest { @@ -34,7 +31,6 @@ impl ProjectReadRequest { since, queries: ProjectReadQuery::default_debug(), probes: Vec::new(), - mutations: Vec::new(), } } } @@ -81,7 +77,6 @@ mod tests { since: Some(Revision::new(7)), queries: ProjectReadQuery::default_debug(), probes: vec![ProjectProbeRequest::unsupported_example_for_test()], - mutations: Vec::new(), }; let json = serde_json::to_string(&request).unwrap(); diff --git a/lp-core/lpc-wire/src/messages/project_read/project_read_response.rs b/lp-core/lpc-wire/src/messages/project_read/project_read_response.rs index c23195549..a01d0668f 100644 --- a/lp-core/lpc-wire/src/messages/project_read/project_read_response.rs +++ b/lp-core/lpc-wire/src/messages/project_read/project_read_response.rs @@ -3,7 +3,6 @@ use super::{ NodeReadResult, ProjectProbeResult, ResourceReadResult, RuntimeReadResult, ShapeReadResult, }; -use crate::slot::WireSlotMutationResponse; use alloc::vec::Vec; use lpc_model::Revision; @@ -16,8 +15,6 @@ pub struct ProjectReadResponse { pub results: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub probes: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub mutations: Vec, } /// One result aligned with a [`super::ProjectReadQuery`]. @@ -48,7 +45,6 @@ mod tests { next: None, })], probes: Vec::new(), - mutations: Vec::new(), }; let json = serde_json::to_string(&response).unwrap(); diff --git a/lp-core/lpc-wire/src/messages/project_read/stream_response.rs b/lp-core/lpc-wire/src/messages/project_read/stream_response.rs index e49dc756a..5417087bf 100644 --- a/lp-core/lpc-wire/src/messages/project_read/stream_response.rs +++ b/lp-core/lpc-wire/src/messages/project_read/stream_response.rs @@ -5,7 +5,6 @@ use lpc_model::Revision; use crate::json::json_write::JsonWrite; use crate::json::json_writer::{JsonWriter, JsonWriterError}; use crate::project::write_runtime_buffer_payload_json; -use crate::slot::WireSlotMutationResponse; use super::{ProjectProbeResult, ProjectReadResponse, ProjectReadResult, ResourceReadResult}; @@ -21,9 +20,7 @@ where writer: JsonWriter, result_count: usize, probe_count: usize, - mutation_count: usize, in_probes: bool, - in_mutations: bool, finished: bool, } @@ -42,9 +39,7 @@ where writer, result_count: 0, probe_count: 0, - mutation_count: 0, in_probes: false, - in_mutations: false, finished: false, }) } @@ -77,21 +72,8 @@ where Ok(()) } - pub fn write_mutation( - &mut self, - mutation: &WireSlotMutationResponse, - ) -> Result<(), JsonWriterError> { - self.begin_mutations()?; - if self.mutation_count > 0 { - self.writer.write_raw(b",")?; - } - self.writer.serde(mutation)?; - self.mutation_count += 1; - Ok(()) - } - pub fn finish(mut self) -> Result> { - self.begin_mutations()?; + self.begin_probes()?; self.writer.write_raw(b"]}")?; self.finished = true; Ok(self.writer.into_inner()) @@ -104,15 +86,6 @@ where } Ok(()) } - - fn begin_mutations(&mut self) -> Result<(), JsonWriterError> { - if !self.in_mutations { - self.begin_probes()?; - self.writer.write_raw(b"],\"mutations\":[")?; - self.in_mutations = true; - } - Ok(()) - } } /// Write one [`ProjectReadResult`] in its externally tagged JSON form. @@ -173,9 +146,6 @@ where for probe in &response.probes { streamed.write_probe(probe)?; } - for mutation in &response.mutations { - streamed.write_mutation(mutation)?; - } streamed.finish() } @@ -195,7 +165,6 @@ mod tests { revision: Revision::new(12), results: Vec::new(), probes: Vec::new(), - mutations: Vec::new(), }; assert_streams_to_same_response(&response); @@ -212,7 +181,6 @@ mod tests { next: None, })], probes: Vec::new(), - mutations: Vec::new(), }; assert_streams_to_same_response(&response); @@ -229,7 +197,6 @@ mod tests { next: None, })], probes: Vec::new(), - mutations: Vec::new(), }; let out = write_project_read_response(JsonWriter::new(ChunkCountingWrite::new(8)), &response) @@ -255,7 +222,6 @@ mod tests { }], })], probes: Vec::new(), - mutations: Vec::new(), }; assert_streams_to_same_response(&response); diff --git a/lp-core/lpc-wire/src/messages/stream_server_message.rs b/lp-core/lpc-wire/src/messages/stream_server_message.rs index 071160b74..540e34a5e 100644 --- a/lp-core/lpc-wire/src/messages/stream_server_message.rs +++ b/lp-core/lpc-wire/src/messages/stream_server_message.rs @@ -64,7 +64,6 @@ mod tests { next: None, })], probes: vec![], - mutations: vec![], }; let bytes = write_project_read_server_message(JsonWriter::new(Vec::new()), 42, &response) .expect("write server message"); diff --git a/lp-core/lpc-wire/src/project/mod.rs b/lp-core/lpc-wire/src/project/mod.rs index 95d9e02fe..eb2399484 100644 --- a/lp-core/lpc-wire/src/project/mod.rs +++ b/lp-core/lpc-wire/src/project/mod.rs @@ -1,14 +1,13 @@ //! Wire-facing project types (`Wire*` where applicable). mod resource_sync; -mod wire_node_status; mod wire_project_handle; +pub use lpc_model::NodeRuntimeStatus; pub use resource_sync::{ WireChannelSampleFormat, WireColorLayout, WireResourceAvailability, WireResourceKindSummary, WireResourceMetadataSummary, WireResourceSummary, WireRuntimeBufferKind, WireRuntimeBufferMetadataPayload, WireRuntimeBufferPayload, WireTextureFormat, write_runtime_buffer_payload_json, }; -pub use wire_node_status::WireNodeStatus; pub use wire_project_handle::WireProjectHandle; diff --git a/lp-core/lpc-wire/src/project_command/mod.rs b/lp-core/lpc-wire/src/project_command/mod.rs new file mode 100644 index 000000000..50793b22a --- /dev/null +++ b/lp-core/lpc-wire/src/project_command/mod.rs @@ -0,0 +1,5 @@ +//! Project-scoped command envelopes. + +mod project_command; + +pub use project_command::{WireProjectCommand, WireProjectCommandResponse}; diff --git a/lp-core/lpc-wire/src/project_command/project_command.rs b/lp-core/lpc-wire/src/project_command/project_command.rs new file mode 100644 index 000000000..45089aaa7 --- /dev/null +++ b/lp-core/lpc-wire/src/project_command/project_command.rs @@ -0,0 +1,83 @@ +//! Project commands that are not runtime project reads. + +use crate::{ + WireOverlayCommitRequest, WireOverlayCommitResponse, WireOverlayMutationRequest, + WireOverlayMutationResponse, WireOverlayReadRequest, WireOverlayReadResponse, + WireProjectInventoryReadRequest, WireProjectInventoryReadResponse, +}; + +/// Project command request. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WireProjectCommand { + ReadOverlay { + request: WireOverlayReadRequest, + }, + MutateOverlay { + request: WireOverlayMutationRequest, + }, + CommitOverlay { + request: WireOverlayCommitRequest, + }, + ReadInventory { + request: WireProjectInventoryReadRequest, + }, +} + +/// Project command response. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WireProjectCommandResponse { + ReadOverlay { + response: WireOverlayReadResponse, + }, + MutateOverlay { + response: WireOverlayMutationResponse, + }, + CommitOverlay { + response: WireOverlayCommitResponse, + }, + ReadInventory { + response: WireProjectInventoryReadResponse, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec::Vec; + use lpc_model::{MutationCmdBatch, ProjectInventory, ProjectOverlay}; + + #[test] + fn project_command_round_trips() { + let request = WireProjectCommand::MutateOverlay { + request: WireOverlayMutationRequest::new(MutationCmdBatch::new(Vec::new())), + }; + + let json = serde_json::to_string(&request).unwrap(); + let decoded: WireProjectCommand = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded, request); + assert!(json.contains("mutate_overlay")); + } + + #[test] + fn project_command_response_round_trips() { + let response = WireProjectCommandResponse::ReadInventory { + response: WireProjectInventoryReadResponse::from_inventory(&ProjectInventory::new()), + }; + + let json = serde_json::to_string(&response).unwrap(); + let decoded: WireProjectCommandResponse = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded, response); + assert!(json.contains("read_inventory")); + + let overlay = WireProjectCommandResponse::ReadOverlay { + response: WireOverlayReadResponse::new(ProjectOverlay::new()), + }; + let json = serde_json::to_string(&overlay).unwrap(); + let decoded: WireProjectCommandResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, overlay); + } +} diff --git a/lp-core/lpc-wire/src/project_inventory/mod.rs b/lp-core/lpc-wire/src/project_inventory/mod.rs new file mode 100644 index 000000000..df296e67f --- /dev/null +++ b/lp-core/lpc-wire/src/project_inventory/mod.rs @@ -0,0 +1,7 @@ +//! Wire envelopes for reading effective project inventory. + +mod project_inventory_read; + +pub use project_inventory_read::{ + WireProjectInventoryReadRequest, WireProjectInventoryReadResponse, +}; diff --git a/lp-core/lpc-wire/src/project_inventory/project_inventory_read.rs b/lp-core/lpc-wire/src/project_inventory/project_inventory_read.rs new file mode 100644 index 000000000..cb9c2985c --- /dev/null +++ b/lp-core/lpc-wire/src/project_inventory/project_inventory_read.rs @@ -0,0 +1,166 @@ +//! Effective project inventory read envelopes. + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use lpc_model::{ + AssetEntry, NodeDefLocation, NodeDefState, NodeId, NodeKind, NodeUseLocation, ProjectInventory, + ProjectNodeOrigin, ProjectNodePlacement, Revision, SlotPath, +}; + +/// Wire request for the current effective project inventory. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WireProjectInventoryReadRequest; + +/// Wire response containing a client-facing view of the current inventory. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct WireProjectInventoryReadResponse { + pub defs: Vec, + pub assets: Vec, + pub nodes: Vec, +} + +impl WireProjectInventoryReadResponse { + pub fn from_inventory(inventory: &ProjectInventory) -> Self { + Self::from_inventory_with_runtime_ids(inventory, |_| None) + } + + pub fn from_inventory_with_runtime_ids( + inventory: &ProjectInventory, + mut runtime_id_for: impl FnMut(&NodeUseLocation) -> Option, + ) -> Self { + let mut defs = inventory + .defs + .values() + .map(WireNodeDefInventoryEntry::from_entry) + .collect::>(); + defs.sort_by(|a, b| a.location.cmp(&b.location)); + + let mut assets = inventory.assets.values().cloned().collect::>(); + assets.sort_by(|a, b| a.location.cmp(&b.location)); + + let mut nodes = inventory + .tree + .nodes + .values() + .map(|node| WireProjectNodeInventoryEntry::from_node(node, runtime_id_for(&node.key))) + .collect::>(); + nodes.sort_by(|a, b| a.key.cmp(&b.key)); + + Self { + defs, + assets, + nodes, + } + } +} + +/// Client-facing summary of one node definition inventory entry. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WireNodeDefInventoryEntry { + pub location: NodeDefLocation, + pub state: WireNodeDefInventoryState, + pub revision: Revision, +} + +impl WireNodeDefInventoryEntry { + fn from_entry(entry: &lpc_model::NodeDefEntry) -> Self { + Self { + location: entry.location.clone(), + state: WireNodeDefInventoryState::from_state(&entry.state), + revision: entry.revision, + } + } +} + +/// Serializable summary of definition state. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WireNodeDefInventoryState { + Loaded { kind: NodeKind }, + NotFound, + Deleted, + ReadError { message: String }, + ParseError { message: String }, + ValidationError { message: String }, +} + +impl WireNodeDefInventoryState { + fn from_state(state: &NodeDefState) -> Self { + match state { + NodeDefState::Loaded(def) => Self::Loaded { kind: def.kind() }, + NodeDefState::NotFound => Self::NotFound, + NodeDefState::Deleted => Self::Deleted, + NodeDefState::ReadError { message } => Self::ReadError { + message: message.clone(), + }, + NodeDefState::ParseError(error) => Self::ParseError { + message: error.to_string(), + }, + NodeDefState::ValidationError(error) => Self::ValidationError { + message: error.message.clone(), + }, + } + } +} + +/// Client-facing summary of one effective project node use. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct WireProjectNodeInventoryEntry { + pub key: NodeUseLocation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_id: Option, + pub parent: Option, + pub def_location: NodeDefLocation, + pub origin: WireProjectNodeOrigin, +} + +impl WireProjectNodeInventoryEntry { + fn from_node(node: &lpc_model::ProjectNode, runtime_id: Option) -> Self { + Self { + key: node.key.clone(), + runtime_id, + parent: node.parent.clone(), + def_location: node.def_location.clone(), + origin: WireProjectNodeOrigin::from_origin(&node.origin), + } + } +} + +/// Serializable summary of project node origin. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WireProjectNodeOrigin { + Root, + Invocation { + slot: SlotPath, + role: ProjectNodePlacement, + }, +} + +impl WireProjectNodeOrigin { + fn from_origin(origin: &ProjectNodeOrigin) -> Self { + match origin { + ProjectNodeOrigin::Root => Self::Root, + ProjectNodeOrigin::Invocation { slot, role, .. } => Self::Invocation { + slot: slot.clone(), + role: role.clone(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_inventory_read_response_round_trips() { + let response = WireProjectInventoryReadResponse::from_inventory(&ProjectInventory::new()); + + let json = serde_json::to_string(&response).unwrap(); + let decoded: WireProjectInventoryReadResponse = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded, response); + } +} diff --git a/lp-core/lpc-wire/src/project_overlay/mod.rs b/lp-core/lpc-wire/src/project_overlay/mod.rs new file mode 100644 index 000000000..f4e55bee8 --- /dev/null +++ b/lp-core/lpc-wire/src/project_overlay/mod.rs @@ -0,0 +1,9 @@ +//! Wire envelopes for project overlay reads, mutations, and commits. + +mod overlay_commit; +mod overlay_mutation; +mod overlay_read; + +pub use overlay_commit::{WireOverlayCommitRequest, WireOverlayCommitResponse}; +pub use overlay_mutation::{WireOverlayMutationRequest, WireOverlayMutationResponse}; +pub use overlay_read::{WireOverlayReadRequest, WireOverlayReadResponse}; diff --git a/lp-core/lpc-wire/src/project_overlay/overlay_commit.rs b/lp-core/lpc-wire/src/project_overlay/overlay_commit.rs new file mode 100644 index 000000000..4fd66e635 --- /dev/null +++ b/lp-core/lpc-wire/src/project_overlay/overlay_commit.rs @@ -0,0 +1,35 @@ +//! Project overlay commit envelopes. + +use lpc_model::CommitResult; + +/// Wire request to commit the current project overlay. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WireOverlayCommitRequest; + +/// Wire response containing the artifact writes performed by commit. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WireOverlayCommitResponse { + pub result: CommitResult, +} + +impl WireOverlayCommitResponse { + pub fn new(result: CommitResult) -> Self { + Self { result } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlay_commit_response_round_trips() { + let response = WireOverlayCommitResponse::new(CommitResult::default()); + + let json = serde_json::to_string(&response).unwrap(); + let decoded: WireOverlayCommitResponse = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded, response); + assert!(json.contains("artifact_changes")); + } +} diff --git a/lp-core/lpc-wire/src/project_overlay/overlay_mutation.rs b/lp-core/lpc-wire/src/project_overlay/overlay_mutation.rs new file mode 100644 index 000000000..a86af34c2 --- /dev/null +++ b/lp-core/lpc-wire/src/project_overlay/overlay_mutation.rs @@ -0,0 +1,80 @@ +//! Project overlay mutation envelopes. + +use lpc_model::{MutationCmdBatch, MutationCmdBatchResult}; + +/// Wire request for an ordered overlay mutation batch. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct WireOverlayMutationRequest { + pub batch: MutationCmdBatch, +} + +impl WireOverlayMutationRequest { + pub fn new(batch: MutationCmdBatch) -> Self { + Self { batch } + } +} + +/// Wire response for an ordered overlay mutation batch. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct WireOverlayMutationResponse { + pub result: MutationCmdBatchResult, +} + +impl WireOverlayMutationResponse { + pub fn new(result: MutationCmdBatchResult) -> Self { + Self { result } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + use lpc_model::{ + ArtifactLocation, AssetBodyOverlay, MutationCmd, MutationCmdId, MutationCmdResult, + MutationEffect, MutationOp, SlotEdit, SlotPath, + }; + + #[test] + fn overlay_mutation_request_round_trips() { + let request = WireOverlayMutationRequest::new(MutationCmdBatch::new(vec![ + MutationCmd { + id: MutationCmdId::new(1), + mutation: MutationOp::PutSlotEdit { + artifact: ArtifactLocation::file("/project.toml"), + edit: SlotEdit::ensure_present(SlotPath::parse("nodes[clock]").unwrap()), + }, + }, + MutationCmd { + id: MutationCmdId::new(2), + mutation: MutationOp::SetArtifactBody { + artifact: ArtifactLocation::file("/shader.glsl"), + edit: AssetBodyOverlay::ReplaceBody(b"void main() {}".to_vec()), + }, + }, + ])); + + let json = serde_json::to_string(&request).unwrap(); + let decoded: WireOverlayMutationRequest = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded, request); + assert!(json.contains("put_slot_edit")); + assert!(json.contains("set_artifact_body")); + } + + #[test] + fn overlay_mutation_response_round_trips() { + let response = WireOverlayMutationResponse::new(MutationCmdBatchResult::new(vec![ + MutationCmdResult::accepted( + MutationCmdId::new(1), + MutationEffect::OverlayChanged { changed: true }, + ), + ])); + + let json = serde_json::to_string(&response).unwrap(); + let decoded: WireOverlayMutationResponse = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded, response); + assert!(json.contains("overlay_changed")); + } +} diff --git a/lp-core/lpc-wire/src/project_overlay/overlay_read.rs b/lp-core/lpc-wire/src/project_overlay/overlay_read.rs new file mode 100644 index 000000000..300abe7b6 --- /dev/null +++ b/lp-core/lpc-wire/src/project_overlay/overlay_read.rs @@ -0,0 +1,41 @@ +//! Project overlay read envelopes. + +use lpc_model::ProjectOverlay; + +/// Wire request for the full pending project overlay. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WireOverlayReadRequest; + +/// Wire response containing the full pending project overlay. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct WireOverlayReadResponse { + pub overlay: ProjectOverlay, +} + +impl WireOverlayReadResponse { + pub fn new(overlay: ProjectOverlay) -> Self { + Self { overlay } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lpc_model::{ArtifactLocation, SlotEdit, SlotPath}; + + #[test] + fn overlay_read_response_round_trips() { + let mut overlay = ProjectOverlay::new(); + overlay.put_slot_edit( + ArtifactLocation::file("/project.toml"), + SlotEdit::ensure_present(SlotPath::parse("nodes[clock]").unwrap()), + ); + let response = WireOverlayReadResponse::new(overlay); + + let json = serde_json::to_string(&response).unwrap(); + let decoded: WireOverlayReadResponse = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded, response); + assert!(json.contains("/project.toml")); + } +} diff --git a/lp-core/lpc-wire/src/server/api.rs b/lp-core/lpc-wire/src/server/api.rs index 3027612e2..d8cece8a3 100644 --- a/lp-core/lpc-wire/src/server/api.rs +++ b/lp-core/lpc-wire/src/server/api.rs @@ -1,5 +1,6 @@ use crate::messages::ProjectReadRequest; use crate::project::WireProjectHandle; +use crate::project_command::{WireProjectCommand, WireProjectCommandResponse}; use crate::server::fs_api::{FsRequest, FsResponse}; use alloc::string::String; use alloc::vec::Vec; @@ -20,6 +21,11 @@ pub enum ClientMsgBody { handle: WireProjectHandle, request: ProjectReadRequest, }, + /// Project-specific command request. + ProjectCommand { + handle: WireProjectHandle, + command: WireProjectCommand, + }, /// List available projects ListAvailableProjects, /// List loaded projects @@ -41,6 +47,10 @@ pub enum ServerMsgBody { ProjectRequest { response: R, }, + /// Response to ProjectCommand + ProjectCommand { + response: WireProjectCommandResponse, + }, /// Response to ListAvailableProjects ListAvailableProjects { projects: Vec, diff --git a/lp-core/lpc-wire/src/slot/access_sync.rs b/lp-core/lpc-wire/src/slot/access_sync.rs index 04e026a9f..ab76cef91 100644 --- a/lp-core/lpc-wire/src/slot/access_sync.rs +++ b/lp-core/lpc-wire/src/slot/access_sync.rs @@ -1,7 +1,7 @@ -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::string::ToString; use alloc::vec::Vec; +use lp_collection::VecMap; use lpc_model::{ Revision, SlotAccess, SlotData, SlotDataAccess, SlotMapDyn, SlotName, SlotOptionDyn, SlotPath, SlotShape, SlotShapeId, SlotShapeLookup, SlotShapeRegistry, SlotShapeView, WithRevision, @@ -104,7 +104,7 @@ pub fn snapshot_slot_shape( } SlotDataAccess::Map(map) => { let value = shape.map_value().expect("slot shape/data mismatch"); - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); for key in map.keys() { entries.insert( key.clone(), diff --git a/lp-core/lpc-wire/src/slot/mod.rs b/lp-core/lpc-wire/src/slot/mod.rs index dd65d1754..5210cfa92 100644 --- a/lp-core/lpc-wire/src/slot/mod.rs +++ b/lp-core/lpc-wire/src/slot/mod.rs @@ -1,7 +1,6 @@ -//! Generic slot sync and mutation wire payloads. +//! Generic slot sync wire payloads. mod access_sync; -mod mutation; mod slot_shape_registry_json; mod sync; @@ -9,10 +8,6 @@ pub use access_sync::{ build_slot_full_sync, build_slot_roots_snapshot, collect_slot_diff, snapshot_slot_root, snapshot_slot_shape, wire_slot_data_from_slot_access, }; -pub use mutation::{ - WireSlotMutationId, WireSlotMutationOp, WireSlotMutationRejection, WireSlotMutationRequest, - WireSlotMutationResponse, WireSlotMutationResult, -}; pub use slot_shape_registry_json::write_slot_shape_registry_snapshot_json; pub use sync::{ WireSlotChange, WireSlotData, WireSlotFullSync, WireSlotPatch, WireSlotRootSnapshot, diff --git a/lp-core/lpc-wire/src/slot/mutation.rs b/lp-core/lpc-wire/src/slot/mutation.rs deleted file mode 100644 index 946d7872e..000000000 --- a/lp-core/lpc-wire/src/slot/mutation.rs +++ /dev/null @@ -1,106 +0,0 @@ -use alloc::string::String; -use lpc_model::{LpValue, Revision, SlotPath}; -use serde::{Deserialize, Serialize}; - -/// Client-visible id for one requested slot mutation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(transparent)] -pub struct WireSlotMutationId(pub u64); - -impl WireSlotMutationId { - pub const fn new(id: u64) -> Self { - Self(id) - } - - pub const fn id(self) -> u64 { - self.0 - } -} - -/// Client request to mutate one server-owned slot. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -pub struct WireSlotMutationRequest { - pub id: WireSlotMutationId, - pub root: String, - pub path: SlotPath, - pub expected_shape_version: Revision, - pub expected_data_version: Revision, - pub op: WireSlotMutationOp, -} - -/// Mutation operation. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "snake_case")] -pub enum WireSlotMutationOp { - SetValue(LpValue), -} - -/// Server response for one slot mutation request. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -pub struct WireSlotMutationResponse { - pub id: WireSlotMutationId, - pub result: WireSlotMutationResult, -} - -/// Accepted or rejected mutation result. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "snake_case")] -pub enum WireSlotMutationResult { - Accepted, - Rejected(WireSlotMutationRejection), -} - -/// Why a slot mutation was rejected. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "snake_case", tag = "reason")] -pub enum WireSlotMutationRejection { - ShapeConflict { current_version: Revision }, - DataConflict { current_version: Revision }, - WrongType, - UnknownRoot, - UnknownPath, - UnsupportedTarget, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mutation_request_round_trips() { - let request = WireSlotMutationRequest { - id: WireSlotMutationId::new(42), - root: String::from("engine.shader_node"), - path: SlotPath::parse("params.exposure").unwrap(), - expected_shape_version: Revision::new(1), - expected_data_version: Revision::new(3), - op: WireSlotMutationOp::SetValue(LpValue::F32(2.0)), - }; - - let json = serde_json::to_string(&request).unwrap(); - let back: WireSlotMutationRequest = serde_json::from_str(&json).unwrap(); - - assert_eq!(back, request); - } - - #[test] - fn mutation_response_round_trips() { - let response = WireSlotMutationResponse { - id: WireSlotMutationId::new(7), - result: WireSlotMutationResult::Rejected(WireSlotMutationRejection::DataConflict { - current_version: Revision::new(5), - }), - }; - - let json = serde_json::to_string(&response).unwrap(); - let back: WireSlotMutationResponse = serde_json::from_str(&json).unwrap(); - - assert_eq!(back, response); - } -} diff --git a/lp-core/lpc-wire/src/tree/wire_child_kind.rs b/lp-core/lpc-wire/src/tree/wire_child_kind.rs index 665856e21..cd2d5ac45 100644 --- a/lp-core/lpc-wire/src/tree/wire_child_kind.rs +++ b/lp-core/lpc-wire/src/tree/wire_child_kind.rs @@ -10,7 +10,7 @@ use super::WireSlotIndex; /// How a child relates to its parent for lifecycle purposes. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(tag = "kind", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] pub enum WireChildKind { /// Structural input from the artifact (`[input]`). Input { diff --git a/lp-core/lpc-wire/src/tree/wire_entry_state.rs b/lp-core/lpc-wire/src/tree/wire_entry_state.rs index 74ce2cccc..796f1b76a 100644 --- a/lp-core/lpc-wire/src/tree/wire_entry_state.rs +++ b/lp-core/lpc-wire/src/tree/wire_entry_state.rs @@ -5,7 +5,7 @@ use alloc::string::String; /// Client-side view of a node's lifecycle state. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(tag = "state", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] pub enum WireEntryState { Pending, Alive, diff --git a/lp-core/lpc-wire/src/tree/wire_tree_delta.rs b/lp-core/lpc-wire/src/tree/wire_tree_delta.rs index 481828cce..f17a99518 100644 --- a/lp-core/lpc-wire/src/tree/wire_tree_delta.rs +++ b/lp-core/lpc-wire/src/tree/wire_tree_delta.rs @@ -1,6 +1,6 @@ //! Structural tree deltas for engine↔client sync (`WireTreeDelta`). -use crate::project::WireNodeStatus; +use crate::project::NodeRuntimeStatus; use crate::tree::{WireChildKind, WireEntryState}; use lpc_model::node::{NodeId, TreePath}; use lpc_model::project::Revision; @@ -8,7 +8,7 @@ use lpc_model::project::Revision; /// Structural delta for the node tree (wire shape). #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(tag = "delta", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] pub enum WireTreeDelta { /// First time the client sees this entry. Created { @@ -17,7 +17,7 @@ pub enum WireTreeDelta { parent: Option, child_kind: Option, children: alloc::vec::Vec, - status: WireNodeStatus, + status: NodeRuntimeStatus, state: WireEntryState, created_frame: Revision, change_frame: Revision, @@ -27,7 +27,7 @@ pub enum WireTreeDelta { /// Status/state changed on an existing entry. EntryChanged { id: NodeId, - status: WireNodeStatus, + status: NodeRuntimeStatus, state: WireEntryState, change_frame: Revision, }, @@ -43,7 +43,7 @@ pub enum WireTreeDelta { #[cfg(test)] mod tests { use super::WireTreeDelta; - use crate::project::WireNodeStatus; + use crate::project::NodeRuntimeStatus; use crate::tree::{WireChildKind, WireEntryState, WireSlotIndex}; use lpc_model::node::{NodeId, TreePath}; use lpc_model::project::Revision; @@ -58,7 +58,7 @@ mod tests { source: WireSlotIndex(0), }), children: alloc::vec![NodeId::new(8), NodeId::new(9)], - status: WireNodeStatus::Created, + status: NodeRuntimeStatus::Created, state: WireEntryState::Pending, created_frame: Revision::new(1), change_frame: Revision::new(1), @@ -73,7 +73,7 @@ mod tests { fn tree_delta_entry_changed_round_trips() { let delta = WireTreeDelta::EntryChanged { id: NodeId::new(7), - status: WireNodeStatus::Ok, + status: NodeRuntimeStatus::Ok, state: WireEntryState::Alive, change_frame: Revision::new(42), }; @@ -102,7 +102,7 @@ mod tests { parent: None, child_kind: None, children: alloc::vec![], - status: WireNodeStatus::Created, + status: NodeRuntimeStatus::Created, state: WireEntryState::Pending, created_frame: Revision::new(0), change_frame: Revision::new(0), diff --git a/lp-core/lpc-wire/tests/source_slot_sync.rs b/lp-core/lpc-wire/tests/source_slot_sync.rs index 8e4707b9a..067a8290c 100644 --- a/lp-core/lpc-wire/tests/source_slot_sync.rs +++ b/lp-core/lpc-wire/tests/source_slot_sync.rs @@ -53,18 +53,20 @@ fn real_source_defs_sync_as_slot_roots() { &project_data, ProjectDef::SHAPE_ID.slot_shape_from(&shape_registry), &shape_registry, - "nodes[shader].def", + "nodes[shader].ref", ), - LpValue::String(String::from("shader.toml")), + LpValue::String(String::from("./shader.toml")), ); let shader_data = root_data(&sync, ®istry, "shader"); + // `source` is an AssetSlot: a custom slot whose synced snapshot is the bare + // authored artifact path value, not a record with a `path` field. assert_value( select( &shader_data, ShaderDef::SHAPE_ID.slot_shape_from(&shape_registry), &shape_registry, - "source.path", + "source", ), LpValue::String(String::from("shader.glsl")), ); diff --git a/lp-fw/fw-emu/Cargo.toml b/lp-fw/fw-emu/Cargo.toml index bd4d96c0b..5a51afae5 100644 --- a/lp-fw/fw-emu/Cargo.toml +++ b/lp-fw/fw-emu/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true unwinding = { version = "0.2", default-features = false, features = ["unwinder", "fde-static", "personality", "panic"] } fw-core = { path = "../fw-core", default-features = false, features = ["emu"] } lpa-server = { path = "../../lp-app/lpa-server", default-features = false, features = ["panic-recovery"] } +lpc-hardware = { path = "../../lp-core/lpc-hardware", default-features = false } lpc-shared = { path = "../../lp-core/lpc-shared", default-features = false } lpfs = { path = "../../lp-base/lpfs", default-features = false } lpc-model = { path = "../../lp-core/lpc-model", default-features = false } @@ -35,6 +36,7 @@ test_unwind = [] [dev-dependencies] lpa-client = { path = "../../lp-app/lpa-client", features = ["serial"] } +lpc-hardware = { path = "../../lp-core/lpc-hardware" } lpc-view = { path = "../../lp-core/lpc-view" } lp-riscv-elf = { path = "../../lp-riscv/lp-riscv-elf", features = ["std"] } lp-riscv-emu = { path = "../../lp-riscv/lp-riscv-emu", features = ["std"] } diff --git a/lp-fw/fw-emu/src/main.rs b/lp-fw/fw-emu/src/main.rs index 28ebea36b..9e0ec9e6b 100644 --- a/lp-fw/fw-emu/src/main.rs +++ b/lp-fw/fw-emu/src/main.rs @@ -23,8 +23,8 @@ use fw_core::log::init_emu_logger; use fw_core::transport::SerialTransport; use lp_riscv_emu_guest::allocator; use lpa_server::{Graphics, LpGraphics, LpServer}; +use lpc_hardware::{HardwareSystem, HwManifest, HwRegistry}; use lpc_model::AsLpPath; -use lpc_shared::hardware::{HardwareRegistry, HardwareSystem, permissive_emu_hardware_manifest}; use lpc_shared::output::OutputProvider; use lpfs::LpFsMemory; use lps_builtins::host_debug; @@ -96,7 +96,7 @@ pub extern "C" fn _lp_main() -> ! { // Create filesystem (in-memory) let base_fs = alloc::boxed::Box::new(LpFsMemory::new()); - let hardware_registry = Rc::new(HardwareRegistry::new(permissive_emu_hardware_manifest())); + let hardware_registry = Rc::new(HwRegistry::new(HwManifest::virtual_single_rmt_gpio_board())); let hardware_system = Rc::new(HardwareSystem::with_virtual_drivers(hardware_registry)); // Create output provider diff --git a/lp-fw/fw-emu/src/output.rs b/lp-fw/fw-emu/src/output.rs index 4bdff538d..bb760a974 100644 --- a/lp-fw/fw-emu/src/output.rs +++ b/lp-fw/fw-emu/src/output.rs @@ -9,13 +9,13 @@ use alloc::collections::BTreeMap; use alloc::format; use alloc::rc::Rc; use alloc::string::ToString; +use alloc::vec::Vec; use core::cell::RefCell; use lp_riscv_emu_guest::println; -use lpc_shared::OutputError; -use lpc_shared::hardware::{ - HardwareEndpointError, HardwareEndpointSpec, HardwareRegistry, HardwareSystem, Ws281xConfig, - Ws281xOutput, +use lpc_hardware::OutputError; +use lpc_hardware::{ + HardwareEndpointError, HardwareSystem, HwEndpointSpec, HwRegistry, Ws281xConfig, Ws281xOutput, }; use lpc_shared::output::{OutputChannelHandle, OutputDriverOptions, OutputFormat, OutputProvider}; @@ -34,7 +34,7 @@ impl SyscallOutputProvider { dead_code, reason = "kept for tests and older callers that construct only a registry" )] - pub fn new_with_hardware_registry(hardware_registry: Rc) -> Self { + pub fn new_with_hardware_registry(hardware_registry: Rc) -> Self { Self::new_with_hardware_system(Rc::new(HardwareSystem::with_virtual_drivers( hardware_registry, ))) @@ -52,7 +52,7 @@ impl SyscallOutputProvider { impl OutputProvider for SyscallOutputProvider { fn open( &self, - endpoint: &HardwareEndpointSpec, + endpoint: &HwEndpointSpec, byte_count: u32, format: OutputFormat, options: Option, @@ -90,7 +90,9 @@ impl OutputProvider for SyscallOutputProvider { .ok_or_else(|| OutputError::InvalidHandle { handle: handle.as_i32(), })?; - output.write(data)?; + let mut raw = Vec::with_capacity(data.len()); + render_rgb8(data, &mut raw); + output.write(&raw)?; println!("[output] write: handle={:?}, len={}", handle, data.len()); Ok(()) } @@ -110,16 +112,22 @@ impl OutputProvider for SyscallOutputProvider { impl SyscallOutputProvider { fn open_ws281x_output( &self, - endpoint: &HardwareEndpointSpec, + endpoint: &HwEndpointSpec, byte_count: u32, options: Option, ) -> Result, OutputError> { + let _ = options; self.hardware_system - .open_ws281x_by_spec(endpoint, Ws281xConfig::new(byte_count, options)) + .open_ws281x_by_spec(endpoint, Ws281xConfig::new(byte_count)) .map_err(endpoint_error_to_output_error) } } +fn render_rgb8(data: &[u16], out: &mut Vec) { + out.clear(); + out.extend(data.iter().map(|sample| (sample >> 8) as u8)); +} + fn endpoint_error_to_output_error(error: HardwareEndpointError) -> OutputError { match error { HardwareEndpointError::Hardware { error } => OutputError::Hardware { error }, diff --git a/lp-fw/fw-esp32/Cargo.toml b/lp-fw/fw-esp32/Cargo.toml index 29ff103fc..f3b05690d 100644 --- a/lp-fw/fw-esp32/Cargo.toml +++ b/lp-fw/fw-esp32/Cargo.toml @@ -16,6 +16,7 @@ esp32c6 = [ "esp-hal/esp32c6", ] server = [ + "lpc-hardware", "lpc-model", "lpc-wire", "lpa-server", @@ -24,6 +25,7 @@ server = [ "ser-write-json", ] # Enable server dependencies (lpa-server, lpc-shared, lpc-model, lpc-wire, lpfs) radio = [ + "lpc-hardware", "lpc-shared", "esp-radio", "esp-radio/esp32c6", @@ -47,6 +49,7 @@ test_shader_compile_incremental = ["dep:fw-checks", "dep:lp-shader", "dep:lpvm-n test_espnow = ["radio"] # ESP-NOW broadcast/receive smoke test with simulated 1Hz button events [dependencies] +lp-collection = { workspace = true, features = ["serde"] } unwinding = { version = "0.2", default-features = false, features = ["unwinder", "fde-static", "personality", "panic"] } embassy-executor = "0.10.0" embassy-time = "0.5.0" @@ -71,6 +74,7 @@ libm = "0.2" # Graphics backend is selected automatically by target architecture # (RV32 → lpvm-native::rt_jit on this firmware). No Cargo feature. lpa-server = { path = "../../lp-app/lpa-server", default-features = false, features = ["panic-recovery"], optional = true } +lpc-hardware = { path = "../../lp-core/lpc-hardware", default-features = false, optional = true } lpc-shared = { path = "../../lp-core/lpc-shared", default-features = false, optional = true } lpfs = { path = "../../lp-base/lpfs", default-features = false, optional = true } lpc-model = { path = "../../lp-core/lpc-model", default-features = false, optional = true } diff --git a/lp-fw/fw-esp32/rust-toolchain.toml b/lp-fw/fw-esp32/rust-toolchain.toml index 8e275b740..ae13833b4 100644 --- a/lp-fw/fw-esp32/rust-toolchain.toml +++ b/lp-fw/fw-esp32/rust-toolchain.toml @@ -1,3 +1,7 @@ +# fw-esp32 builds with `-Zbuild-std`, so it needs `rust-src`. Keep this pin in +# lockstep with the workspace `rust-toolchain.toml` (the recipes `cd` into this +# crate, so this file wins here). `just bump-nightly` updates both. See +# docs/toolchain-notes.md. [toolchain] -channel = "nightly" -components = ["rustfmt", "clippy"] +channel = "nightly-2026-04-27" +components = ["rustfmt", "clippy", "rust-src"] diff --git a/lp-fw/fw-esp32/src/hardware/button.rs b/lp-fw/fw-esp32/src/hardware/button.rs index 9a573b34b..4e39388f8 100644 --- a/lp-fw/fw-esp32/src/hardware/button.rs +++ b/lp-fw/fw-esp32/src/hardware/button.rs @@ -1,102 +1,110 @@ extern crate alloc; use alloc::boxed::Box; +use alloc::format; use alloc::rc::Rc; -use alloc::string::String; use alloc::vec; use alloc::vec::Vec; -use core::cell::RefCell; - -use esp_hal::gpio::{Input, InputConfig, Pull}; -use lpc_model::HardwareEndpointSpec; -use lpc_shared::hardware::{ - ButtonConfig, ButtonDebouncer, ButtonDriver, ButtonEvent, ButtonInput, HardwareAddress, - HardwareCapability, HardwareClaim, HardwareDriver, HardwareEndpoint, HardwareEndpointError, - HardwareEndpointId, HardwareEndpointKind, HardwareError, HardwareLease, HardwareRegistry, + +use esp_hal::gpio::{AnyPin, Input, InputConfig, Pull}; +use lpc_hardware::{ + ButtonConfig, ButtonDebouncer, ButtonDriver, ButtonEvent, ButtonInput, HardwareEndpointError, + HardwareLease, HwAddress, HwCapability, HwClaim, HwDriver, HwEndpoint, HwEndpointId, + HwEndpointKind, HwError, HwRegistry, }; +use lpc_model::HwEndpointSpec; const DRIVER_ID: &str = "esp32-gpio-button"; -const GPIO20_SPEC: &str = "button:gpio:D9"; +const DISPLAY_LABEL: &str = "ESP32 GPIO Button"; +const MAX_ESP32C6_GPIO: u8 = 30; -pub struct Esp32Gpio20ButtonDriver { - registry: Rc, - input: Rc>>>, +pub struct Esp32GpioButtonDriver { + registry: Rc, } -impl Esp32Gpio20ButtonDriver { - pub fn new(registry: Rc, pin: esp_hal::peripherals::GPIO20<'static>) -> Self { - Self { - registry, - input: Rc::new(RefCell::new(Some(Input::new( - pin, - InputConfig::default().with_pull(Pull::Up), - )))), - } +impl Esp32GpioButtonDriver { + pub fn new(registry: Rc) -> Self { + Self { registry } } - fn source() -> HardwareAddress { - HardwareAddress::gpio(20) + fn endpoint_id(address: &HwAddress) -> HwEndpointId { + HwEndpointId::for_driver_address(DRIVER_ID, address) } - fn endpoint_id() -> HardwareEndpointId { - HardwareEndpointId::for_driver_address(DRIVER_ID, &Self::source()) + fn gpio_for_endpoint( + &self, + endpoint_id: &HwEndpointId, + ) -> Result { + for endpoint in self.endpoints() { + if endpoint.id() == endpoint_id { + return Ok(endpoint.address().clone()); + } + } + + Err(HardwareEndpointError::UnknownEndpoint { + kind: HwEndpointKind::Button, + endpoint_id: endpoint_id.clone(), + }) } } -impl HardwareDriver for Esp32Gpio20ButtonDriver { +impl HwDriver for Esp32GpioButtonDriver { fn driver_id(&self) -> &str { DRIVER_ID } fn display_label(&self) -> &str { - "ESP32 GPIO Button" + DISPLAY_LABEL } } -impl ButtonDriver for Esp32Gpio20ButtonDriver { - fn endpoints(&self) -> Vec { - let source = Self::source(); - vec![HardwareEndpoint::new( - Self::endpoint_id(), - HardwareEndpointSpec::from_static(GPIO20_SPEC), - HardwareEndpointKind::Button, - DRIVER_ID, - source.clone(), - "D9", - self.registry.endpoint_status_for(&source), - )] +impl ButtonDriver for Esp32GpioButtonDriver { + fn endpoints(&self) -> Vec { + let mut endpoints = Vec::new(); + for resource in self.registry.manifest().resources() { + if !resource.supports(HwCapability::GpioInput) { + continue; + } + if !has_board_assigned_label(resource.address(), resource.display_label()) { + continue; + } + let address = resource.address().clone(); + let spec = button_gpio_spec(resource.display_label()); + endpoints.push(HwEndpoint::new( + Self::endpoint_id(&address), + spec, + HwEndpointKind::Button, + DRIVER_ID, + address, + resource.display_label(), + self.registry.endpoint_status_for(resource.address()), + )); + } + endpoints } fn open( &self, - endpoint_id: &HardwareEndpointId, + endpoint_id: &HwEndpointId, config: ButtonConfig, ) -> Result, HardwareEndpointError> { - if endpoint_id != &Self::endpoint_id() { - return Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Button, - endpoint_id: endpoint_id.clone(), - }); - } - - let source = Self::source(); + let source = self.gpio_for_endpoint(endpoint_id)?; + let gpio = gpio_number(&source)?; self.registry - .ensure_capability(&source, HardwareCapability::GpioInput)?; + .ensure_capability(&source, HwCapability::GpioInput)?; let lease = self .registry - .claim_bundle(HardwareClaim::new(DRIVER_ID, vec![source.clone()]))?; - - let Some(input) = self.input.borrow_mut().take() else { - self.registry.release(&lease)?; - return Err(HardwareEndpointError::EndpointUnavailable { - endpoint_id: endpoint_id.clone(), - reason: String::from("GPIO20 is already open"), - }); - }; + .claim_bundle(HwClaim::new(DRIVER_ID, vec![source.clone()]))?; + + let input = Input::new( + // Board init drops the concrete HAL GPIO token after startup. The hardware registry + // owns logical exclusivity, so the driver recreates the erased pin after claiming it. + unsafe { AnyPin::steal(gpio) }, + InputConfig::default().with_pull(Pull::Up), + ); - Ok(Box::new(Esp32ButtonInput::new_gpio20( + Ok(Box::new(Esp32ButtonInput::new( Rc::clone(&self.registry), - Rc::clone(&self.input), source, lease, input, @@ -106,19 +114,17 @@ impl ButtonDriver for Esp32Gpio20ButtonDriver { } pub struct Esp32ButtonInput { - registry: Rc, - source: HardwareAddress, - input_home: Rc>>>, + registry: Rc, + source: HwAddress, lease: Option, input: Option>, debouncer: ButtonDebouncer, } impl Esp32ButtonInput { - fn new_gpio20( - registry: Rc, - input_home: Rc>>>, - source: HardwareAddress, + fn new( + registry: Rc, + source: HwAddress, lease: HardwareLease, input: Input<'static>, config: ButtonConfig, @@ -126,28 +132,25 @@ impl Esp32ButtonInput { Self { registry, source: source.clone(), - input_home, lease: Some(lease), input: Some(input), debouncer: ButtonDebouncer::new(source, config.stable_ms()), } } - pub fn close(&mut self) -> Result<(), HardwareError> { + pub fn close(&mut self) -> Result<(), HwError> { let release_result = if let Some(lease) = self.lease.take() { self.registry.release(&lease) } else { Ok(()) }; - if let Some(input) = self.input.take() { - *self.input_home.borrow_mut() = Some(input); - } + let _ = self.input.take(); release_result } } impl ButtonInput for Esp32ButtonInput { - fn source(&self) -> &HardwareAddress { + fn source(&self) -> &HwAddress { &self.source } @@ -162,3 +165,34 @@ impl Drop for Esp32ButtonInput { let _ = self.close(); } } + +fn gpio_number(address: &HwAddress) -> Result { + let Some(raw) = address.as_str().strip_prefix("/gpio/") else { + return Err(HardwareEndpointError::UnsupportedConfig { + reason: format!("button endpoint address is not a GPIO: {address}"), + }); + }; + let gpio = raw + .parse::() + .map_err(|_| HardwareEndpointError::UnsupportedConfig { + reason: format!("invalid ESP32 GPIO address: {address}"), + })?; + if gpio > MAX_ESP32C6_GPIO { + return Err(HardwareEndpointError::UnsupportedConfig { + reason: format!("ESP32-C6 GPIO {gpio} is outside the supported range"), + }); + } + Ok(gpio) +} + +fn button_gpio_spec(config: &str) -> HwEndpointSpec { + HwEndpointSpec::parse(alloc::format!("button:gpio:{config}")) + .expect("manifest display label should form a valid endpoint spec") +} + +fn has_board_assigned_label(address: &HwAddress, display_label: &str) -> bool { + let Ok(gpio) = gpio_number(address) else { + return false; + }; + !display_label.eq_ignore_ascii_case(&format!("GPIO{gpio}")) +} diff --git a/lp-fw/fw-esp32/src/hardware/espnow_radio_driver.rs b/lp-fw/fw-esp32/src/hardware/espnow_radio_driver.rs index 9335d4934..29fa9195f 100644 --- a/lp-fw/fw-esp32/src/hardware/espnow_radio_driver.rs +++ b/lp-fw/fw-esp32/src/hardware/espnow_radio_driver.rs @@ -3,23 +3,26 @@ extern crate alloc; use alloc::boxed::Box; -use alloc::collections::{BTreeMap, BTreeSet, VecDeque}; +use alloc::collections::VecDeque; use alloc::format; use alloc::rc::Rc; use alloc::vec; use alloc::vec::Vec; use core::cell::RefCell; +use lp_collection::{VecMap, VecSet}; use esp_hal::efuse::{InterfaceMacAddress, interface_mac_address}; use esp_hal::peripherals::WIFI; -use esp_radio::esp_now::{BROADCAST_ADDRESS, EspNow, EspNowError, ReceivedData}; +use esp_radio::esp_now::{ + BROADCAST_ADDRESS, EspNow, EspNowError, EspNowManager, EspNowReceiver, EspNowSender, + ReceivedData, +}; use esp_radio::wifi::{ControllerConfig, WifiController}; -use lpc_shared::hardware::{ - HardwareAddress, HardwareCapability, HardwareClaim, HardwareDriver, HardwareEndpoint, - HardwareEndpointError, HardwareEndpointId, HardwareEndpointKind, HardwareEndpointSpec, - HardwareEndpointStatus, HardwareLease, HardwareRegistry, RADIO_MAX_PACKET_LEN, RadioChannelId, - RadioConfig, RadioDevice, RadioDeviceId, RadioDrainReport, RadioDriver, RadioEventId, - RadioMessage, RadioMessageKind, +use lpc_hardware::{ + HardwareEndpointError, HardwareLease, HwAddress, HwCapability, HwClaim, HwDriver, HwEndpoint, + HwEndpointId, HwEndpointKind, HwEndpointSpec, HwEndpointStatus, HwRegistry, + RADIO_MAX_PACKET_LEN, RadioChannelId, RadioConfig, RadioDevice, RadioDeviceId, + RadioDrainReport, RadioDriver, RadioEventId, RadioMessage, RadioMessageKind, }; const DRIVER_ID: &str = "esp32-espnow-radio0"; @@ -30,24 +33,29 @@ const RADIO_QUEUE_CAPACITY: usize = 16; const SEEN_RING_LEN: usize = 32; pub struct Esp32EspNowRadioDriver { - registry: Rc, - _controller: &'static WifiController<'static>, - esp_now_slot: Rc>>>, - address: HardwareAddress, + registry: Rc, + // Keep the controller alive: dropping it deinitializes Wi-Fi/ESP-NOW and + // invalidates the `'static` interface handed to the open device. + _controller: WifiController<'static>, + // The single ESP-NOW interface, taken (and `split` into sender/receiver) the + // first time the endpoint is opened. The hardware registry lease enforces + // exclusive access; this only exists because `split` consumes the interface. + esp_now: RefCell>>, + address: HwAddress, device_id: RadioDeviceId, default_channel: u8, } impl Esp32EspNowRadioDriver { pub fn new( - registry: Rc, + registry: Rc, wifi: WIFI<'static>, ) -> Result { Self::with_channel(registry, wifi, DEFAULT_ESPNOW_CHANNEL) } pub fn with_channel( - registry: Rc, + registry: Rc, wifi: WIFI<'static>, default_channel: u8, ) -> Result { @@ -56,13 +64,12 @@ impl Esp32EspNowRadioDriver { .map_err(|error| HardwareEndpointError::Other { message: format!("ESP-NOW Wi-Fi init failed: {error:?}"), })?; - let controller = Box::leak(Box::new(controller)); Ok(Self { registry, _controller: controller, - esp_now_slot: Rc::new(RefCell::new(Some(interfaces.esp_now))), - address: HardwareAddress::radio(0), + esp_now: RefCell::new(Some(interfaces.esp_now)), + address: HwAddress::radio(0), device_id: station_device_id(), default_channel, }) @@ -76,16 +83,16 @@ impl Esp32EspNowRadioDriver { self.default_channel } - fn endpoint_id(&self) -> HardwareEndpointId { - HardwareEndpointId::for_driver_spec(self.driver_id(), &endpoint_spec()) + fn endpoint_id(&self) -> HwEndpointId { + HwEndpointId::for_driver_spec(self.driver_id(), &endpoint_spec()) } - fn endpoint_status(&self) -> HardwareEndpointStatus { + fn endpoint_status(&self) -> HwEndpointStatus { self.registry.endpoint_status_for(&self.address) } } -impl HardwareDriver for Esp32EspNowRadioDriver { +impl HwDriver for Esp32EspNowRadioDriver { fn driver_id(&self) -> &str { DRIVER_ID } @@ -96,18 +103,18 @@ impl HardwareDriver for Esp32EspNowRadioDriver { } impl RadioDriver for Esp32EspNowRadioDriver { - fn endpoints(&self) -> Vec { + fn endpoints(&self) -> Vec { let Some(resource) = self.registry.manifest().resource(&self.address) else { return Vec::new(); }; - if !resource.supports(HardwareCapability::Radio) { + if !resource.supports(HwCapability::Radio) { return Vec::new(); } - vec![HardwareEndpoint::new( + vec![HwEndpoint::new( self.endpoint_id(), endpoint_spec(), - HardwareEndpointKind::Radio, + HwEndpointKind::Radio, self.driver_id(), self.address.clone(), resource.display_label(), @@ -117,12 +124,12 @@ impl RadioDriver for Esp32EspNowRadioDriver { fn open( &self, - endpoint_id: &HardwareEndpointId, + endpoint_id: &HwEndpointId, config: RadioConfig, ) -> Result, HardwareEndpointError> { if endpoint_id != &self.endpoint_id() { return Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Radio, + kind: HwEndpointKind::Radio, endpoint_id: endpoint_id.clone(), }); } @@ -132,7 +139,7 @@ impl RadioDriver for Esp32EspNowRadioDriver { let endpoint = self.endpoints().into_iter().next().ok_or_else(|| { HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Radio, + kind: HwEndpointKind::Radio, endpoint_id: endpoint_id.clone(), } })?; @@ -148,13 +155,12 @@ impl RadioDriver for Esp32EspNowRadioDriver { } self.registry - .ensure_capability(&self.address, HardwareCapability::Radio)?; - let lease = self.registry.claim_bundle(HardwareClaim::new( - self.driver_id(), - vec![self.address.clone()], - ))?; + .ensure_capability(&self.address, HwCapability::Radio)?; + let lease = self + .registry + .claim_bundle(HwClaim::new(self.driver_id(), vec![self.address.clone()]))?; - let Some(esp_now) = self.esp_now_slot.borrow_mut().take() else { + let Some(esp_now) = self.esp_now.borrow_mut().take() else { let _ = self.registry.release(&lease); return Err(HardwareEndpointError::EndpointUnavailable { endpoint_id: endpoint_id.clone(), @@ -162,7 +168,7 @@ impl RadioDriver for Esp32EspNowRadioDriver { }); }; if let Err(error) = esp_now.set_channel(channel) { - *self.esp_now_slot.borrow_mut() = Some(esp_now); + *self.esp_now.borrow_mut() = Some(esp_now); let _ = self.registry.release(&lease); return Err(map_esp_now_error("set channel", error)); } @@ -174,45 +180,50 @@ impl RadioDriver for Esp32EspNowRadioDriver { log::warn!("[fw-esp32] ESP-NOW version query failed: {error:?}"); } } + let (manager, sender, receiver) = esp_now.split(); Ok(Box::new(Esp32EspNowRadioDevice::new( Rc::clone(&self.registry), - Rc::clone(&self.esp_now_slot), lease, - esp_now, + manager, + sender, + receiver, self.device_id, ))) } } struct Esp32EspNowRadioDevice { - registry: Rc, - esp_now_slot: Rc>>>, + registry: Rc, lease: Option, - esp_now: Option>, + _manager: EspNowManager<'static>, + sender: EspNowSender<'static>, + receiver: EspNowReceiver<'static>, device_id: RadioDeviceId, - subscriptions: BTreeSet, - queues: BTreeMap, + subscriptions: VecSet, + queues: VecMap, next_event_id: u32, seen: SeenRing, } impl Esp32EspNowRadioDevice { fn new( - registry: Rc, - esp_now_slot: Rc>>>, + registry: Rc, lease: HardwareLease, - esp_now: EspNow<'static>, + manager: EspNowManager<'static>, + sender: EspNowSender<'static>, + receiver: EspNowReceiver<'static>, device_id: RadioDeviceId, ) -> Self { Self { registry, - esp_now_slot, lease: Some(lease), - esp_now: Some(esp_now), + _manager: manager, + sender, + receiver, device_id, - subscriptions: BTreeSet::new(), - queues: BTreeMap::new(), + subscriptions: VecSet::new(), + queues: VecMap::new(), next_event_id: 0, seen: SeenRing::new(), } @@ -225,7 +236,7 @@ impl Esp32EspNowRadioDevice { } fn pull_received(&mut self) { - while let Some(received) = self.esp_now.as_mut().and_then(|esp_now| esp_now.receive()) { + while let Some(received) = self.receiver.receive() { self.process_received(received); } } @@ -264,14 +275,6 @@ impl Esp32EspNowRadioDevice { } fn close(&mut self) { - if let Some(esp_now) = self.esp_now.take() { - let mut esp_now_slot = self.esp_now_slot.borrow_mut(); - if esp_now_slot.is_none() { - *esp_now_slot = Some(esp_now); - } else { - log::warn!("Esp32EspNowRadioDevice: ESP-NOW interface was already returned"); - } - } if let Some(lease) = self.lease.take() { if let Err(error) = self.registry.release(&lease) { log::warn!("Esp32EspNowRadioDevice: failed to release hardware lease: {error}"); @@ -310,13 +313,7 @@ impl RadioDevice for Esp32EspNowRadioDevice { )?; let mut packet = [0u8; RADIO_MAX_PACKET_LEN]; let len = message.encode(&mut packet); - let Some(esp_now) = self.esp_now.as_mut() else { - return Err(HardwareEndpointError::EndpointUnavailable { - endpoint_id: HardwareEndpointId::for_driver_spec(DRIVER_ID, &endpoint_spec()), - reason: "ESP-NOW interface is closed".into(), - }); - }; - esp_now + self.sender .send(&BROADCAST_ADDRESS, &packet[..len]) .map_err(|error| map_esp_now_error("send", error))? .wait() @@ -441,8 +438,8 @@ fn station_device_id() -> RadioDeviceId { RadioDeviceId::new(u32::from_le_bytes([bytes[2], bytes[3], bytes[4], bytes[5]])) } -fn endpoint_spec() -> HardwareEndpointSpec { - HardwareEndpointSpec::from_static(ENDPOINT_SPEC) +fn endpoint_spec() -> HwEndpointSpec { + HwEndpointSpec::from_static(ENDPOINT_SPEC) } fn validate_channel(channel: u8) -> Result<(), HardwareEndpointError> { diff --git a/lp-fw/fw-esp32/src/hardware/manifest_loader.rs b/lp-fw/fw-esp32/src/hardware/manifest_loader.rs index 88e4c0ceb..bc1b9aa57 100644 --- a/lp-fw/fw-esp32/src/hardware/manifest_loader.rs +++ b/lp-fw/fw-esp32/src/hardware/manifest_loader.rs @@ -3,15 +3,13 @@ extern crate alloc; use alloc::string::{String, ToString}; use core::str; -use lpc_shared::hardware::{ - HardwareManifest, HardwareManifestFile, default_esp32c6_hardware_manifest, -}; +use lpc_hardware::{HardwareManifestFile, HwManifest, default_esp32c6_hardware_manifest}; use lpfs::LpFs; use lpfs::lp_path::AsLpPath; const HARDWARE_MANIFEST_PATH: &str = "/hardware.toml"; -pub fn load_hardware_manifest(fs: &dyn LpFs) -> HardwareManifest { +pub fn load_hardware_manifest(fs: &dyn LpFs) -> HwManifest { match fs.read_file(HARDWARE_MANIFEST_PATH.as_path()) { Ok(bytes) => parse_override(&bytes).unwrap_or_else(|message| { log::warn!( @@ -29,7 +27,7 @@ pub fn load_hardware_manifest(fs: &dyn LpFs) -> HardwareManifest { } } -fn parse_override(bytes: &[u8]) -> Result { +fn parse_override(bytes: &[u8]) -> Result { let text = str::from_utf8(bytes).map_err(|error| error.to_string())?; HardwareManifestFile::read_toml(text) .and_then(|manifest| manifest.to_manifest()) diff --git a/lp-fw/fw-esp32/src/lp_fs_flash.rs b/lp-fw/fw-esp32/src/lp_fs_flash.rs index ed34380d8..0f7e11ffa 100644 --- a/lp-fw/fw-esp32/src/lp_fs_flash.rs +++ b/lp-fw/fw-esp32/src/lp_fs_flash.rs @@ -8,7 +8,7 @@ use core::cell::RefCell; use hashbrown::HashMap; use lpfs::lp_path::{LpPath, LpPathBuf}; -use lpfs::{ChangeType, FsChange, FsError, FsVersion, LpFs, LpFsMemory, LpFsView}; +use lpfs::{FsError, FsEvent, FsEventKind, FsVersion, LpFs, LpFsMemory, LpFsView}; use crate::flash_storage::{LpFlashStorage, lpfs_config}; use littlefs_rust::{Error as LfsError, FileType as LfsFileType, Filesystem, OpenFlags}; @@ -23,7 +23,7 @@ pub struct LpFsFlash { struct LpFsFlashInner { fs: Filesystem, current_version: FsVersion, - changes: HashMap, + changes: HashMap, } impl LpFsFlash { @@ -67,13 +67,11 @@ impl LpFsFlash { } } - fn record_change(&self, path: &LpPath, change_type: ChangeType) { + fn record_change(&self, path: &LpPath, kind: FsEventKind) { let mut inner = self.inner.borrow_mut(); inner.current_version = inner.current_version.next(); let version = inner.current_version; - inner - .changes - .insert(path.to_path_buf(), (version, change_type)); + inner.changes.insert(path.to_path_buf(), (version, kind)); } /// Convert LpPath to littlefs path (strip leading /) @@ -224,12 +222,12 @@ impl LpFs for LpFsFlash { .write_file(lfs_path, data) .map_err(|e| FsError::Filesystem(format!("write {}: {e}", path.as_str())))?; drop(inner); - let change_type = if existed { - ChangeType::Modify + let kind = if existed { + FsEventKind::Modify } else { - ChangeType::Create + FsEventKind::Create }; - self.record_change(path, change_type); + self.record_change(path, kind); Ok(()) } @@ -362,7 +360,7 @@ impl LpFs for LpFsFlash { } })?; drop(inner); - self.record_change(path, ChangeType::Delete); + self.record_change(path, FsEventKind::Delete); Ok(()) } @@ -376,7 +374,7 @@ impl LpFs for LpFsFlash { } let lfs_path = Self::to_lfs_path(path); self.delete_dir_recursive(lfs_path)?; - self.record_change(path, ChangeType::Delete); + self.record_change(path, FsEventKind::Delete); Ok(()) } @@ -405,16 +403,16 @@ impl LpFs for LpFsFlash { self.inner.borrow().current_version } - fn get_changes_since(&self, since_version: FsVersion) -> Vec { + fn get_changes_since(&self, since_version: FsVersion) -> Vec { self.inner .borrow() .changes .iter() - .filter_map(|(path, (version, change_type))| { + .filter_map(|(path, (version, kind))| { if *version >= since_version { - Some(FsChange { + Some(FsEvent { path: path.clone(), - change_type: *change_type, + kind: *kind, }) } else { None @@ -430,9 +428,9 @@ impl LpFs for LpFsFlash { .retain(|_, (version, _)| *version >= before_version); } - fn record_changes(&mut self, changes: Vec) { + fn record_changes(&mut self, changes: Vec) { for change in changes { - self.record_change(change.path.as_path(), change.change_type); + self.record_change(change.path.as_path(), change.kind); } } } diff --git a/lp-fw/fw-esp32/src/main.rs b/lp-fw/fw-esp32/src/main.rs index c77c197e9..d69ce4dec 100644 --- a/lp-fw/fw-esp32/src/main.rs +++ b/lp-fw/fw-esp32/src/main.rs @@ -361,10 +361,10 @@ use { alloc::{boxed::Box, rc::Rc, sync::Arc}, board::esp32c6::init::{init_board, start_runtime}, core::cell::RefCell, - hardware::button::Esp32Gpio20ButtonDriver, + hardware::button::Esp32GpioButtonDriver, hardware::manifest_loader::load_hardware_manifest, lpa_server::{ButtonService, Graphics, LpGraphics, LpServer}, - lpc_shared::hardware::{HardwareRegistry, HardwareSystem}, + lpc_hardware::{HardwareSystem, HwRegistry}, lpc_shared::output::OutputProvider, lpfs::LpFsMemory, output::{Esp32OutputProvider, Esp32RmtWs281xDriver}, @@ -500,7 +500,7 @@ fn boot_firmware(spawner: embassy_executor::Spawner) -> FirmwareApp { // Initialize board (clock, heap, runtime) and get hardware peripherals esp_println::println!("[INIT] Initializing board..."); - let (sw_int, timg0, rmt_peripheral, usb_device, gpio18, flash, _gpio4, gpio20, wifi) = + let (sw_int, timg0, rmt_peripheral, usb_device, _gpio18, flash, _gpio4, _gpio20, wifi) = init_board(); esp_println::println!("[INIT] Board initialized, starting runtime..."); start_runtime(timg0, sw_int); @@ -594,15 +594,15 @@ fn boot_firmware(spawner: embassy_executor::Spawner) -> FirmwareApp { hardware_manifest.board_id(), hardware_manifest.board_name() ); - let hardware_registry = Rc::new(HardwareRegistry::new(hardware_manifest)); + let hardware_registry = Rc::new(HwRegistry::new(hardware_manifest)); let mut hardware_system = HardwareSystem::new(Rc::clone(&hardware_registry)); - hardware_system.add_ws281x_driver(Box::new(Esp32RmtWs281xDriver::new(Rc::clone( - &hardware_registry, - )))); - hardware_system.add_button_driver(Box::new(Esp32Gpio20ButtonDriver::new( + hardware_system.add_ws281x_driver(Box::new(Esp32RmtWs281xDriver::new( Rc::clone(&hardware_registry), - gpio20, + rmt, ))); + hardware_system.add_button_driver(Box::new(Esp32GpioButtonDriver::new(Rc::clone( + &hardware_registry, + )))); #[cfg(feature = "radio")] { let radio_driver = Esp32EspNowRadioDriver::new(Rc::clone(&hardware_registry), wifi) @@ -622,16 +622,6 @@ fn boot_firmware(spawner: embassy_executor::Spawner) -> FirmwareApp { esp_println::println!("[INIT] Creating output provider..."); let output_provider = Esp32OutputProvider::new(Rc::clone(&hardware_system)); - // Initialize RMT channel with GPIO18 (hardcoded for now) - // Use 256 LEDs as a reasonable default (will work for demo project which has 241 LEDs) - const NUM_LEDS: usize = 256; - esp_println::println!( - "[INIT] Initializing RMT channel with GPIO18, {} LEDs...", - NUM_LEDS - ); - Esp32OutputProvider::init_rmt(rmt, gpio18, NUM_LEDS).expect("Failed to initialize RMT channel"); - esp_println::println!("[INIT] RMT channel initialized"); - let output_provider: Rc> = Rc::new(RefCell::new(output_provider)); esp_println::println!("[INIT] Output provider created"); diff --git a/lp-fw/fw-esp32/src/output/provider.rs b/lp-fw/fw-esp32/src/output/provider.rs index 74354c68a..5877141c2 100644 --- a/lp-fw/fw-esp32/src/output/provider.rs +++ b/lp-fw/fw-esp32/src/output/provider.rs @@ -6,31 +6,34 @@ extern crate alloc; use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::format; use alloc::rc::Rc; use alloc::string::ToString; +use alloc::vec::Vec; use core::cell::RefCell; +use lp_collection::VecMap; -use esp_hal::Blocking; -use esp_hal::gpio::interconnect::PeripheralOutput; -use esp_hal::rmt::{ConfigError as RmtConfigError, Rmt}; -use lpc_shared::OutputError; -use lpc_shared::hardware::{ - HardwareEndpointError, HardwareEndpointSpec, HardwareSystem, Ws281xConfig, Ws281xOutput, +use lpc_hardware::OutputError; +use lpc_hardware::{ + HardwareEndpointError, HardwareSystem, HwEndpointSpec, Ws281xConfig, Ws281xOutput, }; +use lpc_shared::DisplayPipeline; use lpc_shared::output::{OutputChannelHandle, OutputDriverOptions, OutputFormat, OutputProvider}; -use crate::output::Esp32RmtWs281xDriver; +const MAX_LEDS: usize = 256; +const FRAME_INTERVAL_US: u64 = 16_667; +const MID_FRAME_US: u64 = 8_333; struct ChannelState { output: Box, + byte_count: u32, + pipeline: DisplayPipeline, } /// ESP32 OutputProvider implementation. pub struct Esp32OutputProvider { hardware_system: Rc, - channels: RefCell>, + channels: RefCell>, next_handle: RefCell, } @@ -38,27 +41,16 @@ impl Esp32OutputProvider { pub fn new(hardware_system: Rc) -> Self { Self { hardware_system, - channels: RefCell::new(BTreeMap::new()), + channels: RefCell::new(VecMap::new()), next_handle: RefCell::new(1), } } - - pub fn init_rmt( - rmt: Rmt<'static, Blocking>, - pin: O, - num_leds: usize, - ) -> Result<(), RmtConfigError> - where - O: PeripheralOutput<'static>, - { - Esp32RmtWs281xDriver::init_rmt(rmt, pin, num_leds) - } } impl OutputProvider for Esp32OutputProvider { fn open( &self, - endpoint: &HardwareEndpointSpec, + endpoint: &HwEndpointSpec, byte_count: u32, format: OutputFormat, options: Option, @@ -81,13 +73,16 @@ impl OutputProvider for Esp32OutputProvider { }); } + let byte_count = capped_byte_count(byte_count); let output = self .hardware_system - .open_ws281x_by_spec( - endpoint, - Ws281xConfig::new(byte_count, Some(options.clone())), - ) + .open_ws281x_by_spec(endpoint, Ws281xConfig::new(byte_count)) .map_err(endpoint_error_to_output_error)?; + let pipeline = DisplayPipeline::new(byte_count / 3, options.clone()).map_err(|error| { + OutputError::InvalidConfig { + reason: format!("DisplayPipeline allocation failed: {error}"), + } + })?; let handle_id = *self.next_handle.borrow(); *self.next_handle.borrow_mut() += 1; @@ -97,9 +92,14 @@ impl OutputProvider for Esp32OutputProvider { "Esp32OutputProvider::open: Opened channel handle={handle_id}, endpoint={endpoint}, byte_count={byte_count}" ); - self.channels - .borrow_mut() - .insert(handle_id, ChannelState { output }); + self.channels.borrow_mut().insert( + handle_id, + ChannelState { + output, + byte_count, + pipeline, + }, + ); Ok(handle) } @@ -118,7 +118,30 @@ impl OutputProvider for Esp32OutputProvider { OutputError::InvalidHandle { handle: handle_id } })?; - channel.output.write(data) + let mut num_leds = (channel.byte_count / 3) as usize; + let expected_len = num_leds * 3; + + if data.len() > expected_len { + let new_byte_count = capped_byte_count_for_len(data.len()); + channel.output.resize(Ws281xConfig::new(new_byte_count))?; + channel.pipeline.resize(new_byte_count / 3); + channel.byte_count = new_byte_count; + num_leds = (channel.byte_count / 3) as usize; + } else if data.len() < expected_len { + return Err(OutputError::DataLengthMismatch { + expected: expected_len as u32, + actual: data.len(), + }); + } + + let mut rmt_buffer = Vec::with_capacity(num_leds * 3); + rmt_buffer.resize(num_leds * 3, 0); + + channel.pipeline.write_frame(0, data); + channel.pipeline.write_frame(FRAME_INTERVAL_US, data); + channel.pipeline.tick(MID_FRAME_US, &mut rmt_buffer); + + channel.output.write(&rmt_buffer) } fn close(&self, handle: OutputChannelHandle) -> Result<(), OutputError> { @@ -131,6 +154,15 @@ impl OutputProvider for Esp32OutputProvider { } } +fn capped_byte_count_for_len(data_len: usize) -> u32 { + capped_byte_count(((data_len / 3) * 3) as u32) +} + +fn capped_byte_count(byte_count: u32) -> u32 { + let max_byte_count = (MAX_LEDS * 3) as u32; + byte_count.min(max_byte_count) +} + fn endpoint_error_to_output_error(error: HardwareEndpointError) -> OutputError { match error { HardwareEndpointError::Hardware { error } => OutputError::Hardware { error }, diff --git a/lp-fw/fw-esp32/src/output/rmt/channel.rs b/lp-fw/fw-esp32/src/output/rmt/channel.rs index aa036607e..a4c77da09 100644 --- a/lp-fw/fw-esp32/src/output/rmt/channel.rs +++ b/lp-fw/fw-esp32/src/output/rmt/channel.rs @@ -89,6 +89,8 @@ impl<'ch> LedChannel<'ch> { where O: PeripheralOutput<'ch>, { + log::info!("LedChannel::new: configuring RMT channel{RMT_CH_IDX} for {num_leds} LEDs"); + // Set up interrupt handler (only needs to be done once, but safe to call multiple times) // TODO: Use a static flag to only set up once let handler = InterruptHandler::new(rmt_interrupt_handler, Priority::max()); diff --git a/lp-fw/fw-esp32/src/output/rmt_ws281x_driver.rs b/lp-fw/fw-esp32/src/output/rmt_ws281x_driver.rs index 45170ea83..1f3f1f657 100644 --- a/lp-fw/fw-esp32/src/output/rmt_ws281x_driver.rs +++ b/lp-fw/fw-esp32/src/output/rmt_ws281x_driver.rs @@ -8,101 +8,150 @@ use alloc::rc::Rc; use alloc::string::ToString; use alloc::vec; use alloc::vec::Vec; +use core::cell::RefCell; use esp_hal::Blocking; -use esp_hal::gpio::interconnect::PeripheralOutput; -use esp_hal::rmt::{ConfigError as RmtConfigError, Rmt}; -use lpc_shared::hardware::{ - HardwareAddress, HardwareCapability, HardwareClaim, HardwareDriver, HardwareEndpoint, - HardwareEndpointError, HardwareEndpointId, HardwareEndpointKind, HardwareEndpointSpec, - HardwareEndpointStatus, HardwareLease, HardwareRegistry, Ws281xConfig, Ws281xDriver, - Ws281xOutput, +use esp_hal::gpio::AnyPin; +use esp_hal::rmt::Rmt; +use lpc_hardware::{ + HardwareEndpointError, HardwareLease, HwAddress, HwCapability, HwClaim, HwDriver, HwEndpoint, + HwEndpointId, HwEndpointKind, HwEndpointSpec, HwEndpointStatus, HwRegistry, OutputError, + Ws281xConfig, Ws281xDriver, Ws281xOutput, }; -use lpc_shared::output::OutputDriverOptions; -use lpc_shared::{DisplayPipeline, OutputError}; use crate::output::{LedChannel, LedTransaction}; const DRIVER_ID: &str = "esp32-rmt-ws281x0"; const DISPLAY_LABEL: &str = "ESP32 RMT WS281x 0"; -const OUTPUT_GPIO: u32 = 18; const MAX_LEDS: usize = 256; -const ENDPOINT_SPEC: &str = "ws281x:rmt:D10"; +const MAX_ESP32C6_GPIO: u8 = 30; -// Unsafe static to store the currently initialized GPIO18-backed LED channel. +// Unsafe static to store the currently initialized LED channel. // This is needed because LedChannel has lifetime constraints that do not fit the // trait object owned by the root hardware system. static mut LED_CHANNEL: Option> = None; +static mut LED_GPIO: Option = None; static mut CURRENT_TRANSACTION: Option> = None; pub struct Esp32RmtWs281xDriver { - registry: Rc, - gpio_address: HardwareAddress, - timing_address: HardwareAddress, + registry: Rc, + timing_address: HwAddress, + rmt: Rc>>>, } impl Esp32RmtWs281xDriver { - pub fn new(registry: Rc) -> Self { + pub fn new(registry: Rc, rmt: Rmt<'static, Blocking>) -> Self { Self { registry, - gpio_address: HardwareAddress::gpio(OUTPUT_GPIO), - timing_address: HardwareAddress::rmt_ws281x(0), + timing_address: HwAddress::rmt_ws281x(0), + rmt: Rc::new(RefCell::new(Some(rmt))), } } - pub fn init_rmt( - rmt: Rmt<'static, Blocking>, - pin: O, - num_leds: usize, - ) -> Result<(), RmtConfigError> - where - O: PeripheralOutput<'static>, - { - unsafe { - let channel_ptr = core::ptr::addr_of_mut!(LED_CHANNEL); - if (*channel_ptr).is_some() { - return Ok(()); - } - let channel = LedChannel::new(rmt, pin, num_leds)?; - (*channel_ptr) = Some(core::mem::transmute(channel)); - } - Ok(()) - } - - fn endpoint_id(&self) -> HardwareEndpointId { - HardwareEndpointId::for_driver_spec(self.driver_id(), &endpoint_spec()) + fn endpoint_id(&self, spec: &HwEndpointSpec) -> HwEndpointId { + HwEndpointId::for_driver_spec(self.driver_id(), spec) } - fn endpoint_status(&self) -> HardwareEndpointStatus { - let gpio_status = self.registry.endpoint_status_for(&self.gpio_address); + fn endpoint_status(&self, gpio_address: &HwAddress) -> HwEndpointStatus { + let gpio_status = self.registry.endpoint_status_for(gpio_address); if !gpio_status.is_available() { return gpio_status; } match self.registry.endpoint_status_for(&self.timing_address) { - HardwareEndpointStatus::Available => { - if rmt_channel_is_initialized() { - HardwareEndpointStatus::Available + HwEndpointStatus::Available => { + if rmt_channel_is_available_for(gpio_address) { + HwEndpointStatus::Available } else { - HardwareEndpointStatus::Unavailable { - reason: "RMT channel is not initialized".into(), + HwEndpointStatus::Unavailable { + reason: "RMT channel is already initialized for another GPIO".into(), } } } - HardwareEndpointStatus::Reserved { reason } => HardwareEndpointStatus::Unavailable { + HwEndpointStatus::Reserved { reason } => HwEndpointStatus::Unavailable { reason: format!("RMT timing resource is reserved: {reason}"), }, - HardwareEndpointStatus::InUse { claimant } => HardwareEndpointStatus::Unavailable { + HwEndpointStatus::InUse { claimant } => HwEndpointStatus::Unavailable { reason: format!("RMT timing resource is in use by {claimant}"), }, - HardwareEndpointStatus::Unavailable { reason } => { - HardwareEndpointStatus::Unavailable { reason } + HwEndpointStatus::Unavailable { reason } => HwEndpointStatus::Unavailable { reason }, + } + } + + fn gpio_for_endpoint( + &self, + endpoint_id: &HwEndpointId, + ) -> Result { + for endpoint in self.endpoints() { + if endpoint.id() == endpoint_id { + return Ok(endpoint.address().clone()); + } + } + + Err(HardwareEndpointError::UnknownEndpoint { + kind: HwEndpointKind::Ws281x, + endpoint_id: endpoint_id.clone(), + }) + } + + fn ensure_rmt_initialized( + &self, + gpio_address: &HwAddress, + num_leds: usize, + ) -> Result<(), HardwareEndpointError> { + let gpio = u32::from(gpio_number(gpio_address)?); + unsafe { + let channel_ptr = core::ptr::addr_of_mut!(LED_CHANNEL); + let gpio_ptr = core::ptr::addr_of_mut!(LED_GPIO); + if (*channel_ptr).is_some() { + if (*gpio_ptr) == Some(gpio) { + log::info!( + "ensure_rmt_initialized: reusing existing RMT channel on GPIO{gpio} ({num_leds} LEDs)" + ); + return Ok(()); + } + log::warn!( + "ensure_rmt_initialized: RMT channel already bound to GPIO{:?}, cannot reinit on GPIO{gpio}", + *gpio_ptr + ); + return Err(HardwareEndpointError::EndpointUnavailable { + endpoint_id: HwEndpointId::new(gpio_address.as_str()), + reason: "RMT channel is already initialized for another GPIO".into(), + }); } + + let Some(rmt) = self.rmt.borrow_mut().take() else { + log::warn!( + "ensure_rmt_initialized: RMT peripheral already taken; cannot init GPIO{gpio}" + ); + return Err(HardwareEndpointError::EndpointUnavailable { + endpoint_id: HwEndpointId::new(gpio_address.as_str()), + reason: "RMT peripheral is already in use".into(), + }); + }; + log::info!( + "ensure_rmt_initialized: initializing RMT WS281x channel on GPIO{gpio} ({num_leds} LEDs)" + ); + // Board init drops the concrete HAL GPIO token after startup. The hardware registry + // owns logical exclusivity, so the driver recreates the erased pin after claiming it. + let pin = AnyPin::steal(gpio as u8); + let channel = LedChannel::new(rmt, pin, num_leds).map_err(|error| { + log::error!( + "ensure_rmt_initialized: LedChannel::new failed on GPIO{gpio}: {error:?}" + ); + HardwareEndpointError::Other { + message: format!("RMT channel init failed: {error:?}"), + } + })?; + (*channel_ptr) = Some(core::mem::transmute(channel)); + (*gpio_ptr) = Some(gpio); + log::info!("ensure_rmt_initialized: RMT WS281x channel ready on GPIO{gpio}"); } + Ok(()) } } -impl HardwareDriver for Esp32RmtWs281xDriver { +impl HwDriver for Esp32RmtWs281xDriver { fn driver_id(&self) -> &str { DRIVER_ID } @@ -113,53 +162,62 @@ impl HardwareDriver for Esp32RmtWs281xDriver { } impl Ws281xDriver for Esp32RmtWs281xDriver { - fn endpoints(&self) -> Vec { - let Some(resource) = self.registry.manifest().resource(&self.gpio_address) else { - return Vec::new(); - }; - if !resource.supports(HardwareCapability::GpioOutput) - || self - .registry - .ensure_capability(&self.timing_address, HardwareCapability::Rmt) - .is_err() + fn endpoints(&self) -> Vec { + if self + .registry + .ensure_capability(&self.timing_address, HwCapability::Rmt) + .is_err() || self .registry - .ensure_capability(&self.timing_address, HardwareCapability::Ws281xOutput) + .ensure_capability(&self.timing_address, HwCapability::Ws281xOutput) .is_err() { return Vec::new(); } - vec![HardwareEndpoint::new( - self.endpoint_id(), - endpoint_spec(), - HardwareEndpointKind::Ws281x, - self.driver_id(), - self.gpio_address.clone(), - resource.display_label(), - self.endpoint_status(), - )] + let mut endpoints = Vec::new(); + for resource in self.registry.manifest().resources() { + if !resource.supports(HwCapability::GpioOutput) + || !has_board_assigned_label(resource.address(), resource.display_label()) + { + continue; + } + let address = resource.address().clone(); + let spec = ws281x_rmt_spec(resource.display_label()); + endpoints.push(HwEndpoint::new( + self.endpoint_id(&spec), + spec, + HwEndpointKind::Ws281x, + self.driver_id(), + address, + resource.display_label(), + self.endpoint_status(resource.address()), + )); + } + endpoints } fn open( &self, - endpoint_id: &HardwareEndpointId, + endpoint_id: &HwEndpointId, config: Ws281xConfig, ) -> Result, HardwareEndpointError> { - if endpoint_id != &self.endpoint_id() { - return Err(HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Ws281x, - endpoint_id: endpoint_id.clone(), - }); - } + let gpio_address = self.gpio_for_endpoint(endpoint_id)?; validate_byte_count(config.byte_count())?; - - let endpoint = self.endpoints().into_iter().next().ok_or_else(|| { - HardwareEndpointError::UnknownEndpoint { - kind: HardwareEndpointKind::Ws281x, + log::info!( + "Esp32RmtWs281xDriver::open: endpoint={endpoint_id}, gpio={}, byte_count={}", + gpio_address.as_str(), + config.byte_count() + ); + + let endpoint = self + .endpoints() + .into_iter() + .find(|endpoint| endpoint.id() == endpoint_id) + .ok_or_else(|| HardwareEndpointError::UnknownEndpoint { + kind: HwEndpointKind::Ws281x, endpoint_id: endpoint_id.clone(), - } - })?; + })?; if !endpoint.is_available() { return Err(HardwareEndpointError::EndpointUnavailable { endpoint_id: endpoint_id.clone(), @@ -172,81 +230,54 @@ impl Ws281xDriver for Esp32RmtWs281xDriver { } self.registry - .ensure_capability(&self.gpio_address, HardwareCapability::GpioOutput)?; + .ensure_capability(&gpio_address, HwCapability::GpioOutput)?; self.registry - .ensure_capability(&self.timing_address, HardwareCapability::Rmt)?; + .ensure_capability(&self.timing_address, HwCapability::Rmt)?; self.registry - .ensure_capability(&self.timing_address, HardwareCapability::Ws281xOutput)?; - let lease = self.registry.claim_bundle(HardwareClaim::new( + .ensure_capability(&self.timing_address, HwCapability::Ws281xOutput)?; + let lease = self.registry.claim_bundle(HwClaim::new( self.driver_id(), - vec![self.gpio_address.clone(), self.timing_address.clone()], + vec![gpio_address.clone(), self.timing_address.clone()], ))?; - let options = config.display_options_cloned().unwrap_or_default(); - let pipeline = - DisplayPipeline::new(config.byte_count() / 3, options.clone()).map_err(|error| { - let _ = self.registry.release(&lease); - HardwareEndpointError::Other { - message: format!("DisplayPipeline allocation failed: {error}"), - } - })?; + if let Err(error) = self.ensure_rmt_initialized( + &gpio_address, + capped_byte_count(config.byte_count()) as usize / 3, + ) { + let _ = self.registry.release(&lease); + return Err(error); + } Ok(Box::new(Esp32RmtWs281xOutput { registry: Rc::clone(&self.registry), lease: Some(lease), byte_count: config.byte_count(), - pipeline, - options, })) } } pub struct Esp32RmtWs281xOutput { - registry: Rc, + registry: Rc, lease: Option, byte_count: u32, - pipeline: DisplayPipeline, - options: OutputDriverOptions, } impl Ws281xOutput for Esp32RmtWs281xOutput { - fn write(&mut self, data: &[u16]) -> Result<(), OutputError> { - let mut num_leds = (self.byte_count / 3) as usize; - let expected_len = num_leds * 3; - - if data.len() > expected_len { - let new_byte_count = capped_byte_count_for_len(data.len()); - self.resize(Ws281xConfig::new( - new_byte_count, - Some(self.options.clone()), - ))?; - num_leds = (self.byte_count / 3) as usize; - } else if data.len() < expected_len { + fn write(&mut self, data: &[u8]) -> Result<(), OutputError> { + let expected_len = byte_len_for_byte_count(self.byte_count); + if data.len() != expected_len { return Err(OutputError::DataLengthMismatch { expected: expected_len as u32, actual: data.len(), }); } - let mut rmt_buffer = Vec::with_capacity(num_leds * 3); - rmt_buffer.resize(num_leds * 3, 0); - - self.pipeline.write_frame(0, data); - self.pipeline.write_frame(16667, data); - self.pipeline.tick(8333, &mut rmt_buffer); - - transmit_rmt_buffer(&rmt_buffer) + transmit_rmt_buffer(data) } fn resize(&mut self, config: Ws281xConfig) -> Result<(), OutputError> { validate_byte_count(config.byte_count()).map_err(endpoint_error_to_output_error)?; - let byte_count = capped_byte_count(config.byte_count()); - let num_leds = byte_count / 3; - if let Some(options) = config.display_options_cloned() { - self.options = options; - } - self.pipeline.resize(num_leds); - self.byte_count = byte_count; + self.byte_count = capped_byte_count(config.byte_count()); Ok(()) } } @@ -261,17 +292,17 @@ impl Drop for Esp32RmtWs281xOutput { } } -fn rmt_channel_is_initialized() -> bool { +fn rmt_channel_is_available_for(gpio_address: &HwAddress) -> bool { + let Ok(gpio) = gpio_number(gpio_address) else { + return false; + }; unsafe { let channel_ptr = core::ptr::addr_of!(LED_CHANNEL); - (*channel_ptr).is_some() + let gpio_ptr = core::ptr::addr_of!(LED_GPIO); + (*channel_ptr).is_none() || (*gpio_ptr) == Some(u32::from(gpio)) } } -fn endpoint_spec() -> HardwareEndpointSpec { - HardwareEndpointSpec::from_static(ENDPOINT_SPEC) -} - fn transmit_rmt_buffer(rmt_buffer: &[u8]) -> Result<(), OutputError> { unsafe { let tx_ptr = core::ptr::addr_of_mut!(CURRENT_TRANSACTION); @@ -312,15 +343,15 @@ fn validate_byte_count(byte_count: u32) -> Result<(), HardwareEndpointError> { Ok(()) } -fn capped_byte_count_for_len(data_len: usize) -> u32 { - capped_byte_count(((data_len / 3) * 3) as u32) -} - fn capped_byte_count(byte_count: u32) -> u32 { let max_byte_count = (MAX_LEDS * 3) as u32; byte_count.min(max_byte_count) } +fn byte_len_for_byte_count(byte_count: u32) -> usize { + ((byte_count / 3) as usize) * 3 +} + fn endpoint_error_to_output_error(error: HardwareEndpointError) -> OutputError { match error { HardwareEndpointError::Hardware { error } => OutputError::Hardware { error }, @@ -329,3 +360,34 @@ fn endpoint_error_to_output_error(error: HardwareEndpointError) -> OutputError { }, } } + +fn gpio_number(address: &HwAddress) -> Result { + let Some(raw) = address.as_str().strip_prefix("/gpio/") else { + return Err(HardwareEndpointError::UnsupportedConfig { + reason: format!("WS281x endpoint address is not a GPIO: {address}"), + }); + }; + let gpio = raw + .parse::() + .map_err(|_| HardwareEndpointError::UnsupportedConfig { + reason: format!("invalid ESP32 GPIO address: {address}"), + })?; + if gpio > MAX_ESP32C6_GPIO { + return Err(HardwareEndpointError::UnsupportedConfig { + reason: format!("ESP32-C6 GPIO {gpio} is outside the supported range"), + }); + } + Ok(gpio) +} + +fn has_board_assigned_label(address: &HwAddress, display_label: &str) -> bool { + let Ok(gpio) = gpio_number(address) else { + return false; + }; + !display_label.eq_ignore_ascii_case(&format!("GPIO{gpio}")) +} + +fn ws281x_rmt_spec(config: &str) -> HwEndpointSpec { + HwEndpointSpec::parse(format!("ws281x:rmt:{config}")) + .expect("manifest display label should form a valid endpoint spec") +} diff --git a/lp-fw/fw-esp32/src/serial/io_task.rs b/lp-fw/fw-esp32/src/serial/io_task.rs index 9332ce577..661e55644 100644 --- a/lp-fw/fw-esp32/src/serial/io_task.rs +++ b/lp-fw/fw-esp32/src/serial/io_task.rs @@ -323,6 +323,7 @@ fn into_small_server_msg( ServerMsgBody::LoadProject { handle } => ServerMsgBody::LoadProject { handle }, ServerMsgBody::UnloadProject => ServerMsgBody::UnloadProject, ServerMsgBody::ProjectRequest { .. } => return None, + ServerMsgBody::ProjectCommand { response } => ServerMsgBody::ProjectCommand { response }, ServerMsgBody::ListAvailableProjects { projects } => { ServerMsgBody::ListAvailableProjects { projects } } diff --git a/lp-fw/fw-esp32/src/tests/test_button.rs b/lp-fw/fw-esp32/src/tests/test_button.rs index be2f6d076..aa7cc4223 100644 --- a/lp-fw/fw-esp32/src/tests/test_button.rs +++ b/lp-fw/fw-esp32/src/tests/test_button.rs @@ -6,25 +6,28 @@ extern crate alloc; use alloc::rc::Rc; use embassy_time::{Duration, Instant, Timer}; -use lpc_shared::hardware::{ - ButtonConfig, ButtonInput, HardwareRegistry, default_esp32c6_hardware_manifest, -}; +use lpc_hardware::{ButtonConfig, ButtonDriver, HwRegistry, default_esp32c6_hardware_manifest}; use crate::board::esp32c6::init::{init_board, start_runtime}; -use crate::hardware::button::Esp32ButtonInput; +use crate::hardware::button::Esp32GpioButtonDriver; const POLL_INTERVAL: Duration = Duration::from_millis(5); pub async fn run_button_test(_: embassy_executor::Spawner) -> ! { - let (sw_int, timg0, _rmt_peripheral, _usb_device, gpio18, _flash, _gpio4, gpio20, _wifi) = + let (sw_int, timg0, _rmt_peripheral, _usb_device, _gpio18, _flash, _gpio4, _gpio20, _wifi) = init_board(); start_runtime(timg0, sw_int); - drop(gpio18); - let hardware_registry = Rc::new(HardwareRegistry::new(default_esp32c6_hardware_manifest())); - let mut button = - Esp32ButtonInput::open_gpio20(hardware_registry, gpio20, ButtonConfig::default()) - .expect("D9/GPIO20 button opens"); + let hardware_registry = Rc::new(HwRegistry::new(default_esp32c6_hardware_manifest())); + let button_driver = Esp32GpioButtonDriver::new(hardware_registry); + let button_endpoint = button_driver + .endpoints() + .into_iter() + .find(|endpoint| endpoint.spec().as_str() == "button:gpio:D9") + .expect("D9/GPIO20 button endpoint exists"); + let mut button = button_driver + .open(button_endpoint.id(), ButtonConfig::default()) + .expect("D9/GPIO20 button opens"); let start = Instant::now(); esp_println::println!( diff --git a/lp-fw/fw-esp32/src/tests/test_espnow.rs b/lp-fw/fw-esp32/src/tests/test_espnow.rs index 2b650669a..e09e1fb3a 100644 --- a/lp-fw/fw-esp32/src/tests/test_espnow.rs +++ b/lp-fw/fw-esp32/src/tests/test_espnow.rs @@ -13,9 +13,9 @@ use alloc::vec::Vec; use embassy_time::{Duration, Ticker}; use esp_println::println; -use lpc_shared::hardware::{ - HardwareAddress, HardwareRegistry, HardwareSystem, RadioChannelId, RadioConfig, - RadioMessageKind, default_esp32c6_hardware_manifest, +use lpc_hardware::{ + HardwareSystem, HwAddress, HwRegistry, RadioChannelId, RadioConfig, RadioMessageKind, + default_esp32c6_hardware_manifest, }; use crate::board::esp32c6::init::{init_board, start_runtime}; @@ -29,7 +29,7 @@ pub async fn run_espnow_test(_: embassy_executor::Spawner) -> ! { let (sw_int, timg0, _rmt, _usb_device, _gpio18, _flash, _gpio4, _gpio20, wifi) = init_board(); start_runtime(timg0, sw_int); - let registry = Rc::new(HardwareRegistry::new(default_esp32c6_hardware_manifest())); + let registry = Rc::new(HwRegistry::new(default_esp32c6_hardware_manifest())); let mut hardware_system = HardwareSystem::new(Rc::clone(®istry)); let radio_driver = Esp32EspNowRadioDriver::new(Rc::clone(®istry), wifi) .expect("ESP-NOW radio driver initializes"); @@ -38,10 +38,7 @@ pub async fn run_espnow_test(_: embassy_executor::Spawner) -> ! { hardware_system.add_radio_driver(Box::new(radio_driver)); let mut radio = hardware_system - .open_radio_by_address( - &HardwareAddress::radio(0), - RadioConfig::new(Some(espnow_channel)), - ) + .open_radio_by_address(&HwAddress::radio(0), RadioConfig::new(Some(espnow_channel))) .expect("ESP-NOW radio opens"); radio .subscribe_channel(DIAGNOSTIC_CHANNEL) diff --git a/lp-shader/lp-shader/Cargo.toml b/lp-shader/lp-shader/Cargo.toml index 4fc486689..322b8173c 100644 --- a/lp-shader/lp-shader/Cargo.toml +++ b/lp-shader/lp-shader/Cargo.toml @@ -14,6 +14,7 @@ std = [] naga = ["dep:lps-frontend"] [dependencies] +lp-collection = { workspace = true, features = ["serde"] } lpir = { path = "../lpir" } lps-shared = { path = "../lps-shared" } lpvm = { path = "../lpvm" } diff --git a/lp-shader/lp-shader/src/compile_px_desc.rs b/lp-shader/lp-shader/src/compile_px_desc.rs index 13fe76065..f258589ed 100644 --- a/lp-shader/lp-shader/src/compile_px_desc.rs +++ b/lp-shader/lp-shader/src/compile_px_desc.rs @@ -1,12 +1,12 @@ //! Descriptor for pixel shader compilation (GLSL + output format + texture binding specs). -use alloc::collections::BTreeMap; use alloc::string::String; +use lp_collection::VecMap; use lpir::CompilerConfig; use lps_shared::{TextureBindingSpec, TextureStorageFormat}; -pub type TextureBindingSpecs = BTreeMap; +pub type TextureBindingSpecs = VecMap; /// Frontend used for GLSL source before LPIR lowering. #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/lp-shader/lp-shader/src/compute_abi.rs b/lp-shader/lp-shader/src/compute_abi.rs index 76e1842e1..2fa4922ae 100644 --- a/lp-shader/lp-shader/src/compute_abi.rs +++ b/lp-shader/lp-shader/src/compute_abi.rs @@ -1,9 +1,9 @@ //! Serial compute shader ABI validation. use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::String; +use lp_collection::VecMap; use lps_shared::path_resolve::LpsTypePathExt; use lps_shared::{LpsModuleSig, LpsType}; @@ -21,9 +21,9 @@ pub const COMPUTE_TICK_FN: &str = "tick"; #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ComputeAbi { /// Uniform-backed values written before each compute tick. - pub consumed: BTreeMap, + pub consumed: VecMap, /// Private-global-backed values read after a compute tick. - pub produced: BTreeMap, + pub produced: VecMap, } /// Expected shader-visible representation of a produced slot. diff --git a/lp-shader/lp-shader/src/px_shader.rs b/lp-shader/lp-shader/src/px_shader.rs index ad2ca9bda..46edd2b4a 100644 --- a/lp-shader/lp-shader/src/px_shader.rs +++ b/lp-shader/lp-shader/src/px_shader.rs @@ -1,10 +1,10 @@ //! Compiled pixel shader: module + instance, uniforms at render time. use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::String; use core::cell::RefCell; +use lp_collection::VecMap; use lps_shared::{ LpsFnKind, LpsFnSig, LpsModuleSig, LpsType, LpsValueF32, StructMember, TextureBindingSpec, @@ -298,7 +298,7 @@ fn apply_uniform_fields( members: &[StructMember], fields: &[(String, LpsValueF32)], path_prefix: &str, - texture_specs: &BTreeMap, + texture_specs: &VecMap, ) -> Result<(), LpsError> { for member in members { let name = member diff --git a/lp-shader/lp-shader/src/tests.rs b/lp-shader/lp-shader/src/tests.rs index ed1e75e96..d29d440a4 100644 --- a/lp-shader/lp-shader/src/tests.rs +++ b/lp-shader/lp-shader/src/tests.rs @@ -1,10 +1,10 @@ use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::rc::Rc; use alloc::string::String; use alloc::vec; use alloc::vec::Vec; use core::cell::RefCell; +use lp_collection::VecMap; use lps_shared::{ FnParam, LpsFnKind, LpsFnSig, LpsModuleSig, LpsType, LpsValueF32, ParamQualifier, StructMember, @@ -1447,7 +1447,7 @@ vec4 render(vec2 pos) { } fn test_meta_params_gradient_rgba16() -> LpsModuleSig { - let mut texture_specs = BTreeMap::new(); + let mut texture_specs = VecMap::new(); texture_specs.insert( String::from("params.gradient"), test_default_texture_binding_spec(), @@ -1649,7 +1649,7 @@ fn render_frame_nested_params_gradient_wrong_texture_value_type_reports_dotted_p #[test] fn render_frame_nested_params_gradient_height_one_mismatch_reports_dotted_path() { let engine = test_engine(); - let mut texture_specs = BTreeMap::new(); + let mut texture_specs = VecMap::new(); texture_specs.insert( String::from("params.gradient"), texture_binding::height_one( diff --git a/lp-shader/lpir/src/builder.rs b/lp-shader/lpir/src/builder.rs index ebf1e8e4e..424687249 100644 --- a/lp-shader/lpir/src/builder.rs +++ b/lp-shader/lpir/src/builder.rs @@ -1,8 +1,8 @@ //! Stack-based builders for [`crate::lpir_module::IrFunction`] and [`crate::lpir_module::LpirModule`]. -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use crate::lpir_module::{ImportDecl, IrFunction, LpirBody, LpirModule, SlotDecl, VMCTX_VREG}; use crate::lpir_op::LpirOp; @@ -469,7 +469,7 @@ impl FunctionBuilder { /// Build an [`LpirModule`]. pub struct ModuleBuilder { imports: Vec, - functions: BTreeMap, + functions: VecMap, next_func_id: u16, } @@ -477,7 +477,7 @@ impl Default for ModuleBuilder { fn default() -> Self { Self { imports: Vec::new(), - functions: BTreeMap::new(), + functions: VecMap::new(), next_func_id: 0, } } diff --git a/lp-shader/lpir/src/lpir_module.rs b/lp-shader/lpir/src/lpir_module.rs index 5a8374bf4..1bf9260ab 100644 --- a/lp-shader/lpir/src/lpir_module.rs +++ b/lp-shader/lpir/src/lpir_module.rs @@ -1,9 +1,9 @@ //! Module and function containers. -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; use core::ops::{Index, IndexMut}; +use lp_collection::VecMap; use lp_collection::{ChunkedVec, chunked_vec}; @@ -204,7 +204,7 @@ impl IrFunction { #[derive(Clone, Debug, Default)] pub struct LpirModule { pub imports: Vec, - pub functions: BTreeMap, + pub functions: VecMap, } impl LpirModule { diff --git a/lp-shader/lpir/src/tests/validate.rs b/lp-shader/lpir/src/tests/validate.rs index 9933e2358..0cc815e4e 100644 --- a/lp-shader/lpir/src/tests/validate.rs +++ b/lp-shader/lpir/src/tests/validate.rs @@ -1,9 +1,9 @@ //! Validator positive and negative tests. -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec; use alloc::vec::Vec; +use lp_collection::VecMap; use crate::builder::FunctionBuilder; use crate::lpir_module::{ImportDecl, IrFunction, LpirModule, VMCTX_VREG}; @@ -98,7 +98,7 @@ fn validate_err_break_outside_loop() { }; let m = LpirModule { imports: Vec::new(), - functions: BTreeMap::from([(FuncId(0), f)]), + functions: VecMap::from([(FuncId(0), f)]), }; let errs = validate_module(&m).expect_err("expected validation errors"); assert!(errs.iter().any(|e| e.message.contains("loop"))); @@ -153,7 +153,7 @@ fn validate_err_undefined_vreg() { }; let m = LpirModule { imports: Vec::new(), - functions: BTreeMap::from([(FuncId(0), f)]), + functions: VecMap::from([(FuncId(0), f)]), }; let errs = validate_function(m.functions.values().next().unwrap(), &m).expect_err("undefined v0"); @@ -180,7 +180,7 @@ fn validate_err_copy_type_mismatch() { }; let m = LpirModule { imports: Vec::new(), - functions: BTreeMap::from([(FuncId(0), f)]), + functions: VecMap::from([(FuncId(0), f)]), }; let errs = validate_module(&m).expect_err("copy types"); assert!(errs.iter().any(|e| e.message.contains("copy"))); @@ -206,7 +206,7 @@ fn validate_err_call_arity() { needs_vmctx: false, sret: false, }], - functions: BTreeMap::from([(FuncId(0), func)]), + functions: VecMap::from([(FuncId(0), func)]), }; let errs = validate_module(&m).expect_err("call arity"); assert!(errs.iter().any(|e| e.message.contains("arg count"))); @@ -233,7 +233,7 @@ fn validate_err_callee_oob() { }; let m = LpirModule { imports: Vec::new(), - functions: BTreeMap::from([(FuncId(0), f)]), + functions: VecMap::from([(FuncId(0), f)]), }; let errs = validate_module(&m).expect_err("callee"); assert!(errs.iter().any(|e| e.message.contains("callee"))); @@ -255,7 +255,7 @@ fn validate_err_continue_outside_loop() { }; let m = LpirModule { imports: Vec::new(), - functions: BTreeMap::from([(FuncId(0), f)]), + functions: VecMap::from([(FuncId(0), f)]), }; let errs = validate_module(&m).expect_err("continue"); assert!(errs.iter().any(|e| e.message.contains("loop"))); @@ -294,7 +294,7 @@ fn validate_err_duplicate_switch_case() { let f = b.finish(); let m = LpirModule { imports: Vec::new(), - functions: BTreeMap::from([(FuncId(0), f)]), + functions: VecMap::from([(FuncId(0), f)]), }; let errs = validate_module(&m).expect_err("dup case"); assert!( @@ -328,7 +328,7 @@ fn validate_err_return_value_type() { }; let m = LpirModule { imports: Vec::new(), - functions: BTreeMap::from([(FuncId(0), f)]), + functions: VecMap::from([(FuncId(0), f)]), }; let errs = validate_module(&m).expect_err("return type"); assert!(errs.iter().any(|e| e.message.contains("return value"))); @@ -353,7 +353,7 @@ fn validate_err_vreg_pool_oob() { }; let m = LpirModule { imports: Vec::new(), - functions: BTreeMap::from([(FuncId(0), f)]), + functions: VecMap::from([(FuncId(0), f)]), }; let errs = validate_module(&m).expect_err("pool"); assert!(errs.iter().any(|e| e.message.contains("pool"))); @@ -455,7 +455,7 @@ fn validate_err_slot_addr_oob() { }; let m = LpirModule { imports: Vec::new(), - functions: BTreeMap::from([(FuncId(0), f)]), + functions: VecMap::from([(FuncId(0), f)]), }; let errs = validate_function(m.functions.values().next().unwrap(), &m).expect_err("bad slot"); assert!(errs.iter().any(|e| e.message.contains("slot"))); @@ -480,7 +480,7 @@ fn validate_err_sret_with_return_types() { }; let m = LpirModule { imports: Vec::new(), - functions: BTreeMap::from([(FuncId(0), f)]), + functions: VecMap::from([(FuncId(0), f)]), }; let errs = validate_module(&m).expect_err("sret return_types"); assert!( @@ -508,7 +508,7 @@ fn validate_err_sret_wrong_vreg_index() { }; let m = LpirModule { imports: Vec::new(), - functions: BTreeMap::from([(FuncId(0), f)]), + functions: VecMap::from([(FuncId(0), f)]), }; let errs = validate_module(&m).expect_err("sret index"); assert!(errs.iter().any(|e| e.message.contains("vmctx+1"))); @@ -539,7 +539,7 @@ fn validate_err_sret_return_values() { }; let m = LpirModule { imports: Vec::new(), - functions: BTreeMap::from([(FuncId(0), f)]), + functions: VecMap::from([(FuncId(0), f)]), }; let errs = validate_module(&m).expect_err("sret return values"); assert!( @@ -560,7 +560,7 @@ fn validate_err_import_sret_nonempty_returns() { needs_vmctx: false, sret: true, }], - functions: BTreeMap::new(), + functions: VecMap::new(), }; let errs = validate_module(&m).expect_err("import sret rets"); assert!( @@ -581,7 +581,7 @@ fn validate_err_import_sret_first_not_ptr() { needs_vmctx: false, sret: true, }], - functions: BTreeMap::new(), + functions: VecMap::new(), }; let errs = validate_module(&m).expect_err("import sret ptr"); assert!(errs.iter().any(|e| e.message.contains("first param_types"))); diff --git a/lp-shader/lpir/src/validate.rs b/lp-shader/lpir/src/validate.rs index 2c0dc6ee6..190cdf105 100644 --- a/lp-shader/lpir/src/validate.rs +++ b/lp-shader/lpir/src/validate.rs @@ -1,11 +1,11 @@ //! Well-formedness checks for [`LpirModule`] and [`IrFunction`]. -use alloc::collections::BTreeSet; use alloc::format; use alloc::string::String; use alloc::vec; use alloc::vec::Vec; use core::fmt; +use lp_collection::VecSet; use crate::lpir_module::{ImportDecl, IrFunction, LpirModule}; use crate::lpir_op::LpirOp; @@ -90,7 +90,7 @@ pub fn validate_function( } fn validate_imports(module: &LpirModule, errs: &mut Vec) { - let mut seen: BTreeSet<(&str, &str)> = BTreeSet::new(); + let mut seen: VecSet<(&str, &str)> = VecSet::new(); for imp in &module.imports { let key = (imp.module_name.as_str(), imp.func_name.as_str()); if !seen.insert(key) { @@ -127,7 +127,7 @@ enum StackEntry { /// Paired with [`LpirOp::End`] that closes the `Block` region. Block, Switch { - cases: BTreeSet, + cases: VecSet, default_arm: bool, }, Arm, @@ -327,7 +327,7 @@ fn validate_function_inner( stack.push(StackEntry::Block); } LpirOp::SwitchStart { .. } => stack.push(StackEntry::Switch { - cases: BTreeSet::new(), + cases: VecSet::new(), default_arm: false, }), LpirOp::CaseStart { value, .. } => { diff --git a/lp-shader/lps-filetests/Cargo.toml b/lp-shader/lps-filetests/Cargo.toml index bbb3a4a04..8ec66a547 100644 --- a/lp-shader/lps-filetests/Cargo.toml +++ b/lp-shader/lps-filetests/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] lps-shared = { path = "../lps-shared" } +lp-collection = { workspace = true } lps-diagnostics = { path = "../lps-diagnostics", features = ["std"] } lps-frontend = { path = "../lps-frontend" } lps-glsl = { path = "../lps-glsl" } diff --git a/lp-shader/lps-filetests/src/parse/mod.rs b/lp-shader/lps-filetests/src/parse/mod.rs index ff1bc1a7b..3825db51b 100644 --- a/lp-shader/lps-filetests/src/parse/mod.rs +++ b/lp-shader/lps-filetests/src/parse/mod.rs @@ -72,7 +72,7 @@ pub fn parse_test_file(path: &Path) -> Result { let mut pending_set_uniforms: Vec = Vec::new(); let mut config_overrides: Vec<(String, String)> = Vec::new(); let mut seen_compile_opt_keys: HashSet = HashSet::new(); - let mut texture_specs: test_type::TextureSpecs = std::collections::BTreeMap::new(); + let mut texture_specs: test_type::TextureSpecs = test_type::TextureSpecs::new(); let mut texture_fixtures: test_type::TextureFixtures = std::collections::BTreeMap::new(); let mut in_texture_data: Option = None; let mut pending_setup_failure: Option = None; diff --git a/lp-shader/lps-filetests/src/parse/test_type.rs b/lp-shader/lps-filetests/src/parse/test_type.rs index b944e4ad0..1a924f8d2 100644 --- a/lp-shader/lps-filetests/src/parse/test_type.rs +++ b/lp-shader/lps-filetests/src/parse/test_type.rs @@ -1,12 +1,13 @@ //! Test type enum and related types. use crate::targets::Annotation; +use lp_collection::VecMap; use lps_shared::TextureBindingSpec; use lps_shared::TextureStorageFormat; use std::collections::BTreeMap; /// File-level `// texture-spec:` map keyed by sampler uniform name. -pub type TextureSpecs = BTreeMap; +pub type TextureSpecs = VecMap; /// Parsed `// texture-data:` blocks keyed by name. pub type TextureFixtures = BTreeMap; diff --git a/lp-shader/lps-filetests/src/test_error/mod.rs b/lp-shader/lps-filetests/src/test_error/mod.rs index 8b0fdaa11..da90f1d05 100644 --- a/lp-shader/lps-filetests/src/test_error/mod.rs +++ b/lp-shader/lps-filetests/src/test_error/mod.rs @@ -84,7 +84,7 @@ pub fn run_error_test( fn collect_glsl_error_test_diagnostics( user_source: &str, - texture_specs: &std::collections::BTreeMap, + texture_specs: &crate::parse::test_type::TextureSpecs, options: &CompileOptions, ) -> Result<(), Vec> { let prep = lps_frontend::prepared_glsl_for_compile(user_source); diff --git a/lp-shader/lps-filetests/src/test_run/compile.rs b/lp-shader/lps-filetests/src/test_run/compile.rs index fbb3f7398..19cb86a2f 100644 --- a/lp-shader/lps-filetests/src/test_run/compile.rs +++ b/lp-shader/lps-filetests/src/test_run/compile.rs @@ -1,10 +1,10 @@ //! Compile GLSL source for a specific target (one LPVM module per file). use crate::targets::Target; +use lp_collection::VecMap; use lp_riscv_emu::LogLevel; use lpir::CompilerConfig; use lps_shared::TextureBindingSpec; -use std::collections::BTreeMap; use std::sync::Mutex; use super::filetest_lpvm::CompiledShader; @@ -62,7 +62,7 @@ pub fn compile_for_target( _relative_path: &str, log_level: LogLevel, compiler_config: &CompilerConfig, - texture_specs: &BTreeMap, + texture_specs: &VecMap, ) -> anyhow::Result { CompiledShader::compile_glsl(source, target, log_level, compiler_config, texture_specs) } @@ -102,7 +102,7 @@ uniform sampler2D tex; "#; let target = jit_q32_target(); let cfg = CompilerConfig::default(); - let empty = BTreeMap::new(); + let empty = VecMap::new(); let err = match compile_for_target(glsl, &target, "", LogLevel::None, &cfg, &empty) { Err(e) => e, Ok(_) => panic!("expected texture spec validation error"), @@ -122,7 +122,7 @@ uniform sampler2D tex; "#; let target = jit_q32_target(); let cfg = CompilerConfig::default(); - let mut specs = BTreeMap::new(); + let mut specs = VecMap::new(); specs.insert(String::from("tex"), sample_spec()); compile_for_target(glsl, &target, "", LogLevel::None, &cfg, &specs) .expect("compile with matching texture spec"); @@ -135,7 +135,7 @@ float add(float a, float b) { return a + b; } "#; let target = jit_q32_target(); let cfg = CompilerConfig::default(); - let mut specs = BTreeMap::new(); + let mut specs = VecMap::new(); specs.insert(String::from("orphanTex"), sample_spec()); let err = match compile_for_target(glsl, &target, "", LogLevel::None, &cfg, &specs) { Err(e) => e, diff --git a/lp-shader/lps-filetests/src/test_run/filetest_lpvm.rs b/lp-shader/lps-filetests/src/test_run/filetest_lpvm.rs index 945b02117..ecf856cb0 100644 --- a/lp-shader/lps-filetests/src/test_run/filetest_lpvm.rs +++ b/lp-shader/lps-filetests/src/test_run/filetest_lpvm.rs @@ -1,5 +1,6 @@ //! LPVM-backed filetest compilation: one module per `.glsl` file, fresh instance per `// run:`. +use lp_collection::VecMap; use lp_riscv_emu::{CycleModel, LogLevel}; use lpir::{CompilerConfig, FloatMode as LpirFloatMode, LpirModule}; use lps_shared::{LpsFnSig, LpsModuleSig, TextureBindingSpec}; @@ -17,7 +18,6 @@ use lpvm_wasm::{ WasmOptions as LpvmWasmOptions, rt_wasmtime::{WasmLpvmEngine, WasmLpvmInstance, WasmLpvmModule}, }; -use std::collections::BTreeMap; use crate::targets::{Backend, FloatMode as TargetFloatMode, Frontend, Target}; @@ -173,7 +173,7 @@ impl FiletestInstance { fn lower_glsl( source: &str, - texture_specs: &BTreeMap, + texture_specs: &VecMap, texel_fetch_bounds: lpir::TexelFetchBoundsMode, ) -> anyhow::Result<(LpirModule, LpsModuleSig)> { let naga = lps_frontend::compile(source).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -190,7 +190,7 @@ impl CompiledShader { target: &Target, emu_log_level: LogLevel, compiler_config: &CompilerConfig, - texture_specs: &BTreeMap, + texture_specs: &VecMap, ) -> anyhow::Result { let (ir, meta) = match target.frontend { Frontend::Naga => lower_glsl( diff --git a/lp-shader/lps-filetests/src/test_run/texture_fixture.rs b/lp-shader/lps-filetests/src/test_run/texture_fixture.rs index 7df75cb9b..e3c574d6f 100644 --- a/lp-shader/lps-filetests/src/test_run/texture_fixture.rs +++ b/lp-shader/lps-filetests/src/test_run/texture_fixture.rs @@ -422,7 +422,7 @@ mod tests { #[test] fn runtime_validate_errors_on_missing_fixture() { use std::collections::BTreeMap; - let mut specs = BTreeMap::new(); + let mut specs = TextureSpecs::new(); specs.insert( "tex".into(), sample_spec( @@ -439,7 +439,7 @@ mod tests { #[test] fn runtime_validate_errors_on_extra_fixture() { use std::collections::BTreeMap; - let specs: TextureSpecs = BTreeMap::new(); + let specs: TextureSpecs = TextureSpecs::new(); let mut fixtures = BTreeMap::new(); fixtures.insert( "orphan".into(), @@ -462,7 +462,7 @@ mod tests { #[test] fn runtime_validate_errors_on_format_mismatch() { use std::collections::BTreeMap; - let mut specs = BTreeMap::new(); + let mut specs = TextureSpecs::new(); specs.insert( "tex".into(), sample_spec( @@ -492,7 +492,7 @@ mod tests { #[test] fn runtime_validate_errors_on_height_one_mismatch() { use std::collections::BTreeMap; - let mut specs = BTreeMap::new(); + let mut specs = TextureSpecs::new(); specs.insert( "tex".into(), sample_spec(TextureStorageFormat::R16Unorm, TextureShapeHint::HeightOne), diff --git a/lp-shader/lps-filetests/tests/rv32n_imm_range.rs b/lp-shader/lps-filetests/tests/rv32n_imm_range.rs index 6a131086b..7a65f5ad1 100644 --- a/lp-shader/lps-filetests/tests/rv32n_imm_range.rs +++ b/lp-shader/lps-filetests/tests/rv32n_imm_range.rs @@ -11,7 +11,7 @@ //! return, runs it through `NativeEmuEngine` in `FloatMode::Q32`, and //! checks the result against `i32`-wrapping LPIR semantics. -use std::collections::BTreeMap; +use lp_collection::VecMap; use lpir::builder::FunctionBuilder; use lpir::{FloatMode, FuncId, IrType, LpirModule, LpirOp}; @@ -204,7 +204,7 @@ where let module = LpirModule { imports: vec![], - functions: BTreeMap::from([(FuncId(0), func)]), + functions: VecMap::from([(FuncId(0), func)]), }; let sig = LpsModuleSig { diff --git a/lp-shader/lps-filetests/tests/rv32n_smoke.rs b/lp-shader/lps-filetests/tests/rv32n_smoke.rs index 9e782da18..bf0d63f6a 100644 --- a/lp-shader/lps-filetests/tests/rv32n_smoke.rs +++ b/lp-shader/lps-filetests/tests/rv32n_smoke.rs @@ -6,7 +6,7 @@ //! Note: The native backend currently does not support imports (builtin functions). //! This test constructs LPIR directly without imports. -use std::collections::BTreeMap; +use lp_collection::VecMap; use lpir::{FloatMode, FuncId, IrFunction, IrType, LpirModule, LpirOp, VReg, VRegRange}; use lps_shared::{FnParam, LpsFnSig, LpsModuleSig, LpsType}; @@ -44,7 +44,7 @@ fn build_iadd_module() -> (LpirModule, LpsModuleSig) { let module = LpirModule { imports: vec![], - functions: BTreeMap::from([(FuncId(0), func)]), + functions: VecMap::from([(FuncId(0), func)]), }; let sig = LpsModuleSig { diff --git a/lp-shader/lps-frontend/Cargo.toml b/lp-shader/lps-frontend/Cargo.toml index 98bc83a82..6f8e8c370 100644 --- a/lp-shader/lps-frontend/Cargo.toml +++ b/lp-shader/lps-frontend/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +lp-collection = { workspace = true, features = ["serde"] } libm = "0.2" smallvec = { version = "1", default-features = false } lps-shared = { path = "../lps-shared" } diff --git a/lp-shader/lps-frontend/src/lower.rs b/lp-shader/lps-frontend/src/lower.rs index aa2502869..0af68fef3 100644 --- a/lp-shader/lps-frontend/src/lower.rs +++ b/lp-shader/lps-frontend/src/lower.rs @@ -1,11 +1,11 @@ //! Naga module → LPIR [`lpir::LpirModule`] lowering entry point. use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::String; use alloc::vec; use alloc::vec::Vec; +use lp_collection::VecMap; use lpir::{ CalleeRef, FuncId, FunctionBuilder, ImportDecl, IrFunction, IrType, LpirModule, LpirOp, @@ -29,7 +29,7 @@ pub struct LowerOptions { /// When non-empty, must match every `Texture2D` uniform (validated before lowering; see /// [`lps_shared::validate_texture_binding_specs_against_module`]). An empty map skips that check /// so `lower()` stays usable when specs are applied later. - pub texture_specs: BTreeMap, + pub texture_specs: VecMap, /// Whether `texelFetch` lowering emits coordinate clamp ops (see [`lpir::TexelFetchBoundsMode`]). pub texel_fetch_bounds: lpir::TexelFetchBoundsMode, } @@ -37,7 +37,7 @@ pub struct LowerOptions { impl Default for LowerOptions { fn default() -> Self { Self { - texture_specs: BTreeMap::new(), + texture_specs: VecMap::new(), texel_fetch_bounds: lpir::TexelFetchBoundsMode::default(), } } @@ -64,7 +64,7 @@ pub fn lower_with_options( let mut import_map = register_math_imports(&mut mb); import_map.extend(register_texture_imports(&mut mb)); let lpfn_map = lower_lpfn::register_lpfn_imports(&mut mb, naga_module)?; - let mut func_map: BTreeMap, CalleeRef> = BTreeMap::new(); + let mut func_map: VecMap, CalleeRef> = VecMap::new(); for (i, (handle, _)) in naga_module.functions.iter().enumerate() { func_map.insert(*handle, CalleeRef::Local(FuncId(i as u16))); } @@ -152,9 +152,9 @@ fn compute_global_layout( ) -> Result<(GlobalVarMap, Option, Option), LowerError> { type GlobalKey = (Option, AddressSpace); - let mut groups: BTreeMap>> = BTreeMap::new(); + let mut groups: VecMap>> = VecMap::new(); let mut key_order: Vec = Vec::new(); - let mut seen_key: BTreeMap = BTreeMap::new(); + let mut seen_key: VecMap = VecMap::new(); for (h, gv) in module.global_variables.iter() { let key = (gv.name.clone(), gv.space); @@ -164,7 +164,7 @@ fn compute_global_layout( } } - let mut global_map: GlobalVarMap = BTreeMap::new(); + let mut global_map: GlobalVarMap = VecMap::new(); let mut uniforms_members: Vec = Vec::new(); let mut globals_members: Vec = Vec::new(); @@ -486,8 +486,8 @@ fn push_literal_to_builder(fb: &mut FunctionBuilder, lit: &naga::Literal) -> Opt } } -fn register_math_imports(mb: &mut ModuleBuilder) -> BTreeMap { - let mut m = BTreeMap::new(); +fn register_math_imports(mb: &mut ModuleBuilder) -> VecMap { + let mut m = VecMap::new(); let mut reg = |module: &str, name: &str, params: &[IrType], rets: &[IrType], needs_vmctx: bool| { let r = mb.add_import(ImportDecl { @@ -530,8 +530,8 @@ fn register_math_imports(mb: &mut ModuleBuilder) -> BTreeMap } /// `@texture::*` sampler builtins (result pointer ABI; [`ImportDecl::sret`]). -fn register_texture_imports(mb: &mut ModuleBuilder) -> BTreeMap { - let mut m = BTreeMap::new(); +fn register_texture_imports(mb: &mut ModuleBuilder) -> VecMap { + let mut m = VecMap::new(); let mut reg = |func_name: &str, user_param_count: usize| { let mut param_types = Vec::with_capacity(user_param_count); param_types.push(IrType::Pointer); @@ -560,11 +560,11 @@ fn lower_function( module: &Module, func: &Function, name: &str, - func_map: &BTreeMap, CalleeRef>, - import_map: &BTreeMap, - lpfn_map: &BTreeMap, CalleeRef>, + func_map: &VecMap, CalleeRef>, + import_map: &VecMap, + lpfn_map: &VecMap, CalleeRef>, global_map: GlobalVarMap, - texture_specs: &BTreeMap, + texture_specs: &VecMap, texel_fetch_bounds: lpir::TexelFetchBoundsMode, uniforms_type: Option<&LpsType>, ) -> Result { diff --git a/lp-shader/lps-frontend/src/lower_array.rs b/lp-shader/lps-frontend/src/lower_array.rs index b70438966..340233958 100644 --- a/lp-shader/lps-frontend/src/lower_array.rs +++ b/lp-shader/lps-frontend/src/lower_array.rs @@ -1,9 +1,9 @@ //! Stack-slot arrays: zero-fill, initializer lists, indexed load/store with bounds clamping. -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use smallvec::smallvec; @@ -711,9 +711,9 @@ fn collect_flat_compose_components( /// GLSL uses the leftmost `[]` size, which matches `dimensions[last]` in our shape walk. pub(crate) fn scan_naga_multidim_array_length_literals( func: &Function, - aggregate_map: &BTreeMap, AggregateInfo>, -) -> BTreeMap, i32> { - let mut fixes = BTreeMap::new(); + aggregate_map: &VecMap, AggregateInfo>, +) -> VecMap, i32> { + let mut fixes = VecMap::new(); let entries: Vec<(usize, Handle, &Expression)> = func .expressions .iter() diff --git a/lp-shader/lps-frontend/src/lower_ctx.rs b/lp-shader/lps-frontend/src/lower_ctx.rs index 233c1dd06..fa5223ac9 100644 --- a/lp-shader/lps-frontend/src/lower_ctx.rs +++ b/lp-shader/lps-frontend/src/lower_ctx.rs @@ -1,10 +1,10 @@ //! Per-function lowering context (builder, expression cache, local maps). -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::String; use alloc::vec; use alloc::vec::Vec; +use lp_collection::VecMap; use lpir::{CalleeRef, FunctionBuilder, IrType, LpirModule, LpirOp, SlotId, VReg}; use naga::{ @@ -83,20 +83,20 @@ pub(crate) struct GlobalVarInfo { } /// Map from Naga GlobalVariable handle to its lowering info. -pub(crate) type GlobalVarMap = BTreeMap, GlobalVarInfo>; +pub(crate) type GlobalVarMap = VecMap, GlobalVarInfo>; /// Naga stores `uniform Block { } name` instance as a function [`LocalVariable`] initialized from /// the corresponding [`GlobalVariable`]; map proxy locals to that global for VMContext loads. fn scan_uniform_instance_local_to_global( module: &Module, func: &Function, -) -> BTreeMap, Handle> { - let mut m = BTreeMap::new(); +) -> VecMap, Handle> { + let mut m = VecMap::new(); fn walk_block( block: &naga::Block, module: &Module, func: &Function, - m: &mut BTreeMap, Handle>, + m: &mut VecMap, Handle>, ) { for stmt in block.iter() { match stmt { @@ -263,34 +263,34 @@ pub(crate) struct LowerCtx<'a> { pub func: &'a Function, pub ir_module: Option<&'a LpirModule>, pub expr_cache: Vec>, - pub local_map: BTreeMap, VRegVec>, + pub local_map: VecMap, VRegVec>, /// Stack (and `in` by-value) aggregates keyed by [`LocalVariable`]. - pub aggregate_map: BTreeMap, AggregateInfo>, + pub aggregate_map: VecMap, AggregateInfo>, /// Call results for functions returning an aggregate: [`Expression::CallResult`] → stack slot /// (same layout as `aggregate_map` entries, always [`AggregateSlot::Local`]). - pub(crate) call_result_aggregates: BTreeMap, AggregateInfo>, + pub(crate) call_result_aggregates: VecMap, AggregateInfo>, /// Naga emits `.length()` on multi-dim arrays as the outer type-tree size, not GLSL's leftmost `[]`. /// Pairs `Load(array)` + `Literal(U32)` (and chained `Literal(I32)` copies) get corrected values here. - pub(crate) array_length_literal_fixes: BTreeMap, i32>, - pub param_aliases: BTreeMap, VRegVec>, + pub(crate) array_length_literal_fixes: VecMap, i32>, + pub param_aliases: VecMap, VRegVec>, /// VRegs per function argument: scalars/vectors (flattened), or one pointer for aggregates / `inout` bases. - pub(crate) arg_vregs: BTreeMap, + pub(crate) arg_vregs: VecMap, /// `in`/`out`/`inout` parameters: Naga type handle of the pointee (for Load/Store). - pub(crate) pointer_args: BTreeMap>, - pub func_map: BTreeMap, CalleeRef>, - pub import_map: BTreeMap, - pub lpfn_map: BTreeMap, CalleeRef>, + pub(crate) pointer_args: VecMap>, + pub func_map: VecMap, CalleeRef>, + pub import_map: VecMap, + pub lpfn_map: VecMap, CalleeRef>, pub return_types: Vec, /// Present when the shader function returns an aggregate by sret (LPIR void return, memcpy to `addr`). pub sret: Option, /// Map from Naga GlobalVariable handle to VMContext / lowering info. pub(crate) global_map: GlobalVarMap, /// [`Expression::index`] → deferred uniform array field / indexed element (see [`UniformVmctxDeferred`]). - pub(crate) uniform_vmctx_deferred: BTreeMap, + pub(crate) uniform_vmctx_deferred: VecMap, /// `uniform Block { } instance` locals → backing [`GlobalVariable`] (uniform). - pub(crate) uniform_instance_locals: BTreeMap, Handle>, + pub(crate) uniform_instance_locals: VecMap, Handle>, /// Compile-time [`TextureBindingSpec`] keyed by sampler uniform name ([`crate::LowerOptions`]). - pub(crate) texture_specs: &'a BTreeMap, + pub(crate) texture_specs: &'a VecMap, /// Mirrors [`crate::LowerOptions::texel_fetch_bounds`] for `texelFetch` lowering. pub(crate) texel_fetch_bounds: lpir::TexelFetchBoundsMode, /// Uniform block metadata for canonical paths and std430 offsets (same as [`LpsModuleSig::uniforms_type`]). @@ -302,11 +302,11 @@ impl<'a> LowerCtx<'a> { module: &'a Module, func: &'a Function, name: &str, - func_map: &BTreeMap, CalleeRef>, - import_map: &BTreeMap, - lpfn_map: &BTreeMap, CalleeRef>, + func_map: &VecMap, CalleeRef>, + import_map: &VecMap, + lpfn_map: &VecMap, CalleeRef>, global_map: GlobalVarMap, - texture_specs: &'a BTreeMap, + texture_specs: &'a VecMap, texel_fetch_bounds: lpir::TexelFetchBoundsMode, uniforms_type: Option<&'a LpsType>, ) -> Result { @@ -326,8 +326,8 @@ impl<'a> LowerCtx<'a> { None }; - let mut arg_vregs: BTreeMap = BTreeMap::new(); - let mut pointer_args: BTreeMap> = BTreeMap::new(); + let mut arg_vregs: VecMap = VecMap::new(); + let mut pointer_args: VecMap> = VecMap::new(); let mut pending_in_aggregate_specs: Vec = Vec::new(); for (i, arg) in func.arguments.iter().enumerate() { let inner = &module.types[arg.ty].inner; @@ -378,8 +378,8 @@ impl<'a> LowerCtx<'a> { let in_aggregate_read_only = in_aggregate_param_read_only(module, func)?; - let mut pending_in_aggregate_value_param: BTreeMap, AggregateInfo> = - BTreeMap::new(); + let mut pending_in_aggregate_value_param: VecMap, AggregateInfo> = + VecMap::new(); for spec in pending_in_aggregate_specs { let use_readonly = in_aggregate_read_only .get(&spec.arg_i) @@ -427,15 +427,15 @@ impl<'a> LowerCtx<'a> { } let param_idx = scan_param_argument_indices(module, func); - let mut param_aliases: BTreeMap, VRegVec> = BTreeMap::new(); + let mut param_aliases: VecMap, VRegVec> = VecMap::new(); for (lv, arg_i) in ¶m_idx { if let Some(vs) = arg_vregs.get(arg_i) { param_aliases.insert(*lv, vs.clone()); } } - let mut local_map: BTreeMap, VRegVec> = BTreeMap::new(); - let mut aggregate_map: BTreeMap, AggregateInfo> = BTreeMap::new(); + let mut local_map: VecMap, VRegVec> = VecMap::new(); + let mut aggregate_map: VecMap, AggregateInfo> = VecMap::new(); for (lv_handle, var) in func.local_variables.iter() { if param_aliases.contains_key(&lv_handle) { continue; @@ -567,7 +567,7 @@ impl<'a> LowerCtx<'a> { expr_cache, local_map, aggregate_map, - call_result_aggregates: BTreeMap::new(), + call_result_aggregates: VecMap::new(), array_length_literal_fixes, param_aliases, arg_vregs, @@ -578,7 +578,7 @@ impl<'a> LowerCtx<'a> { return_types, sret, global_map, - uniform_vmctx_deferred: BTreeMap::new(), + uniform_vmctx_deferred: VecMap::new(), uniform_instance_locals, texture_specs, texel_fetch_bounds, @@ -753,13 +753,13 @@ impl<'a> LowerCtx<'a> { fn scan_param_argument_indices( module: &Module, func: &Function, -) -> BTreeMap, u32> { - let mut m = BTreeMap::new(); +) -> VecMap, u32> { + let mut m = VecMap::new(); fn walk_block( block: &naga::Block, module: &Module, func: &Function, - m: &mut BTreeMap, u32>, + m: &mut VecMap, u32>, ) { for stmt in block.iter() { match stmt { diff --git a/lp-shader/lps-frontend/src/lower_lpfn.rs b/lp-shader/lps-frontend/src/lower_lpfn.rs index 00a25e359..0d2037f91 100644 --- a/lp-shader/lps-frontend/src/lower_lpfn.rs +++ b/lp-shader/lps-frontend/src/lower_lpfn.rs @@ -1,9 +1,9 @@ //! LPFX builtin calls → `@lpfn::…` imports, scalar and vector value arguments, and out-parameters. -use alloc::collections::{BTreeMap, BTreeSet}; use alloc::format; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::{VecMap, VecSet}; use lpir::{CalleeRef, ImportDecl, IrType, LpirOp, ModuleBuilder, VReg}; use naga::{ @@ -31,9 +31,9 @@ pub(crate) enum LpfnArgKind { pub(crate) fn register_lpfn_imports( mb: &mut ModuleBuilder, naga_module: &NagaModule, -) -> Result, CalleeRef>, LowerError> { +) -> Result, CalleeRef>, LowerError> { let handles = collect_lpfn_callee_handles(naga_module); - let mut map = BTreeMap::new(); + let mut map = VecMap::new(); for h in handles { let decl = build_lpfn_import_decl(&naga_module.module, h)?; let r = mb.add_import(decl); @@ -43,7 +43,7 @@ pub(crate) fn register_lpfn_imports( } fn collect_lpfn_callee_handles(naga_module: &NagaModule) -> Vec> { - let mut seen = BTreeSet::new(); + let mut seen = VecSet::new(); for (fh, _) in &naga_module.functions { let f = &naga_module.module.functions[*fh]; walk_block_for_lpfn_calls(&naga_module.module, f, &f.body, &mut seen); @@ -55,7 +55,7 @@ fn walk_block_for_lpfn_calls( module: &Module, func: &Function, block: &Block, - seen: &mut BTreeSet>, + seen: &mut VecSet>, ) { for stmt in block.iter() { match stmt { diff --git a/lp-shader/lps-frontend/src/lower_texture.rs b/lp-shader/lps-frontend/src/lower_texture.rs index b086ed1ef..c23020b3f 100644 --- a/lp-shader/lps-frontend/src/lower_texture.rs +++ b/lp-shader/lps-frontend/src/lower_texture.rs @@ -761,9 +761,9 @@ vec4 render(vec2 pos) { #[cfg(test)] mod texture_sampling_tests { - use alloc::collections::BTreeMap; use alloc::format; use alloc::string::String; + use lp_collection::VecMap; use naga::{Expression, SampleLevel}; @@ -845,7 +845,7 @@ vec4 render(vec2 pos) { } "#; let naga = compile(glsl).expect("compile"); - let mut specs = BTreeMap::new(); + let mut specs = VecMap::new(); specs.insert(String::from("tex"), rgba16_general2d_spec()); let opts = LowerOptions { texture_specs: specs, @@ -869,7 +869,7 @@ vec4 render(vec2 pos) { } "#; let naga = compile(glsl).expect("compile"); - let mut specs = BTreeMap::new(); + let mut specs = VecMap::new(); specs.insert(String::from("tex"), rgba16_height_one_spec()); let opts = LowerOptions { texture_specs: specs, @@ -910,7 +910,7 @@ vec4 render(vec2 pos) { } "#; let naga = compile(glsl).expect("compile"); - let mut specs = BTreeMap::new(); + let mut specs = VecMap::new(); specs.insert( String::from("tex"), TextureBindingSpec { @@ -968,7 +968,7 @@ vec4 render(vec2 pos) { /// texture binding validation even though the outer member has no name. #[test] fn anonymous_uniform_block_passes_validation() { - use alloc::collections::BTreeMap; + use lp_collection::VecMap; let glsl = r#" layout(std430, binding = 0) uniform Decl { float time; @@ -978,7 +978,7 @@ float f() { return time; } "#; let naga = compile(glsl).expect("compile"); let (_ir, sig) = lower(&naga).expect("lower"); - let specs = BTreeMap::new(); + let specs = VecMap::new(); let result = lps_shared::validate_texture_binding_specs_against_module(&sig, &specs); assert!(result.is_ok(), "Validation failed: {result:?}"); } diff --git a/lp-shader/lps-frontend/src/readonly_in_scan.rs b/lp-shader/lps-frontend/src/readonly_in_scan.rs index e90e5d8be..8fa2486b2 100644 --- a/lp-shader/lps-frontend/src/readonly_in_scan.rs +++ b/lp-shader/lps-frontend/src/readonly_in_scan.rs @@ -3,8 +3,8 @@ //! Used to elide the entry `Memcpy` from the pointer parameter into a stack slot when the //! aggregate is never written and is not passed onward as a callee `inout`/`out` argument. -use alloc::collections::BTreeMap; use alloc::string::String; +use lp_collection::VecMap; use naga::{ AddressSpace, Block, Expression, Function, Handle, LocalVariable, Module, Statement, TypeInner, @@ -26,8 +26,8 @@ use crate::lower_struct::peel_struct_access_index_chain_to_local; pub(crate) fn in_aggregate_param_read_only( module: &Module, func: &Function, -) -> Result, LowerError> { - let mut out = BTreeMap::new(); +) -> Result, LowerError> { + let mut out = VecMap::new(); for (i, arg) in func.arguments.iter().enumerate() { let i = i as u32; match &module.types[arg.ty].inner { diff --git a/lp-shader/lps-frontend/src/sampler2d_metadata_tests.rs b/lp-shader/lps-frontend/src/sampler2d_metadata_tests.rs index a0167d339..017d46dce 100644 --- a/lp-shader/lps-frontend/src/sampler2d_metadata_tests.rs +++ b/lp-shader/lps-frontend/src/sampler2d_metadata_tests.rs @@ -6,9 +6,9 @@ //! //! Naga then emits a sampled 2D `Image` in `AddressSpace::Handle` for the separate-texture form. -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec; +use lp_collection::VecMap; use lps_shared::LayoutRules; use lps_shared::layout::{type_alignment, type_size}; @@ -251,7 +251,7 @@ vec4 render(vec2 pos) { } "#; let naga = compile(glsl).expect("compile"); - let mut texture_specs = BTreeMap::new(); + let mut texture_specs = VecMap::new(); texture_specs.insert(String::from("inputColor"), sample_texture_binding_spec()); let options = LowerOptions { texture_specs, @@ -298,7 +298,7 @@ vec4 render(vec2 pos) { return vec4(pos, 0.0, 1.0); } "#; let naga = compile(glsl).expect("parse"); let spec = sample_texture_binding_spec(); - let mut texture_specs = BTreeMap::new(); + let mut texture_specs = VecMap::new(); texture_specs.insert(String::from("inputColor"), spec.clone()); let options = LowerOptions { texture_specs, @@ -315,7 +315,7 @@ uniform sampler2D inputColor; vec4 render(vec2 pos) { return vec4(pos, 0.0, 1.0); } "#; let naga = compile(glsl).expect("parse"); - let mut texture_specs = BTreeMap::new(); + let mut texture_specs = VecMap::new(); // Non-empty options → validation runs; wrong key so `inputColor` is still missing. texture_specs.insert(String::from("otherSampler"), sample_texture_binding_spec()); let options = LowerOptions { @@ -336,7 +336,7 @@ fn lower_with_options_extra_spec_errors_with_spec_name() { float f() { return 1.0; } "#; let naga = compile(glsl).expect("parse"); - let mut texture_specs = BTreeMap::new(); + let mut texture_specs = VecMap::new(); texture_specs.insert(String::from("onlyInSpecMap"), sample_texture_binding_spec()); let options = LowerOptions { texture_specs, @@ -359,7 +359,7 @@ vec4 render(vec2 pos) { } "#; let naga = compile(glsl).expect("compile"); - let mut texture_specs = BTreeMap::new(); + let mut texture_specs = VecMap::new(); texture_specs.insert(String::from("inputColor"), sample_texture_binding_spec()); let opts = LowerOptions { texture_specs, @@ -380,7 +380,7 @@ vec4 render(vec2 pos) { } "#; let naga = compile(glsl).expect("compile"); - let mut texture_specs = BTreeMap::new(); + let mut texture_specs = VecMap::new(); texture_specs.insert(String::from("inputColor"), sample_texture_binding_spec()); let opts = LowerOptions { texture_specs, @@ -404,7 +404,7 @@ vec4 render(vec2 pos) { } "#; let naga = compile(glsl).expect("compile"); - let mut texture_specs = BTreeMap::new(); + let mut texture_specs = VecMap::new(); texture_specs.insert(String::from("inputColor"), sample_texture_binding_spec()); let opts = LowerOptions { texture_specs, diff --git a/lp-shader/lps-glsl/src/compile.rs b/lp-shader/lps-glsl/src/compile.rs index adfd5ad53..0203d422b 100644 --- a/lp-shader/lps-glsl/src/compile.rs +++ b/lp-shader/lps-glsl/src/compile.rs @@ -3,12 +3,12 @@ use lps_shared::{LpsModuleSig, TextureBindingSpec}; use crate::{CompileJob, CompileStepResult, Diagnostic, TopLevelIndex}; -use alloc::collections::BTreeMap; use alloc::string::String; +use lp_collection::VecMap; #[derive(Debug, Clone, Default)] pub struct CompileOptions { - pub texture_specs: BTreeMap, + pub texture_specs: VecMap, pub texel_fetch_bounds: lpir::TexelFetchBoundsMode, } diff --git a/lp-shader/lps-glsl/src/hir.rs b/lp-shader/lps-glsl/src/hir.rs index 7b1d95ed1..4e6336454 100644 --- a/lp-shader/lps-glsl/src/hir.rs +++ b/lp-shader/lps-glsl/src/hir.rs @@ -1,8 +1,8 @@ use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use lps_shared::{FnParam, LayoutRules, LpsFnKind, LpsFnSig, LpsModuleSig, LpsType, StructMember}; @@ -61,15 +61,15 @@ enum HirBuildState { struct HirBuildFunctionState { array_size_consts: ArraySizeConsts, structs: StructTypes, - uniforms: BTreeMap, + uniforms: VecMap, uniforms_type: Option, - global_vars: BTreeMap, + global_vars: VecMap, globals_type: Option, global_inits: Vec, functions_sigs: Vec, imports: ImportRegistry, - globals: BTreeMap, - body_map: BTreeMap, + globals: VecMap, + body_map: VecMap, functions: Vec, function_meta: Vec, next_function: usize, @@ -613,7 +613,7 @@ fn build_array_size_consts( tokens: &[Token], index: &TopLevelIndex, ) -> Result { - let mut consts = BTreeMap::new(); + let mut consts = VecMap::new(); for konst in &index.consts { if !matches!(konst.ty.name.as_str(), "int" | "uint") { continue; @@ -667,7 +667,7 @@ fn build_struct_types( index: &TopLevelIndex, array_size_consts: &ArraySizeConsts, ) -> Result { - let mut structs = BTreeMap::new(); + let mut structs = VecMap::new(); for decl in &index.structs { let mut members = Vec::new(); for member in &decl.members { @@ -723,8 +723,8 @@ fn build_uniforms( index: &TopLevelIndex, structs: &StructTypes, array_size_consts: &ArraySizeConsts, -) -> Result<(BTreeMap, Option, usize), Diagnostic> { - let mut uniforms = BTreeMap::new(); +) -> Result<(VecMap, Option, usize), Diagnostic> { + let mut uniforms = VecMap::new(); let mut members = Vec::new(); let mut offset = lps_shared::VMCTX_HEADER_SIZE; for uniform in &index.uniforms { @@ -764,8 +764,8 @@ struct GlobalInit { fn function_body_map( bodies: Vec<(String, ParsedFunctionBody)>, -) -> BTreeMap { - let mut body_map = BTreeMap::new(); +) -> VecMap { + let mut body_map = VecMap::new(); for (name, body) in bodies { body_map.insert(name, body); } @@ -779,16 +779,9 @@ fn build_global_vars( structs: &StructTypes, array_size_consts: &ArraySizeConsts, uniforms_size: usize, -) -> Result< - ( - BTreeMap, - Option, - Vec, - ), - Diagnostic, -> { +) -> Result<(VecMap, Option, Vec), Diagnostic> { let mut order = Vec::::new(); - let mut by_name = BTreeMap::)>::new(); + let mut by_name = VecMap::)>::new(); for global in &index.globals { let ty = type_ref_to_lps_with_structs(&global.ty, structs, array_size_consts)?; if let Some((existing_ty, _span, init_span)) = by_name.get_mut(&global.name) { @@ -807,7 +800,7 @@ fn build_global_vars( by_name.insert(global.name.clone(), (ty, global.span, global.init_span)); } - let mut globals = BTreeMap::new(); + let mut globals = VecMap::new(); let mut members = Vec::new(); let mut inits = Vec::new(); let mut offset = lps_shared::VMCTX_HEADER_SIZE + uniforms_size; @@ -850,15 +843,15 @@ fn build_global_consts( source: &str, tokens: &[Token], index: &TopLevelIndex, - uniforms: &BTreeMap, - global_vars: &BTreeMap, + uniforms: &VecMap, + global_vars: &VecMap, functions: &[FunctionSig], structs: &StructTypes, array_size_consts: &ArraySizeConsts, imports: &mut ImportRegistry, - texture_specs: &BTreeMap, -) -> Result, Diagnostic> { - let mut globals = BTreeMap::new(); + texture_specs: &VecMap, +) -> Result, Diagnostic> { + let mut globals = VecMap::new(); for konst in &index.consts { let ty = type_ref_to_lps_with_structs(&konst.ty, structs, array_size_consts)?; let Some(init_span) = konst.init_span else { @@ -900,13 +893,13 @@ fn synthesize_shader_init( tokens: &[Token], inits: &[GlobalInit], functions: &[FunctionSig], - uniforms: &BTreeMap, - global_consts: &BTreeMap, - global_vars: &BTreeMap, + uniforms: &VecMap, + global_consts: &VecMap, + global_vars: &VecMap, structs: &StructTypes, array_size_consts: &ArraySizeConsts, imports: &mut ImportRegistry, - texture_specs: &BTreeMap, + texture_specs: &VecMap, ) -> Result, Diagnostic> { if inits.is_empty() { return Ok(None); diff --git a/lp-shader/lps-glsl/src/hir/array_size.rs b/lp-shader/lps-glsl/src/hir/array_size.rs index 85330b9be..4cdf64702 100644 --- a/lp-shader/lps-glsl/src/hir/array_size.rs +++ b/lp-shader/lps-glsl/src/hir/array_size.rs @@ -1,9 +1,9 @@ -use alloc::collections::BTreeMap; use alloc::string::String; +use lp_collection::VecMap; use crate::{Token, TokenKind, lex}; -pub(super) type ArraySizeConsts = BTreeMap; +pub(super) type ArraySizeConsts = VecMap; pub(super) fn eval_array_size_expr(source: &str, consts: &ArraySizeConsts) -> Option { let tokens = lex(source).ok()?; diff --git a/lp-shader/lps-glsl/src/hir/function.rs b/lp-shader/lps-glsl/src/hir/function.rs index a82eac680..c241a8ed7 100644 --- a/lp-shader/lps-glsl/src/hir/function.rs +++ b/lp-shader/lps-glsl/src/hir/function.rs @@ -1,7 +1,7 @@ -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use lps_shared::LpsType; @@ -23,7 +23,7 @@ pub(super) struct GlobalConst { #[derive(Debug, Default)] pub(super) struct ImportRegistry { - pub(super) imports: BTreeMap, + pub(super) imports: VecMap, } impl ImportRegistry { diff --git a/lp-shader/lps-glsl/src/hir/typeck.rs b/lp-shader/lps-glsl/src/hir/typeck.rs index 4e778bb9c..6aa8b3847 100644 --- a/lp-shader/lps-glsl/src/hir/typeck.rs +++ b/lp-shader/lps-glsl/src/hir/typeck.rs @@ -1,7 +1,7 @@ -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use lps_shared::{ LpsType, ParamQualifier, TextureBindingSpec, TextureShapeHint, TextureStorageFormat, @@ -36,16 +36,16 @@ use super::{ pub(super) struct TypeCtx<'a> { params: &'a [HirParam], functions: &'a [FunctionSig], - uniforms: &'a BTreeMap, - globals: &'a BTreeMap, - global_vars: &'a BTreeMap, + uniforms: &'a VecMap, + globals: &'a VecMap, + global_vars: &'a VecMap, structs: &'a StructTypes, array_size_consts: &'a ArraySizeConsts, imports: &'a mut ImportRegistry, - texture_specs: &'a BTreeMap, + texture_specs: &'a VecMap, pub(super) locals: Vec, pub(super) arena: HirArena, - scopes: Vec>, + scopes: Vec>, loop_depth: usize, } @@ -53,13 +53,13 @@ impl<'a> TypeCtx<'a> { pub(super) fn new( function: &'a FunctionSig, functions: &'a [FunctionSig], - uniforms: &'a BTreeMap, - globals: &'a BTreeMap, - global_vars: &'a BTreeMap, + uniforms: &'a VecMap, + globals: &'a VecMap, + global_vars: &'a VecMap, structs: &'a StructTypes, array_size_consts: &'a ArraySizeConsts, imports: &'a mut ImportRegistry, - texture_specs: &'a BTreeMap, + texture_specs: &'a VecMap, ) -> Self { Self { params: &function.params, @@ -73,20 +73,20 @@ impl<'a> TypeCtx<'a> { texture_specs, locals: Vec::new(), arena: HirArena::default(), - scopes: alloc::vec![BTreeMap::new()], + scopes: alloc::vec![VecMap::new()], loop_depth: 0, } } pub(super) fn global_const( functions: &'a [FunctionSig], - uniforms: &'a BTreeMap, - globals: &'a BTreeMap, - global_vars: &'a BTreeMap, + uniforms: &'a VecMap, + globals: &'a VecMap, + global_vars: &'a VecMap, structs: &'a StructTypes, array_size_consts: &'a ArraySizeConsts, imports: &'a mut ImportRegistry, - texture_specs: &'a BTreeMap, + texture_specs: &'a VecMap, ) -> Self { Self { params: &[], @@ -100,7 +100,7 @@ impl<'a> TypeCtx<'a> { texture_specs, locals: Vec::new(), arena: HirArena::default(), - scopes: alloc::vec![BTreeMap::new()], + scopes: alloc::vec![VecMap::new()], loop_depth: 0, } } @@ -209,10 +209,10 @@ impl<'a> TypeCtx<'a> { } => { let condition = self.type_expr(condition)?; let condition = self.coerce_expr(condition, &LpsType::Bool)?; - self.scopes.push(BTreeMap::new()); + self.scopes.push(VecMap::new()); let accept = self.type_statements(accept, return_ty)?; self.scopes.pop(); - self.scopes.push(BTreeMap::new()); + self.scopes.push(VecMap::new()); let reject = self.type_statements(reject, return_ty)?; self.scopes.pop(); Ok(alloc::vec![HirStmt::If { @@ -228,7 +228,7 @@ impl<'a> TypeCtx<'a> { body, span, } => { - self.scopes.push(BTreeMap::new()); + self.scopes.push(VecMap::new()); let init = self.type_statements(init, return_ty)?; let condition = if let Some(condition) = condition { let condition = self.type_expr(condition)?; @@ -238,7 +238,7 @@ impl<'a> TypeCtx<'a> { .push_expr(*span, LpsType::Bool, HirExprKind::BoolLiteral(true)) }; self.loop_depth += 1; - self.scopes.push(BTreeMap::new()); + self.scopes.push(VecMap::new()); let body = self.type_statements(body, return_ty)?; self.scopes.pop(); let continuing = self.type_statements(continuing, return_ty)?; @@ -259,7 +259,7 @@ impl<'a> TypeCtx<'a> { let condition = self.type_expr(condition)?; let condition = self.coerce_expr(condition, &LpsType::Bool)?; self.loop_depth += 1; - self.scopes.push(BTreeMap::new()); + self.scopes.push(VecMap::new()); let body = self.type_statements(body, return_ty)?; self.scopes.pop(); self.loop_depth -= 1; @@ -269,7 +269,7 @@ impl<'a> TypeCtx<'a> { body, condition, .. } => { self.loop_depth += 1; - self.scopes.push(BTreeMap::new()); + self.scopes.push(VecMap::new()); let body = self.type_statements(body, return_ty)?; self.scopes.pop(); self.loop_depth -= 1; @@ -290,7 +290,7 @@ impl<'a> TypeCtx<'a> { Ok(alloc::vec![HirStmt::Continue]) } ParsedStmt::Block { statements, .. } => { - self.scopes.push(BTreeMap::new()); + self.scopes.push(VecMap::new()); let statements = self.type_statements(statements, return_ty)?; self.scopes.pop(); Ok(statements) diff --git a/lp-shader/lps-glsl/src/hir/types.rs b/lp-shader/lps-glsl/src/hir/types.rs index 6c337c0c8..728c604cc 100644 --- a/lp-shader/lps-glsl/src/hir/types.rs +++ b/lp-shader/lps-glsl/src/hir/types.rs @@ -1,6 +1,6 @@ -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use lps_shared::{LpsModuleSig, LpsType, ParamQualifier, TextureBindingSpec}; @@ -12,10 +12,10 @@ use crate::body::{BinaryOp, IncDecOp, UnaryOp}; pub struct HirModule { pub functions: Vec, pub meta: LpsModuleSig, - pub uniforms: BTreeMap, - pub globals: BTreeMap, + pub uniforms: VecMap, + pub globals: VecMap, pub imports: Vec, - pub texture_specs: BTreeMap, + pub texture_specs: VecMap, pub texel_fetch_bounds: lpir::TexelFetchBoundsMode, } @@ -42,7 +42,7 @@ pub struct ImportInfo { pub sret: bool, } -pub(super) type StructTypes = BTreeMap; +pub(super) type StructTypes = VecMap; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ImportKey { diff --git a/lp-shader/lps-glsl/src/lower.rs b/lp-shader/lps-glsl/src/lower.rs index eb00c4e91..3cfe21dbe 100644 --- a/lp-shader/lps-glsl/src/lower.rs +++ b/lp-shader/lps-glsl/src/lower.rs @@ -1,8 +1,8 @@ -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec; use alloc::vec::Vec; +use lp_collection::VecMap; use lpir::{ CalleeRef, FuncId, FunctionBuilder, ImportDecl, IrType, LpirModule, LpirOp, ModuleBuilder, @@ -39,7 +39,7 @@ pub struct LoweredModule { pub fn lower_hir(module: HirModule) -> Result { let mut mb = ModuleBuilder::new(); - let mut import_map = BTreeMap::new(); + let mut import_map = VecMap::new(); for import in &module.imports { let callee = mb.add_import(ImportDecl { module_name: import.module_name.clone(), @@ -77,7 +77,7 @@ pub fn lower_hir(module: HirModule) -> Result { fn lower_function( function: &HirFunction, module: &HirModule, - import_map: &BTreeMap, + import_map: &VecMap, ) -> Result { let return_types = scalar_ir_types(&function.return_ty)?; let mut fb = FunctionBuilder::new(&function.name, &return_types); @@ -129,9 +129,9 @@ struct LowerCtx<'a> { params: Vec, locals: Vec, arena: &'a HirArena, - import_map: &'a BTreeMap, + import_map: &'a VecMap, param_qualifiers: Vec, - texture_specs: &'a BTreeMap, + texture_specs: &'a VecMap, texel_fetch_bounds: lpir::TexelFetchBoundsMode, } diff --git a/lp-shader/lps-shared/Cargo.toml b/lp-shader/lps-shared/Cargo.toml index ecf86e03f..0e3820088 100644 --- a/lp-shader/lps-shared/Cargo.toml +++ b/lp-shader/lps-shared/Cargo.toml @@ -14,6 +14,7 @@ std = [] schemars = ["dep:schemars"] [dependencies] +lp-collection = { workspace = true, features = ["serde"] } lps-q32 = { path = "../lps-q32" } serde = { workspace = true, features = ["derive"] } schemars = { workspace = true, optional = true } diff --git a/lp-shader/lps-shared/src/sig.rs b/lp-shader/lps-shared/src/sig.rs index d3e0ffbc8..1f976466d 100644 --- a/lp-shader/lps-shared/src/sig.rs +++ b/lp-shader/lps-shared/src/sig.rs @@ -1,7 +1,7 @@ //! Function signature shapes (no registry / overload resolution). -use alloc::collections::BTreeMap; use alloc::{string::String, vec::Vec}; +use lp_collection::VecMap; use crate::{LayoutRules, LpsType, TextureBindingSpec, VMCTX_HEADER_SIZE}; @@ -61,7 +61,7 @@ pub struct LpsModuleSig { /// Compile-time [`TextureBindingSpec`] per sampler uniform name. Empty when the module has no /// textures or when using the texture-free default lower path; populated after validation by the /// options-aware frontend entry. - pub texture_specs: BTreeMap, + pub texture_specs: VecMap, } impl LpsModuleSig { diff --git a/lp-shader/lps-shared/src/texture_binding_validate.rs b/lp-shader/lps-shared/src/texture_binding_validate.rs index 90dc5bbe1..5dbf01376 100644 --- a/lp-shader/lps-shared/src/texture_binding_validate.rs +++ b/lp-shader/lps-shared/src/texture_binding_validate.rs @@ -3,9 +3,9 @@ use crate::path::parse_path; use crate::path_resolve::LpsTypePathExt; use crate::{LpsModuleSig, LpsType, StructMember, TextureBindingSpec}; -use alloc::collections::{BTreeMap, BTreeSet}; use alloc::format; use alloc::string::String; +use lp_collection::{VecMap, VecSet}; /// Every [`LpsType::Texture2D`] uniform in [`LpsModuleSig::uniforms_type`] must have a matching /// spec entry; every spec key must name a declared sampler. Empty specs with no texture uniforms @@ -15,7 +15,7 @@ use alloc::string::String; /// is present but not a struct. pub fn validate_texture_binding_specs_against_module( meta: &LpsModuleSig, - specs: &BTreeMap, + specs: &VecMap, ) -> Result<(), String> { let declared = declared_texture2d_paths(meta)?; for name in &declared { @@ -35,16 +35,16 @@ pub fn validate_texture_binding_specs_against_module( Ok(()) } -fn declared_texture2d_paths(meta: &LpsModuleSig) -> Result, String> { +fn declared_texture2d_paths(meta: &LpsModuleSig) -> Result, String> { let Some(u) = meta.uniforms_type.as_ref() else { - return Ok(BTreeSet::new()); + return Ok(VecSet::new()); }; let LpsType::Struct { members, .. } = u else { return Err(String::from( "uniforms metadata is not a struct (cannot validate texture bindings)", )); }; - let mut out = BTreeSet::new(); + let mut out = VecSet::new(); collect_texture2d_paths_from_members(u, members, &[], &mut out)?; Ok(out) } @@ -55,7 +55,7 @@ fn collect_texture2d_paths_from_members( uniforms_root: &LpsType, members: &[StructMember], prefix: &[String], - out: &mut BTreeSet, + out: &mut VecSet, ) -> Result<(), String> { for m in members { match &m.ty { @@ -176,7 +176,7 @@ mod tests { globals_type: None, ..Default::default() }; - let specs = BTreeMap::new(); + let specs = VecMap::new(); let err = validate_texture_binding_specs_against_module(&meta, &specs).unwrap_err(); assert!( err.contains("inputColor") && err.contains("no texture binding spec"), @@ -195,7 +195,7 @@ mod tests { globals_type: None, ..Default::default() }; - let mut specs = BTreeMap::new(); + let mut specs = VecMap::new(); specs.insert( String::from("extraTex"), TextureBindingSpec { @@ -227,7 +227,7 @@ mod tests { globals_type: None, ..Default::default() }; - let mut specs = BTreeMap::new(); + let mut specs = VecMap::new(); specs.insert( String::from("u_tex"), TextureBindingSpec { @@ -267,7 +267,7 @@ mod tests { globals_type: None, ..Default::default() }; - let mut specs = BTreeMap::new(); + let mut specs = VecMap::new(); specs.insert( String::from("params.gradient"), TextureBindingSpec { @@ -301,7 +301,7 @@ mod tests { globals_type: None, ..Default::default() }; - let specs = BTreeMap::new(); + let specs = VecMap::new(); let err = validate_texture_binding_specs_against_module(&meta, &specs).unwrap_err(); assert!( err.contains("params.gradient") && err.contains("no texture binding spec"), @@ -329,7 +329,7 @@ mod tests { globals_type: None, ..Default::default() }; - let mut specs = BTreeMap::new(); + let mut specs = VecMap::new(); specs.insert( String::from("params.gradient"), TextureBindingSpec { @@ -383,7 +383,7 @@ mod tests { globals_type: None, ..Default::default() }; - let mut specs = BTreeMap::new(); + let mut specs = VecMap::new(); for path in ["inputColor", "params.gradient"] { specs.insert( String::from(path), @@ -425,7 +425,7 @@ mod tests { globals_type: None, ..Default::default() }; - validate_texture_binding_specs_against_module(&meta, &BTreeMap::new()).unwrap(); + validate_texture_binding_specs_against_module(&meta, &VecMap::new()).unwrap(); } #[test] @@ -442,7 +442,7 @@ mod tests { globals_type: None, ..Default::default() }; - let specs = BTreeMap::new(); + let specs = VecMap::new(); let err = validate_texture_binding_specs_against_module(&meta, &specs).unwrap_err(); assert!(err.contains("no name"), "{err}"); } @@ -464,7 +464,7 @@ mod tests { globals_type: None, ..Default::default() }; - let mut specs = BTreeMap::new(); + let mut specs = VecMap::new(); specs.insert( String::from("ignored"), TextureBindingSpec { @@ -507,7 +507,7 @@ mod tests { globals_type: None, ..Default::default() }; - let specs = BTreeMap::new(); + let specs = VecMap::new(); let err = validate_texture_binding_specs_against_module(&meta, &specs).unwrap_err(); assert!( err.contains("uniform arrays") && err.contains("layers"), @@ -523,7 +523,7 @@ mod tests { globals_type: None, ..Default::default() }; - let specs = BTreeMap::new(); + let specs = VecMap::new(); validate_texture_binding_specs_against_module(&meta, &specs).unwrap(); } @@ -551,7 +551,7 @@ mod tests { ..Default::default() }; // No texture specs needed - should pass - validate_texture_binding_specs_against_module(&meta, &BTreeMap::new()).unwrap(); + validate_texture_binding_specs_against_module(&meta, &VecMap::new()).unwrap(); } /// Test for truly anonymous members (name: None) - should be skipped @@ -576,6 +576,6 @@ mod tests { ..Default::default() }; // Should pass - anonymous scalar members are skipped - validate_texture_binding_specs_against_module(&meta, &BTreeMap::new()).unwrap(); + validate_texture_binding_specs_against_module(&meta, &VecMap::new()).unwrap(); } } diff --git a/lp-shader/lpvm-cranelift/Cargo.toml b/lp-shader/lpvm-cranelift/Cargo.toml index bfe366fd0..bf9ee668a 100644 --- a/lp-shader/lpvm-cranelift/Cargo.toml +++ b/lp-shader/lpvm-cranelift/Cargo.toml @@ -35,6 +35,7 @@ riscv32-object = [ ] [dependencies] +lp-collection = { workspace = true, features = ["serde"] } libm = "0.2" spin = { workspace = true } lpvm = { path = "../lpvm", default-features = false } diff --git a/lp-shader/lpvm-cranelift/src/cranelift_host_memory.rs b/lp-shader/lpvm-cranelift/src/cranelift_host_memory.rs index c6fd651e9..f6b53f27c 100644 --- a/lp-shader/lpvm-cranelift/src/cranelift_host_memory.rs +++ b/lp-shader/lpvm-cranelift/src/cranelift_host_memory.rs @@ -1,11 +1,11 @@ //! Global-allocator linear [`LpvmMemory`] for Cranelift JIT (any target with [`alloc`]). //! //! Guest and host share one address space: [`LpvmBuffer::guest_base`] equals the zero-extended -//! host pointer. A [`spin::Mutex`] + [`BTreeMap`] tracks live allocations so `free`/`realloc` +//! host pointer. A [`spin::Mutex`] + [`VecMap`] tracks live allocations so `free`/`realloc` //! only accept buffers this allocator created (Rust's global `dealloc` needs the exact layout). use alloc::alloc::{alloc, dealloc, realloc}; -use alloc::collections::BTreeMap; +use lp_collection::VecMap; use core::alloc::Layout; @@ -14,13 +14,13 @@ use spin::Mutex; /// Real `alloc` / `dealloc` / `realloc` via the global allocator (host or embedded `#[global_allocator]`). pub struct CraneliftHostMemory { - live: Mutex>, + live: Mutex>, } impl CraneliftHostMemory { pub fn new() -> Self { Self { - live: Mutex::new(BTreeMap::new()), + live: Mutex::new(VecMap::new()), } } diff --git a/lp-shader/lpvm-cranelift/src/emit/mod.rs b/lp-shader/lpvm-cranelift/src/emit/mod.rs index e5e12d421..2b39fb2cf 100644 --- a/lp-shader/lpvm-cranelift/src/emit/mod.rs +++ b/lp-shader/lpvm-cranelift/src/emit/mod.rs @@ -2,11 +2,11 @@ use alloc::vec::Vec; -use alloc::collections::BTreeMap; use cranelift_codegen::ir::{AbiParam, ArgumentPurpose, Signature, types}; use cranelift_codegen::ir::{Block, FuncRef, InstBuilder, StackSlot, TrapCode, Value}; use cranelift_codegen::isa::{CallConv, TargetIsa}; use cranelift_frontend::{FunctionBuilder, Variable}; +use lp_collection::VecMap; use lpir::FloatMode; use lpir::lpir_module::{IrFunction, LpirModule}; @@ -37,8 +37,8 @@ pub(crate) struct EmitCtx<'a> { pub import_func_refs: &'a [FuncRef], pub slots: &'a [StackSlot], pub ir: &'a LpirModule, - /// Rank `0..functions.len()-1` for each [`LpirFuncId`] (BTreeMap key order). - pub func_id_to_ir_rank: &'a BTreeMap, + /// Rank `0..functions.len()-1` for each [`LpirFuncId`] (VecMap key order). + pub func_id_to_ir_rank: &'a VecMap, pub pointer_type: types::Type, /// `SlotAddr` definition and transitive `Iadd` results use native pointer SSA type (see `vreg_wide_addr_chain`). pub vreg_wide_addr: Vec, @@ -46,7 +46,7 @@ pub(crate) struct EmitCtx<'a> { pub lpir_builtins: Option, /// `IrFunction::sret_arg` is set (Cranelift `StructReturn` on the sret pointer param). pub uses_struct_return: bool, - /// Per local function (IR rank, BTreeMap key order): does the callee's Cranelift signature + /// Per local function (IR rank, VecMap key order): does the callee's Cranelift signature /// use `StructReturn`? Needed at call sites to allocate a buffer for implicit multi-scalar /// sret callees (e.g. RV32 `vec3 foo()` callee with no `sret_arg` LPIR vreg) and load /// results back from the buffer. diff --git a/lp-shader/lpvm-cranelift/src/jit_module.rs b/lp-shader/lpvm-cranelift/src/jit_module.rs index ddaa3e455..f697d35b0 100644 --- a/lp-shader/lpvm-cranelift/src/jit_module.rs +++ b/lp-shader/lpvm-cranelift/src/jit_module.rs @@ -1,8 +1,8 @@ //! Host JIT module: finalized code, GLSL metadata, signatures. -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use cranelift_codegen::ir::{Signature, types}; use cranelift_codegen::isa::{CallConv, OwnedTargetIsa}; @@ -30,10 +30,10 @@ pub(crate) struct JitModule { pub(crate) glsl_meta: LpsModuleSig, pub(crate) func_names: Vec, pub(crate) func_ids: Vec, - pub(crate) name_to_index: BTreeMap, - pub(crate) signatures: BTreeMap, + pub(crate) name_to_index: VecMap, + pub(crate) signatures: VecMap, /// Scalar return words per function (LPIR), even when the ABI uses StructReturn (empty `returns`). - pub(crate) logical_return_words: BTreeMap, + pub(crate) logical_return_words: VecMap, pub(crate) ir_param_counts: Vec, pub(crate) call_conv: CallConv, pub(crate) pointer_type: types::Type, diff --git a/lp-shader/lpvm-cranelift/src/lpvm_module.rs b/lp-shader/lpvm-cranelift/src/lpvm_module.rs index b764b536f..84052f3e5 100644 --- a/lp-shader/lpvm-cranelift/src/lpvm_module.rs +++ b/lp-shader/lpvm-cranelift/src/lpvm_module.rs @@ -1,10 +1,10 @@ //! LPVM trait implementation: [`CraneliftModule`] wraps the live JIT ([`crate::jit_module::JitModule`]) //! in [`Arc`] so finalized code stays valid and [`Clone`] is cheap. -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::sync::Arc; use alloc::vec::Vec; +use lp_collection::VecMap; use cranelift_codegen::ir::Signature; use lpir::FloatMode; @@ -45,7 +45,7 @@ impl CraneliftModule { self.0.glsl_meta() } - pub(crate) fn name_to_index(&self) -> &BTreeMap { + pub(crate) fn name_to_index(&self) -> &VecMap { &self.0.name_to_index } @@ -53,7 +53,7 @@ impl CraneliftModule { &self.0.ir_param_counts } - pub(crate) fn logical_return_words(&self) -> &BTreeMap { + pub(crate) fn logical_return_words(&self) -> &VecMap { &self.0.logical_return_words } diff --git a/lp-shader/lpvm-cranelift/src/module_lower.rs b/lp-shader/lpvm-cranelift/src/module_lower.rs index c3f1e840a..10ded7285 100644 --- a/lp-shader/lpvm-cranelift/src/module_lower.rs +++ b/lp-shader/lpvm-cranelift/src/module_lower.rs @@ -1,8 +1,8 @@ //! Shared LPIR → Cranelift lowering for any [`cranelift_module::Module`] (JIT or object). -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use cranelift_codegen::ir::{FuncRef, Signature, StackSlot, StackSlotData, StackSlotKind, types}; use cranelift_codegen::isa::CallConv; @@ -37,11 +37,11 @@ pub(crate) enum LpirFuncEmitOrder { pub(crate) struct LoweredLpirModule { pub func_ids: Vec, pub func_names: Vec, - pub signatures: BTreeMap, + pub signatures: VecMap, /// LPIR scalar return words per function (for StructReturn ABIs where `Signature::returns` is empty). - pub logical_return_words: BTreeMap, + pub logical_return_words: VecMap, pub ir_param_counts: Vec, - pub name_to_index: BTreeMap, + pub name_to_index: VecMap, pub call_conv: CallConv, pub pointer_type: types::Type, pub float_mode: FloatMode, @@ -80,7 +80,7 @@ pub(crate) fn lower_lpir_into_module( None }; - let func_id_to_ir_rank: BTreeMap = ir + let func_id_to_ir_rank: VecMap = ir .functions .keys() .enumerate() @@ -102,8 +102,8 @@ pub(crate) fn lower_lpir_into_module( }; let mut func_ids = Vec::with_capacity(indices.len()); - let mut signatures = BTreeMap::new(); - let mut logical_return_words = BTreeMap::new(); + let mut signatures = VecMap::new(); + let mut logical_return_words = VecMap::new(); let mut func_names = Vec::with_capacity(indices.len()); let mut ir_param_counts = Vec::with_capacity(indices.len()); @@ -125,7 +125,7 @@ pub(crate) fn lower_lpir_into_module( func_ids.push(id); } - let mut name_to_index = BTreeMap::new(); + let mut name_to_index = VecMap::new(); for (j, name) in func_names.iter().enumerate() { name_to_index.insert(name.clone(), j); } @@ -138,7 +138,7 @@ pub(crate) fn lower_lpir_into_module( } // Per-IR-rank flag: does the callee's Cranelift signature use StructReturn? - // Indexed by IR rank (BTreeMap key order), matching `func_id_to_ir_rank` and `id_at_ir`. + // Indexed by IR rank (VecMap key order), matching `func_id_to_ir_rank` and `id_at_ir`. let mut callee_struct_return: Vec = vec![false; ir.functions.len()]; for (fid, f) in ir.functions.iter() { let r = func_id_to_ir_rank[fid]; diff --git a/lp-shader/lpvm-native/Cargo.toml b/lp-shader/lpvm-native/Cargo.toml index 87236fc4c..b1e98f7a1 100644 --- a/lp-shader/lpvm-native/Cargo.toml +++ b/lp-shader/lpvm-native/Cargo.toml @@ -27,6 +27,7 @@ emu = [ ] [dependencies] +lp-collection = { workspace = true, features = ["serde"] } lpir = { path = "../lpir" } lp-perf = { path = "../../lp-base/lp-perf", default-features = false } lps-q32 = { path = "../lps-q32" } diff --git a/lp-shader/lpvm-native/src/abi/func_abi.rs b/lp-shader/lpvm-native/src/abi/func_abi.rs index 2c627fa6b..c7fea981e 100644 --- a/lp-shader/lpvm-native/src/abi/func_abi.rs +++ b/lp-shader/lpvm-native/src/abi/func_abi.rs @@ -1,8 +1,8 @@ //! Per-function ABI state for register roles, params, return, and allocation. -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use lpir::LpirModule; use lps_shared::LpsModuleSig; @@ -137,7 +137,7 @@ impl FuncAbi { #[derive(Clone, Debug)] pub struct ModuleAbi { isa: IsaTarget, - func_abis: BTreeMap, + func_abis: VecMap, max_callee_sret_bytes: u32, } @@ -146,7 +146,7 @@ impl ModuleAbi { pub fn from_ir_and_sig(isa: IsaTarget, ir: &LpirModule, sig: &LpsModuleSig) -> Self { use crate::isa::rv32::abi::func_abi_rv32; - let mut func_abis = BTreeMap::new(); + let mut func_abis = VecMap::new(); let mut max_sret_bytes = 0u32; for fn_sig in &sig.functions { diff --git a/lp-shader/lpvm-native/src/compile.rs b/lp-shader/lpvm-native/src/compile.rs index b8422071a..4fd0de458 100644 --- a/lp-shader/lpvm-native/src/compile.rs +++ b/lp-shader/lpvm-native/src/compile.rs @@ -310,9 +310,9 @@ pub fn compile_module( #[cfg(test)] mod tests { use super::*; - use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec; + use lp_collection::VecMap; use lpir::{FuncId, IrFunction, IrType, LpirModule, LpirOp, VReg, types::VRegRange}; use lps_shared::{LpsFnKind, LpsFnSig, LpsModuleSig, LpsType}; @@ -340,7 +340,7 @@ mod tests { fn test_compile_module_empty() { let ir = LpirModule { imports: vec![], - functions: BTreeMap::new(), + functions: VecMap::new(), }; let sig = LpsModuleSig::default(); let result = compile_module( @@ -359,7 +359,7 @@ mod tests { fn test_compile_simple_iconst() { let ir = LpirModule { imports: vec![], - functions: BTreeMap::from([( + functions: VecMap::from([( FuncId(0), IrFunction { name: String::from("test"), @@ -440,7 +440,7 @@ mod tests { }; let ir = LpirModule { imports: vec![], - functions: BTreeMap::from([(FuncId(0), func)]), + functions: VecMap::from([(FuncId(0), func)]), }; let sig = LpsModuleSig { functions: vec![LpsFnSig { @@ -500,7 +500,7 @@ mod tests { fn simple_iconst_module() -> (LpirModule, LpsModuleSig) { let ir = LpirModule { imports: vec![], - functions: BTreeMap::from([( + functions: VecMap::from([( FuncId(0), IrFunction { name: String::from("test"), diff --git a/lp-shader/lpvm-native/src/compile/module_job.rs b/lp-shader/lpvm-native/src/compile/module_job.rs index fb18cec3a..6e9690904 100644 --- a/lp-shader/lpvm-native/src/compile/module_job.rs +++ b/lp-shader/lpvm-native/src/compile/module_job.rs @@ -1,6 +1,6 @@ -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use lpir::{FloatMode, LpirModule}; use lps_shared::{LpsFnKind, LpsFnSig, LpsModuleSig, LpsType}; @@ -27,7 +27,7 @@ pub struct NativeCompileJob { float_mode: FloatMode, options: NativeCompileOptions, isa: IsaTarget, - sig_map: BTreeMap, + sig_map: VecMap, } impl NativeCompileJob { @@ -39,7 +39,7 @@ impl NativeCompileJob { isa: IsaTarget, ) -> Self { let function_count = ir.functions.len(); - let mut sig_map = BTreeMap::new(); + let mut sig_map = VecMap::new(); for function in sig.functions.iter().cloned() { sig_map.insert(function.name.clone(), function); } diff --git a/lp-shader/lpvm-native/src/debug/sections.rs b/lp-shader/lpvm-native/src/debug/sections.rs index 1bb2a90c1..ac4ad3b83 100644 --- a/lp-shader/lpvm-native/src/debug/sections.rs +++ b/lp-shader/lpvm-native/src/debug/sections.rs @@ -1,7 +1,7 @@ //! Structured debug sections for [`crate::compile::compile_function`] (`feature = "debug"`). -use alloc::collections::BTreeMap; use alloc::string::String; +use lp_collection::VecMap; use lpir::{IrFunction, LpirModule}; @@ -19,10 +19,10 @@ pub fn build_debug_sections( alloc_output: &AllocOutput, func_abi: &FuncAbi, symbols: &ModuleSymbols, -) -> BTreeMap { +) -> VecMap { #[cfg(feature = "debug")] { - let mut sections = BTreeMap::new(); + let mut sections = VecMap::new(); let interleaved = crate::regalloc::render::render_interleaved( func, @@ -64,6 +64,6 @@ pub fn build_debug_sections( #[cfg(not(feature = "debug"))] { let _ = (func, ir, lowered, code, alloc_output, func_abi, symbols); - BTreeMap::new() + VecMap::new() } } diff --git a/lp-shader/lpvm-native/src/debug_asm.rs b/lp-shader/lpvm-native/src/debug_asm.rs index 76d5be592..1c0cde4d6 100644 --- a/lp-shader/lpvm-native/src/debug_asm.rs +++ b/lp-shader/lpvm-native/src/debug_asm.rs @@ -69,7 +69,7 @@ mod tests { use alloc::string::String; use alloc::vec; - use alloc::collections::BTreeMap; + use lp_collection::VecMap; use lpir::types::VRegRange; use lpir::{FuncId, IrFunction, IrType, LpirModule, LpirOp, VReg}; @@ -81,7 +81,7 @@ mod tests { fn compile_module_asm_contains_lpir() { let ir = LpirModule { imports: vec![], - functions: BTreeMap::from([( + functions: VecMap::from([( FuncId(0), IrFunction { name: String::from("add"), diff --git a/lp-shader/lpvm-native/src/jit_symbol_sizes.rs b/lp-shader/lpvm-native/src/jit_symbol_sizes.rs index e7cf07354..6f604ed4e 100644 --- a/lp-shader/lpvm-native/src/jit_symbol_sizes.rs +++ b/lp-shader/lpvm-native/src/jit_symbol_sizes.rs @@ -1,11 +1,11 @@ //! Sorted JIT entry offsets → per-function sizes (`next - cur`, last uses `buffer_len`). -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; /// Sort `name → byte offset` by offset, then name. -pub(crate) fn sort_by_offset<'a>(map: &'a BTreeMap) -> Vec<(&'a str, u32)> { +pub(crate) fn sort_by_offset<'a>(map: &'a VecMap) -> Vec<(&'a str, u32)> { let mut sorted: Vec<(&'a str, u32)> = map .iter() .map(|(name, off)| (name.as_str(), *off as u32)) @@ -39,7 +39,7 @@ mod tests { #[test] fn sizes_from_btreemap_offsets() { - let mut offsets = BTreeMap::new(); + let mut offsets = VecMap::new(); offsets.insert("alpha".to_string(), 0); offsets.insert("beta".to_string(), 0x40); offsets.insert("gamma".to_string(), 0x60); diff --git a/lp-shader/lpvm-native/src/link.rs b/lp-shader/lpvm-native/src/link.rs index 3fd22773b..2a970601d 100644 --- a/lp-shader/lpvm-native/src/link.rs +++ b/lp-shader/lpvm-native/src/link.rs @@ -1,8 +1,8 @@ //! Linking: relocation resolution and output generation (JIT / ELF). -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use object::write::{Object, Relocation, StandardSection, Symbol, SymbolId, SymbolSection}; use object::{BinaryFormat, Endianness, FileFlags, SymbolFlags, SymbolKind, SymbolScope, elf}; @@ -16,7 +16,7 @@ pub struct LinkedJitImage { /// Executable machine code bytes. pub code: Vec, /// Function name → offset in code. - pub entries: BTreeMap, + pub entries: VecMap, } /// Resolve all relocations and produce a JIT-ready image. @@ -37,7 +37,7 @@ where { // Concatenate all function code let mut code = Vec::new(); - let mut entries = BTreeMap::new(); + let mut entries = VecMap::new(); let mut func_offsets = Vec::with_capacity(module.functions.len()); for func in &module.functions { @@ -118,7 +118,7 @@ pub fn link_elf(module: &CompiledModule, isa: IsaTarget) -> Result, Nati }; let text = obj.section_id(StandardSection::Text); - let mut symbol_ids: BTreeMap = BTreeMap::new(); + let mut symbol_ids: VecMap = VecMap::new(); // Add all function symbols first (before appending section data) for (idx, func) in module.functions.iter().enumerate() { diff --git a/lp-shader/lpvm-native/src/lower.rs b/lp-shader/lpvm-native/src/lower.rs index 2b599d963..a09aa19da 100644 --- a/lp-shader/lpvm-native/src/lower.rs +++ b/lp-shader/lpvm-native/src/lower.rs @@ -3583,7 +3583,7 @@ mod tests { #[test] fn lower_ops_populates_region_tree() { use crate::region::{REGION_ID_NONE, Region}; - use alloc::collections::BTreeMap; + use lp_collection::VecMap; use lpir::FuncId; use lpir::types::VRegRange; @@ -3615,7 +3615,7 @@ mod tests { let ir = LpirModule { imports: vec![], - functions: BTreeMap::from([(FuncId(0), func.clone())]), + functions: VecMap::from([(FuncId(0), func.clone())]), }; let sig = LpsModuleSig::default(); let abi = ModuleAbi::from_ir_and_sig(crate::isa::IsaTarget::Rv32imac, &ir, &sig); diff --git a/lp-shader/lpvm-native/src/regalloc/debug_facade.rs b/lp-shader/lpvm-native/src/regalloc/debug_facade.rs index 535ead7cd..8dfdbdb7e 100644 --- a/lp-shader/lpvm-native/src/regalloc/debug_facade.rs +++ b/lp-shader/lpvm-native/src/regalloc/debug_facade.rs @@ -3,9 +3,9 @@ //! Keeps `TraceSink`, `TracePush`, and trace-derived helpers in one place so //! `walk.rs` and `AllocOutput` stay free of scattered `#[cfg(feature = "debug")]`. -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; pub use crate::regalloc::trace::TraceEntry; @@ -54,11 +54,11 @@ fn is_entry_trace_mnemonic(m: &str) -> bool { /// Non-entry trace rows grouped by VInst index (forward order). pub fn trace_by_vinst_or_empty( output: &crate::regalloc::AllocOutput, -) -> BTreeMap> { +) -> VecMap> { #[cfg(feature = "debug")] { let trace = &output.trace; - let mut map: BTreeMap> = BTreeMap::new(); + let mut map: VecMap> = VecMap::new(); for entry in trace.entries.iter().rev() { if !is_entry_trace_mnemonic(&entry.vinst_mnemonic) { map.entry(entry.vinst_idx).or_default().push(entry); @@ -72,7 +72,7 @@ pub fn trace_by_vinst_or_empty( #[cfg(not(feature = "debug"))] { let _ = output; - BTreeMap::new() + VecMap::new() } } diff --git a/lp-shader/lpvm-native/src/regalloc/render.rs b/lp-shader/lpvm-native/src/regalloc/render.rs index 7c9ae9b66..29887a9ab 100644 --- a/lp-shader/lpvm-native/src/regalloc/render.rs +++ b/lp-shader/lpvm-native/src/regalloc/render.rs @@ -8,10 +8,10 @@ use crate::regalloc::{ Alloc, AllocOutput, Edit, EditPoint, append_entry_trace_metadata_lines, trace_by_vinst_or_empty, }; use crate::vinst::{IcmpCond, ModuleSymbols, VInst, VReg}; -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; +use lp_collection::VecMap; use lpir::{FuncId, IrFunction, LpirModule, LpirOp, print_module}; /// Indentation for LPIR body lines (after `; ` in the file). @@ -41,7 +41,7 @@ fn format_func_header_line(func: &IrFunction, module: &LpirModule, func_abi: &Fu .unwrap_or(FuncId(0)); let m = LpirModule { imports: module.imports.clone(), - functions: BTreeMap::from([(id, f)]), + functions: VecMap::from([(id, f)]), }; let s = print_module(&m); s.lines() @@ -133,8 +133,8 @@ pub fn render_interleaved( ) -> String { let mut lines = Vec::new(); - let mut vinsts_by_src_op: alloc::collections::BTreeMap> = - alloc::collections::BTreeMap::new(); + let mut vinsts_by_src_op: lp_collection::VecMap> = + lp_collection::VecMap::new(); for (inst_idx, inst) in vinsts.iter().enumerate() { if let Some(src_op) = inst.src_op() { @@ -145,7 +145,7 @@ pub fn render_interleaved( } } - let mut rendered_vinsts = alloc::collections::BTreeSet::new(); + let mut rendered_vinsts = lp_collection::VecSet::new(); let trace_by_vinst = trace_by_vinst_or_empty(output); @@ -216,7 +216,7 @@ fn push_vinst_snapshot_block( _vinsts: &[VInst], vreg_pool: &[VReg], output: &AllocOutput, - trace_by_vinst: &alloc::collections::BTreeMap>, + trace_by_vinst: &lp_collection::VecMap>, symbols: &ModuleSymbols, isa: IsaTarget, ) { @@ -240,7 +240,7 @@ fn push_vinst_snapshot_block_raw( inst: &VInst, vreg_pool: &[VReg], output: &AllocOutput, - trace_by_vinst: &alloc::collections::BTreeMap>, + trace_by_vinst: &lp_collection::VecMap>, symbols: &ModuleSymbols, indent_vinst: &str, _indent_edit_read: &str, diff --git a/lp-shader/lpvm-native/src/rt_jit/builtins.rs b/lp-shader/lpvm-native/src/rt_jit/builtins.rs index d1a06768d..cae4e257f 100644 --- a/lp-shader/lpvm-native/src/rt_jit/builtins.rs +++ b/lp-shader/lpvm-native/src/rt_jit/builtins.rs @@ -1,22 +1,22 @@ //! Builtin symbol table for JIT relocation (filled once, then `O(log n)` lookup). -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use lps_builtin_ids::BuiltinId; use lps_builtins::jit_builtin_code_ptr; /// Maps `extern "C"` symbol name → address for auipc+jalr fixups. pub struct BuiltinTable { - symbols: BTreeMap, + symbols: VecMap, } impl BuiltinTable { #[must_use] pub fn new() -> Self { Self { - symbols: BTreeMap::new(), + symbols: VecMap::new(), } } diff --git a/lp-shader/lpvm-native/src/rt_jit/compile_job.rs b/lp-shader/lpvm-native/src/rt_jit/compile_job.rs index 549bb994f..d5936c702 100644 --- a/lp-shader/lpvm-native/src/rt_jit/compile_job.rs +++ b/lp-shader/lpvm-native/src/rt_jit/compile_job.rs @@ -24,8 +24,7 @@ pub struct NativeJitCompileJob { options: NativeCompileOptions, isa: IsaTarget, stage: NativeJitCompileStage, - entry_info: - alloc::collections::BTreeMap, + entry_info: lp_collection::VecMap, } impl NativeJitCompileJob { diff --git a/lp-shader/lpvm-native/src/rt_jit/compiler.rs b/lp-shader/lpvm-native/src/rt_jit/compiler.rs index a2f46c503..f912c555f 100644 --- a/lp-shader/lpvm-native/src/rt_jit/compiler.rs +++ b/lp-shader/lpvm-native/src/rt_jit/compiler.rs @@ -1,8 +1,8 @@ //! Concatenate emitted functions, record relocations, patch auipc+jalr at finalize. -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; use lpir::LpirModule; use lps_shared::LpsModuleSig; @@ -39,7 +39,7 @@ pub fn compile_module_jit( builtin_table: &BuiltinTable, options: &NativeCompileOptions, isa: IsaTarget, -) -> Result<(JitBuffer, BTreeMap), NativeError> { +) -> Result<(JitBuffer, VecMap), NativeError> { let float_mode = options.float_mode; // 1. Compile module @@ -60,7 +60,7 @@ pub(crate) fn link_compiled_module_jit( compiled: CompiledModule, builtin_table: &BuiltinTable, isa: IsaTarget, -) -> Result<(JitBuffer, BTreeMap), NativeError> { +) -> Result<(JitBuffer, VecMap), NativeError> { // 2. Link JIT image with builtin resolution lp_perf::emit_begin!(EVENT_SHADER_LINK); let link_result = link_jit(&compiled, isa, |sym| { @@ -86,7 +86,7 @@ pub(crate) fn link_compiled_module_jit( } /// Builds [`JitSymbolEntry`] records (names in `name_buf`) and notifies the profiler sink. -fn emit_jit_symbols(buffer_base: u32, buffer_len: u32, entry_offsets: &BTreeMap) { +fn emit_jit_symbols(buffer_base: u32, buffer_len: u32, entry_offsets: &VecMap) { if entry_offsets.is_empty() { return; } diff --git a/lp-shader/lpvm-native/src/rt_jit/host_memory.rs b/lp-shader/lpvm-native/src/rt_jit/host_memory.rs index a27de4e41..f06371c71 100644 --- a/lp-shader/lpvm-native/src/rt_jit/host_memory.rs +++ b/lp-shader/lpvm-native/src/rt_jit/host_memory.rs @@ -1,11 +1,11 @@ //! Global-allocator based [`LpvmMemory`] for native JIT (no pre-allocated arena). //! //! Guest and host share one address space: [`LpvmBuffer::guest_base`] equals the zero-extended -//! host pointer. A [`spin::Mutex`] + [`BTreeMap`] tracks live allocations so `free`/`realloc` +//! host pointer. A [`spin::Mutex`] + [`VecMap`] tracks live allocations so `free`/`realloc` //! only accept buffers this allocator created (Rust's global `dealloc` needs the exact layout). use alloc::alloc::{alloc, dealloc, realloc}; -use alloc::collections::BTreeMap; +use lp_collection::VecMap; use core::alloc::Layout; @@ -14,13 +14,13 @@ use spin::Mutex; /// Real `alloc` / `dealloc` / `realloc` via the global allocator (host or embedded `#[global_allocator]`). pub struct NativeHostMemory { - live: Mutex>, + live: Mutex>, } impl NativeHostMemory { pub fn new() -> Self { Self { - live: Mutex::new(BTreeMap::new()), + live: Mutex::new(VecMap::new()), } } diff --git a/lp-shader/lpvm-native/src/rt_jit/module.rs b/lp-shader/lpvm-native/src/rt_jit/module.rs index fb2fef556..0ad231bc6 100644 --- a/lp-shader/lpvm-native/src/rt_jit/module.rs +++ b/lp-shader/lpvm-native/src/rt_jit/module.rs @@ -2,8 +2,8 @@ use alloc::sync::Arc; -use alloc::collections::BTreeMap; use alloc::string::String; +use lp_collection::VecMap; use lpir::LpirModule; use lps_shared::LpsModuleSig; use lpvm::{AllocError, LpvmMemory, LpvmModule}; @@ -20,8 +20,8 @@ use super::instance::NativeJitInstance; pub(crate) struct NativeJitModuleInner { pub meta: LpsModuleSig, pub buffer: JitBuffer, - pub entry_offsets: BTreeMap, - pub entry_info: BTreeMap, + pub entry_offsets: VecMap, + pub entry_info: VecMap, pub options: NativeCompileOptions, } @@ -135,8 +135,8 @@ pub(crate) fn build_entry_info( ir: &LpirModule, meta: &LpsModuleSig, isa: IsaTarget, -) -> Result, NativeError> { - let mut entries = BTreeMap::new(); +) -> Result, NativeError> { + let mut entries = VecMap::new(); for ir_func in ir.functions.values() { let gfn = meta .functions diff --git a/lp-shader/lpvm/Cargo.toml b/lp-shader/lpvm/Cargo.toml index 1eaae28da..cb3ab32fe 100644 --- a/lp-shader/lpvm/Cargo.toml +++ b/lp-shader/lpvm/Cargo.toml @@ -14,6 +14,7 @@ default = [] std = [] [dependencies] +lp-collection = { workspace = true, features = ["serde"] } lps-q32 = { path = "../lps-q32", default-features = false } lps-shared = { path = "../lps-shared" } lpir = { path = "../lpir" } \ No newline at end of file diff --git a/lp-shader/lpvm/src/debug.rs b/lp-shader/lpvm/src/debug.rs index b4bb69194..71aa098f2 100644 --- a/lp-shader/lpvm/src/debug.rs +++ b/lp-shader/lpvm/src/debug.rs @@ -6,10 +6,10 @@ //! - rv32c/rv32n: disasm only //! - jit/wasm: (not available) -use alloc::collections::BTreeMap; use alloc::format; use alloc::string::String; use alloc::vec::Vec; +use lp_collection::VecMap; /// Per-function compilation debug info. #[derive(Clone, Debug, Default)] @@ -19,7 +19,7 @@ pub struct FunctionDebugInfo { /// Static instruction count (from disassembly). pub inst_count: usize, /// Named sections. Standard keys: "interleaved", "disasm", "vinst", "liveness", "region". - pub sections: BTreeMap, + pub sections: VecMap, } impl FunctionDebugInfo { @@ -28,7 +28,7 @@ impl FunctionDebugInfo { Self { name: name.into(), inst_count: 0, - sections: BTreeMap::new(), + sections: VecMap::new(), } } @@ -39,7 +39,7 @@ impl FunctionDebugInfo { } /// Add multiple sections from a map. - pub fn with_sections(mut self, sections: BTreeMap) -> Self { + pub fn with_sections(mut self, sections: VecMap) -> Self { self.sections = sections; self } @@ -55,14 +55,14 @@ impl FunctionDebugInfo { #[derive(Clone, Debug, Default)] pub struct ModuleDebugInfo { /// Function name → debug info. - pub functions: BTreeMap, + pub functions: VecMap, } impl ModuleDebugInfo { /// Create empty ModuleDebugInfo. pub fn new() -> Self { Self { - functions: BTreeMap::new(), + functions: VecMap::new(), } } @@ -216,7 +216,7 @@ mod tests { let help = module.help_text("test.glsl", "rv32n"); assert!(help.contains("lp-cli shader-debug -t rv32n test.glsl --fn foo")); assert!(help.contains("lp-cli shader-debug -t rv32n test.glsl --fn bar")); - // BTreeMap iterates in sorted order, so "bar" comes before "foo" + // VecMap iterates in sorted order, so "bar" comes before "foo" assert!(help.contains("Available functions: bar, foo")); } diff --git a/lp-vis/lpv-model/src/schema_gen_smoke.rs b/lp-vis/lpv-model/src/schema_gen_smoke.rs index b8bd39855..a3905af48 100644 --- a/lp-vis/lpv-model/src/schema_gen_smoke.rs +++ b/lp-vis/lpv-model/src/schema_gen_smoke.rs @@ -10,7 +10,7 @@ mod tests { use crate::kind::{Colorspace, Dimension, InterpMethod, Kind, Unit}; use crate::presentation::Presentation; use crate::{ - ArtifactLocator, ChannelName, Effect, EffectRef, Live, LiveCandidate, NodeId, NodeName, + ArtifactSpecifier, ChannelName, Effect, EffectRef, Live, LiveCandidate, NodeId, NodeName, NodePropSpec, ParamsTable, Pattern, Playlist, PlaylistBehavior, PlaylistEntry, ShaderRef, SrcBinding, SrcShape, SrcSlot, SrcTextureSpec, SrcValueSpec, Stack, Transition, TransitionRef, TreePath, VisualInput, @@ -47,7 +47,7 @@ mod tests { } #[test] fn schema_artifact_spec() { - assert_schema_compiles!(ArtifactLocator); + assert_schema_compiles!(ArtifactSpecifier); } #[test] fn schema_channel_name() { diff --git a/lp-vis/lpv-model/src/visual/live.rs b/lp-vis/lpv-model/src/visual/live.rs index 4d60dccea..7ca0f1740 100644 --- a/lp-vis/lpv-model/src/visual/live.rs +++ b/lp-vis/lpv-model/src/visual/live.rs @@ -11,7 +11,7 @@ use crate::visual::transition_ref::TransitionRef; use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; -use lpc_source::ArtifactLocator; +use lpc_model::ArtifactSpecifier; use lpc_source::artifact::src_artifact::SrcArtifact; use lpc_source::prop::binding::SrcBinding; @@ -20,7 +20,7 @@ use lpc_source::prop::binding::SrcBinding; #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct LiveCandidate { - pub visual: ArtifactLocator, + pub visual: ArtifactSpecifier, #[serde(default = "default_priority")] pub priority: f32, #[cfg_attr( diff --git a/lp-vis/lpv-model/src/visual/playlist.rs b/lp-vis/lpv-model/src/visual/playlist.rs index dd3e48967..0b97b339c 100644 --- a/lp-vis/lpv-model/src/visual/playlist.rs +++ b/lp-vis/lpv-model/src/visual/playlist.rs @@ -10,7 +10,7 @@ use crate::visual::transition_ref::TransitionRef; use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; -use lpc_source::ArtifactLocator; +use lpc_model::ArtifactSpecifier; use lpc_source::artifact::src_artifact::SrcArtifact; use lpc_source::prop::binding::SrcBinding; @@ -19,7 +19,7 @@ use lpc_source::prop::binding::SrcBinding; #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct PlaylistEntry { - pub visual: ArtifactLocator, + pub visual: ArtifactSpecifier, #[serde(default, skip_serializing_if = "Option::is_none")] pub duration: Option, #[cfg_attr( diff --git a/lp-vis/lpv-model/src/visual/stack.rs b/lp-vis/lpv-model/src/visual/stack.rs index 57a663b49..15775e462 100644 --- a/lp-vis/lpv-model/src/visual/stack.rs +++ b/lp-vis/lpv-model/src/visual/stack.rs @@ -6,7 +6,7 @@ use crate::visual::{params_table::ParamsTable, visual_input::VisualInput}; use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; -use lpc_source::ArtifactLocator; +use lpc_model::ArtifactSpecifier; use lpc_source::artifact::src_artifact::SrcArtifact; /// One Effect in a Stack's chain. Order is the order of declaration. @@ -14,7 +14,7 @@ use lpc_source::artifact::src_artifact::SrcArtifact; #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct EffectRef { - pub visual: ArtifactLocator, + pub visual: ArtifactSpecifier, #[cfg_attr( feature = "schema-gen", schemars(schema_with = "crate::visual::params_table::toml_value_btree_map_schema") diff --git a/lp-vis/lpv-model/src/visual/transition_ref.rs b/lp-vis/lpv-model/src/visual/transition_ref.rs index 02896263a..2ddd6c9d6 100644 --- a/lp-vis/lpv-model/src/visual/transition_ref.rs +++ b/lp-vis/lpv-model/src/visual/transition_ref.rs @@ -5,7 +5,7 @@ use alloc::collections::BTreeMap; use alloc::string::String; -use lpc_source::ArtifactLocator; +use lpc_model::ArtifactSpecifier; /// Reference to a Transition with playback parameters. /// @@ -27,7 +27,7 @@ use lpc_source::ArtifactLocator; #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct TransitionRef { - pub visual: ArtifactLocator, + pub visual: ArtifactSpecifier, pub duration: f32, #[cfg_attr( feature = "schema-gen", diff --git a/lp-vis/lpv-model/src/visual/visual_input.rs b/lp-vis/lpv-model/src/visual/visual_input.rs index 8a45ffbf6..b03df26bc 100644 --- a/lp-vis/lpv-model/src/visual/visual_input.rs +++ b/lp-vis/lpv-model/src/visual/visual_input.rs @@ -11,14 +11,14 @@ use alloc::collections::BTreeMap; use alloc::string::String; use lpc_model::ChannelName; -use lpc_source::ArtifactLocator; +use lpc_model::ArtifactSpecifier; /// Child visual reference plus optional param overrides (TOML keys `visual`, `params`). #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] // Mutex flat keys; typos → hard errors per 00-design.md §Constraint. pub struct VisualInputVisual { - pub visual: ArtifactLocator, + pub visual: ArtifactSpecifier, /// `toml::Value` per key; schemars uses an open object so serde JSON and /// schema both allow `params` when present. #[cfg_attr( @@ -75,7 +75,7 @@ mod tests { #[test] fn visual_variant_round_trips() { let v = VisualInput::Visual(VisualInputVisual { - visual: ArtifactLocator::path("../patterns/fbm.pattern.toml"), + visual: ArtifactSpecifier::path("../patterns/fbm.pattern.toml"), params: BTreeMap::new(), }); let toml_str = toml::to_string(&v).unwrap(); @@ -88,7 +88,7 @@ mod tests { let mut params = BTreeMap::new(); params.insert("scale".into(), toml::Value::Float(6.0)); let v = VisualInput::Visual(VisualInputVisual { - visual: ArtifactLocator::path("../patterns/fbm.pattern.toml"), + visual: ArtifactSpecifier::path("../patterns/fbm.pattern.toml"), params, }); let toml_str = toml::to_string(&v).unwrap(); diff --git a/projects/test/fyeah-sign/blast.glsl b/projects/test/fyeah-sign/blast.glsl new file mode 100644 index 000000000..eabecce7a --- /dev/null +++ b/projects/test/fyeah-sign/blast.glsl @@ -0,0 +1,39 @@ +layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform float time; +layout(binding = 2) uniform float progress; + +vec3 neon(float t) { + vec3 a = vec3(0.55, 0.45, 0.55); + vec3 b = vec3(0.55, 0.55, 0.45); + vec3 c = vec3(1.0, 1.0, 1.0); + vec3 d = vec3(0.00, 0.33, 0.67); + return clamp(a + b * cos(6.2831853 * (c * t + d)), 0.0, 1.0); +} + +vec4 render(vec2 pos) { + vec2 uv = pos / outputSize; + vec2 p = uv - 0.5; + float aspect = outputSize.x / outputSize.y; + p.x *= aspect; + + float t = clamp(progress, 0.0, 1.0); + float decay = pow(1.0 - t, 1.55); + float envelope = mix(0.10, 1.0, decay); + float ember = pow(1.0 - t, 0.45); + + float radius = dot(p, p); + float a = atan(p.y, p.x); + float spokes = sin(a * 12.0 + time * mix(12.0, 7.0, t)); + float rings = sin(radius * mix(64.0, 42.0, t) - time * mix(18.0, 8.0, t)); + float shards = lpfn_fbm(p * mix(14.0, 8.0, t) + vec2(time * 0.55, -time * 0.32), 2, 0u); + float blast = smoothstep(0.75, 1.0, spokes * 0.55 + rings * 0.45); + float core = smoothstep(0.06, 0.0, radius); + float sparks = smoothstep(0.56, 0.92, shards + blast * 0.45); + float flash = 0.70 + 0.30 * sin(time * mix(24.0, 13.0, t)); + + vec3 color = neon(fract(time * 0.62 + a * 0.159 + radius * 2.2 + t * 0.18)); + color *= 0.12 + (0.45 + 1.85 * max(blast, core)) * flash * envelope; + color += vec3(1.0, 0.95, 0.55) * core * flash * mix(0.30, 1.25, envelope); + color += vec3(1.0, 0.92, 0.72) * sparks * flash * envelope * ember * 0.85; + return vec4(clamp(color, 0.0, 1.0), 1.0); +} diff --git a/projects/test/fyeah-sign/blast.toml b/projects/test/fyeah-sign/blast.toml new file mode 100644 index 000000000..f49d401a1 --- /dev/null +++ b/projects/test/fyeah-sign/blast.toml @@ -0,0 +1,24 @@ +kind = "Shader" +source = { path = "blast.glsl" } +render_order = 0 + +[bindings.time] +source = "..#entry_time" + +[bindings.progress] +source = "..#entry_progress" + +[glsl_opts] +add_sub = "wrapping" +mul = "wrapping" +div = "reciprocal" + +[consumed.time] +kind = "value" +value = "f32" +default = 0.0 + +[consumed.progress] +kind = "value" +value = "f32" +default = 0.0 diff --git a/projects/test/fyeah-sign/button.toml b/projects/test/fyeah-sign/button.toml new file mode 100644 index 000000000..a760942f0 --- /dev/null +++ b/projects/test/fyeah-sign/button.toml @@ -0,0 +1,7 @@ +kind = "Button" +endpoint = "button:gpio:D9" +id = 1 +stable_ms = 30 + +[bindings.down] +target = "bus#trigger" diff --git a/projects/test/fyeah-sign/clock.toml b/projects/test/fyeah-sign/clock.toml new file mode 100644 index 000000000..3e4ef317c --- /dev/null +++ b/projects/test/fyeah-sign/clock.toml @@ -0,0 +1 @@ +kind = "Clock" diff --git a/projects/test/fyeah-sign/fixture.toml b/projects/test/fyeah-sign/fixture.toml new file mode 100644 index 000000000..f4722085e --- /dev/null +++ b/projects/test/fyeah-sign/fixture.toml @@ -0,0 +1,21 @@ +kind = "Fixture" +color_order = "rgb" +brightness = 255 +gamma_correction = false +sampling = "direct" +transform = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + +[bindings.input] +source = "bus#visual.out" + +[bindings.output] +target = "bus#control.out" + +[render_size] +width = 16 +height = 16 + +[mapping] +kind = "SvgPath" +source = "./fyeah-mapping.svg" +sample_diameter = 2.0 diff --git a/projects/test/fyeah-sign/fyeah-mapping.svg b/projects/test/fyeah-sign/fyeah-mapping.svg new file mode 100644 index 000000000..d9e39f01a --- /dev/null +++ b/projects/test/fyeah-sign/fyeah-mapping.svg @@ -0,0 +1,61 @@ + + + + + + + + + path:1,count:23 + + + path:2,count:25 + + + + path:3,count:25 + + + + + path:4,count:27 + + + + path:5,count:21 + + + + path:6,count:26 + + + + path:7,count:30 + + + + path:8,count:29 + + + + path:9,count:7 + + + path:10,count:6 + + + \ No newline at end of file diff --git a/projects/test/fyeah-sign/idle.glsl b/projects/test/fyeah-sign/idle.glsl new file mode 100644 index 000000000..5ca2ae20f --- /dev/null +++ b/projects/test/fyeah-sign/idle.glsl @@ -0,0 +1,75 @@ +layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform float time; + +vec3 paletteRainbow(float t) { + float r = 0.33333; + vec3 v = abs(mod(fract(1.0 - t) + vec3(0.0, 1.0, 2.0) * r, 1.0) * 2.0 - 1.0); + return v * v * (3.0 - 2.0 * v); +} + +vec3 paletteCool(float t) { + vec3 a = vec3(0.46, 0.50, 0.58); + vec3 b = vec3(0.38, 0.36, 0.32); + vec3 c = vec3(1.0, 1.0, 1.0); + vec3 d = vec3(0.18, 0.30, 0.44); + return clamp(a + b * cos(6.2831853 * (c * t + d)), 0.0, 1.0); +} + +vec3 paletteWarm(float t) { + vec3 a = vec3(0.50, 0.42, 0.34); + vec3 b = vec3(0.42, 0.30, 0.24); + vec3 c = vec3(1.0, 1.0, 1.0); + vec3 d = vec3(0.00, 0.10, 0.24); + return clamp(a + b * cos(6.2831853 * (c * t + d)), 0.0, 1.0); +} + +vec3 applyPalette(float t, float palette) { + float p = floor(palette + 0.001); + if (p < 0.5) return paletteCool(t); + if (p < 1.5) return paletteRainbow(t); + return paletteWarm(t); +} + +vec2 movingNoise(vec2 coord, float t) { + vec2 gradient; + float noise = lpfn_psrdnoise( + coord + vec2(t * 0.030, -t * 0.020), + vec2(0.0), + t * 0.090, + gradient, + 0u + ); + float hue = mod(t * 0.055 + noise * 0.23 + dot(coord, vec2(0.018, -0.011)), 1.0); + float edge = atan(gradient.y, gradient.x) * 0.15915494 + 0.5; + float value = mix(0.38, 0.95, edge); + return vec2(hue, value); +} + +vec4 render(vec2 pos) { + const vec2 REF_SIZE = vec2(32.0, 32.0); + vec2 uv = pos / outputSize; + vec2 virtCoord = pos * REF_SIZE / outputSize; + vec2 center = REF_SIZE * 0.5; + vec2 fromCenter = virtCoord - center; + + float zoom = mix(0.040, 0.070, 0.5 + 0.5 * sin(time * 0.32)); + float drift = sin(time * 0.18); + vec2 coord = center + fromCenter * zoom + vec2(drift * 0.60, time * 0.075); + + vec2 tv = movingNoise(coord, time); + float bands = 0.5 + 0.5 * sin((uv.x + uv.y) * 7.0 + time * 0.85 + tv.x * 6.2831853); + float breath = 0.72 + 0.18 * sin(time * 0.75); + + float palettePhase = mod(time, 18.0) * 0.16666667; + float palette = min(floor(palettePhase), 2.0); + float nextPalette = palette + 1.0; + if (nextPalette > 2.5) { + nextPalette = 0.0; + } + float blend = smoothstep(0.78, 1.0, palettePhase - palette); + + vec3 color = mix(applyPalette(tv.x, palette), applyPalette(tv.x, nextPalette), blend); + color *= mix(0.48, 1.0, bands) * tv.y * breath; + color += paletteRainbow(fract(tv.x + 0.20)) * smoothstep(0.88, 1.0, bands) * 0.16; + return vec4(clamp(color, 0.0, 1.0), 1.0); +} diff --git a/projects/test/fyeah-sign/idle.toml b/projects/test/fyeah-sign/idle.toml new file mode 100644 index 000000000..9624e95a7 --- /dev/null +++ b/projects/test/fyeah-sign/idle.toml @@ -0,0 +1,13 @@ +kind = "Shader" +source = { path = "idle.glsl" } +render_order = 0 + +[glsl_opts] +add_sub = "wrapping" +mul = "wrapping" +div = "reciprocal" + +[consumed.time] +kind = "value" +value = "f32" +default = 0.0 diff --git a/projects/test/fyeah-sign/output.toml b/projects/test/fyeah-sign/output.toml new file mode 100644 index 000000000..656c22855 --- /dev/null +++ b/projects/test/fyeah-sign/output.toml @@ -0,0 +1,12 @@ +kind = "Output" +endpoint = "ws281x:rmt:D10" + +[bindings.input] +source = "bus#control.out" + +[options] +white_point = [0.9, 1.0, 1.0] +brightness = 0.15 +interpolation_enabled = true +dithering_enabled = false +lut_enabled = true diff --git a/projects/test/fyeah-sign/playlist.toml b/projects/test/fyeah-sign/playlist.toml new file mode 100644 index 000000000..02eb3a36d --- /dev/null +++ b/projects/test/fyeah-sign/playlist.toml @@ -0,0 +1,20 @@ +kind = "Playlist" +idle_entry = 1 +default_fade = 0.35 + +[bindings.time] +source = "bus#time.seconds" + +[entries.1] +name = "idle" +fade_after = 0.12 +node = { ref = "./idle.toml" } + +[entries.2] +name = "blast" +duration = 10.0 +fade_after = 2.0 +node = { ref = "./blast.toml" } + +[entries.2.bindings.trigger] +source = "bus#trigger" diff --git a/projects/test/fyeah-sign/project.toml b/projects/test/fyeah-sign/project.toml new file mode 100644 index 000000000..2749647be --- /dev/null +++ b/projects/test/fyeah-sign/project.toml @@ -0,0 +1,20 @@ +kind = "Project" +name = "fyeah-sign" + +[nodes.output] +ref = "./output.toml" + +[nodes.clock] +ref = "./clock.toml" + +[nodes.button] +ref = "./button.toml" + +[nodes.radio] +ref = "./radio.toml" + +[nodes.playlist] +ref = "./playlist.toml" + +[nodes.fixture] +ref = "./fixture.toml" diff --git a/projects/test/fyeah-sign/radio.toml b/projects/test/fyeah-sign/radio.toml new file mode 100644 index 000000000..f244615ae --- /dev/null +++ b/projects/test/fyeah-sign/radio.toml @@ -0,0 +1,11 @@ +kind = "ControlRadio" +endpoint = "radio:espnow:0" +channel = 1 +repeat_count = 3 +wifi_channel = 0 + +[bindings.input] +source = "bus#trigger" + +[bindings.output] +target = "bus#trigger" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 8e275b740..7b1a59a28 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,8 @@ +# Pinned nightly: this is a build-std project, and the `unwinding` crate is bound +# to the nightly `core::intrinsics::catch_unwind` ABI (0.2.8 = integer return; +# 0.2.9 switched to the bool return introduced in a later nightly). An unpinned +# `nightly` drifts CI onto a newer toolchain than local dev and breaks the build. +# Bump this date and the matching `unwinding` version together, deliberately. [toolchain] -channel = "nightly" -components = ["rustfmt", "clippy"] +channel = "nightly-2026-04-27" +components = ["rustfmt", "clippy", "rust-src"] diff --git a/scripts/bump-nightly.sh b/scripts/bump-nightly.sh new file mode 100755 index 000000000..675b1693b --- /dev/null +++ b/scripts/bump-nightly.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# +# Bump the pinned nightly toolchain in lockstep with the ABI-coupled `unwinding` +# crate, then validate. See docs/toolchain-notes.md for why the toolchain is +# pinned and why `unwinding` must move with it. +# +# Usage: +# scripts/bump-nightly.sh 2026-06-01 # pin to a specific dated nightly +# scripts/bump-nightly.sh # pin to today's nightly (UTC) +# +# What it does: +# 1. Rewrites the pin in rust-toolchain.toml and .github/workflows/pre-merge.yml. +# 2. Runs `just check` (compiles `unwinding` under build-std via clippy-rv32, and +# catches new-nightly clippy lints) using the *current* `unwinding`. +# 3. Only if that fails: advances `unwinding` to the latest 0.2.x and re-checks +# (a forward bump past the `catch_unwind` int->bool change needs 0.2.9+). +# 4. Leaves all changes in the working tree for review; never commits. On an +# unrecoverable failure it reverts only the speculative `unwinding` bump and +# reports — the toolchain edits stay so you can iterate. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$WORKSPACE_ROOT" + +TOOLCHAIN_FILE="rust-toolchain.toml" +WORKFLOW_FILE=".github/workflows/pre-merge.yml" + +# Resolve the target date: explicit arg, else today (UTC). +DATE="${1:-}" +if [ -z "$DATE" ]; then + DATE="$(date -u +%Y-%m-%d)" + echo "No date given; using today (UTC): $DATE" +fi +if ! printf '%s' "$DATE" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'; then + echo "error: expected a date in YYYY-MM-DD form, got '$DATE'" >&2 + echo "usage: just bump-nightly [YYYY-MM-DD] (no arg = today, UTC)" >&2 + exit 1 +fi +CHANNEL="nightly-$DATE" + +# Portable in-place sed: BSD (macOS) needs an explicit backup suffix arg to -i. +sedi() { + if sed --version >/dev/null 2>&1; then sed -i "$@"; else sed -i '' "$@"; fi +} + +unwinding_version() { + grep -A1 -E '^name = "unwinding"$' Cargo.lock | grep -E '^version' | head -1 \ + | sed -E 's/version = "(.*)"/\1/' +} + +CURRENT_PIN="$(grep -Eo 'nightly-[0-9]{4}-[0-9]{2}-[0-9]{2}' "$TOOLCHAIN_FILE" | head -1 || true)" +echo "Pinning toolchain: ${CURRENT_PIN:-} -> $CHANNEL (unwinding currently $(unwinding_version))" + +# 1. Every rust-toolchain.toml in the repo (workspace root + per-crate pins, e.g. +# lp-fw/fw-esp32 which the recipes `cd` into). They must all match or build-std +# crates resolve a different, possibly unpinned, toolchain. +while IFS= read -r tc; do + sedi -E "s/^channel = \"nightly(-[0-9]{4}-[0-9]{2}-[0-9]{2})?\"/channel = \"$CHANNEL\"/" "$tc" + grep -q "channel = \"$CHANNEL\"" "$tc" \ + || { echo "error: failed to update channel in $tc" >&2; exit 1; } + echo " pinned $tc" +done < <(find . -name rust-toolchain.toml -not -path './target/*' -not -path '*/target/*') + +# 2. workflow: every `toolchain: nightly[-DATE]` (active + commented-out jobs, kept consistent) +sedi -E "s/(toolchain: )nightly(-[0-9]{4}-[0-9]{2}-[0-9]{2})?/\1$CHANNEL/g" "$WORKFLOW_FILE" + +# Snapshot Cargo.lock so we can revert a speculative unwinding bump if it doesn't help. +LOCK_BACKUP="$(mktemp)" +cp Cargo.lock "$LOCK_BACKUP" +trap 'rm -f "$LOCK_BACKUP"' EXIT + +echo +echo "Validating with 'just check' (installs $CHANNEL, compiles unwinding under build-std)..." +if just check; then + echo + echo "OK: $CHANNEL builds clean with unwinding $(unwinding_version) (unchanged)." + echo "Review and commit:" + echo " git add $TOOLCHAIN_FILE $WORKFLOW_FILE && git commit" + exit 0 +fi + +echo +echo "Initial check failed. Advancing 'unwinding' to match the new nightly's catch_unwind ABI..." +cargo update -p unwinding +if cmp -s Cargo.lock "$LOCK_BACKUP"; then + echo + echo "FAILED: 'just check' did not pass and 'unwinding' is already at the latest 0.2.x." >&2 + echo "The failure is unrelated to the unwinding ABI (new clippy lint, other breakage)." >&2 + echo "Toolchain edits left in place. Inspect the output above, or abandon with:" >&2 + echo " git checkout $TOOLCHAIN_FILE $WORKFLOW_FILE" >&2 + exit 1 +fi + +echo " unwinding -> $(unwinding_version); re-validating..." +if just check; then + echo + echo "OK: $CHANNEL builds clean after bumping unwinding to $(unwinding_version)." + echo "Review and commit (note the Cargo.lock change):" + echo " git add $TOOLCHAIN_FILE $WORKFLOW_FILE Cargo.lock && git commit" + exit 0 +fi + +# Neither worked: drop the speculative unwinding bump, keep the toolchain edits. +cp "$LOCK_BACKUP" Cargo.lock +echo +echo "FAILED: $CHANNEL does not build even after bumping unwinding (reverted that bump)." >&2 +echo "Toolchain edits are left in place for iteration. Options:" >&2 +echo " - pin a specific unwinding: cargo update -p unwinding --precise , then 'just check'" >&2 +echo " - if unwinding needs a new MAJOR (e.g. 0.3), bump the req in the crates' Cargo.toml" >&2 +echo " - abandon the bump: git checkout $TOOLCHAIN_FILE $WORKFLOW_FILE Cargo.lock" >&2 +exit 1