BlueprintTools read_graph_dsl drops shared exec chains when Switch cases / multi-output node branches merge into one chain

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_dslwrite_graph_dsl) cannot even be expressed.

What Type of Bug are you experiencing?

Editor

Steps to Reproduce

  1. In a UE 5.8 project, open a Blueprint graph that contains multiple exec branches merging into one shared chain. For example:
    • A Switch node (int or enum) where two or more cases end at the same node (e.g. a Sequence), which is followed by several more calls — or
    • A Cast node whose then and CastFailed outputs both eventually reach the same downstream node.
  2. Query that graph with Unreal MCP (http://localhost:8000/mcp) by calling:
    BlueprintTools.read_graph_dsl(graph={"refPath": "/Game/YourPath/YourBP.YourBP:YourEvent"})
    
    (Replace YourPath/YourBP/YourEvent with an actual asset/event that contains a branch-merge pattern.)
  3. Inspect the returned DSL for the switch / multi-output call form.
  4. 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 :Normal and :Wpgs that both end at the same Sequence:
    • :Normal block expands the full shared chain (SetTextCache → SetNewText → GetLaughFromString → ... → SendToLLM + Clear).
    • :Wpgs block shows only ChangeTextCache — the shared Sequence and everything after it is silently missing, although in the editor ChangeTextCache’s then is wired into that exact same Sequence.
  • A CastToSoundWave node whose CastFailed branch is wired (CastFailed → Knot → Knot → Array RemoveAt) into a chain shared with the success path: the :CastFailed continuation decompiles to an empty block (:CastFailed))).

Expected Result

  • read_graph_dsl should 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_dsl must 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):
    • :Wpgs block was (ChangeTextCache) with no shared chain at all.
    • :CastFailed block was empty, despite three real nodes (two Knots + Array RemoveAt) wired after it.
  • write_graph_dsl raised Unreachable code after branch/return for 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_infos per-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 CastFailed continuation 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’s Content/Python/editor_toolset/toolsets/blueprint_dsl.py.
  • No C++ compilation needed (pure Python), but restart the editor for the Python module to reload.