DreamShaderLang
Plugin referenceTools

Editor bridge

Editor bridge

Editor bridge

DreamShader » Tools » Editor bridge

The editor-side service that exposes DreamShader to an external editor through two transports — a polled request-file directory and a loopback WebSocket server — and a set of generated artifacts under Saved/DreamShader/Bridge/.

Kindeditor service — FDreamShaderEditorBridge, owned by the DreamShaderEditor module
Root<Project>/Saved/DreamShader/Bridge/
Transport Arequest files — Bridge/Requests/*.json, polled every 0.1 s, deleted before dispatch, answered in Bridge/Responses/<requestId>.json
Transport BWebSocket — ws://127.0.0.1:17864, loopback only
LivenessBridge/status.json, rewritten every 2 s while idle, deleted on shutdown
Protocol1 — sent as protocol on a request, echoed on every response and on the status file
Module dependenciesWebSocketNetworking, SQLiteCore
Disabled by-NoDreamShaderEditorBridge; never starts in a commandlet

Synopsis

<Project>/Saved/DreamShader/Bridge/
├─ Requests/                   inbound  *.json, consumed and deleted
├─ Responses/                  outbound <requestId>.json, one per request that carried an id
├─ status.json                 outbound liveness heartbeat; absent means "not running"
├─ diagnostics.json            outbound aggregated diagnostics
├─ diagnostics/                outbound sharded diagnostics
│  ├─ index.json
│  └─ <md5-of-source-path>.json
├─ bridge.db                   outbound SQLite (+ bridge.db-wal, bridge.db-shm)
├─ material-expressions.json   outbound reflected UMaterialExpression catalogue
├─ settings.json               outbound enum alias tables
├─ substrate-builtins.json     outbound Substrate builtin catalogue
├─ pass-keys.json              outbound the .dsp vocabulary, for completion (since 2.1.0)
├─ dreamshader-builtin-catalog.json  outbound the 2.0 binder's node catalog (since 2.0.0)
├─ preview.json                outbound one-shot preview result
└─ Preview/                    outbound rendered PNGs
   └─ <stem>-<crc32>.png

Lifecycle

Startup, in order. Bridge, Bridge/Requests and Bridge/Preview are created first.

StepAction
1Create Bridge/, Bridge/Requests/, Bridge/Responses/, Bridge/Preview/
1aDelete every stale Responses/*.json, stamp the listening-since time, publish status.json
2Delete bridge.db, bridge.db-wal, bridge.db-shm and the whole diagnostics/ directory
3Export material-expressions.json, settings.json, substrate-builtins.json (and their SQLite tables)
4Scan and refresh VirtualFunction declarations
5Register a post-engine-init handler that generates every source file in memory, and then exports dreamshader-builtin-catalog.json (since 2.0.0) — after the sweep, because the catalog is reflection over every loaded expression class and only then is every module loaded
6Register a project-settings property watcher
7Queue a full rescan and write diagnostics.json
8Start the preview WebSocket server on port 17864
9Register a directory watcher on <SourceDirectory> (including directory changes)
10Hook UMaterial::OnMaterialCompilationFinished
11Register the Tools menu, toolbar and context-menu entries
12Register ticker A — 0.1 s: request-file polling and the debounce queue
13Register ticker B — every frame: preview streaming

Ticker B is registered separately, at a zero-second interval, because sharing ticker A would cap every preview stream at 10 FPS regardless of the client's requested frame rate.

Shutdown deletes status.json first, then removes every handle, shuts the WebSocket server down, clears the pending-file map and the diagnostics store, deletes the bridge database again, and unregisters the tool menus unless the engine is already exiting.

One editor owns the bridge

The bridge directory is per project, not per process: one Requests folder, one status.json, one heartbeat. Two editors open on the same project therefore both polled the same queue — whichever got there first consumed the file, the other read a half-deleted one, and both overwrote status.json with their own pid, so a client could not tell which editor was about to answer it.

Since 1.8.0 exactly one process serves the bridge, and says so in a lock file:

File<Project>/Saved/DreamShader/Bridge/owner.lock
Contentspid and an ISO-8601 heartbeat, refreshed on the ordinary heartbeat
Believed whilethe owning process is still running and its heartbeat is under 30s old
Releasedon editor shutdown, so the next editor takes over on its next heartbeat rather than after the stale window

Both conditions are needed. The pid test alone would hand the bridge to a second editor every time the first was mid-compile, since a compile blocks the game thread and stops the heartbeat; the heartbeat test alone would leave the bridge unowned for a stale window after a hard crash.

A non-owning editor still compiles its own Ephemeral materials — those are per-process and nobody else's business — but it does not consume requests, does not write status.json, and does not write generated assets to disk. That last one matters because storage decides how a rebuild persists: without it, two editors would both SavePackage the same file, and a save that loses that race is not a merge — it is a corrupted package or a dead editor. Such a compile reports:

Skipped {AssetPath}; another editor owns this project's DreamShader bridge, and only that one
writes generated assets to disk.

The commandlet is unaffected: it has no bridge, no lock file and no second process to negotiate with, and writing these assets is its entire job.

Only the owner publishes or cleans shared responses, status, diagnostics, bridge.db and manifests. A non-owner keeps its local diagnostics and browser notifications. When it later acquires ownership, it starts a new listening session and publishes its own manifests and current diagnostics; closing a non-owner leaves the running owner's files intact.

Saving, adding or removing an included .dss queues its direct and transitive consumers, like a .dsh include. The watcher retains the previous dependency edges for a deletion batch, so removing an include still causes its consumers to report the missing file.

Request files

Directory<Project>/Saved/DreamShader/Bridge/Requests/
Discovery*.json, files only, non-recursive — subdirectories are never scanned
Poll interval0.1 s
Consumptionevery discovered file is deleted at the end of its loop iteration

The filename is irrelevant; only the JSON contents matter. Use a unique name per request.

A request is deleted before it is dispatched, not after, so a request that crashes the editor cannot be replayed on every subsequent start. The one exception is a file that cannot be read at all: that is almost always one still being written, so it is left alone for the next poll rather than thrown away.

Because the poller may still open a file mid-write, write the JSON to a temporary name elsewhere and rename it into Requests/ so it appears atomically.

[!NOTE] A request carrying a non-empty requestId is answered in Responses/<requestId>.json — including the failures: unparseable JSON, a protocol mismatch, an unrecognized action, and a recompile whose source file vanished before the debounce window elapsed. A request without an id gets no response, which is what every request the shipped VSCode extension sends looks like, and is why that extension keeps working unchanged.

[!WARNING] Requests written while no editor was listening are discarded, not queued. The cutoff is the bridge's own start time, so a request that was waiting on disk when an editor starts is deleted with a log line rather than served — by then whoever sent it has long since timed out, and serving it would mean a compile pass nobody asked for. Check status.json before writing a request.

Actions

action and scope are both matched case-insensitively.

actionRequired fieldsEffectAnswered
ping—Nothing. Confirms the bridge is servingimmediately
recompilescope: "all"Rebuild the dependency graph and queue every source of every root — .dsm, .dsf, .dss, .dsi, .dsp, except a .dsm under a root's Packages — for compilation, forced past the build-key skipimmediately, as queued — see below
recompilescope: "file", sourceFileQueue one file into the debounce queue, forced past the source-hash skipwhen that compile finishes
cleanGeneratedShaders—Delete the generated *.ush includes, then queue a full rescanimmediately
previewMaterialsourceFileRender one preview synchronously and write preview.jsonimmediately, with the render result
reveal-nodefile, lineFind the node generated from that line, open its material editor and select it (since 2.0.0)immediately, with the asset path and expression GUIDs

[!IMPORTANT] recompile with scope: "file" is the only action whose response is deferred. The file goes into the debounce queue and is compiled some ticks later, so answering at dispatch time would report success before anything had been attempted. The id is parked and answered from the compile itself, carrying that compile's diagnostics. Two clients asking for the same file both get an answer.

scope: "all" is the opposite and says so: it answers immediately with queued, because the batch drains across ticks and the outcome is not knowable yet. Do not read that as a result.

[!NOTE] A bad scope, a missing sourceFile, or an unrecognized action is now an error response rather than a silent no-op — provided the request carried a requestId. Without one the behaviour is unchanged: the file is deleted and nothing is said.

Protocol

A request may carry protocol. A missing field is read as this version, not as a mismatch — every request the shipped VSCode extension sends omits it, and rejecting those would break it on every machine that has it installed. A field that is present and different is refused:

Protocol 3 is not understood; this editor speaks 1. Update the DreamShaderLang extension or the
plugin so the two match.

Responses/<requestId>.json

Written atomically — beside the target, then renamed — so appearing and being complete are the same event. Stale responses from a previous session are deleted at startup: an answer nobody is waiting for any more is worse than none, because a client that reconnects would act on it.

FieldTypeNotes
protocolnumberalways 1
requestIdstringechoes the request
okbool
durationMsnumber
messagestringinvariant, never the localised display text
diagnosticsarrayfile, line, column, severity, and code / stage / assetPath when non-empty

Each diagnostic keeps its own file: a .dsm that pulls in a broken .dsh fails at the .dsh's position, and attributing it to the .dsm would send the author to the wrong file.

status.json

The heartbeat, rewritten every 2 s while idle and deleted on shutdown — so a missing file means "not running", definitively, and a client can fall back immediately instead of waiting out a liveness window.

FieldTypeNotes
protocol, pid, project, projectDir, engineDir, pluginVersionidentity
busybooltrue while a request is being served
busyActionstringpresent only when busy
lastResultstringpresent once something has completed
heartbeatUtcstringISO-8601 UTC

[!WARNING] Read busy before judging the heartbeat's age. A compile blocks the game thread, so the heartbeat stops while one runs — a client that only looks at the timestamp reads every real compile as a crash. The remaining ambiguity is a hard crash, which leaves the file behind; the recorded pid is what closes it.

recompile

FieldTypeDefaultNotes
actionstring—"recompile"
scopestring—"all" or "file"
sourceFilestring—required and non-empty when scope is "file"; absolute path recommended

scope: "all" logs DreamShader queued a full .dsm/.dsf recompile scan. A queued file is compiled after the debounce window — Save Debounce Seconds, clamped to [0.05, 10.0], default 0.25 — and only if it still exists on disk. Effective latency is the debounce plus up to 0.1 s of poll delay. Compilation through this path is always transient: the editor generates in memory.

cleanGeneratedShaders

FieldTypeNotes
actionstring"cleanGeneratedShaders"

Deletes only *.ush files, recursively, one at a time — the directory itself is never removed — then queues a full rescan. It refuses to run when Generated Shader Directory has been pointed outside the project's Intermediate directory:

DreamShader refused to clean generated shaders: '{Directory}' is not inside the project Intermediate
directory. Point DreamShaderSettings.GeneratedShaderDirectory back under Intermediate/ before cleaning.

previewMaterial

FieldTypeDefaultConstraint
actionstring—"previewMaterial"
sourceFilestring""must exist and be a .dss, .dsi or .dsm — a source that builds a material or a material instance
meshstring"" → spheresee Meshes
widthnumber512rounded, clamped to [64, 2048]
heightnumber512rounded, clamped to [64, 2048]
requestIdstring(absent)echoed into preview.json when non-empty

[!NOTE] The request-file variant does not read orbitYaw, orbitPitch, stream or frameRate. Those fields exist only on the WebSocket transport. A file request always renders one frame at the default camera angles.

The result is written to preview.json with status ready or error, and logged as DreamShader preview: {Message} at Display on success or Error on failure.

reveal-node

(since 2.0.0) The reverse of node → source navigation: from a line of a .dss source to the node in the Material Editor. It is what the VSCode extension's Reveal in Material Editor command sends; the editor-side half is described in editor integration.

FieldTypeDefaultConstraint
actionstring—"reveal-node"
filestring—required; absolute or project-relative, normalized either way
linenumber—required, 1-based
requestIdstring(absent)names the response file

Served synchronously: the bridge finds the assets stamped with that source file, picks the expressions whose recorded span falls on the line (a node that starts on the line wins over one merely inlined from a call there), opens the Material Editor for the asset that owns them and selects the first.

The response is the ordinary Responses/<requestId>.json envelope with four fields added:

FieldTypeNotes
versionnumberalways 1 — the payload version of the added fields, separate from protocol
assetPathstringthe asset whose graph was opened; for a ThinCustom product the hidden base material, where the graph lives
instanceAssetPathstringpresent only for a ThinCustom product: the instance the base is addressed by
expressionsarrayevery matching expression GUID, hyphenated, the selected one first

Failures come back as ok: false with the reason in diagnostics (stage: "navigate"), coded DSH9050–DSH9057. DSH9051 means no asset from that source is loaded in this editor — compile the file first; DSH9052 means the assets were built before node navigation existed and carry no span table — rebuild with -Force.

Symbol index

(since 2.0.0) Every compile that gets as far as a bound module — a failed one included, because navigation matters most in a file that does not build — refreshes

<Project>/Saved/DreamShader/Index/<root>/<source path relative to that root>.index.json

(schema dreamshader-symbol-index, version 1): the declarations of the source and of every header it includes — kind, name, signature, type, doc, position — every mention of a name as a span, the resolved include paths, and the parameter schema. <root> is Project or a plugin's name, which keeps two source roots that each hold a Materials/M_Foo.dss apart. dsc index writes the same file by the same rule, so a language service reads one place whoever compiled last; a file whose text would not change is not rewritten, so an unchanged compile wakes no watcher.

The VS Code extension answers Go to Definition, Find References, Hover, the Outline, Signature Help and Rename for .dss sources from these files, and completes UE. / Substrate. from dreamshader-builtin-catalog.json — the binder's own table of node classes, pins, properties, aliases and positional orders.

The index of a .dsp (since 2.1.0) has a pipeline section as well: the #pragma pipeline values as bound, the uniforms, the buffers and the passes with their bindings and spans, and the injection-point and format spellings. The rest of the .dsp vocabulary is in pass-keys.json.

Custom Pass shader files

(since 2.1.0) An HLSL pass of a .dsp compiles from a .usf that may live anywhere: next to the .dsp is the usual place, but neither a source root nor a mapped shader directory is required, because the engine only ever compiles a snapshot of the file (HLSL passes). The source-directory watcher acts on DreamShader sources only, so the shader files have watches of their own.

Watchedevery directory holding a file some .dsp compiles from: each Shader = "..." file and each file an hlsl block of the .dsp includes, everything they include by a relative path — a file such an include names that does not exist yet as well, since creating it changes the pass — and the file behind every include by a virtual path that is neither /Engine/ nor /Plugin/
Howone watch per directory, without its subtree. A directory that does not exist is not watched
Files acted on.usf and .ush
Refreshedafter every compile of a .dsp, whether it succeeded or not, and when a .dsp is removed. The compiles the startup scan queues are what register the first ones
GateAuto Compile On Save, as for a source save

A change to one of those files queues every .dsp whose HLSL passes compile from it, not forced: the .dsp's build key holds the text of every file its passes compile from, so a save that changed none of it — an unrelated header in the same folder — is skipped on its key. A watcher that lost track (a rescan) queues every .dsp.

A compile of the .dsp then takes each HLSL pass whose snapshot changed through these steps; one whose snapshot did not change is left as it is.

Step
1Pre-check. The pass's slot is compiled in memory with the new snapshot, for the shader formats of the active feature levels and every format the project's active target platforms target. An error is DSH8322 at the .usf line that caused it, filed like any other compile error; nothing is written, and the previous version of the pass keeps running
2Snapshot. The file, with everything it includes by a relative path, is copied into <DShader>/.dreampass/Slots/<C|P><NN>/, and Registry.json and the registry file of that table are rewritten
3Hot reload. The pipeline asset is updated, the slot shader types FDreamPassCS / FDreamPassPS are recompiled synchronously for every active feature level — the next frame runs the new code — and the asset is saved

A .dsp that is deleted logs DreamShader source removed, existing generated assets were left untouched: {Path}, like any source. Its slots stay in the registry until dsc pass-registry -Gc collects them.

Dependents

A .dsp and the .dss sources around it name each other, so a successful compile queues the other side, unforced: binding runs before the build key's skip, so a dependent the change broke reports its error, and one it did not touch costs a bind.

After a successful compile ofThe bridge queuesWhen
a .dss or a .dsievery .dsp one of whose passes names a product of it as its Materialthe compile wrote an asset — its report has a Generated … line
a .dspevery .dss whose UE.DreamPassBuffer reads the pipeline — its exports may have changedthe compile wrote an asset
a .dss or a .dsievery .dsi whose Parent is one of its products, and theirs in turnalways

A drained batch is sorted so that a .dsp compiles after the sources that build its materials, and a .dss that reads a pipeline's buffer after that .dsp. With Verbose Logs on, each queueing logs DreamShader queued {N} instance source(s), {N} pipeline source(s) and {N} pass-buffer reader(s) after '{Source}' compiled.

WebSocket server

Endpointws://127.0.0.1:17864
Bind address127.0.0.1 — never 0.0.0.0
Port17864, fixed; there is no project setting for it
Connection filtera connection is accepted only when the client IP is exactly 127.0.0.1 or localhost; anything else is refused
Tickevery editor frame
Purposestreaming material preview with live orbit control

Startup and connection logging:

MessageSeverity
DreamShader preview WebSocket server could not load WebSocketNetworking.Warning
DreamShader preview WebSocket server could not be created.Warning
DreamShader preview WebSocket server failed to listen on 127.0.0.1:{Port}.Warning
DreamShader preview WebSocket server listening on 127.0.0.1:{Port}.Display
DreamShader preview WebSocket client connected: {Endpoint}Display
DreamShader preview WebSocket: {Message}Display on success, Error on failure

A failure to listen — most often the port already being in use by another editor instance — is a warning, not an error: the rest of the bridge continues without streaming preview.

Framing

Every outbound message, JSON metadata and raw PNG alike, is sent as a WebSocket binary frame whose payload is:

+--------------------+----------+------------------+
| length (uint32 LE) | tag (u8) | payload bytes …  |
+--------------------+----------+------------------+
FieldWidthValue
length4 bytes, unsigned, little-endian1 + payload byte count — the tag byte is included
tag1 byte1 = UTF-8 JSON, 2 = raw PNG bytes, 3 = raw frame (header + RGBA8)
payloadlength − 1 bytesthe JSON text, the image, or the raw-frame header+pixels

The tag exists because the transport always writes binary frames, so the WebSocket opcode cannot distinguish a JSON message from an image. Images are sent as raw bytes — there is no Base64 anywhere in this protocol.

A session chooses its frame encoding with the encoding field of previewMaterial:

encodingFrame deliverySince
"raw"one self-describing tag-3 message per frame (header + RGBA8), no JSON metadata1.7.0
"png" (default when absent)a tag-1 previewFrame JSON message immediately followed by a tag-2 PNG1.5.0

raw is the efficient path a browser/webview client wants — the pixels go straight onto a canvas with putImageData, with no PNG encode on the editor side and no decode on the client side. png is kept for clients that predate raw.

Correlation rule (png only): the JSON previewFrame is always sent first and the matching PNG binary message immediately after, on the same connection. A client pairs them by arrival order. A raw frame is a single message and needs no pairing.

Raw frame header (tag 3)

The tag-3 payload is a 24-byte little-endian header followed by width × height × 4 bytes of RGBA8 pixels (alpha is always 255).

OffsetFieldTypeNotes
0versionu81
1formatu81 = RGBA8
2widthu16pixels
4heightu16pixels
6flagsu16frame-flag bits, see below
8frameIndexu32monotonic, starts at 0; echo it back in ackFrameIndex
12orbitYawf32the yaw the frame was rendered at
16orbitPitchf32the pitch the frame was rendered at
20probeLineu32the source line the active probe resolved to, or 0

Frame-flag bits:

BitNameMeaning
1 << 0Compilingthe shader map is still building; the pixels are the fallback material
1 << 1ProbeActivea breakpoint is attached and the frame shows that value, not the material
1 << 2ProbePendinga breakpoint was requested but could not attach yet
1 << 3Keyframethe frame was sent because something changed, not because the clock ticked

Inbound messages are plain UTF-8 JSON with no length prefix and no tag byte.

Message types

DirectiontypePurpose
client → editorpreviewMaterialStart a preview session for a .dss, .dsi or .dsm
client → editorpreviewControlAdjust an existing session
client → editorsetProbeAttach a breakpoint-style probe to a Graph line
client → editorclearProbeDetach the active probe
editor → clientpreviewResultSession start result, or a mid-stream error
editor → clientpreviewFrameMetadata for the PNG that follows (png encoding only)
editor → clientprobeStateWhether the probe is attached, pending, or cleared

Dispatch reads both type and action, case-insensitively: a message is treated as previewMaterial when either field equals previewMaterial. previewControl, setProbe and clearProbe are matched on type only. Anything else is silently ignored.

Unparsable JSON gets an immediate reply:

{"type":"previewResult","status":"error","message":"Invalid DreamShader preview request JSON.","updatedAtUtc":"<ISO-8601>"}

previewMaterial — client → editor

FieldTypeDefaultConstraint / effect
type or actionstring—must equal previewMaterial
sourceFilestring""must exist and be a .dss, .dsi or .dsm — a source that builds a material or a material instance
meshstring"" → spheresee Meshes
widthnumber512rounded, clamped to [64, 2048]
heightnumber512rounded, clamped to [64, 2048]
orbitYawnumber, degrees-157.5camera yaw; matches USceneThumbnailInfo's own default
orbitPitchnumber, degrees-11.25camera pitch; no engine-side clamp
requestIdstring(absent)echoed on every message of this session
frameRatenumber, FPS2.0<= 0 disables streaming; otherwise clamped to [0.25, 60.0] and inverted into a frame interval
streambooltruestreaming additionally requires a positive frame interval
encodingstring"png""raw" for tag-3 RGBA8 frames; "png" for the legacy JSON+PNG pair
forceboolfalsetrue regenerates the material even when the source hash is unchanged (the explicit Refresh action); false reuses the last generation, so a re-request for a camera/mesh change or a save the bridge already picked up costs no regeneration

On success the editor resolves and compiles the material transiently, sends a previewResult, sends a probeState, and installs a per-connection session that streams frames from the next tick. The png encoding additionally saves a first-frame PNG under Bridge/Preview/, writes preview.json, and sends that PNG immediately; the raw encoding streams its first frame from the tick loop like every other one. With stream: false or frameRate <= 0, raw delivers exactly one frame after the shader finishes compiling, without enabling streaming. On failure the connection's session is removed.

Frame metadata describes the controls at capture time. Changes received during readback request a new keyframe; they do not relabel the pending pixels. A new previewMaterial request supersedes any pending frame from the previous request, even when the render context is reused.

previewControl — client → editor

Ignored entirely when the connection has no session. When both the message's requestId and the session's requestId are non-empty they must match, otherwise the message is ignored.

FieldTypeWhen absentEffect
requestIdstringno guard appliedsession guard
streamboolkeeps the current valueenable or disable streaming
frameRatenumberkeeps the current ratenew frame interval; <= 0 stops streaming, otherwise clamped to [0.25, 60.0]
orbitYawnumberkeeps the current angledrag-to-rotate
orbitPitchnumberkeeps the current angledrag-to-rotate
width / heightnumberkeeps the current sizeresize the render target; clamped to [64, 2048]
meshstringkeeps the current meshchange the preview primitive
ackFrameIndexnumberno ackreleases the flow-control gate only when it matches the current unacknowledged frame; stale or future indices are ignored

[!NOTE] Every field except requestId/ackFrameIndex keeps its current value when omitted (since 1.7.0), so a bare frame acknowledgement or camera nudge no longer resets the rate. A missing ackFrameIndex on a streaming session is still treated as "the client is alive", releasing the flow-control gate.

setProbe — client → editor

Attaches a probe (the text-source analogue of the Material Editor's Start Previewing Node): the value bound at a Graph line replaces the material on the preview mesh. Ignored when the connection has no session.

FieldTypeDefaultEffect
typestring—must equal setProbe
linenumber01-based line in the session's source file; the nearest binding at or after it is chosen
namestring""disambiguates when several bindings share the line

The reply is a probeState. A probe set before the source has ever generated is remembered and attaches on the next generation rather than failing.

clearProbe — client → editor

Detaches the active probe; the mesh returns to the whole material. Replies with a probeState whose status is cleared.

probeState — editor → client

Sent after any setProbe/clearProbe, after a previewMaterial re-resolves the probe, and whenever a regeneration moves or drops it.

FieldTypeNotes
typestringalways probeState
requestIdstringomitted when empty
sourceFilestringnormalized absolute source path
statusstringattached, pending, or cleared
line / columnnumberpresent when attached; the line the probe resolved to
namestringpresent when attached; the bound variable
componentCountnumberpresent when attached; the value's channel count
messagestringpresent when pending; why it has not attached yet

previewResult — editor → client

FieldTypeNotes
typestringalways previewResult
requestIdstringomitted when empty
statusstringready or error
sourceFilestringnormalized absolute source path
assetPathstringresolved object path
imagePathstringabsolute PNG path — only on the initial result; the mid-stream error variants omit it
meshstringresolved mesh name
messagestringhuman-readable text
updatedAtUtcstringISO-8601 UTC

A previewResult with status: "error" sent mid-stream also turns streaming off; the session stops delivering frames until a new previewMaterial or a previewControl re-enables it.

previewFrame — editor → client

Only sent for the png encoding, always followed immediately by a tag-2 binary message carrying the PNG. A raw-encoding session sends the tag-3 frame instead and never sends previewFrame.

FieldTypeNotes
typestringalways previewFrame
requestIdstringomitted when empty
sourceFilestringnormalized absolute source path
assetPathstringresolved object path
meshstringresolved mesh name
frameIndexnumbermonotonically increasing, starting at 0
flagsnumberframe-flag bits (same set as the raw-frame header)
updatedAtUtcstringISO-8601 UTC

Streaming state machine

Per connection, evaluated once per editor frame.

State fieldDefaultPurpose
RequestId, SourceFilePath, AssetPath, Mesh""session identity
OrbitYaw / OrbitPitch-157.5 / -11.25camera angles, degrees
Width / Height512 / 512render size
FrameIntervalSeconds0.5 (2 FPS)rate limit
FrameIndex0outbound counter
LastAckFrameIndex-1last index the client acknowledged
bFrameInFlightfalseflow-control gate
bStreamingfalsemaster switch

Each tick:

  1. Do nothing when neither streaming nor a raw one-shot is pending, or when there is no render context. An invalid material stops the session with an error.
  2. If a GPU readback is in flight, poll it without blocking. Ready → deliver the frame (a tag-3 raw frame, or a previewFrame+PNG pair) and raise the in-flight gate. Error → send an error previewResult and stop streaming. Neither → return and retry next tick.
  3. Otherwise start a new frame when the previous frame has been acknowledged (or its acknowledgement timed out) and the frame interval has elapsed. A raw one-shot also waits for its shader map.
  4. A failure to start a frame sends an error previewResult and stops streaming.

Two efficiency behaviours sit on top of the loop (since 1.7.0):

  • Dedupe. A frame whose pixels are identical to the last one sent is dropped, so a static material costs no bandwidth once it has settled.
  • Idle back-off. After a few identical frames the render clock relaxes to ~2 FPS until something changes (an edit's recompile, a camera/mesh change, a probe change). A frame rendered while the shader map is still compiling never backs off — the compile landing is the change being waited for. Any change re-arms the full rate and forces the next frame through the dedupe as a keyframe.

A frame whose acknowledgement never arrives (a hidden tab, a lost message) is not waited on forever; after a two-second timeout the next frame is started regardless. Effective frame rate is bounded by the 60 FPS clamp, by the client's acknowledgements, by the idle back-off, and by the editor's real tick rate.

Meshes

Compared case-insensitively. Anything unrecognized, including the empty string, falls back to sphere, and the fallback name is what the result messages report.

ValuePreview primitive
planeplane
cubecube
cylindercylinder
shaderballshader ball
spheresphere
(anything else)sphere

Mesh and orbit are applied by writing the material asset's own thumbnail info, the same fields the native Material Editor's preview-shape button and drag-to-orbit viewport write. A UI-domain material is always drawn on a plane regardless of the requested shape. Full renderer behaviour and its limits are on Preview.

Artifacts

Everything the bridge writes, and who reads it.

PathDirectionRead back by the plugin?
Requests/*.jsoninboundconsumed and deleted
Responses/<requestId>.jsonoutboundno — the client deletes its own
status.jsonoutboundno
owner.lockinternalyes — every editor reads it to find out whether it is the one serving this project
diagnostics.jsonoutboundno — the Material Content Browser reads the same records from the bridge's diagnostics store in memory, so it never waits for a file write
diagnostics/index.json, diagnostics/<md5>.jsonoutboundno
bridge.dboutboundno
material-expressions.jsonoutboundno
settings.jsonoutboundno
substrate-builtins.jsonoutboundno
pass-keys.json (since 2.1.0)outboundno — see Workspace
dreamshader-builtin-catalog.jsonoutboundno — also written by dsc export-catalog, and on Open DreamShader Workspace
preview.jsonoutboundno
../Index/<root>/<source>.index.jsonoutboundno — the symbol index of a source, beside the bridge directory rather than in it
Preview/<stem>-<crc32>.pngoutboundno
<SourceDirectory>/DreamShader.code-workspaceoutboundno — see Workspace

diagnostics.json

{ "version": 1, "updatedAtUtc": "<ISO-8601>", "files": [ { "path": "…", "diagnostics": [ … ] } ] }

Diagnostic object fields. Optional fields are omitted entirely when empty.

FieldTypePresenceValue
messagestringalwaysthe diagnostic text
detailstringwhen non-emptythe raw underlying line
stagestringwhen non-emptya compile's record: preprocess, parse, bind, ir, generate, tools or compile, from its code's range; otherwise generate, materialCompile or virtualFunctionSync
assetPathstringwhen non-emptyobject path of the asset involved
shaderPlatformstringwhen non-emptymaterial-compile diagnostics only
qualityLevelstringwhen non-emptymaterial-compile diagnostics only
codestringwhen non-emptya compile's record: its DSHnnnn code; otherwise generate-error, material-compile or virtual-function-sync
linenumberalways1-based, defaults to 1
columnnumberalways1-based, defaults to 1
severitystringalwayserror, warning or info
sourcestringalwaysDreamShader Lang2, DreamShader, DreamShader Generate, DreamShader Material Compile or DreamShader VirtualFunction

[!NOTE] A compile files its own records -- each with its DSHnnnn code, severity and position: a failed compile all of them, a successful one its warnings and notes (since 2.1.0; before, a successful compile cleared the file's diagnostics and its warnings were only in the log). A client that filters on severity should treat a missing or unknown value as an error.

Stage, code and source travel together:

stagecodesourceProduced by
from the codeDSHnnnnDreamShader Lang2a compile of a source file: every record of a failed one, the warnings of a successful one
generategenerate-errorDreamShader Generatea failed compile whose records do not explain it -- no compiler module, or a refusal worded outside the diagnostics
materialCompilematerial-compileDreamShader Material Compileshader-compile errors on a generated material
virtualFunctionSyncvirtual-function-syncDreamShader VirtualFunctionthe startup VirtualFunction declaration scan

A compile's record carries its own line and column. For generate-error, locations are recovered from messages of the form <path>(<line>,<column>): <message>. Line and column must both be numeric and are clamped to 1 or greater; a line with no parseable location is reported at 1,1 with the leading "<source path>: " prefix stripped.

Material-compile diagnostics carry a display message of the form [{ShaderPlatform} / {QualityLevel}] {Message} and are deduplicated on source, platform, quality, message, line and column.

[!NOTE] Since UE 5.7 the shaderPlatform field carries a shader-format name, for example PCD3D_SM6, because the enumeration walks every shader platform. Below UE 5.7 it carries a feature-level name, for example SM6.

diagnostics/

The same data sharded one file per source, so a client can re-read only what changed.

index.json:

{ "version": 1, "updatedAtUtc": "<ISO-8601>", "files": [ { "path": "…", "file": "<md5>.json", "count": 2 } ] }
FieldTypeNotes
pathstringnormalized source path
filestringsibling file name — the MD5 of the normalized path plus .json
countnumbernumber of diagnostics in that shard

Each <md5>.json holds the same files[] entry shape as diagnostics.json. Stale shards — any *.json other than index.json whose name is no longer an active key — are deleted on every write. All three sinks (diagnostics.json, diagnostics/, the database) are rewritten together.

bridge.db

SQLite, opened read-write-create with PRAGMA journal_mode=WAL and PRAGMA synchronous=NORMAL, so bridge.db-wal and bridge.db-shm appear alongside it.

CREATE TABLE IF NOT EXISTS meta(
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL);

CREATE TABLE IF NOT EXISTS settings_mappings(
    kind TEXT NOT NULL, alias TEXT NOT NULL, normalized_alias TEXT NOT NULL,
    value INTEGER NOT NULL, name TEXT, display_name TEXT, source TEXT NOT NULL,
    PRIMARY KEY(kind, normalized_alias));

CREATE TABLE IF NOT EXISTS material_expressions(
    name TEXT PRIMARY KEY, class_name TEXT NOT NULL, path_name TEXT NOT NULL,
    default_output_type TEXT NOT NULL, json TEXT NOT NULL);

CREATE TABLE IF NOT EXISTS substrate_builtins(
    name TEXT PRIMARY KEY, qualified_name TEXT NOT NULL, class_name TEXT NOT NULL,
    output_type TEXT NOT NULL, is_substrate_output INTEGER NOT NULL,
    detail TEXT, example TEXT, snippet TEXT, json TEXT NOT NULL);

CREATE TABLE IF NOT EXISTS diagnostics(
    path TEXT PRIMARY KEY, json TEXT NOT NULL, updated_at_utc TEXT NOT NULL);

CREATE INDEX IF NOT EXISTS idx_settings_mappings_kind_alias
    ON settings_mappings(kind, alias);
TableContents
metaGeneration timestamps, one row per producer
settings_mappingsEvery ShadingModel / BlendMode / MaterialDomain alias, with its enum value and whether it came from the project settings or the built-in table
material_expressionsOne row per reflected UMaterialExpression, with the full manifest entry in json
substrate_builtinsOne row per Substrate.* builtin, with the full manifest entry in json
diagnosticsOne row per source file, with that file's diagnostics array in json

meta keys: settings.generatedAt, materialExpressions.generatedAt, substrateBuiltins.generatedAt, diagnostics.updatedAt.

[!WARNING] The database is write-only from the plugin's side and is not durable state. It is deleted on bridge startup and on bridge shutdown, and every writer replaces its whole table inside a transaction (DELETE FROM then insert) rather than updating rows. Nothing in the plugin ever reads a row back. Do not store client state in it; treat it as a query-friendly mirror of the JSON artifacts, valid only while the editor is running.

Failure to open the database is a warning — Failed to open DreamShader bridge database: {Path}, or Failed to open DreamShader bridge database for diagnostics: {Path} — and the JSON sinks are still written. The substitution is the database path, not a reason.

Manifests

All three are written at bridge startup and again whenever the workspace is opened. Every one carries a generatedAt ISO-8601 UTC timestamp.

FileschemaversionPayload
settings.jsonDreamShader.Settings1mappings: { ShadingModel[], BlendMode[], MaterialDomain[] }
material-expressions.jsonDreamShader.MaterialExpressions1expressions[]
substrate-builtins.jsonDreamShader.SubstrateBuiltins1builtins[], supported, and unsupportedReason when unsupported
ManifestEntry fields
settings.jsonalias, value (number), name, displayName, source (user or builtin)
material-expressions.jsonname, className, pathName, defaultOutputType, properties[] (name, type, isInput), inputs[], outputs[] (index, name, componentCount, outputType)
substrate-builtins.jsonname, qualifiedName, className, outputType, isSubstrateOutput, detail, example, snippet, parameters[] (qualifier — always in —, type, name, optional placeholder)

User-defined alias entries are emitted before built-in ones, and a built-in alias whose normalized form a user alias already claimed is dropped. Aliases are sorted within each kind. Material expressions are sorted by name.

pass-keys.json (since 2.1.0) is written right after settings.json, at the same moments, because the pass layers it lists are a project setting too. Schema DreamShader.PassKeys, version 1; it has no bridge.db table. Its contents are on Workspace.

[!NOTE] Below UE 5.4 substrate-builtins.json carries an empty builtins array, "supported": false and "unsupportedReason": "Substrate builtins require Unreal Engine 5.4 or newer.", and the MSM_Strata shading model is excluded from settings.json.

Logging: Wrote DreamShader MaterialExpression manifest: {Path} / Failed to write DreamShader MaterialExpression manifest: {Path}, Wrote DreamShader settings manifest: {Path} / Failed to write DreamShader settings manifest: {Path}, and Wrote DreamShader Substrate builtin manifest: {Path} / Failed to write DreamShader Substrate builtin manifest: {Path}. Unreadable settings produce Failed to read DreamShader settings for Bridge manifest. and no file.

preview.json

Rewritten by every one-shot preview, whichever transport requested it.

FieldTypeNotes
versionnumberalways 1
requestIdstringpresent only when non-empty
statusstringready or error
sourceFilestringnormalized absolute source path
assetPathstringresolved object path
imagePathstringabsolute PNG path
meshstringresolved mesh name
messagestringsuccess or error text
updatedAtUtcstringISO-8601 UTC

PNG names are <sanitized source stem>-<crc32 of the project-relative path, 8 hex digits>.png under Bridge/Preview/; a source whose stem sanitizes to nothing becomes DreamShaderPreview. The same source therefore always maps to the same file name, and each render overwrites it.

Kill switch

Switch-NoDreamShaderEditorBridge
Forma bare command-line parameter; no value
EffectDetail
Bridgenot created — no request polling, no WebSocket server, no directory watcher, no diagnostics writer, no manifests, no bridge.db
Startup Ephemeral generationdoes not run
Material Content Browsertab and menu entries are not registered
Tools menu, toolbar, context menusnot registered
Cook hookunaffected — it is installed on a different code path

The bridge is also never created in a commandlet process, with or without the switch; see Commandlet. Use the switch for automation runs in a normal editor process — for example -ExecCmds="Automation RunTests …" — that must not have a file watcher, a generation pass or a listening socket.

Example

Ask a running editor to recompile one file, from PowerShell, writing the request atomically:

$req = @{ action = "recompile"; scope = "file"; sourceFile = "C:/Projects/MyGame/DShader/Materials/M_Sample.dsm" }
$dir = "C:\Projects\MyGame\Saved\DreamShader\Bridge\Requests"
$tmp = Join-Path $env:TEMP ("ds-" + [guid]::NewGuid() + ".json")
$req | ConvertTo-Json | Set-Content -Path $tmp -Encoding utf8
Move-Item $tmp (Join-Path $dir ([IO.Path]::GetFileName($tmp)))

Request a one-shot 512×512 preview on a cube and read the result:

{
  "action": "previewMaterial",
  "sourceFile": "C:/Projects/MyGame/DShader/Materials/M_Sample.dsm",
  "mesh": "cube",
  "width": 512,
  "height": 512,
  "requestId": "8f3c1b"
}

Saved/DreamShader/Bridge/preview.json afterwards:

{
  "version": 1,
  "requestId": "8f3c1b",
  "status": "ready",
  "sourceFile": "C:/Projects/MyGame/DShader/Materials/M_Sample.dsm",
  "assetPath": "/Game/Materials/M_Sample.M_Sample",
  "imagePath": "C:/Projects/MyGame/Saved/DreamShader/Bridge/Preview/M_Sample-3fa91c07.png",
  "mesh": "cube",
  "message": "Rendered preview for /Game/Materials/M_Sample.M_Sample.",
  "updatedAtUtc": "2026-08-02T09:14:22Z"
}

A streaming session over the WebSocket, client side, in order:

→ {"type":"previewMaterial","sourceFile":"…/M_Sample.dsm","mesh":"shaderball",
   "width":512,"height":512,"requestId":"8f3c1b","stream":true,"frameRate":12}
← [len][1] {"type":"previewResult","requestId":"8f3c1b","status":"ready", … "imagePath":"…"}
← [len][2] <PNG bytes>
← [len][1] {"type":"previewFrame","requestId":"8f3c1b","frameIndex":0, …}
← [len][2] <PNG bytes>
→ {"type":"previewControl","requestId":"8f3c1b","ackFrameIndex":0,"frameRate":12,"orbitYaw":-140.0}
← [len][1] {"type":"previewFrame","requestId":"8f3c1b","frameIndex":1, …}
← [len][2] <PNG bytes>

Note the frameRate repeated on the control message: omitting it would drop the session to 2 FPS.

See also

On this page