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/.
| Kind | editor service — FDreamShaderEditorBridge, owned by the DreamShaderEditor module |
| Root | <Project>/Saved/DreamShader/Bridge/ |
| Transport A | request files — Bridge/Requests/*.json, polled every 0.1 s, deleted before dispatch, answered in Bridge/Responses/<requestId>.json |
| Transport B | WebSocket — ws://127.0.0.1:17864, loopback only |
| Liveness | Bridge/status.json, rewritten every 2 s while idle, deleted on shutdown |
| Protocol | 1 — sent as protocol on a request, echoed on every response and on the status file |
| Module dependencies | WebSocketNetworking, 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>.pngLifecycle
Startup, in order. Bridge, Bridge/Requests and Bridge/Preview are created first.
| Step | Action |
|---|---|
| 1 | Create Bridge/, Bridge/Requests/, Bridge/Responses/, Bridge/Preview/ |
| 1a | Delete every stale Responses/*.json, stamp the listening-since time, publish status.json |
| 2 | Delete bridge.db, bridge.db-wal, bridge.db-shm and the whole diagnostics/ directory |
| 3 | Export material-expressions.json, settings.json, substrate-builtins.json (and their SQLite tables) |
| 4 | Scan and refresh VirtualFunction declarations |
| 5 | Register 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 |
| 6 | Register a project-settings property watcher |
| 7 | Queue a full rescan and write diagnostics.json |
| 8 | Start the preview WebSocket server on port 17864 |
| 9 | Register a directory watcher on <SourceDirectory> (including directory changes) |
| 10 | Hook UMaterial::OnMaterialCompilationFinished |
| 11 | Register the Tools menu, toolbar and context-menu entries |
| 12 | Register ticker A — 0.1 s: request-file polling and the debounce queue |
| 13 | Register 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 |
| Contents | pid and an ISO-8601 heartbeat, refreshed on the ordinary heartbeat |
| Believed while | the owning process is still running and its heartbeat is under 30s old |
| Released | on 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 interval | 0.1 s |
| Consumption | every 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
requestIdis answered inResponses/<requestId>.json— including the failures: unparseable JSON, a protocol mismatch, an unrecognizedaction, and arecompilewhose 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.jsonbefore writing a request.
Actions
action and scope are both matched case-insensitively.
action | Required fields | Effect | Answered |
|---|---|---|---|
ping | — | Nothing. Confirms the bridge is serving | immediately |
recompile | scope: "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 skip | immediately, as queued — see below |
recompile | scope: "file", sourceFile | Queue one file into the debounce queue, forced past the source-hash skip | when that compile finishes |
cleanGeneratedShaders | — | Delete the generated *.ush includes, then queue a full rescan | immediately |
previewMaterial | sourceFile | Render one preview synchronously and write preview.json | immediately, with the render result |
reveal-node | file, line | Find 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]
recompilewithscope: "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 missingsourceFile, or an unrecognizedactionis now an error response rather than a silent no-op — provided the request carried arequestId. 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.
| Field | Type | Notes |
|---|---|---|
protocol | number | always 1 |
requestId | string | echoes the request |
ok | bool | |
durationMs | number | |
message | string | invariant, never the localised display text |
diagnostics | array | file, 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.
| Field | Type | Notes |
|---|---|---|
protocol, pid, project, projectDir, engineDir, pluginVersion | identity | |
busy | bool | true while a request is being served |
busyAction | string | present only when busy |
lastResult | string | present once something has completed |
heartbeatUtc | string | ISO-8601 UTC |
[!WARNING] Read
busybefore 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 recordedpidis what closes it.
recompile
| Field | Type | Default | Notes |
|---|---|---|---|
action | string | — | "recompile" |
scope | string | — | "all" or "file" |
sourceFile | string | — | 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
| Field | Type | Notes |
|---|---|---|
action | string | "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
| Field | Type | Default | Constraint |
|---|---|---|---|
action | string | — | "previewMaterial" |
sourceFile | string | "" | must exist and be a .dss, .dsi or .dsm — a source that builds a material or a material instance |
mesh | string | "" → sphere | see Meshes |
width | number | 512 | rounded, clamped to [64, 2048] |
height | number | 512 | rounded, clamped to [64, 2048] |
requestId | string | (absent) | echoed into preview.json when non-empty |
[!NOTE] The request-file variant does not read
orbitYaw,orbitPitch,streamorframeRate. 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.
| Field | Type | Default | Constraint |
|---|---|---|---|
action | string | — | "reveal-node" |
file | string | — | required; absolute or project-relative, normalized either way |
line | number | — | required, 1-based |
requestId | string | (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:
| Field | Type | Notes |
|---|---|---|
version | number | always 1 — the payload version of the added fields, separate from protocol |
assetPath | string | the asset whose graph was opened; for a ThinCustom product the hidden base material, where the graph lives |
instanceAssetPath | string | present only for a ThinCustom product: the instance the base is addressed by |
expressions | array | every 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.
| Watched | every 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/ |
| How | one watch per directory, without its subtree. A directory that does not exist is not watched |
| Files acted on | .usf and .ush |
| Refreshed | after 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 |
| Gate | Auto 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 | |
|---|---|
| 1 | Pre-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 |
| 2 | Snapshot. 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 |
| 3 | Hot 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 of | The bridge queues | When |
|---|---|---|
a .dss or a .dsi | every .dsp one of whose passes names a product of it as its Material | the compile wrote an asset — its report has a Generated … line |
a .dsp | every .dss whose UE.DreamPassBuffer reads the pipeline — its exports may have changed | the compile wrote an asset |
a .dss or a .dsi | every .dsi whose Parent is one of its products, and theirs in turn | always |
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
| Endpoint | ws://127.0.0.1:17864 |
| Bind address | 127.0.0.1 — never 0.0.0.0 |
| Port | 17864, fixed; there is no project setting for it |
| Connection filter | a connection is accepted only when the client IP is exactly 127.0.0.1 or localhost; anything else is refused |
| Tick | every editor frame |
| Purpose | streaming material preview with live orbit control |
Startup and connection logging:
| Message | Severity |
|---|---|
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 … |
+--------------------+----------+------------------+| Field | Width | Value |
|---|---|---|
| length | 4 bytes, unsigned, little-endian | 1 + payload byte count — the tag byte is included |
| tag | 1 byte | 1 = UTF-8 JSON, 2 = raw PNG bytes, 3 = raw frame (header + RGBA8) |
| payload | length − 1 bytes | the 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:
encoding | Frame delivery | Since |
|---|---|---|
"raw" | one self-describing tag-3 message per frame (header + RGBA8), no JSON metadata | 1.7.0 |
"png" (default when absent) | a tag-1 previewFrame JSON message immediately followed by a tag-2 PNG | 1.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).
| Offset | Field | Type | Notes |
|---|---|---|---|
| 0 | version | u8 | 1 |
| 1 | format | u8 | 1 = RGBA8 |
| 2 | width | u16 | pixels |
| 4 | height | u16 | pixels |
| 6 | flags | u16 | frame-flag bits, see below |
| 8 | frameIndex | u32 | monotonic, starts at 0; echo it back in ackFrameIndex |
| 12 | orbitYaw | f32 | the yaw the frame was rendered at |
| 16 | orbitPitch | f32 | the pitch the frame was rendered at |
| 20 | probeLine | u32 | the source line the active probe resolved to, or 0 |
Frame-flag bits:
| Bit | Name | Meaning |
|---|---|---|
1 << 0 | Compiling | the shader map is still building; the pixels are the fallback material |
1 << 1 | ProbeActive | a breakpoint is attached and the frame shows that value, not the material |
1 << 2 | ProbePending | a breakpoint was requested but could not attach yet |
1 << 3 | Keyframe | the 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
| Direction | type | Purpose |
|---|---|---|
| client → editor | previewMaterial | Start a preview session for a .dss, .dsi or .dsm |
| client → editor | previewControl | Adjust an existing session |
| client → editor | setProbe | Attach a breakpoint-style probe to a Graph line |
| client → editor | clearProbe | Detach the active probe |
| editor → client | previewResult | Session start result, or a mid-stream error |
| editor → client | previewFrame | Metadata for the PNG that follows (png encoding only) |
| editor → client | probeState | Whether 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
| Field | Type | Default | Constraint / effect |
|---|---|---|---|
type or action | string | — | must equal previewMaterial |
sourceFile | string | "" | must exist and be a .dss, .dsi or .dsm — a source that builds a material or a material instance |
mesh | string | "" → sphere | see Meshes |
width | number | 512 | rounded, clamped to [64, 2048] |
height | number | 512 | rounded, clamped to [64, 2048] |
orbitYaw | number, degrees | -157.5 | camera yaw; matches USceneThumbnailInfo's own default |
orbitPitch | number, degrees | -11.25 | camera pitch; no engine-side clamp |
requestId | string | (absent) | echoed on every message of this session |
frameRate | number, FPS | 2.0 | <= 0 disables streaming; otherwise clamped to [0.25, 60.0] and inverted into a frame interval |
stream | bool | true | streaming additionally requires a positive frame interval |
encoding | string | "png" | "raw" for tag-3 RGBA8 frames; "png" for the legacy JSON+PNG pair |
force | bool | false | true 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.
| Field | Type | When absent | Effect |
|---|---|---|---|
requestId | string | no guard applied | session guard |
stream | bool | keeps the current value | enable or disable streaming |
frameRate | number | keeps the current rate | new frame interval; <= 0 stops streaming, otherwise clamped to [0.25, 60.0] |
orbitYaw | number | keeps the current angle | drag-to-rotate |
orbitPitch | number | keeps the current angle | drag-to-rotate |
width / height | number | keeps the current size | resize the render target; clamped to [64, 2048] |
mesh | string | keeps the current mesh | change the preview primitive |
ackFrameIndex | number | no ack | releases the flow-control gate only when it matches the current unacknowledged frame; stale or future indices are ignored |
[!NOTE] Every field except
requestId/ackFrameIndexkeeps its current value when omitted (since1.7.0), so a bare frame acknowledgement or camera nudge no longer resets the rate. A missingackFrameIndexon 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.
| Field | Type | Default | Effect |
|---|---|---|---|
type | string | — | must equal setProbe |
line | number | 0 | 1-based line in the session's source file; the nearest binding at or after it is chosen |
name | string | "" | 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.
| Field | Type | Notes |
|---|---|---|
type | string | always probeState |
requestId | string | omitted when empty |
sourceFile | string | normalized absolute source path |
status | string | attached, pending, or cleared |
line / column | number | present when attached; the line the probe resolved to |
name | string | present when attached; the bound variable |
componentCount | number | present when attached; the value's channel count |
message | string | present when pending; why it has not attached yet |
previewResult — editor → client
| Field | Type | Notes |
|---|---|---|
type | string | always previewResult |
requestId | string | omitted when empty |
status | string | ready or error |
sourceFile | string | normalized absolute source path |
assetPath | string | resolved object path |
imagePath | string | absolute PNG path — only on the initial result; the mid-stream error variants omit it |
mesh | string | resolved mesh name |
message | string | human-readable text |
updatedAtUtc | string | ISO-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.
| Field | Type | Notes |
|---|---|---|
type | string | always previewFrame |
requestId | string | omitted when empty |
sourceFile | string | normalized absolute source path |
assetPath | string | resolved object path |
mesh | string | resolved mesh name |
frameIndex | number | monotonically increasing, starting at 0 |
flags | number | frame-flag bits (same set as the raw-frame header) |
updatedAtUtc | string | ISO-8601 UTC |
Streaming state machine
Per connection, evaluated once per editor frame.
| State field | Default | Purpose |
|---|---|---|
RequestId, SourceFilePath, AssetPath, Mesh | "" | session identity |
OrbitYaw / OrbitPitch | -157.5 / -11.25 | camera angles, degrees |
Width / Height | 512 / 512 | render size |
FrameIntervalSeconds | 0.5 (2 FPS) | rate limit |
FrameIndex | 0 | outbound counter |
LastAckFrameIndex | -1 | last index the client acknowledged |
bFrameInFlight | false | flow-control gate |
bStreaming | false | master switch |
Each tick:
- 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.
- If a GPU readback is in flight, poll it without blocking. Ready → deliver the frame (a tag-
3raw frame, or apreviewFrame+PNG pair) and raise the in-flight gate. Error → send an errorpreviewResultand stop streaming. Neither → return and retry next tick. - 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.
- A failure to start a frame sends an error
previewResultand 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.
| Value | Preview primitive |
|---|---|
plane | plane |
cube | cube |
cylinder | cylinder |
shaderball | shader ball |
sphere | sphere |
| (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.
| Path | Direction | Read back by the plugin? |
|---|---|---|
Requests/*.json | inbound | consumed and deleted |
Responses/<requestId>.json | outbound | no — the client deletes its own |
status.json | outbound | no |
owner.lock | internal | yes — every editor reads it to find out whether it is the one serving this project |
diagnostics.json | outbound | no — 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>.json | outbound | no |
bridge.db | outbound | no |
material-expressions.json | outbound | no |
settings.json | outbound | no |
substrate-builtins.json | outbound | no |
pass-keys.json (since 2.1.0) | outbound | no — see Workspace |
dreamshader-builtin-catalog.json | outbound | no — also written by dsc export-catalog, and on Open DreamShader Workspace |
preview.json | outbound | no |
../Index/<root>/<source>.index.json | outbound | no — the symbol index of a source, beside the bridge directory rather than in it |
Preview/<stem>-<crc32>.png | outbound | no |
<SourceDirectory>/DreamShader.code-workspace | outbound | no — see Workspace |
diagnostics.json
{ "version": 1, "updatedAtUtc": "<ISO-8601>", "files": [ { "path": "…", "diagnostics": [ … ] } ] }Diagnostic object fields. Optional fields are omitted entirely when empty.
| Field | Type | Presence | Value |
|---|---|---|---|
message | string | always | the diagnostic text |
detail | string | when non-empty | the raw underlying line |
stage | string | when non-empty | a compile's record: preprocess, parse, bind, ir, generate, tools or compile, from its code's range; otherwise generate, materialCompile or virtualFunctionSync |
assetPath | string | when non-empty | object path of the asset involved |
shaderPlatform | string | when non-empty | material-compile diagnostics only |
qualityLevel | string | when non-empty | material-compile diagnostics only |
code | string | when non-empty | a compile's record: its DSHnnnn code; otherwise generate-error, material-compile or virtual-function-sync |
line | number | always | 1-based, defaults to 1 |
column | number | always | 1-based, defaults to 1 |
severity | string | always | error, warning or info |
source | string | always | DreamShader Lang2, DreamShader, DreamShader Generate, DreamShader Material Compile or DreamShader VirtualFunction |
[!NOTE] A compile files its own records -- each with its
DSHnnnncode, 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:
stage | code | source | Produced by |
|---|---|---|---|
| from the code | DSHnnnn | DreamShader Lang2 | a compile of a source file: every record of a failed one, the warnings of a successful one |
generate | generate-error | DreamShader Generate | a failed compile whose records do not explain it -- no compiler module, or a refusal worded outside the diagnostics |
materialCompile | material-compile | DreamShader Material Compile | shader-compile errors on a generated material |
virtualFunctionSync | virtual-function-sync | DreamShader VirtualFunction | the 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
shaderPlatformfield carries a shader-format name, for examplePCD3D_SM6, because the enumeration walks every shader platform. Below UE 5.7 it carries a feature-level name, for exampleSM6.
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 } ] }| Field | Type | Notes |
|---|---|---|
path | string | normalized source path |
file | string | sibling file name — the MD5 of the normalized path plus .json |
count | number | number 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);| Table | Contents |
|---|---|
meta | Generation timestamps, one row per producer |
settings_mappings | Every ShadingModel / BlendMode / MaterialDomain alias, with its enum value and whether it came from the project settings or the built-in table |
material_expressions | One row per reflected UMaterialExpression, with the full manifest entry in json |
substrate_builtins | One row per Substrate.* builtin, with the full manifest entry in json |
diagnostics | One 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 FROMthen 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.
| File | schema | version | Payload |
|---|---|---|---|
settings.json | DreamShader.Settings | 1 | mappings: { ShadingModel[], BlendMode[], MaterialDomain[] } |
material-expressions.json | DreamShader.MaterialExpressions | 1 | expressions[] |
substrate-builtins.json | DreamShader.SubstrateBuiltins | 1 | builtins[], supported, and unsupportedReason when unsupported |
| Manifest | Entry fields |
|---|---|
settings.json | alias, value (number), name, displayName, source (user or builtin) |
material-expressions.json | name, className, pathName, defaultOutputType, properties[] (name, type, isInput), inputs[], outputs[] (index, name, componentCount, outputType) |
substrate-builtins.json | name, 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.jsoncarries an emptybuiltinsarray,"supported": falseand"unsupportedReason": "Substrate builtins require Unreal Engine 5.4 or newer.", and theMSM_Stratashading model is excluded fromsettings.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.
| Field | Type | Notes |
|---|---|---|
version | number | always 1 |
requestId | string | present only when non-empty |
status | string | ready or error |
sourceFile | string | normalized absolute source path |
assetPath | string | resolved object path |
imagePath | string | absolute PNG path |
mesh | string | resolved mesh name |
message | string | success or error text |
updatedAtUtc | string | ISO-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 |
| Form | a bare command-line parameter; no value |
| Effect | Detail |
|---|---|
| Bridge | not created — no request polling, no WebSocket server, no directory watcher, no diagnostics writer, no manifests, no bridge.db |
| Startup Ephemeral generation | does not run |
| Material Content Browser | tab and menu entries are not registered |
| Tools menu, toolbar, context menus | not registered |
| Cook hook | unaffected — 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
- Workspace and editor extensions — the workspace file and the extensions that drive this protocol
- Commandlet — the headless path, where the bridge never starts
- Preview — the renderer behind
previewMaterial, and its limits - Material Content Browser — the tab that shows each source's diagnostics, read from the store
- Editor integration — the menu commands the request actions mirror
- VirtualFunction tools — the sync pass that produces
virtualFunctionSyncdiagnostics - Packages — which files a full rescan actually queues
- HLSL passes — the pre-check, the snapshots and the slots a
.usfsave goes through - Commandlet —
pass-registry, for the slots of a deleted.dsp - Project settings —
SaveDebounceSeconds,bAutoCompileOnSave,GeneratedShaderDirectory - Material enums — the alias tables exported to
settings.json - Substrate builtins — the catalogue exported to
substrate-builtins.json - UE.Expression — the reflection surface
material-expressions.jsondescribes - Diagnostics — where diagnostics appear and how to read them; every code