# Noisemaker AI development contract This document explains source behavior for agents that must inspect, author, validate, and diagnose Noisemaker programs and effects without guessing. It describes current behavior, not intended behavior. Use `/llms.txt` for concise project orientation. This file contains the complete development contract. An agent should load it when exact authoring, validation, or diagnostic behavior is necessary. Scope boundary: this contract covers the browser shader engine, its WebGL2 and WebGPU backends, and Shade MCP. Unity/HLSL, Blender, Godot, TouchDesigner, Three.js, Babylon.js, and other external ports are separate repositories with their own porting guides and parity harnesses. They are not a tenth engine surface in this contract. `testPixelParity` validates only the browser WebGL2 and WebGPU backends and must not be cited as evidence for an external port. Source snapshots used for this contract: - Noisemaker: `b319e794aabed9d08e98f2747c5a68ea651f8147`. - Shade MCP: `f36a5f627648b7162f684ba7670c22ef4163f166`. - Shade MCP protocol: server name `shade-mcp`, server version `0.4.11`, built on `@modelcontextprotocol/sdk` 1.31.0, which accepts the protocol versions `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05` and `2024-10-07`, echoes the version the client requests, and answers `2025-11-25` to any other request; 18 tools are returned by `tools/list`. - The repository config runs `npx -y github:noisedeck/shade-mcp#f36a5f627648b7162f684ba7670c22ef4163f166`, matching release v0.4.11 delivered to `vendor/shade-mcp/`. Primary source roots: - DSL: `shaders/src/lang/`. - definition and graph runtime: `shaders/src/runtime/`. - effect loading and DSL registration: `shaders/src/renderer/canvas.js`. - backend behavior: `shaders/src/runtime/backends/webgl2.js` and `webgpu.js`. - repository validation policy: `shaders/tests/test-harness.js`. - Shade MCP source for the pinned snapshot: `https://github.com/noisefactorllc/shade-mcp/tree/f36a5f627648b7162f684ba7670c22ef4163f166/src`. ## Freshness and maintenance This file is hand-maintained; no generator produces it. Three repository contract tests, run by `scripts/run-js-tests.js`, check parts of it against source: `shaders/tests/test_contract_graph_shape.mjs` (the `CompiledGraph` type block against `compileGraph()` output), `shaders/tests/test_contract_delivery_pair.mjs` (the snapshot block and pin against `.mcp.json` and `vendor/`), `shaders/tests/test_contract_parity_session.mjs` (the parity statement against the vendored harness), and `shaders/tests/test_contract_frame_lifetime.mjs` (the frame-lifetime binding-persistence, persistent-resize, canvas-upload, and 3D-sampler statements against `pipeline.js` and `webgpu.js`). Everything else is checked only by review. The two source commits above define which source revisions this contract covers. Before updating this contract to a different Noisemaker or Shade MCP revision: 1. Record exact commits for both repositories. Do not use a moving branch name as evidence. 2. Re-audit every cited symbol whose file changed. Include validators, runtime consumers, backend code, tool schemas, metric predicates, and error paths. 3. Re-capture MCP initialization, `tools/list`, and the worked request/response transcript from one server session at the new Shade MCP commit. 4. Re-run the source-level worked-example graph expansion and the backend validation calls. Confirm each returned effect and backend identity. 5. Check the source evidence again before updating the traceability matrix. Until all five steps are complete, unchanged sections remain authoritative only for the pinned snapshots. Running the separately pinned repository MCP configuration does not establish freshness for a different audited snapshot. ## Reading the contract Every field or behavior has one of these statuses: - **enforced**: current validation rejects an illegal value. - **consumed**: runtime code reads the value and changes behavior, but validation may be partial. - **observed**: checked-in definitions contain the value. This does not imply the runtime consumes it. - **unsupported**: there is no current authoring/query contract. It is a gap. An omitted JavaScript property is `undefined`. JSON cannot represent functions, `Map`, `Set`, class instances, `undefined`, or cyclic state. JSON shapes below are the observable projections of those runtime values. Common structural types: ```ts type BackendName = "webgl2" | "webgpu" type NonNegativeInteger = number // semantic predicate: integer && value >= 0 type PositiveInteger = number // semantic predicate: integer && value >= 1 type FiniteNumber = number // semantic predicate: Number.isFinite(value) type Vec2 = [number, number] type Vec3 = [number, number, number] type Vec4 = [number, number, number, number] type JsonPrimitive = string | number | boolean | null type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue } type Diagnostic = { code: "S001" | "S002" | "S003" | "S004" | "S005" | "S006" | "S008" | "P008" | "P009" | "P010" message: string severity: "error" | "warning" nodeId?: string | number | null // null on the P008-P010 subchain warnings location?: { line: number, column: number | undefined } identifier?: string } type CompilationFailure = { code: "ERR_COMPILATION_FAILED" diagnostics: Diagnostic[] } type ExpansionFailure = { code: "ERR_EXPANSION_FAILED" errors: Array<{ message: string, step?: CompiledStep }> } ``` `Diagnostic.location` preserves the parser's one-based line and column when the diagnostic node has `loc`: parser `loc.col` populates `location.column`, and an explicit `loc.column` on a caller-supplied AST retains precedence. Nodes without `loc` still omit `location`; complete AST source coverage is not a public contract. Lexer failures from `lex(source)` or `compile(source)` throw the same native `SyntaxError` with the same message as before. Each carries a non-enumerable `error.diagnostic` property; explicitly serialize that property to obtain: ```ts type LexerDiagnostic = { code: "L001" | "L002" | "L003" | "L004" stage: "lexer" severity: "error" message: string // identical to error.message location: { line: number, column: number } span: { start: number, end: number } } type ParserDiagnostic = { code: "P001" | "P002" | "P003" | "P004" | "P005" | "P006" | "P007" stage: "parser" severity: "error" message: string // identical to error.message location: { line: number, column: number } | null span: { start: number, end: number } | null } ``` L001 identifies an unexpected character, L002 an unterminated single-, double-, or triple-quoted string, L003 an unterminated block comment, and L004 an out-of-range output reference. Locations are one-based, with columns measured in UTF-16 code units. Only LF starts a new line; tabs and CR each count as one column. Spans are zero-based half-open UTF-16 offsets into the original source. An unexpected-character span covers the rejected code unit; an output-reference span covers the full reference; an unterminated construct spans its opening delimiter through EOF, or up to but excluding the unescaped LF that terminates a single-/double-quoted string scan. All lexer diagnostic locations and spans are available. They are computed from source independently of legacy token position counters, so structured coordinates can correct historical multiline counter drift while the legacy error message and successful tokens stay intact. Ordinary error enumeration and `JSON.stringify(error)` are unchanged. Parser failures from any explicit throw path in `parse(tokens)` (also used by `compile(source)`) carry the same non-enumerable diagnostic property shape. The legacy `SyntaxError` class and message are unchanged, including the historical token-counter text inside messages. Diagnostic coordinates are source-derived: the lexer attaches a non-enumerable `position` to every token it produces, recording the token's one-based source `line`/`column` and its zero-based half-open source span, computed independently of the legacy position counters with the same UTF-16/LF-only convention as lexer failures. Parser diagnostics copy that position: `location` is the offending token's source line/column and `span` is its source span. Tokens supplied by callers without a source position fall back to the token's own positive-integer `line`/`col` with an explicitly `null` span, and any token lacking valid coordinates produces a `null` location. Number-coercion failures have no token, so their location is `null` unless the offending AST node has parser-authored coordinates. Successful token arrays keep their public shape: `position` is non-enumerable, so enumeration, `deepEqual`-style structural comparison, and `JSON.stringify` are unchanged. This covers required-token mismatches such as missing parentheses, identifiers, assignment signs, and end of input. Argument-validation failures in the `osc()`, `midi()`, and `audio()` automation transformations expose P003 with the same contract; P003 locations identify the invocation name token, not a specific argument. This covers unknown parameters, unsupported positional arguments, missing or conflicting selectors, and invalid device-name/id strings. Explicit search directive validation failures expose P004: missing or duplicate directives, invalid or missing namespaces, and misplaced top-level or nested directives. P004 locations identify the rejected token, or EOF for a missing required directive, with the same source-derived coordinates and spans. P005 covers invalid render targets, write/write3d in expression context, invalid write surfaces, and invalid write3d texture/geometry arguments. Its location identifies the rejected argument token (EOF when missing), or the write/write3d keyword for expression-context failures; it uses the same rules. P006 covers explicit subchain validation: non-string keyword argument values (a non-string positional argument fails earlier with P002), missing body dots (including EOF and comment-only scans), and empty bodies, locating the rejected or invocation token with the same rules. P007 covers invalid call forms: `from()` argument validation (named arguments, argument count, namespace identifier, and call-expression target), forbidden inline namespace syntax, and mixed positional/keyword arguments. P007 locations identify the invocation name token (for mixed-argument failures, the argument that breaks the ordering rule), or fall back or become `null` as described for caller-supplied tokens. Beyond the shared expectation path, P001 also covers missing expressions after `=` (in assignments and keyword arguments), missing array-closing brackets, non-identifier member segments after `.`, unexpected primary tokens, and number coercion. Number coercion locates the offending AST node's private source position when the node was built from a lexer token (array literals carry the bracket token's source position non-enumerably), falls back to the node's parser-authored `loc`, and is explicitly `null` when neither carries valid coordinates. The two defense-in-depth throw sites that are unreachable through public `parse`/`compile` (a duplicate `render()` directive and the write/write3d dispatch fallback) also carry P005 for contract completeness. Shared token-expectation failures retain P001/P002, including repeated render statements rejected at end of input. Every parser throw path carries a structured diagnostic with source-derived coordinates where a lexer-produced token is available. ## Runtime overview The browser integration path is: 1. `CanvasRenderer.loadManifest()` discovers effect assets. 2. `CanvasRenderer.loadEffect()` imports `definition.js`, loads shader source, registers the effect and its DSL operator, and registers starter state. 3. `CanvasRenderer.compile(source)` calls `compileGraph(source)`. 4. `compileGraph()` lexes/parses/validates, expands effect steps into passes, computes a liveness allocation map, and returns a graph. 5. `Pipeline.compilePrograms()` compiles every referenced program for the active backend. 6. `Pipeline.render(time, presentationTimestamp?)` executes expanded passes in array order. It submits the selected pre-swap surface to every registered sink. It then advances double-buffer bindings. A built-in `CanvasSink` preserves normal canvas presentation. Sources: `shaders/src/renderer/canvas.js` - `loadEffectShaders()`, `registerEffectWithRuntime()`; `shaders/src/runtime/compiler.js` - `compileGraph()`; `shaders/src/runtime/pipeline.js` - `compilePrograms()`, `render()`. ## DSL program ### Introspect Legal namespaces are the current `VALID_NAMESPACES` array in `shaders/src/runtime/tags.js`. Loaded operators are queryable through the exported `ops` object in `shaders/src/lang/ops.js`. A loaded effect definition is queryable with `getEffect(name)` or `getAllEffects()` from `shaders/src/runtime/registry.js`. The parse/compile projections are: ```ts type Token = { type: string, lexeme: string, line: number, col: number } type ParsedProgram = { type: "Program" plans: ParsedStatement[] vars?: Array<{ type: "VarAssign", name: string, expr: ParsedExpr }> render: { type: "OutputRef", name: string } | null namespace: { imports: Array<{ name: string, source: "search", explicit: true }> default: { name: string, source: "search", explicit: true } searchOrder: string[] } trailingComments?: string[] } type SurfaceValue = | { kind: "output", name: string } | { kind: "source", name: string } | { kind: "xyz", name: string } | { kind: "vel", name: string } | { kind: "rgba", name: string } | { kind: "mesh", name: string } | { kind: "state", name: "time" | "frame" | "mouse" | "resolution" | "seed" | "a" } | { kind: "vol", name: string } | { kind: "tex3d", name: string } // identifier arguments of _read3d/_write3d | { kind: "geo", name: string } | { kind: "pipeline", name: string } | { kind: "temp", index: number } type CompiledStep = { op: string args: Record from: number | null temp: number builtin?: boolean namespace?: object rawKwargs?: Record argSources?: Record leadingComments?: string[] } type CompiledPlan = { chain: CompiledStep[] write: { kind: "output", name: string } | null write3d: { tex3d: { kind: "vol", name: string } geo: { kind: "geo", name: string } } | null final: number | null states: object[] leadingComments?: string[] } type CompiledProgram = { plans: Array diagnostics: Diagnostic[] render: string | null vars: ParsedProgram["vars"] searchNamespaces: string[] trailingComments?: string[] } ``` Surface tokens and actual allocation behavior: | Token family | Parser token | Read/write role | Preallocated state | |---|---|---|---| | `oN` | `OUTPUT_REF` | `read`, surface arg, `write`, `render` | `o0`-`o7`, double buffered | | `sN` | `SOURCE_REF` | `read`, surface arg | No initial allocation. Graph references create a dynamic global surface. | | `volN` | `VOL_REF` | Volume argument or `read3d` input. Texture for `write3d`. | `vol0`-`vol7` as 64x4096 2D atlases | | `geoN` | `GEO_REF` | Geometry argument or `read3d` input. Geometry for `write3d`. | `geo0`-`geo7`, screen-sized RGBA16F | | `xyzN` | `XYZ_REF` | `read`, surface arg, `write` | dynamic global state surface when referenced | | `velN` | `VEL_REF` | `read`, surface arg, `write` | dynamic global state surface when referenced | | `rgbaN` | `RGBA_REF` | `read`, surface arg, `write` | dynamic global state surface when referenced | | `meshN` | `MESH_REF` | `read`, surface arg, `write` | `mesh0`-`mesh7` contain positions/normals/UV triplets, with 256x256 defaults. | The lexer enforces `o0` through `o7` for output surface references. `o8`, `o99`, and `read(o8)` throw a located `SyntaxError` before parsing. An output-shaped token after `.` remains a member-path segment, so existing paths such as `foo.o8` are unchanged. Other reference families still accept one or more digits at lexing; volume and geometry parameter resolution separately checks `0` through `7`. Current connections are observable after compilation in `graph.passes[].inputs`, `graph.passes[].outputs`, `graph.renderSurface`, and at runtime in `pipeline.surfaces`, `pipeline.frameReadTextures`, and `pipeline.frameWriteTextures`. Sources: `shaders/src/lang/lexer.js` - `lex()`; `shaders/src/lang/parser.js` - `parse()`; `shaders/src/lang/validator.js` - `validate()`, `toSurface()`; `shaders/src/runtime/pipeline.js` - `createSurfaces()`, `getFrameState()`. ### Act The parser-recognized grammar is below. Semantic validation narrows this syntax as described after the grammar. It supersedes the optional-search grammar in the comment at the top of `parser.js`. `search` is mandatory in code. ```ebnf Program ::= Ignored SearchDirective { Ignored Statement } ( Ignored RenderDirective Semicolons Comments | Ignored ) EOF SearchDirective ::= "search" Namespace { "," Namespace } Namespace ::= Identifier | "render" | "write" | "write3d" | "true" | "false" | "if" | "elif" | "else" | "break" | "continue" | "return" Statement ::= VarAssign | ChainStmt | IfStmt | BreakStmt | ContinueStmt | ReturnStmt VarAssign ::= "let" Identifier "=" Expr IfStmt ::= "if" "(" Expr ")" Block { "elif" "(" Expr ")" Block } [ "else" Block ] Block ::= "{" { Statement Semicolons } "}" BreakStmt ::= "break" ContinueStmt ::= "continue" ReturnStmt ::= "return" [ Expr ] ChainStmt ::= Chain (* validator requires terminal write *) Chain ::= Call { Comments "." Comments ChainElement } ChainElement ::= Call | WriteCall | Write3DCall | Subchain Call ::= Identifier "(" [ ArgList ] ")" ArgList ::= PositionalArgs | KeywordArgs | ExternalInputArgs PositionalArgs ::= Expr { "," Expr } [ "," ] KeywordArgs ::= Identifier ":" Expr { "," Identifier ":" Expr } [ "," ] ExternalInputArgs ::= ExternalInputArg { "," ExternalInputArg } [ "," ] ExternalInputArg ::= Expr | Identifier ":" Expr WriteCall ::= "write" "(" WriteTarget ")" WriteTarget ::= OutputRef | XyzRef | VelRef | RgbaRef | MeshRef | "none" Write3DCall ::= "write3d" "(" (Identifier | OutputRef | VolRef) "," (Identifier | OutputRef | GeoRef) ")" RenderDirective ::= "render" "(" OutputRef ")" Subchain ::= "subchain" "(" SubchainArgs ")" "{" Comments "." Comments Call { Comments "." Comments Call } Comments "}" SubchainArgs ::= /* empty */ | String | SubchainKeywordArgs SubchainKeywordArgs ::= SubchainPair { [ "," ] SubchainPair } [ "," ] SubchainPair ::= Identifier ":" String Expr ::= Additive Additive ::= Multiplicative { ("+" | "-") Multiplicative } Multiplicative ::= Unary { ("*" | "/") Unary } Unary ::= { "+" | "-" } Primary Primary ::= Number | String | TripleString | Boolean | HexColor | Array | ArrowFunction | "Math.PI" | Member | Identifier | SurfaceRef | Call | Chain | "(" Expr ")" Array ::= "[" [ Expr { "," Expr } ] "]" Member ::= Identifier "." MemberSegment { "." MemberSegment } MemberSegment ::= Identifier | SurfaceRef | "let" | "render" | "write" | "write3d" | "subchain" | Boolean | "if" | "elif" | "else" | "break" | "continue" | "return" SurfaceRef ::= OutputRef | SourceRef | VolRef | GeoRef | MeshRef | XyzRef | VelRef | RgbaRef OutputRef ::= "o" Digit { Digit } SourceRef ::= "s" Digit { Digit } VolRef ::= "vol" Digit { Digit } GeoRef ::= "geo" Digit { Digit } MeshRef ::= "mesh" Digit { Digit } XyzRef ::= "xyz" Digit { Digit } VelRef ::= "vel" Digit { Digit } RgbaRef ::= "rgba" Digit { Digit } Boolean ::= "true" | "false" HexColor ::= "#" ( HexDigit HexDigit HexDigit | HexDigit HexDigit HexDigit HexDigit HexDigit HexDigit | HexDigit HexDigit HexDigit HexDigit HexDigit HexDigit HexDigit HexDigit ) Number ::= Digit { Digit } [ "." Digit { Digit } ] | "." Digit { Digit } String ::= '"' { EscapedChar | NonQuoteNonNewline } '"' | "'" { EscapedChar | NonApostropheNonNewline } "'" TripleString ::= '"""' { AnyCharExceptClosingTripleQuote } '"""' ArrowFunction ::= "()" Spaces "=>" Spaces RawExpressionText RawExpressionText ::= { RawExpressionCharacter } Identifier ::= ( Letter | "_" ) { Letter | Digit | "_" } HexDigit ::= Digit | "a" | "b" | "c" | "d" | "e" | "f" | "A" | "B" | "C" | "D" | "E" | "F" Letter ::= "a".."z" | "A".."Z" Digit ::= "0".."9" EscapedChar ::= "\\" AnyChar Comments ::= { LineComment | BlockComment } Ignored ::= { LineComment | BlockComment | ";" } Semicolons ::= { ";" } LineComment ::= "//" { AnyCharExceptNewline } BlockComment ::= "/*" { AnyCharExceptClosingBlockComment } "*/" Spaces ::= { " " | "\t" } RawExpressionCharacter ::= AnyChar NonQuoteNonNewline ::= AnyChar - ( '"' | Newline ) NonApostropheNonNewline ::= AnyChar - ( "'" | Newline ) AnyCharExceptNewline ::= AnyChar - Newline AnyCharExceptClosingTripleQuote ::= AnyChar - start-of-'"""' AnyCharExceptClosingBlockComment ::= AnyChar - start-of-"*/" Newline ::= "\n" AnyChar ::= /* any source character */ EOF ::= /* end of token stream */ ``` `RawExpressionText` is the lexer-captured text after `()=>`, ending at a top-level comma, semicolon, newline, `}`, or unmatched `)`. The `AnyChar*` and `Non*` names above are lexical character classes with the exclusion stated in their names. Block comments are not nested. Reserved keywords are tokenized before `Identifier`, except that the explicit `Namespace` and `MemberSegment` productions accept the listed keyword tokens. Subchain parsing is more permissive than the legal authoring contract. Author only `name` and `id` string pairs, separated by commas, with no duplicates. The parser still accepts either pair first, pairs without commas, and a trailing comma for backward compatibility, and only `name` and `id` are copied to the AST. The enforced key set is `name` and `id`; a single leading positional string literal is shorthand for `name`. Unknown keys are discarded, and a duplicate key keeps its last value, but every such condition is now reported through `compile()`/`validate()` diagnostics with stable codes: `P008` (unknown/discarded key), `P009` (duplicate key), and `P010` (missing separator), each with parser-stage severity `warning` and a source line and column. `parse(tokens, { subchainArguments: 'strict' })` and `compile(src, { subchainArguments: 'strict' })` are the one explicit opt-in path that rejects the same conditions with `SyntaxError` diagnostics carrying the same codes, severity `error`, and source spans. Default parse acceptance is unchanged. `Namespace` must already be registered and valid. Inline call namespaces such as `synth.noise()` are rejected. Put the namespace in `search`, or use `from(namespace, call())` to override resolution for one call. Positional and keyword arguments cannot be mixed except inside the special `midi()` and `audio()` value calls. In those two calls they can appear in either order. Positionals fill the still-unset positional fields densely, so a keyword does not consume its positional slot. `ExternalInputArgs` exists only for those two call names, not for registered effects or other special calls. `read(oN)` has two different legal roles: 1. At a chain head it becomes builtin `_read` and establishes the current input. 2. As a value for a `surface` global it resolves directly to that surface (or a nested call/chain can be expanded to a temporary surface). `.read(oN)` after another chain element is illegal and produces `S001`. A chain statement must have an explicit terminal `write(...)` or `write3d(...)`. A separate `render(oN)` chooses presentation but does not satisfy the write requirement. `render()` accepts only an `OUTPUT_REF` token. Numeric arithmetic is constant-folded during parsing. Operators accept only `Number` nodes. Applying arithmetic to another expression throws `SyntaxError: Expected number`. Division by zero is not rejected and can produce `Infinity`. Array elements reach argument validation. Non-number elements become `0` plus an `S002` warning. Arrow-function bodies are captured as raw text and compiled with `new Function` during validation. Special value calls are transformed by the parser: ```ts osc(type = oscKind.sine, min = 0, max = 1, speed = 1, offset = 0, seed = 1) midi(channel, mode = midiMode.velocity, min = 0, max = 1, sensitivity = 1, cc?: number, nrpn?: number, name?: string, id?: string) midi(zone: midiZone.lower | midiZone.upper, members?: number, mode?: number, min?: number, max?: number, sensitivity?: number, cc?: number, nrpn?: number, name?: string, id?: string) audio(band, min = 0, max = 1, channel?: PositiveInteger, name?: string, id?: string) from(namespaceIdentifier, callExpression) ``` `midi` requires exactly one of `channel` and `zone`. `members` requires `zone`. The `cc`, `nrpn`, `zone`, `members`, `name`, and `id` selectors are keyword-only. Identity strings must be quoted and nonempty, and `id` requires `name`. The parser accepts at most five MIDI positionals, in the original `channel,mode,min,max,sensitivity` order. `audio` requires `band`. `channel`, `name`, and `id` are keyword-only. A named device requires `channel`, and `id` requires `name`. A channel without identity selects the default device. Valid channels are integers 1-32; the host must actually deliver that channel. The parser accepts at most three audio positionals. Both calls reject unknown keywords. Identity strings use JSON decoding so values emitted by `JSON.stringify()` round-trip. The decoder preserves unknown legacy escapes. `audioBand` values are `low=0`, `mid=1`, `high=2`, `vol=3`, and `raw=4`. `midiMode` values are `noteChange=0`, `gateNote=1`, `gateVelocity=2`, `triggerNote=3`, `velocity=4`, `cc=5`, `cc14=6`, `nrpn=7`, `pitchBend=8`, `pressure=9`, and `polyPressure=10`. `midiZone` is `lower=0` or `upper=1`. CC modes default to controller 1. `cc` must be an integer 0-127, or 0-31 in CC14 mode. NRPN mode requires `nrpn`, an integer 0-16382; 16383 is the null address. `members`, when supplied, must be an integer 1-15. Fixed channels in every mode must be integers 1-16. Invalid numeric selectors produce `S002` and an inert `_invalid:true` descriptor. Valid unused controller/parameter selectors can survive a mode change and are retained by the unparser. `osc` has no nested `type`. MIDI channel, mode, controller, parameter, zone, and member count are static selectors. Audio band, channel, and identity are also static. Numeric selectors accept literals or resolved `let` aliases; identity fields require quoted string literals. The automatable fields are `osc.min/max/speed/offset/seed`, `midi.min/max/sensitivity`, and `audio.min/max`. Literal `min` and `max` values are silently clamped to `[0,1]`. Nested automation is limited to eight levels. Variable cycles and a ninth nested level produce `S001`. The compiled object retains named nested references so parse/compile/unparse preserves `let` uses. For the remaining scalar forms, oscillator numeric fields and MIDI `min/max/sensitivity` also normalize booleans and resolvable numeric enum members. The MIDI `channel` is resolved the same way in every mode: an integer from 1 to 16, with no boolean or enum-member normalization. Any other value makes the descriptor inert: a string produces S001, an undefined identifier S003, a boolean or enum member S002 (`midi() channel must be a number`), a non-integer S002 (`must be an integer`), and a number outside 1-16 S002 (`must be at least 1` or `must be at most 16`). Strict MIDI selectors reject booleans and live automation. Audio `min/max` accept only numbers or automation, and audio `channel` accepts a resolved numeric integer from 1 to 32. The enum selectors accept their named members or the implemented numeric values listed above. `oscKind` accepts `sine=0`, `tri=1`, `saw=2`, `sawInv=3`, `square=4`, `noise`/`noise1d=5`, and `noise2d=6`, or numeric values 0 through 6. `noise2d` is the two-stage periodic noise of the `osc2d` effect with both noise stages sampled at a fixed seed-derived position, so it stays within `[0,1]` and loops seamlessly at whole-number speeds. Disallowed forms include: - No `search`. - Multiple or late `search`. - Multiple `render`. - Non-output `render`. - Namespaced call syntax. - Mixed positional/keyword arguments outside `midi()`/`audio()`. - `.write()` inside an expression. - Missing or invalid write targets. - Empty subchains. - A starter in a non-head position. - A non-starter at a chain head. - Inline `.read`/`.read3d`. - Unquoted strings for most value types. - Unknown registered-effect arguments. Unknown subchain metadata keys are no longer a silent exception: they are discarded from the AST and reported with diagnostic code `P008`. Sources: `shaders/src/lang/lexer.js` - `lex()`; `shaders/src/lang/parser.js` - `parseProgram()`, `parseCall()`, `transformMidiInvocation()`, `transformAudioInvocation()`, `parseSubchainCall()`, `parsePrimary()`; `shaders/src/lang/stringLiterals.js` - `decodeJsonStringLiteralContent()`; `shaders/src/lang/validator.js` - `compileAutomationDescriptor()`, `compileChainStatement()`, `processChain()`; `shaders/src/lang/unparser.js` - automation formatters. ### Validate Use these falsifiable stages: | Stage | Measurement | Pass threshold | |---|---|---| | lex | token list ends with one `EOF` | Exactly one terminal `EOF`. No `SyntaxError` is thrown. | | parse | `ParsedProgram.namespace.searchOrder.length` | `>= 1` | | semantic | `diagnostics.filter(d => d.severity === "error").length` | `=== 0` | | expansion | `graph.passes.length` and `graph.renderSurface` | passes `>= 1` and render surface non-null | | backend compile | every referenced program in backend program map | compile completes without throw | | execution | `pipeline.lastPassCount` after a frame | equals number of non-skipped expanded passes after repeat expansion | Warnings (`S002`, `S008`) do not make `compileGraph()` fail. Any semantic error does. `runDslProgram` is a convenient combined validator on either backend; its exact contract is in the MCP section. Sources: `shaders/src/lang/index.js` - `compile()`; `shaders/src/runtime/compiler.js` - `compileGraph()`; `shaders/src/runtime/pipeline.js` - `compilePrograms()`, `render()`. ### Diagnose Declared diagnostic codes are: | Code | Stage/severity | Implemented use | |---|---|---| | `L001` | lexer/error | Unexpected character; additive `error.diagnostic` on the legacy `SyntaxError` | | `L002` | lexer/error | Unterminated string or triple-quoted string; additive `error.diagnostic` | | `L003` | lexer/error | Unterminated comment; additive `error.diagnostic` | | `L004` | lexer/error | Output surface reference outside `o0`-`o7`; additive `error.diagnostic` | | `P001` | parser/error | Required-token mismatch other than closing parenthesis; additive `error.diagnostic` on the legacy `SyntaxError` | | `P002` | parser/error | Expected closing parenthesis; additive `error.diagnostic` on the legacy `SyntaxError` | | `P003` | parser/error | Invalid arguments in `osc()`/`midi()`/`audio()` automation transformations; additive `error.diagnostic` on the legacy `SyntaxError` | | `P004` | parser/error | Explicit search directive validation failures; additive `error.diagnostic` on the legacy `SyntaxError` | | `P005` | parser/error | Invalid render targets, expression-context writes, and invalid write/write3d target arguments; additive `error.diagnostic` on the legacy `SyntaxError` | | `P006` | parser/error | Invalid subchains (non-string arguments, missing body dot, empty body); additive `error.diagnostic` on the legacy `SyntaxError` | | `P007` | parser/error | Invalid call forms: `from()` argument validation, forbidden inline namespace syntax, and mixed positional/keyword arguments; additive `error.diagnostic` on the legacy `SyntaxError` | | `P008` | parser/warning | Unknown subchain argument key (the value is discarded); returned in `compile()` diagnostics with `nodeId: null` | | `P009` | parser/warning | Duplicate subchain argument key (the last value wins); `nodeId: null` | | `P010` | parser/warning | Missing `,` between subchain arguments; `nodeId: null` | | `S001` | semantic/error | unknown effect/argument/enum, invalid surface, illegal inline read, invalid function, and other semantic failures | | `S002` | semantic/warning | clamping, wrong numeric/vector/array form | | `S003` | semantic/error | unresolved identifier or enum choice | | `S004` | semantic/error | assigning `null` or `undefined` | | `S005` | semantic/error | illegal starter/chain/input position | | `S006` | semantic/error | starter chain missing terminal write | | `S008` | semantic/warning | deprecated effect alias | | `R001` | runtime/error | declared but not emitted by the compiler/backends shown here | Raw lexer/parser failures are `SyntaxError` instances with the unchanged legacy messages. Every lexer and parser throw path now also exposes the non-enumerable `error.diagnostic` property described above. `compileGraph()` only wraps semantic diagnostics as `ERR_COMPILATION_FAILED`. Raw lex/parse exceptions escape. Subchain metadata typos and duplicates no longer vanish silently: unknown keys are discarded from the AST, duplicates keep the last value, and both conditions plus missing separators are reported with stable diagnostic codes `P008`, `P009`, and `P010` through the `compile()`/`validate()` diagnostics list. Expansion failures have shape `ExpansionFailure`. Current enumerated messages are `Effect '' not found` with the failing step, and `No render surface specified and no write() found - add render(oN) or write(oN)` without a step. Sources: `shaders/src/lang/diagnostics.js` - default export; `shaders/src/lang/lexer.js` - thrown `SyntaxError` sites; `shaders/src/lang/parser.js` - thrown `SyntaxError` sites; `shaders/src/runtime/compiler.js` - `compileGraph()`; `shaders/src/runtime/expander.js` - `expand()`. ## Effect definition ### Introspect After loading, call `getEffect(name)`/`getAllEffects()` to obtain the live class instance. `analyzeEffect` returns a JSON projection of the definition plus combined `.glsl`/`.wgsl` sources. `checkEffectStructure` returns filesystem and cross-language structure signals. Before loading, inspect `shaders/effects///definition.js` and the generated `shaders/effects/manifest.json` entry. An effect directory has this consumed layout: ```text // definition.js required by Noisemaker catalog glsl/.glsl combined WebGL2 source, or .vert + .frag wgsl/.wgsl WebGPU source help.md optional UI/help content ``` The manifest records each program as GLSL `"combined"` or split `{v:1,f:1}` and WGSL `1`. `loadEffectShaders()` only loads files declared in the manifest. A non-ok HTTP response silently leaves the shader bucket empty, so the missing-source failure occurs later in backend compilation. A network-level fetch rejection instead rejects the shader load, and `loadEffect()` rethrows it as a load error. Sources: `shaders/src/runtime/registry.js` - `getEffect()`, `getAllEffects()`; `shaders/src/renderer/canvas.js` - `loadEffectShaders()`; `shaders/scripts/generate-shader-manifest.mjs`; Shade MCP `src/tools/knowledge/analyze-effect.ts` - `registerAnalyzeEffect()`. ### Act The full constructor-accepted and downstream-consumed structural grammar is below. Inline annotations distinguish copied-only fields from fields with a production consumer: ```ts interface EffectDefinition { // Copied by new Effect(config) name: string namespace?: string func?: string description?: string tags?: string[] globals?: Record passes: PassSpec[] textures?: Record outputTex3d?: string outputGeo?: string uniformLayout?: UniformLayout uniformLayouts?: Record openCategories?: string[] defaultProgram?: string hidden?: boolean deprecatedBy?: string // Copied by Effect and invoked by the production Pipeline. onInit?: (this: EffectHookThis) => void onUpdate?: (this: EffectHookThis, context: { time: number, delta: number, uniforms: Record }) => Record | void onDestroy?: (this: EffectHookThis) => void // Copied and invoked by Pipeline after texture allocation. asyncInit?: (this: EffectHookThis, context: AsyncInitContext) => Promise // Consumed by expander/loader when exposed by a subclass or assigned later. // The base Effect constructor does NOT copy these config keys. outputTex?: string outputXyz?: string outputVel?: string outputRgba?: string textures3d?: Record externalTexture?: string shaders?: Record // populated by loader in normal use // Observed in current definitions/manifests but not consumed by Effect, // expander, or Pipeline as effect-definition contracts. externalMesh?: string builtinMeshes?: unknown } type ProgramSources = { glsl?: string source?: string fragment?: string vertex?: string wgsl?: string vertexWGSL?: string vertexWgsl?: string fragmentEntryPoint?: string vertexEntryPoint?: string computeEntryPoint?: string outputFormat?: string topology?: string uniformLayout?: UniformLayout defines?: Record } type UniformComponents = | "x" | "y" | "z" | "w" | "xy" | "yz" | "zw" | "xyz" | "yzw" | "xyzw" type UniformLayout = | Array<{ name: string, slot: NonNegativeInteger, components: UniformComponents }> | Record | { type: "byte", // WebGPU packUniformsWithByteLayout() layout: Array<{ name: string, offset: number, size: number, type: string }> } type AsyncInitContext = { updateTexture: (textureName: string, canvas: HTMLCanvasElement) => void width: number height: number params: Record isCancelled: () => boolean } type EffectHookThis = InstanceType & { state: Record uniforms: Record } ``` Use `export default new Effect({...})` for data-only definitions. Use a subclass when you must expose consumed fields that the base constructor does not copy. Also use a subclass when you must override lifecycle methods. Do not assume an arbitrary config property is copied: the constructor uses an explicit allowlist. Each uniform-layout slot is one 16-byte `vec4`. A one-character component writes that lane. Two- and three-character components write consecutive lanes starting at the first character. Four characters write the whole slot. Both backend packers consume the strings above by length and starting lane. `validateEffectDefinition()` structurally checks uniform-layout slot/byte forms, including component strings out of `xyzw` order (`yx`) and duplicate or overlapping slot/component claims. It accepts ascending strings with gaps (`xz`, `xw`, `yw`). Whether a string mispacks at runtime (lane count versus shader expectations) is a shader-compilation/ runtime property, not a CPU structure check; buffer write failures from a string the shader does not expect are runtime errors, not definition diagnostics. When an `Effect` wrapper method invokes a config hook, it uses `.call(this)` with the live instance, not the definition object. `this.state` and `this.uniforms` are initialized to empty objects before config fields are copied. Subclasses can add more instance members. The production pipeline invokes `asyncInit()` after texture allocation on resize and after a successful hot recompile. A full initialization cancels in-flight node renders, clears pending 300 ms debounce timers and the scalar parameter cache, then starts each current async node. Step-parameter writes also call `checkAsyncRegen()`. A changed, declared, scalar parameter other than `alpha` triggers a debounced re-run for that node, while object-shaped values and internal keys are ignored. "Changed" means different from the value that node's overlay was last drawn from, which each run records. `Pipeline.setUniform(name, value)` does the same for every async node whose effect reads that uniform, with the node's current values, so a host that sets `density` or `seed` directly (the docs viewer, the Shade MCP harness) gets a redrawn overlay. An automated parameter (an oscillator, MIDI or audio config) reaches `asyncInit` as its default: an overlay drawn once cannot follow it. `isCancelled()` and the guarded `updateTexture()` callback let superseded work stop without uploading. `Pipeline.whenAsyncInitsSettled()` resolves once every started `asyncInit` has settled, including a debounced re-run that is already scheduled; an `asyncInit` error is logged and does not reject it. Async CPU effects draw their overlay over several frames, so a capture awaits this instead of counting frames. Shade MCP's `BrowserSession.selectEffect()` awaits it after a confirmed selection, bounded by the session timeout. The production pipeline also invokes the three lifecycle hooks: `initLifecycleEffects()` — called at the same sites as `initAsyncEffects()`, on resize and after a successful hot recompile — rebuilds the managed-effect set from the current graph and calls `onInit()` once per effect instance per pipeline lifetime (idempotent across recompiles; hook detection requires function-typed keys, so plain-object definitions registered via `registerEffect()` that omit hooks never misfire). `Pipeline.render()` calls `onUpdate({time, delta, uniforms: globalUniforms})` once per frame per managed effect, and each returned uniform binds under fallback semantics: only when the pass does not already resolve that key, so DSL/step-provided and authored values (including synth/media's returned `imageSize` defaults) keep priority. `Pipeline.dispose()` calls `onDestroy()` for every managed effect, with hook errors captured into the existing dispose error path. `func` is the DSL call name. The loader registers the same instance under `func`, `namespace.func`, `namespace/effectDirectory`, and `namespace.effectDirectory`. `globals` insertion order is positional DSL argument order. Append new optional arguments after existing positional arguments to preserve old calls. Sources: `shaders/src/runtime/effect.js` - `Effect.constructor`, lifecycle methods; `shaders/src/renderer/canvas.js` - `registerEffectWithRuntime()` and `applyStepParameterValues()`; `shaders/src/runtime/expander.js` - `expand()`; `shaders/src/runtime/compiler.js` - `recompile()`; `shaders/src/runtime/pipeline.js` - async initialization and regeneration, lifecycle hooks (`initLifecycleEffects()`/`_invokeUpdateHooks()`/`_withRuntimeUniforms()`); `shaders/src/runtime/backends/webgl2.js` and `webgpu.js` - `packUniformsWithLayout()`. ### Validate `validateEffectDefinition(def)` returns `string[]`. Its only pass predicate is `errors.length === 0`. Its checks include: - `name` is a non-empty string and `passes` is a non-empty array. - every pass has a string `program`, only known pass fields, and object `inputs`/`outputs`. - every global has a known `type` and only known spec and `ui` fields, with consistent `default`, `min`/`max`, `choices` and std-enum references. - unknown top-level fields, tags, hook values that are not functions, texture specs, uniform layouts and `enabledBy` conditions are diagnosed. Retired `paramAliases` is an unknown top-level field. That validator does not prove the complete grammar. A development gate must also require: 1. `checkEffectStructure.status === "ok"`. 2. `compileEffect` returns `status === "ok"` for WebGL2. 3. `compileEffect` returns `status === "ok"` for WebGPU when parity is required. 4. `renderEffectFrame.status === "ok"` and the output-specific predicates in the Rendered output section pass. 5. `testPixelParity.status === "ok"` for parity-authorable effects. Items 2-5 are necessary domain predicates. The browser verbs now wait for a page-confirmed requested effect and backend before reporting success. Check the returned identity and reject selection or backend-switch errors. `checkEffectStructure.status === "warning"` when any implemented structural issue is present. The precise issue arrays are in its MCP contract. Sources: `shaders/src/runtime/effect-validator.js` - `validateEffectDefinition()`; Shade MCP `src/tools/analysis/structure.ts` - `checkEffectStructure()`. ### Diagnose Representative definition-validator strings (`effect-validator.js` has about 150 distinct messages, all prefixed with the field they concern): ```text Effect definition is null or undefined Effect definition must be a plain object or Effect instance, not an array Unknown definition field '' Global '': Unknown type '' Unknown tag '' Missing or invalid "name" property Missing or empty "passes" array Pass : Missing "program" string Pass : "inputs" must be an object Pass : "outputs" must be an object "globals" must be an object Global '': Missing "type" ``` Asset/load failures include: ```text No default export in /definition.js No default export in bundle Invalid effect ID: Could not load shader manifest - lazy loading requires manifest.json ``` Backend compilation then reports missing shader source, shader compilation, linking, or program-spec failures enumerated under Cross-backend parity. `validateEffectDefinition()` now checks unknown declarative fields, tag values, global defaults/ranges/choices, pass field values, and texture-spec shape. Shader/declaration binding agreement — whether a pass uniform declared in a definition has a matching declaration in the shader source — and lifecycle signatures are not checked by `validateEffectDefinition()`; shader compilation and runtime behavior remain outside CPU structure validation. Sources: `shaders/src/runtime/effect-validator.js` - `validateEffectDefinition()`; `shaders/src/renderer/canvas.js` - load/import failure sites; backend `compileProgram()` methods. ## Parameters and globals ### Introspect The authoritative author-facing map is `effect.globals`. After registration, `ops[namespace.func].args` is the DSL projection, in positional order. After expansion, `graph.passes[].uniforms` contains resolved defaults, DSL arguments, upstream inherited uniforms, pass-level mappings, and internal globals. `graph.passes[].uniformSpecs` contains numeric automation ranges keyed by shader uniform name. Current definition census contains these `type` strings: ```text boolean color float geometry int mat3 member palette string surface vec2 vec3 vec4 volume ``` The DSL validator has explicit argument branches for `surface`, `color`, `vec3`, `vec4`, `volume`, `geometry`, `string`, `boolean`, and `member`. Other types use the numeric/general branch. A `boolean` argument keeps `true`/`false` (or a state function); it is not converted to `0/1` and is not clamped. During DSL registration, `vec4` is projected as DSL type `color`. Current observed global keys are: ```text choices colorModeUniform default define enum max min randChance randChoices randMax randMin step type ui uniform zero ``` `type`, `default`, `uniform`, `min`, `max`, `choices`, `enum`, `define`, and `colorModeUniform` affect compilation/expansion. `step`, `ui`, and randomization metadata are UI/program-generation policy, not shader binding validation. `ui.format: "vector"` is consumed by the unparser for a `vec4` parameter. With its definition available and an array or typed array of four finite numbers, `formatValue()` emits exact decimal array literals without rounding, color clamping, or exponent notation. Remap's 256 packed vertex-pair globals use this metadata. `unparse()` callers supply definitions through `getEffectDef`; custom formatters run first and may override the result. Other vector/color formatting and legacy hexadecimal input remain unchanged. Sources: `shaders/effects/**/definition.js` - `globals` census; `shaders/src/renderer/canvas.js` - `registerEffectWithRuntime()`; `shaders/src/lang/validator.js` - argument type branches; `shaders/src/runtime/expander.js` - global/uniform mapping. ### Act ```ts interface GlobalSpec { type: "float" | "int" | "boolean" | "member" | "surface" | "volume" | "geometry" | "color" | "vec2" | "vec3" | "vec4" | "mat3" | "palette" | "string" | "button" default?: unknown uniform?: string min?: number max?: number step?: number enum?: string // dotted enum root, e.g. "channel" choices?: Record // null entries are group labels define?: string // compile-time GLSL macro / WGSL const name colorModeUniform?: string // 0 for surface "none", otherwise 1 zero?: unknown // observed program-generation metadata randMin?: number // observed randomization metadata randMax?: number randChance?: number randChoices?: unknown[] ui?: { label?: string control?: "slider" | "checkbox" | "dropdown" | "color" | "button" | "vec3" | "vector3" | false category?: string hidden?: boolean format?: "vector" // consumed by vec4 DSL formatting, not shader validation enabledBy?: EnableCondition hint?: string multiline?: boolean buttonLabel?: string resetOnChange?: boolean // read only when state is reseeded; pulse resetState on change } } type EnableCondition = | string // legacy parameter-name truthiness check | { or?: EnableCondition[] and?: EnableCondition[] not?: EnableCondition param?: string eq?: unknown neq?: unknown gt?: number gte?: number lt?: number lte?: number in?: unknown[] notIn?: unknown[] } ``` Binding pipeline: 1. The object key is the DSL parameter name and positional slot. 2. `registerEffectWithRuntime()` maps `vec4` to DSL `color`, copies `default/min/max/uniform/choices`, and sets `enum`/`enumPath`. 3. If `choices` exists without `enum`, it generates enum root `..`. 4. Choice keys ending in `:` are group labels and are not registered as enum identifiers. Other keys are registered as written and, when different, under a sanitized identifier. 5. The validator stores the resolved value under the DSL parameter name in `step.args`. 6. The expander binds it to `spec.uniform` when present, otherwise the DSL name. 7. `PassSpec.uniforms` can map a shader uniform name to a global parameter name, or supply a literal number, which becomes that pass's uniform value; draws that share one program can carry different constants. 8. `define` creates a program-cache suffix plus a GLSL `#define` or WGSL module `const`; only defines change the program key. Formal value contracts: | Type | DSL form and resolution | |---|---| | numeric/general | number, boolean->`0/1`, enum member, matching bare enum/choice identifier, state identifier, automation, or default. The validator clamps to present `min/max`. | | `member` | Normally an enum member/bare enum identifier. The validator stores a numeric value after enum resolution. | | `surface` | `oN`, `sN`, `xyzN`, `velN`, `rgbaN`, `meshN`, `none`, `read(surface)`, or nested call/chain producing a temp. The validator rejects quoted strings. | | `volume` | `vol0`-`vol7`, `none`, or single-argument `read3d(volN)` | | `geometry` | `geo0`-`geo7`, `none`, or single-argument `read3d(geoN)` | | `color` | Hex color. An invalid form uses the default and produces a diagnostic. | | `vec3` | `vec3(n,n,n)`, hex color RGB, numeric array literal, or default | | `vec4` | `vec4(n,n,n,n)`, hex RGBA, numeric array literal, or default. The normal loader projects it to DSL `color`. | | `string` | quoted string only for `text.text`, `text.font`, `text.justify`, `text.style`. The validator also accepts a matching choice identifier. | | `palette` | Numeric/general argument. The expander additionally writes dependent palette uniforms already present on the pass | Numeric/global arguments can contain these compiled automation descriptors: ```ts type AutomationValue = OscillatorValue | MidiValue | AudioValue type OscillatorValue = { type: "Oscillator" oscType: 0 | 1 | 2 | 3 | 4 | 5 | 6 // 6 = noise2d min: number | AutomationValue max: number | AutomationValue speed: number | AutomationValue offset: number | AutomationValue seed: number | AutomationValue _ast: object _varRef?: string } type MidiValue = { type: "Midi" channel?: number zone?: 0 | 1 members?: number mode: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 cc?: number nrpn?: number min: number | AutomationValue max: number | AutomationValue sensitivity: number | AutomationValue name?: string id?: string _invalid?: true _ast: object _varRef?: string } type AudioValue = { type: "Audio" band: 0 | 1 | 2 | 3 | 4 | undefined min: number | AutomationValue max: number | AutomationValue channel?: PositiveInteger name?: string id?: string _invalid: boolean _ast: object _varRef?: string } ``` Ordinary nested fields are evaluated at the parent's current normalized render time. Automation nested into oscillator `speed` is the exception for oscillator types 0-5: the runtime integrates it from zero to that time. `noise2d` (6) reads its `speed` at the current time and does not integrate it. Its shape determines whether integration uses an exact integral, a constant external snapshot, or quadrature. In either case, the runtime maps a nested source's normalized result to the receiving field's range: - `min/max`: `[0,1]`. - Oscillator `speed`: `[-20,20]`. - Oscillator `offset`: `[-1,1]`. - Oscillator `seed`: `[1,9999]`. - MIDI `sensitivity`: `[0,10]`. Literal values for `speed`, `offset`, `seed`, and `sensitivity` are only required to be finite and are not clamped to those nested-source ranges. The outer automation result is finally mapped through the consuming uniform's range from the pass `uniformSpecs`: `float` and `int` globals without `choices` use `{min: def.min ?? 0, max: def.max ?? 100}`, so a missing bound defaults to 0 or 100. The result is rounded with `Math.round()` only for `int` globals with `choices` whose uniform drives a pass condition; other `int` globals receive the unrounded value. This rounding is specific to `Pipeline.resolvePassUniforms()`'s automation evaluation at render time; it is unrelated to `testUniformResponsiveness`'s own `int` test-value rounding (Validate, below) and to the DSL validator's literal-numeric-argument clamping, which still does not round `int` values. A nested oscillator `speed` is frequency modulation. The runtime integrates the rate from zero to the requested normalized time. This makes seeking and export independent of preceding frames. Literal oscillator fields use an exact closed-form integral where available. External MIDI/audio snapshots with literal fields are treated as constant across the interval. Dynamic nested fields use bounded Gauss-Legendre quadrature. Runtime also guards cycles and depth, returning zero without a consuming range or the range's lower endpoint if an invalid recursive object bypasses the validator. `Pipeline.setMidiState()` and `setAudioState()` install the live state used by automation. `MidiState` keeps an aggregate 16-channel state plus isolated per-port states. Exact `id` selection is authoritative. A name-only selector must uniquely match a connected port in the inventory supplied through `setPortInventory()`, including physically present ports whose open failed. Without an inventory, lookup uses registered connected ports. Missing, disconnected, or ambiguous selection returns `min` rather than falling back to aggregate state. Incoming messages update both the originating port and aggregate state. Disconnect resets the isolated state but retains its disconnected identity for host UI. `MidiInputManager` installs lifecycle handling before awaiting input opens, deduplicates in-flight opens, and ignores obsolete callbacks after a disconnect, replacement, or disable. `getPorts()` returns its physical inventory, including failed opens and disconnected history; `connected` does not mean operational. Only successful current opens register live state. `MidiState.getPorts()` instead reports registered ports. Hosts must feed each message once through `handleMessage(data, port)` to retain source isolation. CC state holds 128 seven-bit values and 32 paired fourteen-bit values per channel and port. Either byte of a CC14 pair updates the value immediately using the retained other byte, initially zero. Aggregate CC14 uses the complete pair from the last contributing port, never bytes from different ports. CC/CC14 values are divided by 127/16383 and mapped through `min/max`. Notes and `sensitivity` do not change these values. NRPN uses CC99/98 to select an address and CC6/38 for data entry. Both selector bytes must arrive before the first write; later selector updates retain the other byte. Values persist per port/channel/parameter. CC6 replaces the coarse value and clears its fine byte; CC38 replaces the fine byte using that parameter's retained coarse value. CC96/97 change the value by one, saturating at 0 and 16383. RPN selection through CC101/100 prevents writes to the prior NRPN, and null address 16383 disables writes. CC121 nulls selectors and resets controllers, bend, and pressure while retaining notes, parameter values, and the runtime's retained-controller set, including CC74. Full reset clears parameter state; disconnect clears the originating port's aggregate values. Pitch bend reads the unsigned fourteen-bit value divided by 16383, with neutral 8192. Channel pressure and per-key polyphonic pressure divide by 127. Fixed polyphonic-pressure bindings read the channel's latest key. These are raw positions; the runtime does not convert bend to semitones or accumulate relative encoder messages. MPE lower/upper zones select the newest held member note by Note On order. Without explicit `members`, each port follows RPN 6 configuration on manager channel 1/16, or defaults to 15 members until configured. CC6 count zero disables a zone; newer configuration takes overlapping members from the other zone. Changed membership clears affected notes and resets expression with CC74 initialized to 64. Explicit `members` overrides discovery for that binding. Note Off falls back to the newest remaining held note; no held note returns `min` in every mode. Repeated Note On replaces the same key; sustain does not retain released notes. Member expression is used directly without combining manager expression. Direct root-channel mutation does not populate the per-port held-note selection; use `handleMessage()`. The validator enforces integer channels 1-16 for every fixed-channel mode. Invalid DSL descriptors are inert and resolve to `min`. `MidiState.getChannel()` still falls back to channel 1 if a direct internal caller supplies an invalid runtime value. `AudioState` likewise holds aggregate analysis and a registry of device/channel states. Exact `id` is authoritative. Name-only selection must be unique in the full inventory supplied through `setDeviceInventory()`, including uncaptured devices; without inventory it uses registered connected devices. Channels must be integers 1-32. A channel with no identity selects independent default-input state, not the aggregate analyser. `setChannelValues()` clamps `low/mid/high/vol` to `[0,1]` and `raw` to `[-1,1]`. `audioBand.raw` returns `min` until `rawReady`, then maps the bipolar value to `[0,1]` before applying `min/max`. Missing, disconnected, ambiguous, out-of-range, or malformed selection returns `min`, with no aggregate fallback. `registerDevice()` creates one isolated state per declared channel. `disconnectDevice()` resets those states while preserving a disconnected inventory entry for host UI. Hosts register the delivered default-input count with `registerDefaultChannels(count)` and populate `getDefaultChannelState(channel)`. `disconnectDefaultInput()` clears those samples and marks them unavailable. `setRawUnavailable()` and `setDeviceRawUnavailable(id)` clear raw readiness without treating missing input as a real zero sample. `resetAggregate()` clears only the legacy samples and smoothing; `reset()` also clears independently captured channel samples while retaining identities and connection flags. `Pipeline.getAudioInputRequirements()` recursively inspects pass uniforms and returns `{needsLegacy,needsLegacyRaw,selected}`. Each selected entry is `{id:string|null,name:string|null,channel:number,needsRaw:boolean}`. Entries with null identity request default-device channels. Duplicate identity/channel tuples are merged and `needsRaw` is ORed. Aggregate audio is reported by the two legacy flags. An invalid outer audio descriptor suppresses both its own request and traversal into nested fields. Effects tagged `audio` force `needsLegacy:true` for backward compatibility. The built-in `AudioInputManager` populates aggregate bands, spectrum, and waveform, and now also resolves every audio capture the compiled graph requires: it registers the browser-selected capture device with `registerDevice()` and its channels with `registerDefaultChannels()`, opens one additional stream per selected-device requirement from `Pipeline.getAudioInputRequirements()` (resolved against `enumerateDevices()`: exact id is authoritative, a name must match exactly one device, re-checked at enable and every 60 update ticks), analyzes each captured channel independently through a channel splitter, and marks the aggregate and every captured channel `rawReady` from the time-domain mean each frame. Selected-device bindings that cannot be captured (unknown id, ambiguous name, open failure, no enumerable device id, or a captured device that does not expose the requested channel) emit a console warning and evaluate to `min`. `defaultFrom` exists in validator operator specs but is not copied by `registerEffectWithRuntime()` from `GlobalSpec`. Effect definitions cannot use it through the normal loader without separate operator registration. Sources: `shaders/src/renderer/canvas.js` - `registerEffectWithRuntime()`; `shaders/src/lang/validator.js` - argument resolution and `compileAutomationDescriptor()`; `shaders/src/lang/unparser.js` - automation round-trip; `shaders/src/runtime/expander.js` - compile-time defines and uniform mapping; `shaders/src/runtime/pipeline.js` - `evaluateAutomation()`, `resolvePassUniforms()`, external state, and `getAudioInputRequirements()`; `shaders/src/runtime/external-input.js` - state and input managers; `shaders/src/lang/enums.js` - `mergeIntoEnums()`; `demo/shaders/lib/demo-ui.js` - `_evaluateEnableCondition()`. ### Validate Numeric input pass/fail is measurable: - Resolved numeric range is `[min,max]` when both bounds exist, `[min,+Infinity)` with only `min`, `(-Infinity,max]` with only `max`, and unbounded when neither exists. - Input below `min` or above `max` is clamped and emits `S002`. Compilation still succeeds because `S002` is a warning. - A strict authoring gate should require zero `S002` diagnostics rather than treating clamping as success. - An `int` test value in `testUniformResponsiveness` is rounded. Normal DSL numeric validation does not round `int` arguments. For automation, require zero semantic errors, stable compile/unparse/recompile, and a finite resolved value at representative normalized times. Selected MIDI and audio tests must follow these steps: 1. Register at least two inputs with the same name to exercise the ambiguous-name failure. 2. Check that exact IDs remain authoritative. 3. Disconnect the selected input. 4. Assert that the result returns to `min`. Raw audio tests must distinguish unavailable raw data from a real zero sample. Both store `raw=0`. Only the real zero sample has `rawReady===true` and therefore maps to the midpoint of `min/max`. Nesting tests must cover eight accepted levels, a rejected ninth level, a variable cycle, deterministic repeated seeks, and recursive audio capture requirements. Runtime responsiveness measures each visible uniform control (not `ui.control: false`, not `ui.hidden`) on the render surface, at paused times t=0 and t=0.37, comparing mean RGBA vectors, a 64x64 grid of pixels at cell centers, and every pixel of the frame. A square render is measured at a 1:0.625 size (reported as `measured_size`) and restored afterwards, so aspect-ratio controls can respond. After each change it waits for async CPU overlays to redraw (`Pipeline.whenAsyncInitsSettled()`), up to the session timeout (`settle_timed_out`). Define, with each value the maximum over both times: ```text luma_delta = abs(mean RGB luma of test - mean RGB luma of reference) max_channel_delta = max(abs(test_c - reference_c)) over r, g, b, a means pixel_delta = mean over grid pixels and RGBA channels of abs(test_pixel - reference_pixel) strong_fraction = share of all pixels with any channel changed by > 16/255 ``` A control responds when `luma_delta > 0.002`, `max_channel_delta > 0.002`, `pixel_delta > 0.002` (reported as `pixel_diff`) or `strong_fraction > 0.0005`; the per-pixel delta sees blur, scale, rotation and offset controls that leave the mean color unchanged, and `strong_fraction` sees a sparse overlay (a few stray hairs) that falls between the grid pixels. For each control the tool captures a fresh reference twice; the second capture against the first is the output's own change between renders. When that change itself exceeds the thresholds (a simulation steps on every render), the control responds only when one measure exceeds twice that change plus its threshold; otherwise it gets no verdict (`name:unstable`, `responds: null`, with the reference change as `noise`). Up to two test values are tried, never the starting value (the value the loaded program set, read from the effect's pass uniforms, to which every control is restored afterwards): for a range, the farther of the 25% and 75% points, then the 38.2% point, which does not line up with right angles or halves a symmetric input is invariant to (`int` values rounded); for a dropdown, its first two other choices (a member enum's members come from the renderer's enum registry); for a boolean, its opposite; for a vector (a tint, a light direction), each component moved a quarter of its range. The first value that moves the output is reported as `test_value`. A control with no other value to set at run time (a surface input, an int with neither range nor choices) is reported `name:untested` with the reason. Before measuring a uniform whose `ui.enabledBy` condition is false at defaults, the tool sets the condition's params to values that satisfy it (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn`, `and`/`or`) and reports them as `enabled_with`; it restores them afterwards. A threshold gate opens halfway into its open range first, so a control whose effect scales with its gate is measured at a visible strength; a member-enum gate sets the enum's value, and values compare per component and within 1e-4, as the UI compares them. A uniform whose gate cannot be opened at run time (the gate is a compile-time `define`, an unknown param, or a `not`), and a compile-time `define` param itself, are reported `gated` (`name:gated`, `responds: null`) and are not measured. A uniform that does not respond is retried with one other control at a time moved (its first test value, and a range's maximum and minimum; a gated control with its gate opened, except that a toggle gating that control stays where it started in the reference): first controls in the same UI category, then controls whose names share a prefix (`zone1_count` for `zone1_v24`, `hslEnable` for `hslHueCenter`), then toggles and dropdowns, then the rest, at most 24 retries. It passes when one makes it respond, and the result reports that `context`; a range that selects what an adjustment changes, or a transform of a feedback that is mixed out at defaults, passes this way. When no retry makes it respond and one of them left the output changing between renders on its own, the control gets no verdict (`unstable`) instead of a fail. The tool's overall status is `ok` only when at least one uniform was measured and every measured uniform responded. It is `skipped` when no control could be set at run time (all are gated or untested), `error` when any measured uniform could not be captured or got no verdict (`unstable`), and otherwise `fail` when a measured uniform did not affect output; `details` names gated, unstable and untested uniforms. Every uniform is also reported in `uniforms` with its test value and measured deltas, and the result carries the `threshold` (0.002) and `strong_threshold` (0.0005). The repository harness gates `--uniforms` on this status (`ok` and `skipped` pass) and prints the measured deltas. Sources: `shaders/src/lang/validator.js` - `clamp()`, numeric arguments, and automation compilation; `shaders/src/runtime/external-input.js` - selected state; `shaders/src/runtime/pipeline.js` - automation evaluation and capture requirements; `shaders/tests/test_midi_audio_parser.js`, `test_external_input.js`, and `test_nested_automation.js`; Shade MCP `src/tools/browser/uniforms.ts` - `testUniformResponsiveness()`. ### Diagnose Parameter-related semantic failures use `Diagnostic`: - `S001`: quoted string for a non-string type, invalid surface/volume/geometry, unknown argument, unresolved enum, illegal string parameter, invalid function, automation cycle, or automation depth overflow. - `S002`: out-of-range value with `(got , clamped to )`, wrong value form, nonnumeric array element, or invalid audio channel. - `S003`: undefined variable, enum identifier, or inline choice. Selected-device audio bindings the built-in manager cannot capture evaluate to `min`; the manager emits a console warning when the graph requires such a capture. `testUniformResponsiveness` returns: ```ts type UniformResponsiveness = { effect_id: string, outcome: Outcome } & ( | { status: "ok" | "skipped" | "fail" | "error", backend: string, page_effect_id: string | null, tested_uniforms: Array<`${string}:pass` | `${string}:fail` | `${string}:error` | `${string}:gated` | `${string}:unstable` | `${string}:untested`>, uniforms: Array<{ name: string, uniform: string, default_value: unknown, test_value: unknown, luma_diff: number | null, max_channel_diff: number | null, pixel_diff?: number, // mean per-pixel delta, when measured strong_fraction?: number, // share of pixels changed by > 16/255 responds: boolean | null, // null when gated, unstable or untested enabled_with?: Record, // gate params set to open ui.enabledBy context?: Record, // the other control a retry moved unstable?: true, noise?: object, // output changes between renders on its own untested?: string, // why no value could be set at run time gated?: true, enabled_by?: unknown, define?: string, error?: string }>, threshold: 0.002, strong_threshold: 0.0005, measured_size?: [number, number], settle_timed_out?: true, details: string, console_errors?: string[] } // backend-switch or selection failure | { status: "error", tested_uniforms: [], uniforms: [], backend: string, page_effect_id: string | null, details: string } // in-page failures: "No effect loaded" (no backend field) and // "Failed to capture baseline on " | { status: "error", tested_uniforms: [], backend?: string, page_effect_id: string | null, details: string } | { status: "error", error: string }) ``` Enumerated in-page failures include `No effect loaded` and `Failed to capture baseline on `. Outer browser/session failures use `{status:"error",error:string}`. Each tested uniform reports its default and selected test value and measured luma and maximum-channel deltas, or a measurement error with null deltas. The tool does not return baseline and test frame means separately. All MIDI fixed-channel modes reject fractional or out-of-range channels with `S002` and return `min`. Invalid CC/NRPN/zone/member selection likewise produces an inert descriptor. Audio bindings the built-in manager cannot capture (unknown device id, ambiguous name, open failure, or a captured device that does not expose the requested channel) emit a console warning from `AudioInputManager` and evaluate to `min`; channel-only, named, id-selected, and raw bindings are populated by the manager itself. Sources: `shaders/src/lang/validator.js` - argument diagnostics; `shaders/src/runtime/external-input.js` - channel fallback and input manager; Shade MCP `src/tools/browser/uniforms.ts` - output paths. ## Passes and render graph ### Introspect `compileGraph(source)` returns the expanded graph: ```ts type CompiledGraph = { id: string // signed 32-bit source hash in base 36 source: string passes: ExpandedPass[] // execution order; no hidden sort programs: Record allocations: Map textures: Map renderSurface: string | null mediaSteps: Array<{textureId: string, uniform: string, stepIndex: number, effect: string}> // per-step external texture bindings, one entry per distinct texture id in program order compiledAt: number // Date.now(), milliseconds since epoch } type ExpandedPass = { id: string name?: string // authored pass label program: string type?: "render" | "compute" // the authored pass type; "render" on generated write/final blits entryPoint?: string drawMode?: "points" | "billboards" | "triangles" drawBuffers?: number count?: number | "auto" | "screen" | "input" countUniform?: string repeat?: number | string blend?: boolean | [string | number, string | number] workgroups?: [number, number, number] storageBuffers?: Record storageTextures?: Record inputs: Record // shader sampler/binding -> virtual texture outputs: Record // fragment attachment -> virtual texture uniforms: Record // shader uniform -> resolved value uniformSpecs?: Record scopedParams?: Record inheritsVolumeSize?: boolean effectKey?: string effectFunc?: string effectNamespace?: string | null nodeId?: string stepIndex?: number conditions?: { runIf?: unknown[], skipIf?: unknown[] } clear?: boolean viewport?: unknown // authored viewport spec viewportResolved?: { x: number, y: number, w: number, h: number } // set per frame by the pipeline samplerTypes?: Record uniformAliases?: Record } ``` To make `Map` values machine-readable: ```js const graphView = { ...graph, allocations: Object.fromEntries(graph.allocations), textures: Object.fromEntries(graph.textures), } ``` Current connections are exactly `passes[i].inputs` and `passes[i].outputs`. For every input entry, the key is the shader sampler/binding name and the value is the resolved virtual texture ID. For every output entry, the key is the shader output attachment name and the value is the resolved target ID. Passes are appended in DSL plan order, effect-step order, and definition pass array order. Builtin read/write/blit passes are inserted at the point the expander encounters them. There is no topological sort, dependency object, barrier plan, cycle detector, or `ERR_CYCLE` in current code. Sources: `shaders/src/runtime/compiler.js` - `compileGraph()`; `shaders/src/runtime/expander.js` - `expand()`; `shaders/src/runtime/resources.js` - `analyzeLiveness()`, `allocateResources()`. ### Act The definition-level authoring grammar is: ```ts interface PassSpec { program: string inputs?: Record outputs?: Record uniforms?: Record // shader name -> GlobalSpec key/name, or a literal compile-time constant entryPoint?: string drawMode?: "points" | "billboards" | "triangles" drawBuffers?: number count?: number | "auto" | "screen" | "input" countUniform?: string repeat?: number | string blend?: boolean | [BlendFactor, BlendFactor] workgroups?: [number, number, number] storageBuffers?: Record storageTextures?: Record // Copied into the expanded pass and consumed by Pipeline.shouldSkipPass() // at execution time. See below. conditions?: PassConditions // Not copied onto the expanded pass object directly. Instead, when present, // expand() derives a define-suffixed variant of this pass's compiled // program (merged with any effect-level compile-time defines) and points // this pass at that variant. See below. defines?: Record // Copied onto the expanded pass verbatim. `name` and `type` are queryable pass labels; backend // shader-kind dispatch remains source-derived, so `type` has no behavioral // consumer on the expanded pass. name?: string type?: "render" | "compute" // Copied onto the expanded pass verbatim. At execution time // Pipeline.resolvePassViewport() resolves the dimension grammar (numbers, // 'screen', percentages, {param}/{screenDivide}/{scale, clamp} forms on the // x/y/w/h/width/height keys) into `viewportResolved` {x, y, w, h} numbers, // cached per pass and re-resolved each frame from the pass uniforms; the // authored spec stays queryable on `viewport`. Backends prefer // `viewportResolved` and otherwise keep the legacy manual-pass direct read // of `viewport`; an output texture's own dimensions still take priority in // both backends. viewport?: { x: number, y: number, w: number, h: number } // Copied onto the expanded pass verbatim and consumed at execution: // `clear` drives the WebGPU render-pass loadOp (WebGL passes do not clear // color targets before a normal draw); `samplerTypes` selects per-binding // samplers in the WebGPU backend. clear?: boolean samplerTypes?: Record } type TextureReference = string type BlendFactor = | "ZERO" | "ONE" | "SRC_COLOR" | "ONE_MINUS_SRC_COLOR" | "DST_COLOR" | "ONE_MINUS_DST_COLOR" | "SRC_ALPHA" | "ONE_MINUS_SRC_ALPHA" | "DST_ALPHA" | "ONE_MINUS_DST_ALPHA" | "CONSTANT_COLOR" | "ONE_MINUS_CONSTANT_COLOR" | "CONSTANT_ALPHA" | "ONE_MINUS_CONSTANT_ALPHA" | "SRC_ALPHA_SATURATE" | "zero" | "one" | "src" | "one-minus-src" | "dst" | "one-minus-dst" | "src-alpha" | "one-minus-src-alpha" | "dst-alpha" | "one-minus-dst-alpha" | number type PassConditions = { skipIf?: Array<{ uniform: string, equals: unknown }> runIf?: Array<{ uniform: string, equals: unknown }> } ``` Input resolution rules, in priority order for each `inputs` entry: 1. `inputTex` and `o` use the current 2D chain input or the literal reference when no input exists. 2. `inputTex3d`, `inputGeo`, `inputXyz`, `inputVel`, and `inputRgba` use the corresponding upstream channel or remain literal. 3. `noise` becomes `global_noise`. `midiNoteGrid` remains `midiNoteGrid`. 4. `feedback`/`selfTex` maps to the chain write surface, or current input. 5. A matching surface global in `step.args` resolves a temp, current pipeline input, named global surface, or `none`. 6. A matching `GlobalSpec.default` resolves `none`, input, global surface, chain-scoped `global_...`, or literal name. 7. An explicit `global_...` value is chain/particle scoped. 8. `outputTex` refers to this effect node's primary output. 9. Anything else is a node-local texture and becomes `_`. Output resolution rules: - `outputTex` becomes the node primary output. The last pass of the last step can write directly to the chain's global surface. - `outputTex3d`, `outputXyz`, `outputVel`, and `outputRgba` create typed node outputs. - `inputTex3d`, `inputGeo`, `inputXyz`, `inputVel`, `inputRgba` write in place to their upstream channel when one exists. - `global_...` is chain/particle scoped. `feedback_...` remains literal. Otherwise the target is node-local. `uniforms: {shaderName: globalName}` reads the already resolved pipeline value, then the referenced global's default. It does not declare a new global. A numeric `uniforms` value instead is copied straight to `pass.uniforms` as a literal, bypassing global lookup entirely; it specializes one compiled variant of a program shared by several passes (for example, one pass's `blurLayer: 1` versus a sibling pass's `blurLayer: 0`) without exposing that selector as a DSL argument. `conditions.skipIf`/`runIf` are copied onto the expanded pass and consumed by `Pipeline.shouldSkipPass()` at execution time, once per frame, after that frame's pass uniforms are resolved. `skipIf` skips the pass if any listed `{uniform, equals}` condition matches; `runIf` skips it unless every listed condition matches. Each condition's `uniform` is looked up first on `pass.uniforms`, then on `globalUniforms` (pass-level values take priority over pipeline-wide ones). A pass with no `conditions` never skips. `defines` lets several passes in one effect share a `program` name while each compiling to a different variant: `expand()` builds a `__KEY_value` suffix from the sorted `defines` entries, points this pass's `program` at the suffixed variant, and (creating it once, from the base variant) compiles that variant with `{...effect-level compile-time defines, ...this pass's defines}` injected as GLSL `#define`s / WGSL module `const`s. This is how one effect definition can `.flatMap()`/clone several `PassSpec` entries for the same `program` (for example, once per `viewMode` value) and have each compile as its own specialized program without a runtime branch in the shader. A `defines` key never appears as a field on the expanded pass object itself; only the resulting `program` name changes. `repeat` is resolved at execution. A number becomes `max(1,floor(repeat))`. A string looks up `globalUniforms[name]`, then `pass.uniforms[name]`. Numeric values use `max(1,floor(value))`, otherwise one. Repeated writes advance frame-local ping-pong bindings after every iteration. `drawBuffers > 1` or more than one output activates MRT. `blend: true` is additive one/one. A two-item blend array selects explicit factors. Unknown WebGL factors fall back to `ONE`. WebGPU lowercases/replaces underscores and lets device validation reject invalid factors. Before surface allocation, the pipeline sums the color-attachment byte cost of every multi-output pass in output insertion order. The costs are: - `rgba32f` and `rgba32float`: 16 bytes. - `rgba8` and `rgba8unorm`: 4 bytes. - Every other or missing format: 8 bytes. If the sum exceeds `capabilities.maxColorBytesPerSample`, the pipeline examines attachments from last to first. It changes only `rgba32f`/`rgba32float` specs to `rgba16f`, subtracting 8 per change. It stops when the group fits or no eligible attachment remains. This mutates `graph.textures` before allocation and deliberately preserves the precision of earlier attachments. It is a best-effort adaptation, not an admission check: an over-budget group with insufficient demotable attachments remains over-budget and emits no structured failure. Fresh allocation uses the mutated graph format. Texture reuse requires matching dimensions and format. A 3D texture must also have matching depth. A double-buffered global surface must have matching read and write textures. The pipeline recreates the allocation when one of these properties differs. Inspect `pipeline.backend.textures` to verify the physical formats. Do not use only `graph.textures` as evidence. A framebuffer failure surfaces through the structured, non-throwing `backend.diagnostics` records (`ERR_MISSING_RENDER_TARGET` for missing FBO/MRT targets, `ERR_GL_ERROR` for post-draw drained `gl.getError()` failures; console output unchanged). `type` does not select a WebGPU compute program. WebGPU detects a compute-only program from `@compute` without `@fragment`. WebGL detects compute-style work from `storageTextures` or an `outputBuffer` output and applies a fragment-pass fallback. Sources: `shaders/src/runtime/expander.js` - pass construction and input/output mapping, `defines`-suffixed program-variant construction; `shaders/src/runtime/pipeline.js` - `render()`, `resolveRepeatCount()`, `applyMrtFormatBudget()`, `shouldSkipPass()`; backend `executePass()` and blend resolvers. ### Validate A source-grounded graph gate can assert: ```text semantic_error_count === 0 expansion_error_count === 0 passes.length >= 1 renderSurface !== null every pass.program resolves in graph.programs every pass output is a nonempty string every pass input is a nonempty string or intentionally omitted for null arg every non-global produced texture has an allocations entry ``` `analyzeLiveness(passes)` returns `{start,end}` indices for every non-`global_` texture touched as an input or output. Both indices are in `[0, passes.length-1]` and `start <= end`. `allocateResources()` assigns `phys_0`, `phys_1`, ... to non-global outputs and reuses a slot only when its previous texture's `end < currentPassIndex`. The allocation map is consumed by the renderer behind the explicit `texturePooling: true` opt-in. `Pipeline.buildTexturePoolingPlan()` reads `graph.allocations` and groups virtual textures that share a physical slot; `Pipeline.recreateTextures()` creates one backend texture per group under the group's primary id and aliases the other members' backend map entries to it, so every pass binds the shared record through either id. A group is pooled only when every member carries an identical plain 2D spec (no `persistent`, `mipmaps`, or 3D policy) and each member's first touch in the pass list is a producing write that does not sample the texture itself, and no member is written by a partial/non-clearing pass (any explicit `drawMode` scatter or `blend`, or a `viewport` write without `clear: true`, which exposes a group-mate's content in the unwritten region) — first-read and cross-frame accumulation semantics match standalone textures; the default (opt-out) pipeline keeps one backend texture per virtual id. `Pipeline.getResourcePlan()` reports both the analyzer's physical allocation map and the sharing the renderer actually materialized (`pooling`, `allocations`, `sharedTextures`, `textures` with per-record `virtualTextures`). There is no dependency/cycle validator. A pass that reads a texture before a producer can compile and may sample a default, stale, or missing texture. A formal authoring gate must perform its own source-order check: ```text For each non-global, non-external input at pass i: some pass j < i must produce it, OR it must be intentionally frame-persistent. ``` That predicate is recommended policy, not an engine-emitted result. Sources: `shaders/src/runtime/resources.js` - liveness/allocation; `shaders/src/runtime/pipeline.js` - `buildTexturePoolingPlan()` and `getResourcePlan()`, which consume `graph.allocations`; backend texture binding fallbacks. ### Diagnose Expansion uses `ExpansionFailure`. Graph/backend failures include: ```ts { code: "ERR_PROGRAM_SPEC_MISSING", program: string, pass: string } { code: "ERR_PROGRAM_NOT_FOUND", program: string, pass: string } { code: "ERR_TEXTURE_NOT_FOUND", texture: string, pass: string } // WebGPU { code: "ERR_NO_MRT_OUTPUTS", pass: string } // WebGPU { code: "ERR_COMPUTE_DISPATCH_UNRESOLVED", pass: string, detail: "Compute dispatch dimensions could not be inferred" } ``` WebGL2 missing FBOs and MRT textures are console warnings, not throws. WebGL errors discovered after a draw are logged with pass/effect/program/output/input context and drained; each drained error is also recorded as an `ERR_GL_ERROR` diagnostic. WebGPU missing MRT members, a missing render-surface storage target (which falls back to a temporary storage texture) and a missing buffer-copy output are console warnings. Device validation arrives as an `uncapturederror` console message. All of these paths also record structured, non-throwing diagnostics in the backend's `backend.diagnostics` collector (`ERR_MISSING_RENDER_TARGET` with `kind` `fbo`/`mrt` on WebGL2 and `mrt`/`storage-surface`/`copy-output` on WebGPU, `ERR_GL_ERROR`, `ERR_DEVICE_VALIDATION`; console output unchanged). Definition fields `name`, `viewport`, `clear`, `samplerTypes`, and `type` are copied by `expand()` onto the expanded passes. `conditions` reaches `Pipeline.shouldSkipPass()` (the mechanism this round's per-`viewMode`/`blendMode` pass-cloning pattern relies on to select which cloned pass variant actually runs). `viewport` is resolved per frame into `viewportResolved` {x, y, w, h} numbers by `Pipeline.resolvePassViewport()` (the authored spec stays on `viewport`), and the backends prefer the resolved numbers with output-texture dimensions still taking priority. `clear` reaches the WebGPU render-pass loadOp and `samplerTypes` the WebGPU per-binding sampler selection. `name` and `type` are queryable pass labels: backend shader-kind dispatch remains source-derived, so `type` has no behavioral consumer on the expanded pass. `inputOverride` on dimension specs is accepted but unconsumed. Sources: `shaders/src/runtime/pipeline.js` - `compilePrograms()`, `render()`, `shouldSkipPass()`; backend `executePass()` implementations; `shaders/src/runtime/expander.js` - constructed pass keys. ## Textures and surface resources ### Introspect Definition texture specs are available at `effect.textures` (2D) and, for class-based effects, `effect.textures3d`. Expanded/scoped specs are returned in `graph.textures: Map`. Allocated backend records are in `pipeline.backend.textures`. Double-buffer records are in `pipeline.surfaces`. ```ts type RuntimeTextureSpec = { width: DimensionSpec height: DimensionSpec depth?: DimensionSpec format: string is3D?: boolean usage: Array<"render" | "sample" | "storage" | "copySrc" | "copyDst"> } type SurfaceRecord2D = { read: string write: string currentFrame: number } type MeshSurfaceRecord = { positions: string normals: string uvs: string width: number height: number } type DeviceCapabilities = { isMobile: boolean floatBlend: boolean floatLinear: boolean colorBufferFloat: boolean maxDrawBuffers: number maxTextureSize: number maxColorBytesPerSample: number | undefined maxStateSize: number } ``` Node-local texture names are prefixed with `node__`. Non-particle names beginning `global_` are chain-scoped. Particle state names are scoped to the creating particle node. Normal global surfaces have `_read`/`_write` backend textures. `pipeline.getCapabilities()` returns the active backend object. `CanvasRenderer.capabilities` delegates to it. Before a pipeline/backend is available, both public paths use `maxDrawBuffers:8`, `maxTextureSize:4096`, `maxColorBytesPerSample:64`, and `maxStateSize:2048` with all float flags true and `isMobile:false`. An initialized WebGL2 backend reads draw-buffer and texture limits from GL. When `EXT_color_buffer_float` exists, it probes tiny MRT groups and reports one of `64`, `48`, `40`, `32`, or the fallback `16`. Without that extension its byte budget is `undefined`. WebGPU reports `device.limits.maxTextureDimension2D || 8192` and `device.limits.maxColorAttachmentBytesPerSample || 32`. Both backends use `maxStateSize:512` on detected mobile devices and `2048` otherwise. Sources: `shaders/src/runtime/expander.js` - texture-spec collection/scoping; `shaders/src/runtime/compiler.js` - `extractTextureSpecs()`; `shaders/src/runtime/pipeline.js` - `getCapabilities()`, `createSurfaces()`, `recreateTextures()`; `shaders/src/renderer/canvas.js` - `capabilities`; backend `init()` methods. ### Act ```ts type DimensionSpec = | number | "screen" | "auto" | "input" // resolves to the screen size | "resolution" // resolves to the screen size | `${number}%` | { param: string, paramDefault?: number, multiply?: number, power?: number, default?: number } | { screenDivide: string, default?: number } | { scale: number, clamp?: { min?: number, max?: number } } // The formats validateEffectDefinition() accepts: those both backends resolve. type TextureFormat2D = | "rgba8" | "rgba16f" | "rgba32f" | "r8" | "r16f" | "r32f" | "rgba8unorm" | "rgba16float" | "rgba32float" | "r8unorm" | "r16float" | "r32float" interface TextureSpec2D { width?: DimensionSpec height?: DimensionSpec format?: TextureFormat2D } interface TextureSpec3D extends TextureSpec2D { depth?: DimensionSpec } type TextureSpec = TextureSpec2D | TextureSpec3D ``` Omitted/falsy definition width or height becomes `screen`. Omitted format becomes `rgba16f`. Graph-generated pass outputs also use screen size, `rgba16f`, and usage `render/sample/copySrc/copyDst`. A 3D spec gets `is3D:true`. Its depth default is `effectSpec.depth || effectSpec.width || 64` and usage is `storage/sample/copySrc/copyDst`. Dimension resolution: | Spec | Resolved pixels | |---|---| | number | `max(1,floor(spec))` | | `screen` / `auto` | the corresponding pipeline screen dimension | | percentage string | `max(1,floor(screenSize * parseFloat(spec)/100))` | | `{param}` | uniform value or `paramDefault` or 64. Then multiply/power. Transformed missing-param can use final `default`. Floor and clamp to at least 1 | | `{screenDivide}` | `max(1,round(screenSize/(uniform or default or 1)))` | | `{scale}` | floor of screenSizexscale, optional min/max clamp, then at least 1 | | any other value/string | screen size | `expand()` also follows a volume/geometry surface's dimensions through `write3d()`/`read3d()` handoffs, so a later chain that reads a previously exported `volN`/`geoN` (via `read3d(volN)`/`read3d(geoN)`, not the producing chain's own local reference) inherits that surface's actual size instead of defaulting to 64. It tracks, per expansion: every chain-scoped `volumeSize_` parameter a `write3d()` call last wrote (and the numeric value it wrote), every `read3d()` call's producer surface and scope, and every surface a `write3d()`/`write3d`-adjacent geometry blit exports to. After all plans expand, it resolves each reader's scope to its writer (following re-exports and repeated same-surface filters transitively, with cycle protection), rewrites that reader's `textureSpecs` width/height/depth to reference the resolved writer's sizing parameter, and rewrites any pass uniform still holding the reader's own placeholder parameter name to the resolved writer's parameter and value. A `volN`/`geoN` surface with no preceding `write3d()` in the compiled program is unaffected and keeps its default sizing (64 for volumes, screen-sized for geometry). Before each `createSurfaces()` allocation pass, numeric uniforms named exactly `volumeSize` or starting with `volumeSize_chain_`/`volumeSize_node_` are clamped against `capabilities.maxTextureSize`. A value whose square fits is unchanged. Otherwise, the algorithm starts at 16. It doubles while the next power of two satisfies both conditions: - It remains below the requested value. - Its square is no larger than the texture limit. Thus `128` becomes `64` for an `8192` limit and `32` for a `2048` limit. A missing/falsy limit or a nonnumeric automation value is left untouched. The pipeline mutates every affected pass uniform before dimension resolution and warns only once per requested-to-effective pair. The same clamp applies to `Pipeline.setUniform()` for unscoped, chain-scoped, and node-scoped names. `CanvasRenderer.applyStepParameterValues()` reaches the chain broadcast path, which aligns the source pass, scoped value, and inherited consumer values before texture recreation. These paths prevent atlas dimensions (`volumeSize` by `volumeSize^2`) and shader addressing from disagreeing after graph load, resize, hot recompile, or a host/UI update. `resolveDimension()` resolves `"screen"`, `"auto"`, `"input"` and `"resolution"` to the screen size without a diagnostic; `"input"` does not query the input texture. Any other string records `ERR_DIMENSION_FALLBACK`. WebGL2 2D textures use nearest minification/magnification and clamp-to-edge, except mipmapped textures (`LINEAR_MIPMAP_LINEAR` minification) and external upload textures (linear). Its format map is `rgba8`, `rgba16f`, `rgba32f`, `r8`, `r16f`, `r32f`, plus the WebGPU spellings `rgba8unorm`, `rgba16float`, `rgba32float`, `r8unorm`, `r16float` and `r32float` for the same formats. Any other string falls back to RGBA8 with an `ERR_UNKNOWN_FORMAT_FALLBACK` diagnostic. WebGL2 3D textures use linear filtering unless the spec sets `filter: "nearest"`, and cannot be render targets. `extractTextureSpecs()` copies a definition's 3D `filter`, and the `Effect` config constructor keeps `textures3d`, so the filter is authorable in both definition styles. WebGPU maps the six WebGL spellings plus `rg8/rg16f/rg32f`, accepts listed WebGPU spellings, and otherwise returns the provided string to WebGPU validation. The normal surface sampler defaults to nearest for parity with WebGL2. External uploads use the default linear sampler. Both backends expose synchronous `updateTextureFromSource(id,source,options)` accepting `HTMLVideoElement`, `HTMLImageElement`, `HTMLCanvasElement`, `ImageBitmap`, or borrowed `VideoFrame`, and return `{width,height}` from the source's display or intrinsic dimensions. For borrowed `VideoFrame` sources, both backends synchronously extract display dimensions and submit to the GPU queue without requiring intermediate `ImageBitmap` conversion; callers retain ownership and may close their frame synchronously immediately after the method returns without waiting for GPU execution. WebGL2 uploads visible pixels via `texImage2D` and rejects anamorphic display scaling (`displayWidth !== rect.width` or `displayHeight !== rect.height`, accounting for 90°/270° rotation) returning `{width: 0, height: 0}`. WebGPU submits `copyExternalImageToTexture()` without an awaitable completion step for video, images, `ImageBitmap`, borrowed `VideoFrame`, and GPU-backed canvases, handling display scaling, rotation, crop, color, and alpha via external image copy semantics. A 2D canvas created with `willReadFrequently` keeps its pixels in CPU memory, so WebGPU uploads it from `getImageData()` through `queue.writeTexture()` (rows reversed for `flipY`), the same unpremultiplied bytes WebGL2's `texImage2D` stores; `copyExternalImageToTexture` would unpremultiply on the GPU and round low-alpha texels differently. Unknown or zero-sized sources return zero dimensions. Canvas/media callers may therefore read the dimensions directly on either backend rather than awaiting a backend-specific promise. Effects that declare an `externalTexture` (e.g. `synth/media`) bind one texture per expanded step that consumes it, named `_step_N` where `N` is the step's node index in the compiled program (a trailing `.write()` is its own node, so it consumes a step too). No pass ever binds the bare `` id, so hosts must upload into each step's texture and publish the source dimensions into each step's `imageSize` uniform for the image to render. `CanvasRenderer.getMediaSteps()` lists each media step's texture id, pass uniform, step index, and effect from the compiled graph — the same bindings `compileGraph()` carries as `graph.mediaSteps` — so hosts no longer reproduce the numbering themselves; texture ids and step numbering are unchanged for hosts that already bind `_step_N`. 2D texture specs may author two opt-in allocation policies and 3D texture specs may author a filtering policy: - `mipmaps: true` (2D only) allocates a full mip chain (`floor(log2(max(w,h))) + 1` levels — WebGL2 allocates every level with per-level `texImage2D` calls at `createTexture()` so the texture is mipmap-complete before the first regeneration) and the pipeline regenerates it from the frame's level-0 writes after every render (`backend.generateMipmaps()`). WebGL2 blits level-1..N with a NEAREST chain; WebGPU downsamples level N-1 into N through cached fullscreen resample pipelines (per-level bind groups are cached on the texture record). Mipmapped textures render/compute-write into level 0 (`renderView`/single-mip views) and sample through the full-chain view with a linear mipmap sampler. Backend texture records expose `mipmaps` and `mipLevels`. Double-buffered global surfaces regenerate both halves each frame; for the read half this is idempotent (its level 0 was not written that frame, so the chain already matches its contents from when it was the write half). - `persistent: true` (2D only) preserves contents when the texture is recreated at a new size (resize or parameter-driven recreation): the pipeline copies the old texture into a temporary, recreates at the new size, and resamples the temporary back through `backend.copyTexture()` (WebGL2 blit; WebGPU `copyTextureToTexture` when dimensions match and neither side has a mip chain, the fullscreen resample pass otherwise — a mipmapped source always takes the resample path, even at matching dimensions, so level 0 is resampled and the chain is regenerated at the new size). Backend texture records expose `persistent`. - `filter: 'nearest' | 'linear'` (3D only) selects the 3D sampling mode: WebGL2 sets it at `createTexture3D()` (default linear, unchanged); WebGPU records the authored filter on the 3D texture (default linear) and resolves the sampler per binding from the texture the binding's sampling call reads: an authored `'nearest'` filter binds the nearest sampler, and everything else — including unauthored 3D — binds the default linear sampler, matching WebGL2's default. A sampler whose sampling calls read several textures has no single inference and keeps the `samplerTypes` override or the surface default. Records expose `filter`. - Unknown texture spec fields (typos) are rejected by `validateEffectDefinition()` with a `Texture 'x': unknown field 'y'` diagnostic; `filter`/`mipmaps`/`persistent` on the wrong container are rejected the same way. Previously accepted programs keep compiling: these keys were never consumed before. - `transient` remains a non-authorable concept (absence of `persistent`). - Implementation and focused regression coverage (validator cases, `extractTextureSpecs()` propagation through `compileGraph()`, pipeline preservation-on-resize and per-frame regeneration against recording backends) live in `shaders/tests/test_mip_controls.js`, wired into `scripts/run-js-tests.js` and `npm run test:shaders:runtime`. Sources: `shaders/src/runtime/pipeline.js` - `resolveDimension()`, `clampVolumeSize()`, `clampGraphVolumeSizes()`, `applyMrtFormatBudget()`, `setUniform()`, `broadcastChainScopedParam()`; `shaders/src/runtime/expander.js` - `write3d`/`read3d` volume/geometry dimension-handoff resolution (`writtenVolumes`, `readVolumes`, `exportedTextures`, `resolveVolume()`, `resolveExport()`) and per-step external-texture binding recording (`mediaSteps`); `shaders/src/renderer/canvas.js` - `applyStepParameterValues()`; `shaders/src/runtime/backends/webgl2.js` - `createTexture()`, `createTexture3D()`, `resolveFormat()`, `probeColorBytesPerSample()`, `updateTextureFromSource()`; `shaders/src/runtime/backends/webgpu.js` - texture creation, samplers, `resolveFormat()`, `updateTextureFromSource()`. ### Validate Measurable resource predicates after `pipeline.resize(width,height)`: ```text every resolved non-screen dimension >= 1 every graph texture has backend record with matching width and height every 3D graph texture has matching depth and is3D === true every global 2D surface has distinct read and write texture IDs every pass output intended for WebGL rendering has an FBO every format is recognized by the target backend before compilation/execution every numeric volumeSize-family pass uniform agrees with its allocated atlas when maxColorBytesPerSample is numeric, every MRT byte sum computed from pipeline.backend.textures is <= it; otherwise reject the graph in host policy ``` WebGL FBO completeness is checked during creation but only logged (console). WebGPU device creation validates size/format/usage asynchronously through normal exceptions or `uncapturederror`. Individual failures of these checks surface as structured, non-throwing `backend.diagnostics` records (`ERR_MISSING_RENDER_TARGET`, `ERR_GL_ERROR`, `ERR_DEVICE_VALIDATION`), but there is no public aggregate resource-validation report. Frame lifetime is behavior, not an authoring flag: - Backend texture objects survive frames until resize, recompilation/recreation, or `dispose()`. - WebGL passes do not clear color targets before a normal draw. - WebGPU normal render attachments use `loadOp:"load"` unless `pass.clear`; authored pass-level `clear` is copied onto the expanded pass and reaches this loadOp. - Global surfaces are double-buffered. Display surfaces swap at frame end. Names classified as state (`xyz`, `vel`, `rgba`, `trail`, suffix variants, names containing `state`, scoped particle state) preserve final frame-local bindings. Graph feedback surfaces — a surface that a pass reads before any pass writes it and that a later pass writes — and surfaces whose global texture spec sets `persistent: true` preserve their final frame-local bindings too, so the next frame reads the latest write even when an intervening update pass was skipped. A surface written before it is first read is frame-local scratch and still swaps. - A node-local texture can therefore retain data across frames. With the opt-in `persistent` texture-spec policy, a recreated allocation also preserves its previous contents across size changes, and backend texture records expose the `persistent` and `mipmaps`/`mipLevels` class flags for query. Sources: backend texture/FBO creation; `shaders/src/runtime/pipeline.js` - `swapBuffers()`, `resize()`, `dispose()`. ### Diagnose Resource failures include: ```text WebGL Error: Texture not found (raw Error in readPixels) WebGPU: {code:"ERR_TEXTURE_NOT_FOUND", pass, texture} WebGL console: FBO incomplete for texture : WebGL console: MRT FBO incomplete for : WebGL console: [executePass] FBO not found for in pass [Pipeline] Capping volumeSize from to : the x volume atlas exceeds this device's max texture size () [Pipeline] Demoting MRT attachment from to rgba16f: pass needs bytes/sample, device allows WebGPU console: Storage texture not found WebGPU uncaptured error: ``` WebGL unknown formats silently become RGBA8. WebGPU unknown formats reach device validation. Unknown dimension forms resolve to screen size. Neither fallback rejects previously accepted input, and both now surface structured, deduplicated diagnostics instead of silence: WebGL unknown formats emit a once-per-format `[WebGL2]` warning plus a queryable `ERR_UNKNOWN_FORMAT_FALLBACK` record in `backend.diagnostics`, and unknown dimension forms emit a once-per-spec `[Pipeline]` warning plus a queryable `ERR_DIMENSION_FALLBACK` record in `pipeline.diagnostics`. Unknown texture spec fields and misplaced `filter`/`mipmaps`/`persistent` keys are rejected by `validateEffectDefinition()`. After MRT adaptation, inspect the backend texture records. The pipeline recreates an allocation when its recorded format does not match the graph. FBO/GL diagnostics keep their console messages and additionally record structured `backend.diagnostics` entries (`ERR_MISSING_RENDER_TARGET`, `ERR_GL_ERROR`). WebGL per-pass and presentation `gl.getError()` logging is armed only for the first three frames after a program compile or 2D texture allocation. It is disabled in steady state to avoid a synchronous CPU/GPU round trip. Late GL errors are therefore not surfaced by those per-frame checks. FBO completeness checks still run when render targets are created. The WebGL MRT budget probe restores the default framebuffer. It deletes its temporary textures/framebuffer and drains errors from rejected probe combinations. This prevents later pass checks from attributing those errors to the wrong operation. Sources: backend texture and pass execution methods, including WebGL `probeColorBytesPerSample()`; `shaders/src/runtime/pipeline.js` - `resolveDimension()`, `clampVolumeSize()`, `createSurfaces()`, and `recreateTextures()`. ## Compatibility and mutation ### Introspect Compile the DSL. Then call `listSteps(compiled)`: ```ts type StepInfo = { stepIndex: number planIndex: number chainIndex: number effectName: string isStarter: boolean isStarterPosition: boolean canReplaceWithStarter: boolean canReplaceWithNonStarter: boolean args: Record } ``` `stepIndex` is the compiled effect step's `temp`. Branch objects do not have `chain` and are skipped by these mutation helpers. Compiled nodes marked `builtin: true`, including `read`, `write`, 3D I/O, and subchain boundaries, are also excluded. A two-effect chain ending in `.write(o0)` therefore returns two entries while retaining the effects' original step and chain indexes. `getCompatibleReplacements()` enumerates the currently registered `ops` and partitions them by starter state. Sources: `shaders/src/lang/transform.js` - `listSteps()`, `getCompatibleReplacements()`, `predictReplacement()`; `shaders/src/lang/ops.js` - `ops`; `shaders/tests/test_transform.js` - effect-only length, index, and builtin-target regressions. ### Act ```ts type ReplacementQuery = | { success: true, compatible: string[], incompatible: string[], predictions: Record, blocked?: { effect: string, issues: ReplacementPredictionIssue[] }[] } | { success: false, error: string } type ReplacementResult = | { success: true, program: CompiledProgram, prediction: ReplacementPrediction } | { success: false, error: string, prediction?: ReplacementPrediction } getCompatibleReplacements( compiled: CompiledProgram, stepIndex: number, options?: { searchOrder?: string[], preflight?: boolean, manifest?: object } ): ReplacementQuery replaceEffect( compiled: CompiledProgram, stepIndex: number, newEffectName: string, newArgs?: Record, options?: { searchOrder?: string[], preflight?: boolean, manifest?: object } ): ReplacementResult ``` Every successful listing attaches `predictions`: a per-candidate prediction (`predictReplacement()` in `shaders/src/lang/transform.js`) of the candidate's compatibility dimensions — shader availability (registered definition in the runtime registry; `available` is `unknown` when no definitions are registered at all), arguments (unknown names and missing no-default arguments), types, ranges (min/max/choices, reported under the argument name), passes, outputs, sampler topology (with the replaced effect's topology as `changedFrom`), and backend support from the optional `options.manifest` (`effects/manifest.json` shape). Dimensions without available data are reported as `unknown`, never invented. Listing-time predictions use empty arguments, so requires-arguments candidates are not blocked, and missing no-default arguments are informative rather than hard issues. With the explicit `preflight: true` opt-in, candidates whose prediction found hard issues (availability, unknown arguments, type mismatches, range/choice violations) move out of `compatible` into `blocked` with the reasons; the default classification is otherwise unchanged, so previously accepted input is only rejected behind the opt-in. `replaceEffect` attaches the same prediction to every success as `prediction`, and with `preflight: true` it refuses mutation when the prediction finds hard issues, returning `success: false` with `error: "Replacement preflight failed: ..."` plus the prediction. The checkable current definition of compatible is exactly: ```text target effect exists in ops AND isStarterPosition(step) === isStarterOp(target effect) ``` where `isStarterPosition(step)` is: ```text chainIndex === 0 OR (isStarterOp(step.op) AND (step.from === null OR step.from === undefined)) ``` A step is in starter position if it is either (a) the first step in the chain (`chainIndex === 0`), or (b) an inline surface producer — a registered starter effect with no pipeline predecessor (`from === null/undefined`) that the compiler flattened into the chain as a dependency of a surface-type parameter (e.g. `heightTex: noise()`). No other shape participates. `replaceEffect` performs these steps: 1. Deep-clone the compiled program. 2. Start with every target argument default. 3. Apply `newArgs` over those defaults. 4. Round non-integer numeric `newArgs` to three decimal places. 5. Run `predictReplacement()`. With `options.preflight === true` and any issue, return `{ success: false, error: "Replacement preflight failed: ...", prediction }` without mutating. 6. Add the namespace to `searchNamespaces` if needed. 7. Set the resolved operator name and arguments. 8. Reset namespace metadata. Sources: `shaders/src/lang/transform.js` - `replaceEffect()`, `predictReplacement()`. ### Validate Mutation succeeds only when `result.success === true`. That proves effect existence and starter-position compatibility. It does **not** prove the mutated program compiles or renders. A complete falsifiable gate is: ```text replacement.success === true AND unparse(replacement.program) parses AND semantic_error_count === 0 AND expansion_error_count === 0 AND both required backend compilers succeed AND rendered-output thresholds pass AND parity status is ok when required ``` `getCompatibleReplacements().compatible` is therefore a candidate list, not a swappability guarantee. The additive `predictions` field (and, behind the `preflight: true` opt-in, `blocked` with reasons) reports the predicted compatibility dimensions per candidate, but only a real compile/render/parity run proves an end-to-end replacement. Sources: `shaders/tests/test_transform.js`; compiler, render, and parity contracts in this document. ### Diagnose Enumerated mutation rejection strings are: ```text Invalid compiled program: missing plans Step with index not found Effect '' not found Cannot replace starter effect '' with non-starter effect ''. The first effect in a chain must be a starting effect. Cannot replace non-starter effect '' with starter effect ''. Starting effects can only appear at the beginning of a chain. Replacement preflight failed: ``` After a successful replacement, all parse, semantic, expansion, backend, output, and parity failures use their respective surfaces' diagnostic channels. Parameter names/types/ranges, pass shape, shader availability, and backend support are predicted before mutation by `predictReplacement()`. Availability, arguments, types and ranges raise issues that can block; passes, outputs and sampler topology are reported for information and never block; backend support is predicted only when the caller supplies `options.manifest`; dimensions without data are reported `unknown`, never invented. The `preflight: true` opt-in turns hard issues into rejection; without it the prediction is returned and mutation is unchanged. Builtin step indexes use the same `Step with index not found` failure as other non-target indexes. Callers do not need to parse builtin names because builtin nodes are absent from `listSteps()` and cannot be resolved by either mutation helper. Sources: `shaders/src/lang/transform.js` - all `success:false` paths. ## Rendered output ### Introspect `renderEffectFrame` reads the render surface through `backend.readPixels()` on both backends, after draining submitted work, and falls back to the WebGL default framebuffer (the presented canvas) only when no surface is readable. For a timed capture it redraws at the requested paused time. The decoded success payload is: ```ts type ImageMetrics = { mean_rgb: Vec3 // each component in [0,1] mean_alpha: number // [0,1] std_rgb: Vec3 // each population stddev in [0,0.5] luma_variance: number // population variance in [0,0.25] unique_sampled_colors: number // integer in [1,sample_count] is_all_zero: boolean is_all_transparent: boolean is_essentially_blank: boolean is_monochrome: boolean } type RenderSuccess = { effect_id: string // requested id page_effect_id: string | null // viewer's current id status: "ok" backend: string requested_resolution?: [number, number] warning?: string // present when frame size differs from request frame: { image_uri: string | null, width: number, height: number } metrics: ImageMetrics console_errors?: string[] } type RenderFailure = { effect_id: string page_effect_id: string | null status: "error" backend?: "unknown" | string requested_resolution?: [number, number] error: string console_errors?: string[] } | OuterEffectError ``` The sample stride is `max(1,floor(width*height/1000))`. Every `stride`th pixel is sampled. Therefore sample count is `ceil(width*height/stride)` and can exceed 1000 (for the worked 256x256 capture it is 1009). Colors are exact 8-bit RGB triplets. Alpha is not part of color cardinality. Sources: Shade MCP `src/tools/browser/render.ts` - `renderEffectFrame()`; `src/harness/types.ts` - `ImageMetrics`, `RenderResult`. The engine-native output path exposes sinks and bounded asynchronous frame export independently of Shade MCP: ```ts type SinkDescriptor = { width: number height: number format: "rgba8unorm" colorSpace: "srgb" | "display-p3" alphaMode: "opaque" | "straight" | "premultiplied" fps: number // finite and > 0 } type PackedFrame = { width: number height: number rowStride: number // width * 4, with no row padding data: Uint8Array // top-down packed RGBA8 rows } type Sink = { configure(descriptor: SinkDescriptor): unknown submit(textureId: string, presentationTimestamp: number): boolean | unknown close(options?: { backendLost?: boolean }): unknown deferRender?(): boolean } type SinkStats = { accepted: number, dropped: number, failed: number } type FrameExportStats = { accepted: number dropped: number completed: number failed: number } ``` `Pipeline.sinkManager.stats` is a `Map` for active sinks. `FrameExportQueue.stats` is one stable mutable `FrameExportStats` object, and `queue.available` is true exactly when the queue is configured, open, and has a free slot. `CanvasSink`, `SinkManager`, and `FrameExportQueue` are exported from `shaders/src/index.js`. The generic sink boundary does not validate descriptors: `Pipeline.resize()` forwards its `width` and `height` arguments, and `SinkManager.configure()` stores and broadcasts the supplied value. When a `FrameExportQueue` uses the WebGL2 or WebGPU adapter, slot creation requires `width` and `height` to be positive safe integers. The format, color-space, alpha-mode, and fps predicates shown above must also hold. Sources: `shaders/src/runtime/sink.js`; `shaders/src/runtime/frame-export.js`; `shaders/src/index.js`. ### Act Call the MCP verb documented in the tool section. For deterministic MCP captures, supply `time`. With an explicit `time`, the released verb (`src/tools/browser/render.ts` at the pinned snapshot) awaits its frame-count warmup while the render loop is still live, bounds both render and DSL warmup waits at `session.timeoutMs` with a diagnostic, unpauses in a `finally`, and after the pause redraws explicitly at the requested paused time before the readback — the same value handed to `setPausedTime`, which is what a paused single-frame render draws. A time-less request keeps the historical live-frame readback (the last frame the running loop drew), which is nondeterministic with the capture moment; a timed request reproduces byte-identically per requested time. The repository harness (`shaders/tests/test-harness.js`) renders through this verb on both backends and adds its temporal metrics to the result. For direct rendering, use `CanvasRenderer`/`Pipeline`. Then read `pipeline.getOutput()` with backend readback. Uniform override keys are shader uniform names as consumed by `pipeline.setUniform`, not necessarily DSL global names. `setUniform` also writes a pass-level uniform that the pass feeds from that name under another name (`uniforms: { mixAmt: "mix" }`, recorded as `pass.uniformAliases`), as the UI parameter paths do, so `setUniform("mix", v)` reaches `mixAmt`. `resolution` sets the browser viewport and calls the renderer's `resize(width, height)`, which sets the pipeline's render size; the viewer's layout still sizes the presented canvas. The returned frame is read from the render surface, so the worked call requesting 256x256 captures 256x256 although the demo viewer presents it in a 90x90 canvas. `CanvasRenderer` sizes an unsized canvas element (no width/height attributes) from its `width`/`height` options at construction; an element sized by attribute or property keeps that size, and a disagreement with the renderer options emits exactly one diagnostic naming both sizes. `resize()` changes the render resolution but not the canvas element, so a host that requests a different capture resolution must resize the element itself. Sources: Shade MCP `src/tools/browser/render.ts` - request handling; Noisemaker `shaders/src/renderer/canvas.js` - construction-time element sizing, `resize()`; Noisemaker `shaders/src/runtime/pipeline.js` - `setUniform()`, `getOutput()`. For engine-native delivery, compile first. Then register a sink on that concrete pipeline: ```ts const queue = renderer.createFrameExportQueue({ slots: 3, onError(error) { report(error) } }) const remove = renderer.addSink({ configure(descriptor) { queue.configure(descriptor) }, submit(textureId, timestamp) { return queue.enqueue(textureId, timestamp, consumeFrame) }, close(options) { queue.close(options) } }) function serviceExports() { queue.poll() requestAnimationFrame(serviceExports) } serviceExports() ``` `CanvasRenderer.addSink()` and `createFrameExportQueue()` throw before a pipeline exists. `addSink()` returns an idempotent removal function scoped to the current pipeline. Removing it closes that sink once. Sinks are not migrated when recompilation replaces a pipeline. `createFrameExportQueue()` returns null when the active backend does not implement export. WebGL2 and WebGPU both do. The base `Backend` does not. `FrameExportQueue` is not itself a `Sink`: it has `configure()`, `enqueue()`, `poll()`, and `close()`, so a host supplies the small adapter above. The queue owns three slots by default. `slots` must be an integer from 2 through 8. `configure()` preallocates every slot. `enqueue(textureId,timestamp,onFrame, context?)` starts work only when a slot is free and returns a boolean. The host must call `poll()`. There is no internal timer. A successful callback receives `(frame,timestamp,context)` with the latter two values unchanged. Pipeline resize configures all current sinks with exactly `{width,height,format:"rgba8unorm",colorSpace:"srgb", alphaMode:"premultiplied",fps:60}`. A sink added after resize is configured immediately with the same descriptor. Sinks may implement an optional `deferRender(): boolean` method: while any active sink returns `true`, `Pipeline.shouldDeferRender()` returns `true` and the host render loop (`CanvasRenderer._renderLoop`) skips drawing the current tick while holding canvas contents and advancing animation time, incrementing `CanvasRenderer.deferredFrameCount`. Sinks without `deferRender()` never defer rendering. Rendering submits the selected pre-swap texture ID and the caller's presentation timestamp, or `performance.now()` with a `Date.now()` fallback. A graph with no render surface advances the frame without submitting to sinks. Both backend queues produce top-down, tightly packed RGBA8. WebGL2 resolves to an RGBA8 texture, flips vertically in the resolve shader, reads into a pixel pack buffer, and polls a fence with zero timeout. WebGPU resolves to `rgba8unorm` (inverting vertical orientation in the resolve shader to match canvas presentation), copies into 256-byte-aligned staging rows, maps asynchronously, and removes row padding before delivery. `straight` preserves RGBA, `opaque` forces alpha to one, and `premultiplied` multiplies RGB by alpha. `colorSpace` and `fps` are validated descriptor metadata. Neither backend adapter performs a color-space conversion or frame-rate throttle. Each slot reuses one `PackedFrame` object and `Uint8Array`. A callback that retains a frame past later deliveries on the same slot must copy its data. Sources: `shaders/src/renderer/canvas.js` - `addSink()`, `deferredFrameCount`, `createFrameExportQueue()`; `shaders/src/runtime/pipeline.js` - `resize()`, `render()`, `shouldDeferRender()`; `shaders/src/runtime/sink.js` - `shouldDeferRender()`; `shaders/src/runtime/frame-export.js`; `shaders/src/runtime/backends/webgl2-frame-export.js`; `shaders/src/runtime/backends/webgpu-frame-export.js`. ### Validate The implemented predicates are exactly: ```text is_all_zero = every sample has r, g and b <= 0.001 is_all_transparent = every sample has alpha <= 0.001 is_essentially_blank= luma_variance < 0.0001 is_monochrome = unique_sampled_colors <= 1 // exact 8-bit RGB triples ``` For an ordinary non-exempt effect, the repository harness requires: ```text status === "ok" is_all_transparent === false is_essentially_blank === false is_monochrome === false no console error, warning, or page error // two known-benign messages are ignored under CI ``` `is_essentially_blank` means low spatial luma variance, not black. A constant white image satisfies it. The harness exemptions are: - monochrome: `filter/a`, `filter/shape`, `filter/solid`, `synth/solid`. - low-luma-variance: `synth/solid`. - transparency: none. There is no implemented no-animation pass threshold and no default low-variety gate. The harness `renderEffectFrame` wrapper (`shaders/tests/test-harness.js`) additionally returns three additive metrics on every successful frame: `temporal_diff` (normalized mean absolute RGB difference between two renders at normalized times 0 and 0.5, so a looping effect cannot alias to "static" through phase alignment), `is_no_animation` = `temporal_diff <= 0.01`, and `is_low_variety` = `unique_sampled_colors <= 5`. These are repository-local diagnostic thresholds; Shade MCP's filter modification tool now uses a different output-to-input pixel comparison. The probe is an external two-frame comparison; if pixel readback fails, the three metrics are `null` and `metrics.temporal_probe_error` names the failure. The harness exposes an explicit opt-in `--low-variety` gate with exemptions `filter/shape`, `filter/solid`, and `synth/solid`; the default run applies no low-variety or no-animation rejection, and static output is deliberately never gated (legitimate for many effects). Regressions: `shaders/tests/test_frame_metrics.js`, registered in `scripts/run-js-tests.js`. These metrics are returned by this repository's harness wrapper, not by the upstream Shade MCP verb. The separate filter modification tool compares the rendered output with its consumed input at one paused time. It passes only when normalized mean absolute RGB difference exceeds `0.01`; this is not an animation or color- variety validator. The repository sustained benchmark gate uses median frame time after 20 warmup frames and 100 measured frames and fails when `fps < 30`. Equivalently it passes when `fps >= 30`. The MCP benchmark instead passes when `fps >= target_fps`, where the default target is 60. For a `SinkManager`, `submit() === true` increments `accepted`, false increments `dropped`, and a thrown submit, broadcast-`configure()`, or `deferRender()` error increments `failed`. Thrown errors in `deferRender()` are isolated and do not defer rendering; if `onError` was supplied it is invoked with `(error, sink)`. Other return values change no counter. One failing registered sink does not stop later sinks. Adding a sink after the manager is configured calls that sink's `configure()` before registration. If it throws, `add()` propagates the error and creates no stats record. For the pipeline's default manager there is no `onError` callback, so inspect its stats when an external sink can throw. For a `FrameExportQueue`, a successful adapter `begin()` increments `accepted`. Unconfigured, closed, or full-queue enqueue attempts increment `dropped` and return false. Begin, poll, read, and callback failures increment `failed` and invoke `onError` when supplied. A callback increments `completed` only after it returns normally. A failed slot is released for reuse. Descriptor validation and slot allocation failures from `configure()` throw synchronously. When reconfiguration or close destroys a pending slot, the queue increments `dropped` before clearing its callback and context. This also applies to backend-loss close, without attempting GPU destruction. Completed and failed records are already released and are not counted again. A host that separately tracks enqueue results and pending callbacks can use the increment to reconcile outstanding frames after the lifecycle call. `dropped` also includes enqueue attempts rejected before acceptance, so stats alone do not identify which accepted frame was canceled. Sources: Shade MCP `src/tools/browser/render.ts`, `passthrough.ts`, `benchmark.ts`; `shaders/tests/test-harness.js` - metric gates, exemptions, and sustained benchmark; `shaders/src/runtime/sink.js`; `shaders/src/runtime/frame-export.js`. ### Diagnose In-page render failures include: ```text No renderer No renderer on Failed to read pixels on : no readable render surface ``` Outer browser/session failures use `{effect_id,status:"error",error:string}`. Captured console messages can appear as `console_errors`, but backend console diagnostics are strings rather than typed error objects. The delivered `renderEffectFrame` reads pixels through `backend.readPixels()` on both backends and drains the WebGPU command queue before reading. It reads the half of the render surface's ping-pong pair that the last frame presented (`pipeline.frameReadTextures`), then `global__read`, then the last node output, so the frame size is the pipeline's render size, not the size of the viewer's presented canvas. The repository harness uses this verb on both backends. When `resolution` is requested, the verb echoes `requested_resolution` and includes a `warning` with requested and actual dimensions if the viewer did not honor it. The returned frame dimensions are authoritative. `renderEffectFrame`, `runDslProgram`, and the vendored library helper `computeImageMetrics` share one implementation: the verb reads the frame back in screen order and computes its metrics in Node with `computeImageMetrics`. Each `ImageMetrics` field therefore has one definition across verbs and the library export. Sink validation rejects anything without callable `configure`, `submit`, and `close` methods. A closed manager rejects new sinks, duplicate registration is an error, and removing a sink closes it. Manager close attempts every active sink, then throws the first close error after clearing all registrations. Pipeline disposal closes sinks before backend resources. `backendLost:true` propagates to sink close so a frame queue abandons unsafe GPU resources without calling its adapter. Normal close destroys each slot. Frame-export failures are ordinary `TypeError`, `RangeError`, or `Error` objects, not a shared code union. Stable validation messages include: ```text Sink must implement configure, submit, and close Frame export adapter must implement createSlot, begin, poll, read, and destroySlot Frame export slots must be an integer from 2 through 8 Frame export callback must be a function Frame export width must be a positive integer Frame export height must be a positive integer WebGL2 frame export format must be 'rgba8unorm' WebGPU frame export format must be 'rgba8unorm' WebGL2 frame export colorSpace must be 'srgb' or 'display-p3' WebGPU frame export colorSpace must be 'srgb' or 'display-p3' WebGL2 frame export alphaMode must be 'opaque', 'straight', or 'premultiplied' WebGPU frame export alphaMode must be 'opaque', 'straight', or 'premultiplied' Frame export fps must be finite and positive ``` Adapters additionally report missing textures, source/configured extent mismatches, unusable or wrongly sequenced slots, allocation/compile/link/fence failures, unavailable WebGPU constants, and map failures. Queue `onError` receives those objects but no texture ID, timestamp, slot index, or normalized phase field. On reconfigure/close, accepted pending frames suppress their callbacks and increment `dropped` once before their records are released. Sources: Shade MCP `src/tools/browser/render.ts`; `src/harness/pixel-reader.ts` - `computeImageMetrics()`; `shaders/tests/test-harness.js` - WebGPU render wrapper; `shaders/src/runtime/sink.js`; `shaders/src/runtime/frame-export.js`; backend frame-export adapters. ## Cross-backend parity ### Introspect Before rendering, inspect the manifest and `checkEffectStructure`: - every referenced program must have a GLSL `.glsl` or `.vert`/`.frag` entry. - every referenced program must have a same-stem WGSL `.wgsl` entry. - `structuralParityIssues` must be empty. - `compareShaders` exposes line counts, detected functions, and detected uniform declarations, but does not establish semantic equivalence. - `checkAlgEquiv` is an optional AI judgment, not a numeric parity signal. Backend authorability from current source: | Feature | WebGL2 authoring | WebGPU authoring | Predictable parity status | |---|---|---|---| | fullscreen fragment | GLSL ES 3 combined fragment or split vertex/fragment | WGSL fragment, optional WGSL vertex | authorable when both sources/bindings implement same math | | MRT | multiple fragment outputs. Queried draw-buffer limit and probed combined-byte budget | multiple `@location` outputs/attachments. Device byte limit | pipeline can demote trailing full-float outputs. Reuse validates the allocated format. Parity still requires inspecting allocated backend formats on both devices | | points/billboards/triangles | draw modes supported | draw modes supported | authorable. Coordinate/winding conventions still require pixel test | | compile-time define | GLSL `#define` | typed WGSL module `const` | authorable for boolean/numeric compatible uses | | native compute | absent | compute-only WGSL selected from `@compute` | impossible as native parity | | compute-style fallback | hand-authored GLSL fragment plus `storageTextures`/output mapping | native compute/storage | only authorable when the GLSL fragment explicitly reproduces the algorithm. No translation exists | | storage buffers | no equivalent binding path | parsed/bound by WebGPU | generally impossible parity without a different GLSL algorithm | | 3D sampling | supported | supported | authorable | | rendering directly to 3D texture | explicitly unsupported | storage/render workflows available | impossible direct parity. Use a 2D atlas/common algorithm | There is now a single capability-preflight API that consumes a definition and returns this determination: `preflightEffect(definition, capabilities, shaders)` in `shaders/src/runtime/preflight.js` reports per-backend authorability (`backends.webgl2`/`backends.webgpu` with `authorable` and `reasons`; each backend's source is selected in that backend's own order — WebGL2 `source`, `glsl`, `fragment`; WebGPU `wgsl`, `source`, non-GLSL `fragment` — and must be in that backend's language), the predicted MRT attachment demotions `applyMrtFormatBudget()` will perform (`formatChanges`, trailing rgba32f → rgba16f under `maxColorBytesPerSample`), and predicted `maxTextureSize` clamps (`clamps`). Without shader source information the source checks are skipped, so `authorable` reflects only the structural and limit checks. `Pipeline.preflight()` consumes the pipeline's own graph and capabilities. For a reused pipeline, parity checks must read each allocated backend texture's effective format. Do not use only the adapted graph specification as evidence. Sources: `shaders/src/renderer/canvas.js` - `loadEffectShaders()`; backend `compileProgram()`, `executePass()`, `createTexture3D()`; Shade MCP analysis tools. ### Act Author shader programs with the same filename stem. Keep binding names/types aligned with the expanded pass. WebGL `compileProgram()` accepts `spec.source`, `spec.glsl`, or `spec.fragment`, plus optional `spec.vertex`. As of noisemaker `ff1bfbc1`, `spec.defines` is injected as `#define`s into a custom `spec.vertex` whenever one is present and `spec.defines` is non-empty, the same way it already was into the fragment stage; before that fix a custom vertex shader referencing a `defines`-only identifier (for example a per-pass `defines`-selected constant from `PassSpec.defines`, see Passes and render graph) would fail to compile with an undeclared-identifier error. WebGPU selects `spec.wgsl`, then `source` (whatever its language; GLSL there fails at WGSL compilation), then a `fragment` without `#version`. WebGPU detects entry points from annotations and uses `entryPoint`/specific entry-point fields where provided. Pixel parity request: ```ts testPixelParity({ effect_id?: string, effects?: string, // CSV, not expanded glob syntax epsilon?: number, // default 1; no runtime range validation seed?: number // default 42 }) ``` The tool performs these steps: 1. Select WebGL2. 2. Wait for a post-selection graph for the requested effect and backend. 3. Render six live frames for warmup. 4. Capture the offscreen render surface at time zero. 5. Select WebGPU. 6. Repeat steps 2-4. The tool writes the seed to pipeline/pass uniforms. WebGPU rows are vertically flipped to the same top-down orientation as WebGL before comparison. `setBackend()` rejects a switch that fails to reach the requested backend. Each parity leg selects the effect and waits for a graph built after that selection, with the page-confirmed effect identity and backend checked before capture. A failed leg returns an error identifying the leg and observed page state. `effect_id` is the requested ID and `page_effect_id` is the page's ID from the last leg that ran, or null if unavailable. The tool's browser session launches on the WebGL2 launch path, so where that browser cannot obtain a Vulkan-backed WebGPU adapter — a GPU-less container without a system Vulkan driver, for instance — the WebGPU leg's rebuild falls back to WebGL2 and the leg fails closed with the page-confirmed mismatch (`WebGPU leg: The viewer is rendering on WebGL2, not the requested webgpu`) instead of comparing a fallback frame; the comparison itself is unchanged where a WebGPU-flagged browser session runs the same legs. Sources: Shade MCP `src/tools/browser/parity.ts` - `testPixelParity()`, `warmUp()`, `captureSurface()`; Shade MCP `src/harness/browser-session.ts` - `selectEffect()`; Noisemaker `demo/shaders/index.html` - `selectEffect()`. ### Validate For RGBA8 channel arrays of equal dimensions: ```text channel_diff = abs(webgl_channel - webgpu_channel) // [0,255] channel_mismatch = channel_diff > epsilon max_diff = max(channel_diff) // [0,255] mean_diff = sum(channel_diff) / channel_count // [0,255] mismatch_count = count(channel_mismatch) // [0,channel_count] mismatch_percent = 100 * mismatch_count / channel_count // [0,100] status = "ok" iff mismatch_percent < 1 ``` The required literal contract marker is `mismatch_percent < 1`. Solid-color diagnostics compute raw 0-255 RGB population variance for each backend. A backend is solid when all three variances are `< 5`. Variances and means are rounded to integer arrays in output. The predicate uses unrounded values. Y-flip diagnostics compare normal mean absolute difference with a vertically flipped WebGPU comparison: ```text y_flip_ratio = y_flip_mean_diff / mean_diff, or 1 when mean_diff === 0 y_flip_detected = mean_diff > 2 && y_flip_mean_diff < mean_diff && y_flip_ratio < 0.7 y_flip_clean_flip = mean_diff > 2 && y_flip_ratio < 0.25 ``` `epsilon` is described as 0-255 but Zod does not enforce that range. A strict caller must require `0 <= epsilon <= 255` and a finite value before calling. These predicates validate the two captured buffers. Successful captures also require a confirmed backend switch and requested-effect graph for each leg; check the returned `effect_id`, final-leg `page_effect_id`, and `backend` before accepting the numeric verdict. Sources: Shade MCP `src/tools/browser/parity.ts` - comparison, solid, and Y-flip predicates. ### Diagnose ```ts type ParitySuccessOrMismatch = { effect_id: string // requested id page_effect_id: string | null // final WebGPU leg backend?: string status: "ok" | "mismatch" maxDiff: number meanDiff: number mismatchCount: number mismatchPercent: number resolution: [number, number] glslSolid: boolean wgslSolid: boolean glslVariance: [number, number, number] wgslVariance: [number, number, number] yFlipDetected: boolean yFlipCleanFlip: boolean yFlipMismatchPercent: number yFlipMeanDiff: number yFlipRatio: number issues: string[] details: string console_errors?: string[] } type ParityFailure = { effect_id: string page_effect_id: string | null // last leg that ran status: "error" maxDiff?: 0 meanDiff?: 0 mismatchCount?: 0 mismatchPercent?: 0 resolution?: [number, number] details?: string error?: string console_errors?: string[] } | OuterEffectError ``` Enumerated in-tool error details are `WebGL2 leg: Failed to capture WebGL2`, `WebGPU leg: Failed to capture WebGPU`, and `Capture size mismatch: glsl x vs wgsl x`. Compile/browser failures can instead use the outer `{effect_id,status:"error",error}` shape. Issues include `GLSL SOLID COLOR (mean=...)`, `WGSL SOLID COLOR (mean=...)`, and `Y-FLIP`/`PARTIAL Y-FLIP DETECTED (...)`. Solid-color and Y-flip issues do not change `status`. Status depends only on mismatch percentage. A strict gate must also assert both solid flags false and `yFlipDetected === false` unless the effect is intentionally solid. Selection failures, backend-switch failures, and identity mismatches return `status:"error"` with the failed leg in `details` and the page-confirmed effect/backend when available. They do not produce a numeric parity success. Backend compilation failures: ```ts // Shared pipeline { code: "ERR_PROGRAM_SPEC_MISSING", program: string, pass: string } // WebGL2 ShaderDiagnostic { code: "ERR_SHADER_MISSING", backend: "webgl2", stage: "missing-source", program: string, detail: "Shader source missing for program ''. You may need to regenerate the shader manifest." } { code: "ERR_SHADER_COMPILE", detail: string, source: string } { code: "ERR_SHADER_LINK", detail: string, program: string } { code: "ERR_UNIFORM_BLOCK_TOO_LARGE", detail: string, program: WebGLProgram } { code: "ERR_PROGRAM_NOT_FOUND", pass: string, program: string } { code: "ERR_TOO_MANY_TEXTURES", pass: string, limit: number } // WebGPU { code: "ERR_NO_WGSL_SOURCE", detail: string, program: string } { code: "ERR_SHADER_COMPILE", detail: string, program: string } { code: "ERR_PROGRAM_NOT_FOUND", pass: string, program: string } { code: "ERR_TEXTURE_NOT_FOUND", pass: string, texture: string } { code: "ERR_NO_MRT_OUTPUTS", pass: string } { code: "ERR_COMPUTE_DISPATCH_UNRESOLVED", pass: string, detail: string } ``` Sources: backend compile/execute methods; Shade MCP `src/tools/browser/parity.ts`. ## Shade MCP tool contracts ### Introspect 1. Call MCP `initialize`. 2. Send `notifications/initialized`. 3. Call `tools/list`. For the snapshot at the top of this document, `tools/list` returned the 18 verb names documented below. Every tool descriptor declares `inputSchema.$schema:"http://json-schema.org/draft-07/schema#"` and `execution:{taskSupport:"forbidden"}`. None declares an `outputSchema`. `renderEffectFrame.uniforms` and `runDslProgram.uniforms` are object schemas with string `propertyNames` and numeric `additionalProperties`. The Zod 4 upgrade did not change their `Record` application contract. The 0.4.x releases keep accepted argument types and defaults and change result schemas: every result carries `outcome` and multi-effect results use the batch envelope (below); `definition.js` projections can carry `partial` and `partialReasons`; `checkEffectStructure` can report `unusedFilesUnchecked`; `ImageMetrics` has one definition for every verb; `testUniformResponsiveness` reports an unresponsive uniform as `fail` and an unstable uniform without a verdict as `error`; the six direct browser check/capture verbs separate the requested `effect_id` from the viewer's `page_effect_id`; and the capture verbs read the presented half of the render surface. Knowledge-search results are source guidance, not a rendered-loop validation result. The transport result for every verb is: ```ts type McpTextResult = { content: [{ type: "text", text: string /* JSON envelope */ }, ...Array<{ type: "image", data: string, mimeType: "image/png" }>] isError?: true } type Outcome = "ok" | "fail" | "warning" | "skipped" | "error" ``` Decode `JSON.parse(result.content.find(x => x.type === "text").text)` before applying the payload types below. Every text payload is JSON, including errors thrown before a handler's own error handling, which arrive as `{ outcome: "error", status: "error", error: string }`. The same envelope wraps MCP SDK `tools/call` errors for invalid arguments and unknown or disabled tools; their SDK message remains in `error` and the result has `isError:true`. Every single result carries a top-level `outcome`, computed from the tool's own `status`: `fail` for a negative verdict (parity `mismatch`, `passthrough`, `divergent`, a uniform `fail`, `meets_target: false`), `warning` for findings to review, `skipped` when nothing was testable, `error` when no verdict was produced (or, for a payload with no `status`, when it carries an `error` string), otherwise `ok`. An `outcome` key inside the payload never overrides it. Verb-specific fields stay beside it. `isError:true` marks a whole-call failure: a single `error` result, or a batch in which every entry is an `error`. These tools do not declare MCP structured content/output schemas. The repository config supplies: ```text SHADE_EFFECTS_DIR=./shaders/effects SHADE_VIEWER_ROOT=. SHADE_VIEWER_PATH=/demo/shaders/ SHADE_GLOBALS_PREFIX=__noisemaker ``` The package requires Node 22 or newer. Browser sessions are headless unless `SHADE_HEADLESS` is `0` or `false`. `SHADE_SWIFTSHADER=1` or `true` opts into Chromium's software rasterizer. `SHADE_TIMEOUT_MS` supplies the session default for Playwright operations that honor it and is also used explicitly by backend, status, and graph readiness polls. The parity six-frame warmup instead retains an explicit `30000` ms bound. `SHADE_AI_TIMEOUT_MS` bounds AI provider requests. Both configurable timeouts are parsed with `parseInt(value, 10)` and default to `120000` ms when that result is non-finite or nonpositive. Fractional text is truncated and a numeric prefix followed by nonnumeric text is accepted. `SHADE_AI_MODEL` overrides the provider model. Without an override, Anthropic uses `claude-opus-5` and OpenAI uses `gpt-5.2`. SIGINT and SIGTERM close live browser sessions and the shared viewer server before process exit. The advertised MCP server version is injected from `package.json` at build time. The post-build check initializes a standalone copy containing only the built files. The check requires the advertised version to match the package. Therefore, a vendored build does not need a parent `package.json` at runtime. The browser harness binds its temporary HTTP server to `127.0.0.1`. Viewer and effect requests are confined to their configured roots after both lexical and realpath checks. Dotfiles, dot-directories, traversal, and symlink escapes are rejected. Responses no longer add wildcard CORS. Known asset extensions receive explicit media types and unlisted extensions remain serveable as `application/octet-stream`. Successful file responses set `Vary: Origin`. They echo `Access-Control-Allow-Origin` only for these origins: - An opaque `Origin: null`. - An `http`/`https` loopback origin on `127.0.0.1`, `[::1]`, or `localhost`, with an optional port. Other origins receive no allow-origin header. Sources: `.mcp.json`; `.codex/config.toml`; Shade MCP `package.json`; `src/index.ts` - registration and shutdown; `src/version.ts`; `tsup.config.ts`; `scripts/check-dist-selfcontained.mjs`; `src/config.ts`; `src/ai/provider.ts`; `src/harness/browser-session.ts`; `src/harness/live-sessions.ts`; `src/harness/server-manager.ts`; `src/tools/browser/parity.ts` - warmup timeout. ### Act Shared input and batch behavior: ```ts type EffectSelector = { effect_id?: string effects?: string // comma-separated IDs; takes precedence over effect_id } type BrowserSelector = EffectSelector & { backend?: "webgl2" | "webgpu" // defaults to webgl2 where present } // Exactly one selected ID returns T; several return the batch envelope. // Every T and every entry also carries `outcome: Outcome`. type Batched = T | { // "error" when every entry is an error; "fail" when any entry is fail or // error; "warning" when any is warning; "skipped" when all are skipped; // otherwise "ok" outcome: Outcome summary: { total: number, ok: number, fail: number, warning: number, skipped: number, error: number } results: T[] } type OuterEffectError = { effect_id: string, status: "error", error: string } ``` `effects` is split on commas and trimmed. Selectors are literal IDs; the tool descriptions now specify comma-separated IDs without promising glob expansion. With neither selector, `resolveEffectIds()` scans `SHADE_EFFECTS_DIR`: one discovered effect is selected, multiple effects cause an error, and a flat effect directory uses its basename. Filesystem analysis tools reject absolute effect IDs and IDs containing `.` or `..` path segments with `Invalid effect id: ` before reading. Browser selectors remain literal. No numeric schema below uses Zod `.int()`, `.finite()`, `.min()`, `.max()`, or a positive-resolution refinement unless explicitly stated. Descriptive ranges are not validation, except in `runDslProgram`, whose bounds are enforced (see its schema). The direct browser check/capture verbs call `BrowserSession.setBackend()` before capture/compile work. It now rejects a switch that does not reach the requested page backend. `selectEffect()` waits for a post-selection graph and checks the page's current effect identity; a missing or different identity fails closed. These results report the page-confirmed backend and keep the requested ID in `effect_id`. The viewer-result branches of `compileEffect`, `renderEffectFrame`, `benchmarkEffectFPS`, `testUniformResponsiveness`, `testNoPassthrough`, and `testPixelParity` include `page_effect_id` with the page-confirmed ID, or null when unavailable; the IDs can differ only on `status:"error"`. Per-effect outer exceptions use `OuterEffectError` without page identity. #### compileEffect ```ts type CompileEffectInput = EffectSelector & { backend?: "webgl2" | "webgpu" // default webgl2 } type CompileEffectResult = Batched< | { effect_id: string, status: "ok" | "error", backend: string, page_effect_id: string | null, passes: Array<{ id: string, status: "ok" | "error", errors?: string[] }>, message: string, console_errors?: string[] } | OuterEffectError > ``` The tool waits for the selected effect's post-selection graph rather than viewer status text, then reports its expanded passes. Passes without authored names use `pass_`; success message is `Compiled successfully`. A compile failure is read from viewer status text containing the whole word `error` or `failed`, so a success message naming an effect such as `scanlineError` is not a failure. Backend-switch, selection, compile, and missing page-identity failures return `status:"error"`, with the page-confirmed effect in `page_effect_id` and backend when available. Source: Shade MCP `src/tools/browser/compile.ts` - `compileEffectSchema`, `compileEffect()`. #### renderEffectFrame ```ts type RenderEffectFrameInput = BrowserSelector & { warmup_frames?: number // default 10 capture_image?: boolean // default false uniforms?: Record time?: number // seconds; timed capture (see below) resolution?: [number, number] } type RenderEffectFrameResult = Batched ``` With an explicit `time` the verb warms up while the loop is live, pauses at the requested time, and redraws at that time before the readback, so the returned frame is the requested paused frame and reproduces byte-identically per requested time. Without `time` the verb returns the live loop's last frame, nondeterministic with the capture moment. Metric definitions, ranges, and thresholds are in Rendered output. A captured image is a `data:image/png;base64,...` URI. Otherwise `image_uri` is null. Source: Shade MCP `src/tools/browser/render.ts` - `renderEffectFrameSchema`, `renderEffectFrame()`. #### describeEffectFrame ```ts type DescribeEffectFrameInput = EffectSelector & { prompt: string backend?: "webgl2" | "webgpu" // default webgl2 capture_image?: boolean // default false } type DescribeEffectFrameResult = Batched< | { effect_id: string, status: "ok", frame?: { image_uri: string }, vision: Record | null } | { effect_id: string, status: "error", error: string } > ``` The intended AI JSON is `{description,tags,notes}`, but a parsed object is not schema-checked. A parsed primitive or array and invalid JSON instead produce `{description:,tags:[],notes:null}`. `frame` is omitted unless `capture_image` is true, avoiding an unsolicited base64 payload. Failure strings include the common no-key message, `Failed to render frame`, and `Failed to render frame: `. Source: Shade MCP `src/tools/browser/describe.ts` - schema and `describeEffectFrame()`; `src/ai/provider.ts` - provider/no-key behavior. #### benchmarkEffectFPS ```ts type BenchmarkEffectFPSInput = BrowserSelector & { target_fps?: number // default 60 duration_seconds?: number // default 5 resolution?: [number, number] } type BenchmarkEffectFPSResult = Batched< | { effect_id: string, status: "ok", backend: string, page_effect_id: string | null, requested_resolution?: [number, number], frame?: { width: number, height: number }, warning?: string, achieved_fps: number, meets_target: boolean, stats: { frame_count: number, avg_frame_time_ms: number, jitter_ms: number, min_frame_time_ms: number, max_frame_time_ms: number }, console_errors?: string[] } // backend-switch or selection failure: zeroed measurement | { effect_id: string, page_effect_id: string | null, status: "error", backend: string, achieved_fps: 0, meets_target: false, stats: { frame_count: 0, avg_frame_time_ms: 0, jitter_ms: 0, min_frame_time_ms: 0, max_frame_time_ms: 0 }, error: string } | OuterEffectError > ``` `achieved_fps = frame_count/(sum(frame_times_ms)/1000)`, rounded to two decimals, and `meets_target = achieved_fps >= target_fps` on that rounded value. Jitter is sample standard deviation with divisor `frame_count-1`, or zero for at most one frame. Reported values are rounded to two decimals. Tool status remains `ok` when `meets_target` is false. Callers must inspect the boolean. The tool confirms the requested backend and effect before measurement and reports the page-confirmed backend and effect ID in its result. Source: Shade MCP `src/tools/browser/benchmark.ts` - schema and `benchmarkEffectFPS()`. #### testUniformResponsiveness ```ts type TestUniformResponsivenessInput = BrowserSelector type TestUniformResponsivenessResult = Batched< | ({ effect_id: string } & UniformResponsiveness) | OuterEffectError > ``` The test set, perturbation rule, `luma_diff > 0.002` and `max_channel_diff > 0.002` thresholds, per-uniform measurements, and aggregate status are in Parameters and globals. The tool confirms the requested backend and effect before capture. WebGPU reads its offscreen surface through the backend's async pixel reader after submitted work completes. Source: Shade MCP `src/tools/browser/uniforms.ts` - schema and `testUniformResponsiveness()`. #### testNoPassthrough ```ts type TestNoPassthroughInput = BrowserSelector type TestNoPassthroughResult = Batched< | { effect_id: string, status: "ok" | "passthrough", page_effect_id: string | null, backend: string, isFilterEffect: true, similarity: number, changed_fraction: number, strong_fraction: number, threshold: 0.01, inputTexture: string, identity_at_defaults?: true, varied?: Record, details: string, console_errors?: string[] } | { effect_id: string, status: "skipped" | "error", page_effect_id: string | null, backend?: string, isFilterEffect: false, similarity: null, details: string, console_errors?: string[] } // a filter whose input or output texture could not be read, or whose // input and output differ in size | { effect_id: string, status: "error", backend: string, page_effect_id: string | null, isFilterEffect: true, similarity: null, inputTexture?: string, details: string } | OuterEffectError > ``` The tool classifies a filter by an expanded pass input key or value naming `inputTex`, `inputTex3d`, or a user surface, including starter effects. An input that the same or a later pass writes is the effect's own previous output (a feedback loop), not its input, and is skipped. A volume filter consuming `inputTex3d` is compared with the last texture its own node writes (its output volume), not with the 2D render surface. It renders at paused times t=0 and t=0.37, reads the consumed input texture (a `global_` input through the half the frame presented, then `global__read`) and the output surface through the backend's async reader, and compares RGB on a 64x64 grid of pixels and on every pixel. `similarity` is the normalized mean absolute RGB difference on the grid, despite its name; `changed_fraction` is the share of grid pixels changed by more than 2/255; `strong_fraction` is the share of all pixels changed by more than 16/255. Output is unchanged when `similarity <= 0.01`, `changed_fraction <= 0.01` and `strong_fraction <= 0.0005` at both times. Many filters are the identity at their defaults (zero glitchiness, a zero mix): an unchanged filter is measured again with each ungated, visible runtime range control at the farther of its 25% and 75% points from the value the program set (read from the pass uniforms), restored afterwards, and reports `identity_at_defaults` with those `varied` values when that changes its input. Only a filter unchanged both ways is a `passthrough`. Nonfilters return `skipped` with `Not a filter effect`. The comparison works on WebGL2 and WebGPU. Source: Shade MCP `src/tools/browser/passthrough.ts` - schema and `testNoPassthrough()`. #### testPixelParity ```ts type TestPixelParityInput = EffectSelector & { epsilon?: number // default 1, described 0..255 but not range-checked seed?: number // default 42 } type TestPixelParityResult = Batched< ParitySuccessOrMismatch | ParityFailure | OuterEffectError > ``` All output fields and predicates are in Cross-backend parity. Source: Shade MCP `src/tools/browser/parity.ts` - schema and `testPixelParity()`. #### runDslProgram ```ts type RunDslProgramInput = { dsl: string effects?: string // comma-separated Portable package IDs // under SHADE_EFFECTS_DIR, max 16; omitted // means built-in effects only backend?: "webgl2" | "webgpu" // default webgl2 warmup_frames?: number // integer, default 10 (0-120) frames?: number[] // integers, default [1, 120, 600]; 1-6 // strictly increasing values in 1-1200 resolution?: [number, number] // integers, default [960, 540]; // 1-1920 x 1-1080 cell_resolution?: [number, number] // integers, default [320, 180]; // 1-1920 x 1-1080 capture_image?: boolean // legacy no-op: the grid is always returned // as MCP image content uniforms?: Record } type RunDslProgramResult = | { status: "ok", backend: string, resolution: { width: number, height: number }, warmup_frames: number, frames: number[], surfaces: string[], render_target: string, captures: Array<{ surface: string, frame: number, width: number, height: number, metrics: ImageMetrics }>, grid: { width: number, height: number, cell_width: number, cell_height: number, rows: string[], columns: number[] }, frame: { width: number, height: number, image_uri: null }, metrics: ImageMetrics, // the final capture's metrics console_errors?: string[] } | { status: "error", error: string, console_errors?: string[] } ``` The verb renders in a fresh blank page: `CanvasRenderer` (and `compile`) is imported from `SHADE_DSL_RENDERER_MODULE` (default `/shaders/src/index.js` under `SHADE_VIEWER_ROOT`) with `SHADE_DSL_ASSETS_BASE` and `SHADE_DSL_USE_BUNDLES` (defaults compatible with a Noisemaker repository root), `renderer.compile(dsl)` runs the program, `renderer.stop()` ends the loop, and each capture frame is read per written surface through the backend's async `readPixels`. The whole batch is bounded by `session.timeoutMs` (`SHADE_TIMEOUT_MS`, default `120000` ms). Each capture carries the full `ImageMetrics` record; `metrics` repeats the final capture of the render target. The per-surface PNG contact sheet (`grid` dimensions with `cell_resolution` cells) is returned as the MCP `image` content block after the JSON text block; `frame.image_uri` is always null (read the image content instead). With `effects`, each listed Portable package is served at `/effects/`, its `definition.json` fetched, `definition.shaders` rebuilt from the per-pass `glsl/`/`wgsl` program sources, and the raw Portable definition registered live — this requires a Noisemaker renderer that supports `registerPortableEffect` (present since the canvas-registration change; built-in effect names stay protected and duplicate names are rejected). Source: Shade MCP `src/tools/browser/dsl.ts` - schema and `runDslProgram()`. The worked example below records a complete paired source validation exchange. #### checkEffectStructure ```ts type CheckEffectStructureInput = { effect_id: string } type CheckEffectStructureResult = | { status: "ok" | "warning", unusedFiles: string[], namingIssues: Array<{ type: "func" | "global", name: string, reason: "Must be camelCase" }>, nameCollisions: Array<{ type: "uniform_function" | "reserved_word" | "builtin_shadow", name: string, file: string, message: string }>, leakedInternalUniforms: string[], missingDescription: boolean, structuralParityIssues: Array<{ type: "missing_wgsl" | "missing_glsl", program: string, message: string }>, requiredUniformIssues: unknown[], multiPass: boolean, passCount: number, // set when the definition's passes are computed at run time; the // unused-file check was skipped and unusedFiles is empty unusedFilesUnchecked?: string } | { status: "error", error: string } ``` Status is warning when any unused file, naming issue, name collision, leaked internal uniform, missing description, or structural parity issue exists. `requiredUniformIssues` is initialized but never populated or included in the status predicate. Pass `name` values are not checked. GLSL reserved/builtin collision checks use regex-extracted declarations. Source: Shade MCP `src/tools/analysis/structure.ts` - `checkEffectStructure()`. #### checkAlgEquiv ```ts type CheckAlgEquivInput = { effect_id: string } type CheckAlgEquivResult = | { status: "ok" | "divergent", pairs: Array<{ program: string, parity: "equivalent" | "divergent" | "error" | string, confidence?: "high" | "medium" | "low" | string, notes?: string, concerns?: string[], [key: string]: unknown }>, unmatchedGlsl?: string[], unmatchedWgsl?: string[], summary: string } | { status: "error", error: string } ``` Only same-stem `.glsl` and `.wgsl` files are paired. Split `.vert`/`.frag` files are ignored. AI JSON is not schema-validated. Overall divergence counts only entries whose `parity` is exactly `divergent`. AI parse/error entries can leave overall status `ok`. The handler writes the returned `program` after the parsed AI object. Therefore, it always names the program matched by filename stem from disk, even if the model emits its own value. Errors include the no-key message, `Missing glsl/ or wgsl/ directory`, and `No matching GLSL/WGSL pairs found`. Source: Shade MCP `src/tools/analysis/alg-equiv.ts` - `checkAlgEquiv()`. #### compareShaders ```ts type CompareShadersInput = { effect_id: string } type ShaderStructure = { lines: number functions: string[] uniforms: string[] } type CompareShadersResult = { status: "ok" programs: Array< | { program: string, glsl: ShaderStructure, wgsl: ShaderStructure, lineDiff: number, functionCountDiff: number } | { program: string, glsl: ShaderStructure | null, wgsl: ShaderStructure | null, note: "No WGSL counterpart" | "No GLSL counterpart" } > summary: string } ``` Only `.glsl` and `.wgsl` files are scanned. Function/uniform detection uses regex after comment stripping. This tool always returns status `ok`, including missing counterparts. Inspect `note` and null sides. Source: Shade MCP `src/tools/analysis/compare.ts` - extractors and `compareShaders()`. #### analyzeBranching ```ts type AnalyzeBranchingInput = { effect_id: string backend?: "webgl2" | "webgpu" // default webgl2 } type AnalyzeBranchingResult = | ({ status: "ok" | "warning" } & Record) | { status: "error", error: string } ``` GLSL mode reads `.glsl`. WebGPU reads `.wgsl`. AI output is not schema- validated. The handler first computes `warning` when the sum of all returned opportunity-array lengths is at least two and `ok` otherwise, irrespective of severity. Parsed AI JSON is spread first and the computed `status` is written last, and the envelope computes `outcome` from that status, so neither a model-provided `status`, `outcome`, nor `error` field changes the verdict. Callers must still validate the untyped `shaders`, `opportunities`, and summary content before using it. Errors before the AI call use `status:"error"` and include no AI key, missing shader directory, and no shader files. Source: Shade MCP `src/tools/analysis/branching.ts` - `analyzeBranching()`. #### searchEffects ```ts type SearchEffectsInput = { query: string, limit?: number /* default 10 */ } type SearchEffectsResult = { query: string results: Array<{ id: string, description: string, tags: string[], score: number }> total: number } ``` The query is lowercased, synonym-expanded, and whitespace-tokenized. Score is an unbounded sum of relevance weights: - Full ID substring of the expanded query: +20 (a query with appended synonyms rarely matches). - Per keyword: ID +8, name +15, description +5, tag +8, namespace +12. Only positive scores are returned, descending, sliced to `limit`. The shared effect index is rebuilt after five seconds. Concurrent readers share one rebuild within the current invalidation epoch. A `definition.json` that does not parse, or an effect directory with no definition, is skipped with a server warning. A `definition.js` that does not parse is listed with `func: "unknown"`, no globals or passes, and `partial: true` with the parse error in `partialReasons`. A build begun before explicit invalidation cannot publish its stale index into the cache. Source: Shade MCP `src/tools/knowledge/search-effects.ts`; `src/knowledge/effect-index.ts` - initialization and `search()`; `src/knowledge/shared-instances.ts` - cache lifetime and rebuild sharing. #### analyzeEffect ```ts type AnalyzeEffectInput = { effect_id: string } type AnalyzeEffectResult = | { effectId: string, name?: string, description?: string, namespace?: string, tags: string[], globals: Record, passes: Array>, format: "js" | "json", partial?: true, partialReasons?: string[], shaders: Record<`glsl/${string}.glsl` | `wgsl/${string}.wgsl`, string> } | { error: string } ``` For `definition.js`, Shade parses the module without running it and reads the effect config as literal values, whether it is passed to `new Effect(...)` or `super(...)` or written as class fields. Every global is returned, including define-only globals, with `name` (the key), `type` (defaulting to `float`), `uniform` (defaulting to the global's key), `default`, `min`, `max`, `step`, `choices`, `control`, `define` and `ui`, and every pass with `name`, `program` (defaulting to `main`), `type`, `inputs` and `outputs`. A value computed at run time (a spread, `flatMap`, a reference to another binding) is left out, and the result carries `partial: true` with `partialReasons` naming each path. The projection does not carry Noisemaker-specific top-level fields (`uniformLayout`, `textures`, ...) or global and pass fields outside that set. Shader reads ignore split `.vert`/`.frag` files. The repository's harness exposes the complete live schema: `node shaders/tests/test-harness.js --describe ` (see below) returns every authored definition field per effect, imported live, plus an audit of what the Shade projection leaves out, with a corpus gate pinning that the projection never contradicts the live definition. Source: Shade MCP `src/tools/knowledge/analyze-effect.ts`; `src/formats/definition-js.ts` - `parseDefinitionJs()`. #### searchShaderSource ```ts type SearchShaderSourceInput = { query: string context_lines?: number // default 5 limit?: number // default 10 } type SearchShaderSourceResult = { query: string matchCount: number results: Array<{ effectId: string, file: string, lineNumber: number, matchLine: string, context: string }> } ``` Only `.glsl` files are indexed. `query` is compiled case-insensitively as a regular expression. Invalid regex syntax is escaped and treated literally. Context line numbers are one-based. After a match, nearby lines equal to the context window are skipped. Source: Shade MCP `src/tools/knowledge/search-source.ts`; `src/knowledge/glsl-index.ts` - `search()`. #### searchShaderKnowledge ```ts type SearchShaderKnowledgeInput = { query: string category?: string limit?: number // default 5 } type SearchShaderKnowledgeResult = { query: string category: string matchCount: number databaseStats: { totalDocuments: number, totalTerms: number, indexed: boolean, categories: Record } results: Array<{ title: string, category: string, score: number, snippet: string, tags?: string[], content?: string }> } ``` Search uses synonym expansion and TF-IDF cosine similarity. Returned score is rounded to three decimals and must be at least `0.05`. Cosine similarity is in `[0,1]`. Full `content` is included only for the first three results. Source: Shade MCP `src/tools/knowledge/search-knowledge.ts`; `src/knowledge/vector-db.ts` - `search()`, `getStats()`. #### listEffects ```ts type ListEffectsInput = { namespace?: string } type ListEffectsResult = { namespace: string count: number effects: Array<{ id: string, name: string | undefined, description: string, tags: string[], passes: number, partial?: true }> } ``` Without a filter, `namespace` in output is `all`. The list uses Shade's lossy definition projection. Pass counts and names reflect that parser, not imported Noisemaker class instances. It shares the five-second refreshable effect index with `searchEffects` and its invalidation-epoch behavior. Source: Shade MCP `src/tools/utility/list-effects.ts`; `src/knowledge/effect-index.ts` - `list()`; `src/knowledge/shared-instances.ts` - shared refreshable index. #### generateManifest ```ts type GenerateManifestInput = Record // schema has no properties and no additionalProperties:false; handler ignores args type GenerateManifestResult = | { status: "ok", path: string, effectCount: number } | { error: string } ``` This verb writes `/manifest.json` immediately with compact, sorted JSON. It scans definition source with regexes for description, external texture/mesh, tags, surface globals, and starter heuristics, and scans shader files. A successful write invalidates the cached effect index. A build already in flight may return its own result to a caller waiting on that build, but it cannot repopulate the shared cache after invalidation; the next list or search starts a build for the new epoch. This verb changes state. Do not call it during read-only introspection. The only explicit error is `Effects directory not found: `. Filesystem write exceptions escape the handler. Source: Shade MCP `src/tools/utility/generate-manifest.ts` - `registerGenerateManifest()`; `src/knowledge/shared-instances.ts` - invalidation. ### Validate A tool exchange is protocol-valid when it returns an MCP result rather than a JSON-RPC/MCP `error`. That is not tool or domain success. Treat `result.isError === true` as a tool-call failure first. Its text is the JSON error envelope. Otherwise, parse the JSON text; `outcome === "ok"` is the common pass gate. Apply the predicate for that verb: | Verb | Domain pass predicate | |---|---| | `compileEffect` | every result `status === "ok"` and every pass status `ok` | | `renderEffectFrame` | status `ok`, then output metrics/harness exemptions | | `describeEffectFrame` | status `ok` and required application-specific vision fields present | | `benchmarkEffectFPS` | status `ok` and `meets_target === true` | | `testUniformResponsiveness` | status `ok` and every entry ends `:pass` | | `testNoPassthrough` | `ok`, or `skipped` only for a confirmed nonfilter | | `testPixelParity` | status `ok`, no disallowed solid/Y-flip flags | | `runDslProgram` | status `ok` with the full per-capture metric pass and the grid image | | `checkEffectStructure` | status `ok`, all issue arrays empty, and no `unusedFilesUnchecked` (when it is present, unused files were not checked) | | `checkAlgEquiv` | status `ok`, every pair exactly `equivalent`, no unmatched lists | | `compareShaders` | both sides non-null and no `note` for every program | | `analyzeBranching` | Validate the AI-derived fields. The tool returns `warning` when the opportunity count is `>=2`. Otherwise, it returns `ok`. | | search/list/analyze | The requested record is present. These tools have no success status. | | `generateManifest` | status `ok`, then independently inspect the written diff | Batch callers must validate every `results` member; the batch `summary` counts them by outcome. Never treat transport success, outer `status:"ok"`, or a nonempty result as universal validation. For the six direct browser check/capture verbs named above, check the page-confirmed `page_effect_id` against the requested `effect_id`, check the reported backend, and require domain success. The session now rejects a failed backend switch or an effect selection without a confirmed new graph. ### Diagnose Selector/scan failures thrown before per-effect loops include: ```text Effects directory not found: . Specify effect_id or set SHADE_EFFECTS_DIR. Failed to scan effects directory: No effects found in . Specify effect_id. Multiple effects found (). Please specify effect_id or effects parameter. Available: ... ``` Browser tools catch per-effect exceptions as `OuterEffectError`. Analysis/knowledge tools keep their own error payload forms (`{status:"error",error}` or `{error}`), and every payload carries the common `outcome` beside them. All 18 tools return through the shared `toolResult()` envelope, and the server routes every handler through `guardToolErrors()`, so an exception thrown before a handler's own error handling also returns the JSON error envelope with `isError:true`. SDK-generated `tools/call` errors for schema rejection and unknown or disabled tools use the same envelope. AI-dependent tools use this exact no-key message: ```text No AI API key found. Set ANTHROPIC_API_KEY or OPENAI_API_KEY. Alternatively, create a .anthropic or .openai file in the project root. ``` Provider calls allow at most one retry. `describeEffectFrame` and `checkAlgEquiv` request at most 1500 output tokens. `analyzeBranching` requests at most 3000. Provider calls are bounded by `SHADE_AI_TIMEOUT_MS` as described under Introspect. Browser results can append `console_errors:string[]` through `runWithConsoleCapture`, but field presence depends on the wrapper/result and the messages are not categorized. Tool-specific failures are enumerated under each verb above. For `compileEffect`, `renderEffectFrame`, `describeEffectFrame`, `benchmarkEffectFPS`, `testUniformResponsiveness`, `testNoPassthrough`, and `testPixelParity`, the browser session waits for a graph built for the requested effect and rejects an unconfirmed effect or backend. `runDslProgram` uses a fresh renderer and reports its own backend. A tool call can still fail through an outer browser/session error; inspect the returned status and identity rather than treating transport success as validation. Sources: Shade MCP `src/tools/resolve-effects.ts`; all tool registration files; `src/ai/provider.ts` - `NO_AI_KEY_MESSAGE`; `src/harness/browser-session.ts` - console capture. ## Fully worked, source-complete effect and validation finding This instance is the repository's `synth/testPattern` effect. The source blocks below are complete, not excerpts. Its definition and shader files are unchanged at the Noisemaker source snapshot named above. The current Shade MCP release is the source commit and immutable pin named there. The effect declares one pass; the DSL write to `o0` adds a terminal blit, yielding the two-pass expanded graph shown below. The configured MCP session validates the requested effect on both WebGL2 and WebGPU. Returned frame dimensions are authoritative; the 256×256 request is rendered and returned at 256×256 (the demo viewer presents it in a 90×90 canvas). MCP request JSON is shown in wire order. Each response is the JSON value decoded from the server's `content[0].text` field. ### Authoring inputs `shaders/effects/synth/testPattern/definition.js`: ```js import { Effect } from '../../../src/runtime/effect.js' export default new Effect({ name: "Test Pattern", namespace: "synth", func: "testPattern", tags: ["util"], description: "Test patterns for debugging and calibration", globals: { pattern: { type: "int", default: 0, uniform: "pattern", choices: { checkerboard: 0, colorBars: 1, gradient: 2, uvMap: 3, gridLines: 4, colorGrid: 5, dotGrid: 6 }, ui: { label: "pattern", control: "dropdown" } }, gridSize: { type: "int", default: 4, min: 1, max: 16, uniform: "gridSize", ui: { label: "grid size", control: "slider", enabledBy: { param: "pattern", in: [0, 4, 5, 6] } } } }, passes: [ { name: "main", program: "testPattern", inputs: {}, outputs: { color: "outputTex" } } ] }) ``` `shaders/effects/synth/testPattern/glsl/testPattern.glsl`: ```glsl #version 300 es precision highp float; uniform vec2 resolution; uniform vec2 tileOffset; uniform vec2 fullResolution; uniform int gridSize; uniform int pattern; out vec4 fragColor; // 3x5 pixel font for digits 0-9 // Each digit is encoded as 15 bits (3 columns x 5 rows, row-major) const int GLYPH[10] = int[10]( 0x7B6F, // 0: 111 101 101 101 111 0x2492, // 1: 010 010 010 010 010 0x73E7, // 2: 111 001 111 100 111 0x72CF, // 3: 111 001 011 001 111 0x5BC9, // 4: 101 101 111 001 001 0x79CF, // 5: 111 100 111 001 111 0x79EF, // 6: 111 100 111 101 111 0x7249, // 7: 111 001 001 001 001 0x7BEF, // 8: 111 101 111 101 111 0x7BCF // 9: 111 101 111 001 111 ); // Sample a glyph at local coordinates (0-2, 0-4) bool sampleGlyph(int digit, int x, int y) { if (digit < 0 || digit > 9 || x < 0 || x > 2 || y < 0 || y > 4) return false; int bitIndex = y * 3 + (2 - x); // row-major, top-left origin return ((GLYPH[digit] >> bitIndex) & 1) == 1; } // Render a number at a position within a cell bool renderNumber(int number, vec2 cellUV) { // Determine how many digits we need int numDigits = 1; if (number >= 10) numDigits = 2; if (number >= 100) numDigits = 3; // Glyph dimensions in UV space (centered, cell-local) float glyphWidth = 0.15; float glyphHeight = 0.35; float spacing = 0.05; float totalWidth = float(numDigits) * glyphWidth + float(numDigits - 1) * spacing; float startX = 0.5 - totalWidth * 0.5; float startY = 0.5 - glyphHeight * 0.5; // Check if we're in the vertical range for glyphs if (cellUV.y < startY || cellUV.y >= startY + glyphHeight) return false; // Extract digits (right to left) int digits[3]; int temp = number; for (int i = 0; i < 3; i++) { digits[i] = temp % 10; temp /= 10; } // Check each digit position (left to right) for (int d = 0; d < numDigits; d++) { float digitX = startX + float(d) * (glyphWidth + spacing); if (cellUV.x >= digitX && cellUV.x < digitX + glyphWidth) { // We're in this digit's horizontal range float localX = (cellUV.x - digitX) / glyphWidth; float localY = (cellUV.y - startY) / glyphHeight; // Map to 3x5 grid int gx = int(localX * 3.0); int gy = int(localY * 5.0); // Get the correct digit (numDigits-1-d because digits[] is reversed) int digit = digits[numDigits - 1 - d]; return sampleGlyph(digit, gx, gy); } } return false; } // Pattern 0: Numbered checkerboard vec4 checkerboard(vec2 uv) { int n = max(gridSize, 1); int cellX = int(uv.x * float(n)) % n; int cellY = int(uv.y * float(n)) % n; int cellNum = (n - 1 - cellY) * n + cellX; bool isWhiteCell = ((cellX + cellY) % 2) == 0; vec2 cellUV = fract(uv * float(n)); bool isGlyph = renderNumber(cellNum, cellUV); float cellColor = isWhiteCell ? 1.0 : 0.0; float glyphColor = isWhiteCell ? 0.0 : 1.0; float finalColor = isGlyph ? glyphColor : cellColor; return vec4(vec3(finalColor), 1.0); } // Pattern 1: 8 vertical SMPTE-style color bars vec4 colorBars(vec2 uv) { int bar = int(uv.x * 8.0); bar = clamp(bar, 0, 7); // white, yellow, cyan, green, magenta, red, blue, black vec3 colors[8] = vec3[8]( vec3(1.0, 1.0, 1.0), vec3(1.0, 1.0, 0.0), vec3(0.0, 1.0, 1.0), vec3(0.0, 1.0, 0.0), vec3(1.0, 0.0, 1.0), vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, 1.0), vec3(0.0, 0.0, 0.0) ); return vec4(colors[bar], 1.0); } // Pattern 2: Horizontal black-to-white gradient ramp vec4 gradient(vec2 uv) { return vec4(vec3(uv.x), 1.0); } // Pattern 3: UV map (R=u, G=v, B=0) vec4 uvMap(vec2 uv) { return vec4(uv.x, uv.y, 0.0, 1.0); } // Pattern 4: Thin white grid lines on black vec4 gridLines(vec2 uv) { int n = max(gridSize, 1); vec2 cellUV = fract(uv * float(n)); vec2 edge = min(cellUV, 1.0 - cellUV); // Use direct calculation instead of fwidth() for tile-aware rendering. // This maintains the same line thickness in normal rendering while ensuring // continuity across tile boundaries during large-format print export. vec2 fw = vec2(1.0) / fullResolution * float(n); float line = 1.0 - smoothstep(0.0, 2.0 * fw.x, edge.x) * smoothstep(0.0, 2.0 * fw.y, edge.y); return vec4(vec3(line), 1.0); } // HSV to RGB (hue only, full saturation & value) vec3 hue2rgb(float h) { float r = abs(h * 6.0 - 3.0) - 1.0; float g = 2.0 - abs(h * 6.0 - 2.0); float b = 2.0 - abs(h * 6.0 - 4.0); return clamp(vec3(r, g, b), 0.0, 1.0); } // Pattern 5: Each cell gets a unique hue vec4 colorGrid(vec2 uv) { int n = max(gridSize, 1); int cellX = int(uv.x * float(n)) % n; int cellY = int(uv.y * float(n)) % n; int cellIndex = cellY * n + cellX; float hue = fract(float(cellIndex) * 0.618033988749895); return vec4(hue2rgb(hue), 1.0); } // Pattern 6: Filled circle at each grid intersection vec4 dotGrid(vec2 uv) { int n = max(gridSize, 1); vec2 scaled = uv * float(n); vec2 nearest = round(scaled); float dist = length(scaled - nearest); float dot = 1.0 - smoothstep(0.12, 0.15, dist); return vec4(vec3(dot), 1.0); } void main() { vec2 globalCoord = gl_FragCoord.xy + tileOffset; vec2 uv = globalCoord / fullResolution; if (pattern == 1) { fragColor = colorBars(uv); } else if (pattern == 2) { fragColor = gradient(uv); } else if (pattern == 3) { fragColor = uvMap(uv); } else if (pattern == 4) { fragColor = gridLines(uv); } else if (pattern == 5) { fragColor = colorGrid(uv); } else if (pattern == 6) { fragColor = dotGrid(uv); } else { fragColor = checkerboard(uv); } } ``` `shaders/effects/synth/testPattern/wgsl/testPattern.wgsl`: ```wgsl // WGSL version – WebGPU struct Uniforms { resolution: vec2f, gridSize: i32, pattern: i32, tileOffset: vec2f, fullResolution: vec2f, } @group(0) @binding(0) var uniforms: Uniforms; // 3x5 pixel font for digits 0-9 // Each digit is encoded as 15 bits (3 columns x 5 rows, row-major) const GLYPH = array( 0x7B6F, // 0: 111 101 101 101 111 0x2492, // 1: 010 010 010 010 010 0x73E7, // 2: 111 001 111 100 111 0x72CF, // 3: 111 001 011 001 111 0x5BC9, // 4: 101 101 111 001 001 0x79CF, // 5: 111 100 111 001 111 0x79EF, // 6: 111 100 111 101 111 0x7249, // 7: 111 001 001 001 001 0x7BEF, // 8: 111 101 111 101 111 0x7BCF // 9: 111 101 111 001 111 ); // Sample a glyph at local coordinates (0-2, 0-4) fn sampleGlyph(digit: i32, x: i32, y: i32) -> bool { if (digit < 0 || digit > 9 || x < 0 || x > 2 || y < 0 || y > 4) { return false; } let bitIndex = y * 3 + (2 - x); // row-major, top-left origin return ((GLYPH[digit] >> u32(bitIndex)) & 1) == 1; } // Render a number at a position within a cell fn renderNumber(number: i32, cellUV: vec2f) -> bool { // Determine how many digits we need var numDigits = 1; if (number >= 10) { numDigits = 2; } if (number >= 100) { numDigits = 3; } // Glyph dimensions in UV space (centered, scaled to fit nicely) let glyphWidth = 0.15; let glyphHeight = 0.35; let spacing = 0.05; let totalWidth = f32(numDigits) * glyphWidth + f32(numDigits - 1) * spacing; let startX = 0.5 - totalWidth * 0.5; let startY = 0.5 - glyphHeight * 0.5; // Check if we're in the vertical range for glyphs if (cellUV.y < startY || cellUV.y >= startY + glyphHeight) { return false; } // Extract digits (right to left) var digits = array(0, 0, 0); var temp = number; for (var i = 0; i < 3; i++) { digits[i] = temp % 10; temp = temp / 10; } // Check each digit position (left to right) for (var d = 0; d < numDigits; d++) { let digitX = startX + f32(d) * (glyphWidth + spacing); if (cellUV.x >= digitX && cellUV.x < digitX + glyphWidth) { // We're in this digit's horizontal range let localX = (cellUV.x - digitX) / glyphWidth; let localY = (cellUV.y - startY) / glyphHeight; // Map to 3x5 grid let gx = i32(localX * 3.0); let gy = i32(localY * 5.0); // Get the correct digit (numDigits-1-d because digits[] is reversed) let digit = digits[numDigits - 1 - d]; return sampleGlyph(digit, gx, gy); } } return false; } // Pattern 0: Numbered checkerboard fn checkerboard(uv: vec2f) -> vec4f { let n = max(uniforms.gridSize, 1); let cellX = i32(uv.x * f32(n)) % n; let cellY = i32(uv.y * f32(n)) % n; let cellNum = (n - 1 - cellY) * n + cellX; let isWhiteCell = ((cellX + cellY) % 2) == 0; let cellUV = fract(uv * f32(n)); let isGlyph = renderNumber(cellNum, cellUV); let cellColor = select(0.0, 1.0, isWhiteCell); let glyphColor = select(1.0, 0.0, isWhiteCell); let finalColor = select(cellColor, glyphColor, isGlyph); return vec4f(vec3f(finalColor), 1.0); } // Pattern 1: 8 vertical SMPTE-style color bars fn colorBars(uv: vec2f) -> vec4f { var bar = i32(uv.x * 8.0); bar = clamp(bar, 0, 7); // white, yellow, cyan, green, magenta, red, blue, black let colors = array( vec3f(1.0, 1.0, 1.0), vec3f(1.0, 1.0, 0.0), vec3f(0.0, 1.0, 1.0), vec3f(0.0, 1.0, 0.0), vec3f(1.0, 0.0, 1.0), vec3f(1.0, 0.0, 0.0), vec3f(0.0, 0.0, 1.0), vec3f(0.0, 0.0, 0.0) ); return vec4f(colors[bar], 1.0); } // Pattern 2: Horizontal black-to-white gradient ramp fn gradientRamp(uv: vec2f) -> vec4f { return vec4f(vec3f(uv.x), 1.0); } // Pattern 3: UV map (R=u, G=v, B=0) fn uvMapPattern(uv: vec2f) -> vec4f { return vec4f(uv.x, uv.y, 0.0, 1.0); } // Pattern 4: Thin white grid lines on black fn gridLines(uv: vec2f) -> vec4f { let n = max(uniforms.gridSize, 1); let cellUV = fract(uv * f32(n)); let edge = min(cellUV, 1.0 - cellUV); // Non-tiling: original fwidth-based AA (byte-identical baseline). // Tiling: analytic AA width mirroring glsl/testPattern.glsl, which is // seam-stable across tiles where screen-space derivatives are not. let isTile = length(uniforms.tileOffset) > 0.0; // fwidthFine must be evaluated in uniform control flow (function scope), // so compute it unconditionally, then override only when tiling. The // analytic-width divide is skipped entirely on the non-tile path. var fw = fwidthFine(uv * f32(n)); var edgeMul = 1.5; if (isTile) { let fr = select(uniforms.resolution, uniforms.fullResolution, uniforms.fullResolution.x > 0.0); fw = vec2f(1.0) / fr * f32(n); edgeMul = 2.0; } let line = 1.0 - smoothstep(0.0, edgeMul * fw.x, edge.x) * smoothstep(0.0, edgeMul * fw.y, edge.y); return vec4f(vec3f(line), 1.0); } // HSV to RGB (hue only, full saturation & value) fn hue2rgb(h: f32) -> vec3f { let r = abs(h * 6.0 - 3.0) - 1.0; let g = 2.0 - abs(h * 6.0 - 2.0); let b = 2.0 - abs(h * 6.0 - 4.0); return clamp(vec3f(r, g, b), vec3f(0.0), vec3f(1.0)); } // Pattern 5: Each cell gets a unique hue fn colorGrid(uv: vec2f) -> vec4f { let n = max(uniforms.gridSize, 1); let cellX = i32(uv.x * f32(n)) % n; let cellY = i32(uv.y * f32(n)) % n; let cellIndex = cellY * n + cellX; let hue = fract(f32(cellIndex) * 0.618033988749895); return vec4f(hue2rgb(hue), 1.0); } // Pattern 6: Filled circle at each grid intersection fn dotGrid(uv: vec2f) -> vec4f { let n = max(uniforms.gridSize, 1); let scaled = uv * f32(n); let nearest = round(scaled); let dist = length(scaled - nearest); let d = 1.0 - smoothstep(0.12, 0.15, dist); return vec4f(vec3f(d), 1.0); } @fragment fn main(@builtin(position) position: vec4f) -> @location(0) vec4f { // Tile-aware global UV (mirror glsl/testPattern.glsl). Non-tiling // (tileOffset=(0,0), fullResolution=resolution) is byte-identical. let fr = select(uniforms.resolution, uniforms.fullResolution, uniforms.fullResolution.x > 0.0); let uv = (position.xy + uniforms.tileOffset) / fr; if (uniforms.pattern == 1) { return colorBars(uv); } else if (uniforms.pattern == 2) { return gradientRamp(uv); } else if (uniforms.pattern == 3) { return uvMapPattern(uv); } else if (uniforms.pattern == 4) { return gridLines(uv); } else if (uniforms.pattern == 5) { return colorGrid(uv); } else if (uniforms.pattern == 6) { return dotGrid(uv); } else { return checkerboard(uv); } } ``` The legal DSL invocation uses the generated enum identifier, writes the effect chain to `o0`, and explicitly renders it: ```noisemaker search synth testPattern(pattern: checkerboard, gridSize: 4).write(o0) render(o0) ``` `pattern` is a DSL `Identifier`, not the string literal `"checkerboard"`. `gridSize` is a numeric expression. `compileGraph()` registers nothing: with the checked-in effect registered first (`registerEffect()`, `registerOp()`, `registerStarterOps()` and `mergeIntoEnums()`, as `shaders/tests/test_contract_graph_shape.mjs` does), it expands this exact program to two expanded passes: ```json [ {"id":"node_0_pass_0","program":"node_0_testPattern","inputs":{},"outputs":{"color":"node_0_out"}}, {"id":"node_1_write_blit","program":"blit","type":"render","inputs":{"src":"node_0_out"},"outputs":{"color":"global_o0"}} ] ``` The first is the one declared effect pass. The second is the terminal write blit. A direct `compileGraph()` invocation against the current pinned Noisemaker source reproduced this exact pass array. No generator or repository contract test on current `main` locks the worked example to the document. Re-run the source-level check under the freshness protocol when either pinned source changes. Sources: `shaders/effects/synth/testPattern/definition.js` and its complete GLSL/WGSL program files; `shaders/src/renderer/canvas.js` - `registerEffectWithRuntime()` enum export; `shaders/src/runtime/expander.js` - program expansion. ### Exact MCP validation transcript One configured-pin session returned server `shade-mcp` version `0.4.11` and listed 18 tools. All eight calls ran in that session, none returning MCP `isError`; the passthrough call returned the expected domain status `skipped` (`outcome:"skipped"`) because this is a source effect. On hosts where the parity verb's browser cannot obtain a WebGPU adapter, its WebGPU leg fails closed (see the parity tool contract). Page console-message arrays are omitted from the transcript responses. ```json {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"checkEffectStructure","arguments":{"effect_id":"synth/testPattern"}}} {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"compileEffect","arguments":{"effect_id":"synth/testPattern","backend":"webgl2"}}} {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"compileEffect","arguments":{"effect_id":"synth/testPattern","backend":"webgpu"}}} {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"renderEffectFrame","arguments":{"effect_id":"synth/testPattern","backend":"webgl2","warmup_frames":10,"capture_image":false,"resolution":[256,256]}}} {"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"testUniformResponsiveness","arguments":{"effect_id":"synth/testPattern","backend":"webgl2"}}} {"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"testNoPassthrough","arguments":{"effect_id":"synth/testPattern","backend":"webgl2"}}} {"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"testPixelParity","arguments":{"effect_id":"synth/testPattern","epsilon":1,"seed":42}}} {"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"benchmarkEffectFPS","arguments":{"effect_id":"synth/testPattern","backend":"webgl2","duration_seconds":1,"target_fps":30,"resolution":[256,256]}}} ``` Responses, in the same order (the frame metrics and benchmark times vary by capture; these are the values from this session): ```json {"outcome":"ok","unusedFiles":[],"namingIssues":[],"nameCollisions":[],"leakedInternalUniforms":[],"missingDescription":false,"structuralParityIssues":[],"requiredUniformIssues":[],"multiPass":false,"passCount":1,"status":"ok"} {"outcome":"ok","status":"ok","passes":[{"id":"main","status":"ok"},{"id":"pass_1","status":"ok"}],"message":"Compiled successfully","backend":"WebGL2","effect_id":"synth/testPattern","page_effect_id":"synth/testPattern"} {"outcome":"ok","status":"ok","passes":[{"id":"main","status":"ok"},{"id":"pass_1","status":"ok"}],"message":"Compiled successfully","backend":"WebGPU","effect_id":"synth/testPattern","page_effect_id":"synth/testPattern"} {"outcome":"ok","status":"ok","backend":"WebGL2","requested_resolution":[256,256],"frame":{"image_uri":null,"width":256,"height":256},"metrics":{"mean_rgb":[0.5034687809712587,0.5034687809712587,0.5034687809712587],"mean_alpha":1,"std_rgb":[0.4999879674137903,0.4999879674137903,0.4999879674137903],"luma_variance":0.24998796755857344,"unique_sampled_colors":2,"is_all_zero":false,"is_all_transparent":false,"is_essentially_blank":false,"is_monochrome":false},"effect_id":"synth/testPattern","page_effect_id":"synth/testPattern"} {"outcome":"ok","status":"ok","tested_uniforms":["pattern:pass","gridSize:pass"],"uniforms":[{"name":"pattern","uniform":"pattern","default_value":0,"test_value":1,"luma_diff":0.0031082153320312056,"max_channel_diff":0.0031082153320312056,"pixel_diff":0.37701416015625,"strong_fraction":0.875299072265625,"responds":true},{"name":"gridSize","uniform":"gridSize","default_value":4,"test_value":12,"luma_diff":0.002946472167968728,"max_channel_diff":0.002946472167968728,"pixel_diff":0.39990234375,"strong_fraction":0.48483123779296877,"responds":true,"enabled_with":{"pattern":0}}],"threshold":0.002,"strong_threshold":0.0005,"details":"Uniforms affect output","measured_size":[1024,640],"backend":"WebGL2","effect_id":"synth/testPattern","page_effect_id":"synth/testPattern"} {"outcome":"skipped","status":"skipped","isFilterEffect":false,"similarity":null,"details":"Not a filter effect","backend":"WebGL2","effect_id":"synth/testPattern","page_effect_id":"synth/testPattern"} {"outcome":"ok","status":"ok","maxDiff":0,"meanDiff":0,"mismatchCount":0,"mismatchPercent":0,"resolution":[1024,1024],"effect_id":"synth/testPattern","page_effect_id":"synth/testPattern","backend":"WebGPU","glslSolid":false,"wgslSolid":false,"glslVariance":[16256,16256,16256],"wgslVariance":[16256,16256,16256],"yFlipDetected":false,"yFlipCleanFlip":false,"yFlipMismatchPercent":70.9,"yFlipMeanDiff":180.79,"yFlipRatio":1,"issues":[],"details":"Pixel parity OK (maxDiff=0, meanDiff=0.00)"} {"outcome":"ok","status":"ok","backend":"WebGL2","effect_id":"synth/testPattern","page_effect_id":"synth/testPattern","requested_resolution":[256,256],"frame":{"width":256,"height":256},"achieved_fps":60.59,"meets_target":true,"stats":{"frame_count":60,"avg_frame_time_ms":16.5,"jitter_ms":1.3,"min_frame_time_ms":7.3,"max_frame_time_ms":18.6}} ``` The structure scan has no issues and reports the one declared effect pass. Both compile results report the two expanded passes and the page-confirmed backend. The rendered frame is nonblank (`mean_alpha = 1`, `luma_variance = 0.24998796755857344`) at the returned 256×256 resolution. `gridSize` responds to the tested change (`luma_diff = 0.002946472167968728`, above the `0.002` threshold). The parity comparison reports zero mismatches; the benchmark meets 30 FPS at the 256×256 render size. These results apply to the identified effect and backends. Sources: the checked-in effect definition, shader programs, and graph expander; Shade MCP browser tools and harness helpers at the pinned source commit. ## Surfaces x capabilities traceability matrix Every cell points to an executable contract or an explicitly identified gap. | Surface | Introspect | Act | Validate | Diagnose | |---|---|---|---|---| | DSL program | `Token`, `ParsedProgram`, `CompiledProgram`, registry keys, `CompiledGraph` | Complete EBNF plus call/read/write/render and nested-automation contracts | parse/unparse/compile and render metric predicates | `SyntaxError`, `Diagnostic`, `CompilationFailure` | | Effect definition | `Effect` fields, registry/manifest, `checkEffectStructure` | `EffectDefinition` typed grammar | validator, structure scan, and page-confirmed browser selection | exact validator messages, structural arrays, backend and selection errors | | Parameters and globals | `effect.globals`, transformed `uniforms`, registry params, MIDI/audio state and capture requirements | `GlobalSpec`, nested automation, and selected-input contracts | compile reflection, state-selection predicates, per-uniform numeric deltas | transform errors, type/range and capture failures | | Passes and render graph | `Effect.passes`, `CompiledGraph.passes`, `CompiledGraph.textures`, `CompiledGraph.allocations` | `PassSpec` typed grammar, sampler/output binding rules, and MRT byte adaptation | graph analysis and per-pass compile, plus the queryable runtime allocation/reuse plan | unresolved inputs and backend compile/resource failures | | Textures and surface resources | texture specs, `DeviceCapabilities`, and runtime surface dimensions/formats | `TextureSpec` typed grammar, exact accepted policies, and volume atlas clamp | allocation existence, size/format/limit inspection, render metrics | unsupported/missing surface failures and GL/device failures record structured `backend.diagnostics` entries (`ERR_MISSING_RENDER_TARGET`/`ERR_GL_ERROR`/`ERR_DEVICE_VALIDATION`); unknown-format/dimension fallbacks record `ERR_UNKNOWN_FORMAT_FALLBACK`/`ERR_DIMENSION_FALLBACK` | | Compatibility and mutation | compiled AST, `listSteps`, `getCompatibleReplacements` | typed `replaceEffect` contract | candidate precheck and post-selection browser identity | compatibility and transform errors | | Rendered output | frame dimensions, `ImageMetrics`, sink stats, and `PackedFrame` | `RenderEffectFrameInput`, `Sink`, and `FrameExportQueue` contracts | exact metric predicates plus bounded queue/accounting rules | metric, capture, sink, and export failures | | Cross-backend parity | source-pair availability and parity report | GLSL ES 3.00/WGSL authoring contracts | buffer diff predicates with confirmed effect/backend on both legs | compile/readback/divergence reports | | Shade MCP tool contracts | `tools/list`, effect scanners, all output types | Full signatures for all 18 verbs | verb-specific domain predicates | the common `outcome` envelope, selector errors and tool-specific fields | External ports are deliberately outside this matrix. Their repositories define separate candidate renderers, golden-image comparators, tolerance policies, and failure reports. This document makes no cross-runtime validation claim for those ports. The matrix has 9 surfaces x 4 capabilities = 36 filled cells.