Summary
BlueprintTools.read_graph_dsl silently drops a shared exec chain whenever multiple branches merge into the same downstream nodes — e.g. a Switch node whose cases all end at one shared Sequence, or a multi-output node (Cast then/CastFailed, IsValid, latent tasks) whose branches re-join.
The shared chain is expanded inside only the first branch; every other branch that merges into it loses the chain entirely (the branch stops at its last unique statement, or even decompiles to an empty continuation). The output is therefore a lossy, incorrect representation of the graph — the exact kind of hazard that causes “AI wrote the wrong logic” incidents.
The write_graph_dsl direction has the symmetric bug: it rejects any statements after a (switch ...) or multi-output call form as “unreachable”, so the faithful round-trip (read_graph_dsl → write_graph_dsl) cannot even be expressed.
What Type of Bug are you experiencing?
Editor
Steps to Reproduce
- In a UE 5.8 project, open a Blueprint graph that contains multiple exec branches merging into one shared chain. For example:
- A
Switchnode (int or enum) where two or more cases end at the same node (e.g. aSequence), which is followed by several more calls — or - A
Castnode whosethenandCastFailedoutputs both eventually reach the same downstream node.
- A
- Query that graph with Unreal MCP (
http://localhost:8000/mcp) by calling:
(ReplaceBlueprintTools.read_graph_dsl(graph={"refPath": "/Game/YourPath/YourBP.YourBP:YourEvent"})YourPath/YourBP/YourEventwith an actual asset/event that contains a branch-merge pattern.) - Inspect the returned DSL for the
switch/ multi-output call form. - If any branch shares downstream nodes with another branch, the shared chain is missing from all but the first-expanded branch.
Concrete minimal reproductions (observed on this machine):
- A
Switch on EXGXunFeiVoiceDictationRespType(enum) with cases:Normaland:Wpgsthat both end at the sameSequence::Normalblock expands the full shared chain (SetTextCache → SetNewText → GetLaughFromString → ... → SendToLLM + Clear).:Wpgsblock shows onlyChangeTextCache— the sharedSequenceand everything after it is silently missing, although in the editorChangeTextCache’sthenis wired into that exact sameSequence.
- A
CastToSoundWavenode whoseCastFailedbranch is wired (CastFailed → Knot → Knot → Array RemoveAt) into a chain shared with the success path: the:CastFailedcontinuation decompiles to an empty block (:CastFailed))).
Expected Result
read_graph_dslshould represent the shared chain once, after the(switch ...)/ multi-output call form, and every branch that merges into it should visibly terminate there — e.g.:(switch Type Value (:Normal (SetTextCache) (SetNewText)) (:Wpgs (ChangeTextCache))) (GetLaughFromString) ; shared chain: emitted once, after the switch- No branch may silently lose statements, and no continuation may decompile to an empty block while its real body exists.
write_graph_dslmust accept that form and re-wire the shared statements into every branch exit (round-trip fidelity).
Observed Result
- The shared chain is expanded inside the first branch only; the second (and any later) branch that merges into it stops at its last unique statement — or, when the branch starts at the shared node, decompiles to an empty continuation.
- For the examples above (before the fix):
:Wpgsblock was(ChangeTextCache)with no shared chain at all.:CastFailedblock was empty, despite three real nodes (two Knots +Array RemoveAt) wired after it.
write_graph_dslraisedUnreachable code after branch/returnfor any statement placed after the(switch ...)/ multi-output form.
Note: verified against real UE 5.8 Blueprints, cross-checked with two independent tools (Fathom exec lines and UnrealMCP
get_node_infosper-pin connections both show the correct merge; only the DSL is wrong).
Affects Versions
5.8
Platform(s)
Windows
Additional Notes
Root Cause
The problem is in the engine’s experimental plugin:
Engine/Plugins/Experimental/Toolsets/EditorToolset/Content/Python/editor_toolset/toolsets/blueprint_dsl.py
1. Decompiler (read_graph_dsl direction) — missing multi-way join handling
The decompiler tracks already-emitted exec nodes in a per-graph exec_visited set shared by all branches. _emit_switch and _emit_multi_exec_call emit each continuation independently with no notion of a join point — unlike _emit_branch, which computes a join node (_find_join_node) and stops each branch there (stop_node).
Consequence: the first continuation walks the shared chain and marks every node visited; the next continuation hits the already-visited shared node inside _emit_stmts and returns immediately, dropping the chain:
# _emit_stmts — before the fix
if type_id.startswith(_SWITCH_PFX):
self._emit_switch(node, ni, state)
return # switch always terminates; no join is returned/continued
# _emit_switch — before the fix: each case expanded independently,
# exec_visited silently truncates every branch after the first.
cont_forms = self._build_cont_forms(exec_conts, state) # no stop_node
2. Transpiler (write_graph_dsl direction) — shared chain cannot be expressed
_process_switch and _create_call_node hard-code ctx.pending_exec = [] after wiring the node, so the DSL has no way to say “statements after this form are wired into every branch exit”:
# _process_switch — before the fix
self._wire_exec(ni, ctx)
ctx.pending_exec = [] # switch severs the enclosing exec flow → anything
# after the (switch ...) raises "Unreachable code"
Fix
Both directions now handle branch-merge natively.
1. Decompiler — multi-way join detection + stop_node
A new helper finds the earliest exec node reachable from 2+ branch starts (counting how many branches reach each node via BFS, then taking the first node on any branch’s path with count ≥ 2; in an acyclic exec graph every merging branch’s first shared node is the same one):
def _find_join_node_multi(self, entries):
"""Return the first exec node reachable from 2+ branch starts (multi-way join)."""
reach_counts = collections.Counter()
for entry in entries:
seen = set()
queue = collections.deque([entry])
while queue:
n = queue.popleft()
if n is None or n in seen:
continue
seen.add(n)
reach_counts[n] += 1
ni = self._get_node_info(n)
for pin in ni.output_pins:
if pin.type_id == EXEC_TYPE and pin.connected_pins:
queue.append(pin.connected_pins[0].node)
for entry in entries: # first shared node on any path
queue = collections.deque([entry])
seen = set()
while queue:
n = queue.popleft()
if n is None or n in seen:
continue
seen.add(n)
if reach_counts[n] >= 2:
return n
ni = self._get_node_info(n)
for pin in ni.output_pins:
if pin.type_id == EXEC_TYPE and pin.connected_pins:
queue.append(pin.connected_pins[0].node)
return None
_emit_switch / _emit_multi_exec_call now compute the join, pass it as stop_node into _build_cont_forms (so every branch stops at the merge), and return the join node; _emit_stmts continues the walk there, emitting the shared chain exactly once after the form:
if type_id.startswith(_SWITCH_PFX):
node = self._emit_switch(node, ni, state) # returns the join (or None)
continue
Branches that terminate on their own (a (return) case, a nested switch) never reach the join and stay fully expanded inside their own continuation.
2. Transpiler — merge branch exits into ctx.pending_exec
_process_switch and _create_call_node now collect each branch’s exit after processing its body — a non-empty body contributes its trailing pending_exec; an empty continuation contributes its own exec output pin — and merge them into ctx.pending_exec, so statements after the form are wired into every branch exit:
pending_after = []
for pin_name, stmts in exec_conts:
...
if stmts:
cont_ctx = _TranspileCtx(..., pending_exec=[exec_pin], ...)
self._process_stmts(stmts, cont_bindings, cont_ctx)
pending_after.extend(cont_ctx.pending_exec)
else:
pending_after.append(exec_pin) # empty body: exec pin itself continues
ctx.pending_exec = pending_after
The DSL grammar (USAGE doc) now documents the pattern:
(switch int Value
(:0 (SetTextCache) (SetNewText))
(:1 (ChangeTextCache)))
(GetLaughFromString) ; shared by both cases — wired into each case exit
Verification
After applying the fix (as a plugin fork — see below) and restarting the editor, the same graphs now decompile correctly. For the enum-switch reproduction:
(event TextEvent
(switch Utilities|FlowControl|Switch|SwitchonEnum 0
(:Normal
(Development|PrintString "SetTextCache")
(Development|PrintString "SetNewText"))
(:Wpgs
(Development|PrintString "ChangeTextCache")))
(Development|PrintString "GetLaughFromString")) ; shared chain: exactly once
- The shared chain appears exactly once, after the switch, at event-body indent.
- The
CastFailedcontinuation is no longer empty: its shared tail is emitted after the call form. - The transpiler side accepts the new form and re-wires the shared statements into every branch exit (verified by connection-level unit tests).
- The plugin’s unit-test suite (
test_blueprint_dsl.py, pure-Python fake callbacks, no editor needed) passes 242/242, including 8 new regression tests covering: switch cases merging into one chain (shared chain emitted once, at the right indent), a self-terminating(return)case coexisting with merging cases, fully independent cases staying expanded, multi-output (Cast) branch merges, and transpiler wiring of post-form shared statements into all branch exits.
How to apply the fix without modifying the engine
- Do not edit the engine directly (
Engine/Plugins/Experimental/Toolsets/EditorToolset/...). - Fork the experimental plugin into your project at
{Project}/Plugins/EditorToolset/(uplugin +Source/+Content/), which takes precedence over the built-in engine plugin, then apply the patch to the project copy’sContent/Python/editor_toolset/toolsets/blueprint_dsl.py. - No C++ compilation needed (pure Python), but restart the editor for the Python module to reload.