Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions docs/adr/2026-07-28-effects-are-projects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# ADR: Effects are projects — embeddable projects, promoted controls, vendoring by copy

- **Status:** Accepted
- **Date:** 2026-07-28
- **Deciders:** Photomancer
- **Supersedes:** The deliberate authoring guard "kind Project cannot be
created as a child node" (`lpc-registry` node authoring; the guard
existed because child-project semantics were undefined — this ADR
defines them). Builds on scoped buses
(`2026-07-28-scoped-buses.md`), node authoring operations
(`2026-07-27-node-authoring-operations.md`), and node card faces
(`2026-07-26-node-card-faces.md`).
- **Superseded by:** None

## Context

The product goal is a library of visual effects that are easy to author
standalone and easy to drop into an existing project's playlist. The
kickoff design sketched a new `composite` node kind: a folder with a
`composite.json` boundary file, promoted controls, and a forwarded
output. Discovery collapsed that design:

- `ProjectDef` is already a minimal container — `format`, `uid`,
`name`, `nodes{}`. No fixture/board/bus baggage lives in the def.
- The loader already treats a project node as a generic no-op container;
the inventory walk follows `nodes{}` refs generically; nothing in the
load path prevents nesting. The only hard block was the authoring
guard.
- A separate `composite` kind would duplicate the container shape,
split the gallery/open/authoring flows into two parallel paths, and
force an answer to "when does a composite graduate into a project?"

An effect kinda *is* a project. What an effect needs beyond the bare
container — bus isolation, a playable output, curated knobs,
provenance — are additive properties, not a different structure.

## Decision

**No new node kind.** The kind string stays `project`; there is no
format bump and no migration. "Effect" is semantic — a project that
declares promoted controls and ships in the Effects gallery category —
not structural.

The effect boundary contract is:

1. **Bus scope** — every project node is a bus scope (scoped-buses
ADR): embedding is isolation-safe by construction.
2. **Output mirror** — every project node exposes produced `output`
mirroring its scope's `visual.out` (scoped-buses ADR): an effect is
playlist-playable like any visual node.
3. **Promoted controls** — `ProjectDef` gains an optional
`controls: { <name>: PromotedControlDef }` map. A promoted control is
an **alias, not a mirror**: it carries a target (`node:./child#slot`,
a direct child's slot) plus optional label/unit/min/max display
overrides, and **no value** — values live on the target slot, so
overlay dirty state, transient edits, and bound-violet UI state all
observe the one real slot with no sync machinery. (This deliberately
differs from `ShaderSlotDef`, which owns defaults; an alias that
carried a default would create two sources of truth.)
4. **Provenance** — `ProjectDef` gains optional `author`, `version`,
`license` strings (no semver semantics yet). `uid` and `name`
already exist.

Supporting decisions:

- **Promotion is Studio-side DTO aliasing.** The effect card face emits
panel controls whose slot **address is the inner child's**; the
standard address-routed write path does the rest. No server-side
alias slots, no new write machinery. Targets are restricted to direct
children in this slice.
- **Vendoring by copy.** Importing an effect copies its folder into the
host project (`effects/<name>/`) and attaches it with the existing
byte-oriented create operation (`WireCreateNodeRequest`'s
file+assets seam, exactly the reuse the node-authoring ADR designed
for). No reference/link semantics, no update/diff flow this slice;
the effect's own provenance fields are the only lineage.
- **Non-root `format` tolerance.** `format` is only probed at the
project root; a child `project.json` carrying `format` loads with the
field ignored. Vendored effect folders keep their `format` so a
copied-out folder remains standalone-openable; a future offline
upgrader owns skew between root and vendored formats.
- **Relative refs survive vendoring by construction.** `node:` binding
refs are node-tree-relative and artifact refs are file-relative, so a
copied folder's internal wiring is location-independent.

## Consequences

- One authoring surface: effects are opened, edited, created, and
version-controlled exactly like projects. The gallery's Effects
category and the declared `controls` are the only distinctions.
- Existing artifacts are byte-identical: all new `ProjectDef` fields are
optional and serialize skip-if-default.
- Old firmware rejecting the new optional fields is a non-issue (fields
are additive; device never upgrades formats — offline upgrading is
Studio's job).
- The one hard error class this introduces: a `controls` entry whose
target does not resolve to a direct child's slot is a load error with
a path-qualified message (fail loud at load, not silently dead knobs).
- Naming: "project" remains the formal name; UI copy leans on the
Effects category so users mostly meet "effect". The copy ambiguity
with "the project you have open" is handled in Studio copy, not in
the model.

## Alternatives considered

- **A dedicated `composite`/`effect` node kind** — rejected: duplicates
the container structure, forks every authoring/gallery/open flow,
requires schema+kind churn, and leaves "project vs composite" as a
permanent taxonomy question. The unification costs one authoring
guard and two optional field groups.
- **Auto-collected controls** (promote every panel-flagged inner slot)
— rejected for effects: the promoted set is the effect's public API
and should be curated. (Auto-collect may still make sense as a
default for plain projects later.)
- **Server-side alias slots** for promotion — rejected: heavyweight
(new slot kind, write-path forwarding, dirty-state sync) for what the
DTO layer does with an address.
- **Import by reference** (shared library folder, linked updates) —
deferred: update/versioning semantics deserve their own slice;
copy-vendoring is predictable and offline-safe.
182 changes: 182 additions & 0 deletions docs/adr/2026-07-28-scoped-buses.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# ADR: Scoped buses with writer-shadowing

- **Status:** Accepted
- **Date:** 2026-07-28
- **Deciders:** Photomancer
- **Supersedes:** The playlist-entry "suppress produced visual defaults"
loader rule (an undocumented special case of this model; see Context).
Builds on declarative default bindings
(`2026-07-09-declarative-default-bindings.md`) and the
effects-are-projects decision
(`2026-07-28-effects-are-projects.md`).
- **Superseded by:** None

## Context

The bus is the engine's "just works" wiring: a shader's consumed `time`
slot declares `default_bind: bus:time`, a clock's produced `seconds`
declares `default_bind: bus:time`, and a project with both animates with
zero authored bindings. Channels were global — one flat
`ChannelName → bindings` namespace per loaded project.

Embedding a project as a child node (the effects slice: an effect **is**
a project) breaks the flat namespace: the effect's inner shader would
publish `visual.out` onto the host's bus and clobber the host's visual,
and two effects side by side would fight over every conventional channel
name. The pre-existing loader rule that suppressed produced visual
defaults for playlist-entry children was exactly this problem in
miniature, solved as a special case: entry shaders are *alternatives*,
so their produced `visual.out` defaults were silently dropped and the
playlist published the blended result instead.

Constraints that shaped the decision:

- **No embedded-mode branches.** An effect must behave identically
standalone and embedded; any "if embedded, disable X" rule creates
context-dependent behavior (works alone, breaks inside a host, or vice
versa).
- **Keep the magic.** Consumed defaults like `time` must keep flowing
into effects from the host — an effect with no clock of its own should
inherit host time, or it does not animate.
- **Zero migration.** Every existing project is a flat single-project
tree; the model change must leave their load results and artifact
bytes bit-for-bit identical, with no format bump.
- **Device parity.** Whatever the mechanism, it must run identically on
sim and firmware paths — which means the loader (shared via
lpa-server) rather than any host-specific service.

## Decision

Bus channels are keyed by `(scope, name)`. Scopes form a tree computed
at load time from the projected node spine:

1. **Every project node introduces a named scope** around its children.
The root project is simply the outermost scope. Scope introduction is
a property of the node kind, not the invocation site.
2. **Isolating invocation sites introduce an anonymous scope per owned
child.** Today: playlist `entries[k].node` children. This *replaces*
the suppression rule — an entry shader's produced `visual.out`
default now lands in a scope nobody reads, which is behaviorally
identical but falls out of the general model. Playlist entries are
*alternatives* (must be isolated from each other); project children
are *collaborators* (must share one scope). Container kinds differ in
child semantics, which is why "container ⇒ scope" would be the wrong
generalization and the model has two primitives instead of one.
3. **Consume resolution (writer-shadowing):** a consumed `bus:<name>`
endpoint — authored or declared default — resolves to the nearest
enclosing scope, starting at the consumer's own scope and walking
outward, **that has at least one writer** for that channel. If no
scope has a writer, it resolves to the **root** scope, so unfilled
channels surface on the root bus exactly as before and a host can
later fill them.
4. **Produce resolution (locality):** a produced `bus:<name>` endpoint
always writes the producer's **own nearest scope**. Never outward. An
effect cannot clobber a host channel by construction.
5. **Output mirror:** every project node exposes a produced `output`
slot mirroring its own scope's `visual.out` channel. Root included —
uniformity is the point; the root's mirror is simply unread today.
The mirror is what makes an effect playlist-playable with zero
playlist changes: the playlist already reads each entry child's
produced `output` slot directly (slot reads are node-addressed and
bus-independent). A scope with no visual writer renders cleared —
a project without a visual is a legitimate shape, not an error.

A **non-root** project node's mirror additionally carries a produced
*default* onto `visual.out` at fallback priority. Per rule 4 that
lands in the project node's OWN nearest scope — the parent's — because
the node itself sits there; only its children are inside the scope it
introduces. So an embedded effect contributes its visual to its host
by default and is genuinely drop-in, while its interior stays
isolated. Root is excluded (its containing scope is the scope its
mirror reads, so a default there would be self-referential).

Accepted consequence (2026-07-29): embedding an effect in a project
that already drives `visual.out` leaves two equal-priority writers,
which resolves as **ambiguous** until the author picks one. This was
chosen over staying explicit (which left effects inert on arrival)
and over auto-publishing only when unclaimed (order-dependent, and
hard to explain when a later shader silently displaces the effect).
`ProjectDef` carries no `bindings` map yet, so that pick cannot
currently be authored on the project node — a gap to close when
contention shows up in practice.

The consume/produce asymmetry is deliberate: reads inherit (that is the
"just works" magic — lexical-style shadowing means an effect without a
clock inherits host time, and an effect *with* a clock shadows time for
its own subtree only, which is the self-contained-speed story), while
writes stay local (that is the encapsulation guarantee).

### Implementation shape

- Writer sets are computed at load time, per `(scope, channel)`: authored
bus-target bindings plus produce-direction declared defaults (after
rule 4 assigns them to their own scope), minus defaults overridden by
an authored target on the same slot. Registration is two-pass — pass 1
collects writers, pass 2 registers all bindings with consume endpoints
resolved per rule 3. Binding registration order is unchanged (probe
output ordering rides binding indices). Everything recomputes on
refresh/mutation like all binding registration.
- The engine keys channels with an internal
`ScopedChannel { scope: ScopeId, channel: ChannelName }`;
`ScopeId::Project(node)` for named scopes, `ScopeId::Entry(child)`
for anonymous entry scopes (distinct variants, so an entry-owned
project node gets both). The type lives entirely inside `lpc-engine`;
authored artifacts, schemas, and the wire format are untouched. At the
probe boundary, root-scope channels display as bare names (identical
to before) and non-root channels display scope-qualified.
- The mirror is a `ProjectNode` runtime node on the playlist pattern,
minus the blending: `produce("output")` resolves its own scope's
`visual.out` on demand and remembers the producer's `VisualProduct`
handle, the published row carries the project node's *own* handle
(product rows always name their owning node — playlist parity), and
`RenderNode` dispatch forwards one hop to the remembered producer
(cleared when there is none). The only binding the mirror registers is
the non-root produced default from rule 5; existing flat projects have
no non-root project nodes, so their binding graph and bus pane are
unchanged. Its state root is engine-side (not part of the model's
static shape catalog): no schema surface.
- `bus:time`'s default writer is the clock node's produced
`ClockState.seconds` slot (`default_bind = "bus:time"`); there is no
engine-injected time. Inheritance therefore covers effects (host
clock's writer is in an enclosing scope), but a standalone effect
project with no clock anywhere gets no time — effect workbench
projects must include a clock in their preview rig.

## Consequences

- Existing projects: single root scope; every resolution identical;
artifact bytes identical; no format bump. Nesting could not exist
before, so the change is backward compatible by construction.
- Playlists: entry isolation now falls out of anonymous scopes; the
suppression special case is deleted. Entry children's produced
defaults now *register* (into the anonymous scope) instead of being
dropped, which is invisible on the root bus pane but makes the entry
shader's output slot show as bound — Studio labeling handles the
scope qualifier (M3 of the composite-effects roadmap).
- A consumed channel written nowhere resolves to root and surfaces
unfilled — hosts can fill an effect's dangling consumes later, which
is the promotion story's escape hatch.
- Cross-scope references are intentionally not expressible. `bus:^foo`
(parent) / `bus:/foo` (root) syntax is **reserved** for a future
slice; the `BindingRef` parser continues to reject such strings
naturally. Channel visibility/export declarations are likewise
deferred.

## Alternatives considered

- **No auto-bind when embedded** — rejected: context-dependent behavior
(an effect works standalone, silently stops animating when embedded,
or vice versa). Violates the no-embedded-branches constraint.
- **Scope only `visual.out`** — rejected: partial scoping is confusing
(why is `visual.out` isolated but a custom `energy` channel shared?);
two effects would still collide on any non-visual channel.
- **Lint forbidding buses inside effects** — rejected: breaks the
`time` default, i.e. breaks "just works" for the most common case.
- **Path-prefixed channel names as the authored model** (authors write
`fx.visual.out`) — rejected: same semantics, worse model — pushes
scope bookkeeping onto authors and makes vendoring a rename problem.
(The internal `ScopedChannel` key is not this: authors never see it.)
- **One shared scope per playlist** (instead of per-entry anonymous
scopes) — rejected: moves the `visual.out` collision from host level
to playlist level instead of fixing it; entries are alternatives, not
collaborators.
47 changes: 47 additions & 0 deletions docs/debt/example-shaders-not-compile-gated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
status: open
opened: 2026-07-29
area: examples/**/*.glsl, CI (just check / build-ci), lps-filetests
class: missing-gate
related:
- docs/defects/2026-07-29-uniform-struct-array-runtime-index.md
---
# Shipped example shaders are not compile-gated on non-host targets

**Condition** — nothing in CI compiles the GLSL under `examples/` for the
device- and browser-canonical shader targets. `examples_valid.rs` loads
every example as a project (which exercises the HOST backend only), and the
filetest suite covers `lp-shader/lps-filetests/filetests/**`, never
`examples/`. An example can therefore ship a construct that compiles on the
host and fails on `rv32n` / `rv32c` / `wasm` / `interp` — the targets that
actually run on device and in the browser sim.

**How it bites** — the failure surfaces only when a human opens that
example in Studio. It presents as a runtime shader-compile error on a node
that otherwise mounted and "runs", which is easy to read as a preview glitch
rather than broken shipped content. First occurrence:
`docs/defects/2026-07-29-uniform-struct-array-runtime-index.md`, where the
construct failed on 4 of 5 targets while every automated gate stayed green.

**Why it is not just "add an assertion"** — the natural-looking guard does
not work. An engine-level render test (load the example, tick, render,
assert nonzero pixels) runs the host backend by construction, so it cannot
observe another target's lowering. Asserting node runtime status does not
help either: on the host there is no error to report. The gate has to
compile the example sources against the other targets explicitly.

**Workarounds until fixed**
- When authoring example GLSL, copy the shape of an existing example that
is already known-good on device rather than inventing one; the uniform
struct-array idiom in `examples/events/shader.glsl` is the reference.
- After adding or editing an example shader, open it in the browser sim
once and read the node's status — that is currently the only end-to-end
check.

**Fix direction** — extend the filetest runner (or add a small harness) to
compile every `examples/**/*.glsl` for `ALL_TARGETS`, run-free
(compile-only), and fail on any target that rejects a shipped example.
Compile-only keeps it cheap and needs no uniform values or expected
outputs. Note the filetest harness currently treats `compile-fail` as an
expected-failure category, so this gate must assert on it rather than
reuse that path.
Loading
Loading