BlueprintTools read_graph_dsl omits 3rd+ inputs on n-ary operator nodes (BooleanAND / Add / Multiply / Subtract)

Summary

BlueprintTools.read_graph_dsl (and its counterpart write_graph_dsl) silently drops the third and any additional inputs when a Blueprint operator node has more than two inputs (A/B/C/...).

This affects n-ary / variadic operator nodes — e.g. BooleanAND, BooleanOR, Add (+), Subtract (-), Multiply (*) — which Unreal allows to carry more than two pins.

Example: a Multiply node with three inputs should decompile to:

(* A B C)

but instead returned (before the fix):

(* A B)

…with the third operand silently missing. Any AI/agent or tooling that reads the Blueprint through read_graph_dsl therefore receives an incorrect, lossy representation of the graph — a real correctness hazard (exactly the kind of thing that has historically caused “AI wrote the wrong logic” incidents).

What Type of Bug are you experiencing?

Editor

Steps to Reproduce

Provide a numbered list of steps that we can follow to reproduce this issue:

  1. In a UE 5.8 project, open a Blueprint function that contains any operator node with 3+ inputs. For example:
    • Math | Boolean | ANDBoolean with three inputs (A/B/C), or
    • Utilities | Operators | Multiply (or Add / Subtract) with three inputs.
  2. Query that function graph with Unreal MCP (http://localhost:8000/mcp) by calling:
    BlueprintTools.read_graph_dsl(graph={"refPath": "/Game/YourPath/YourBP.YourBP:YourFunction"})
    
    (Replace YourPath/YourBP/YourFunction with an actual asset/function that contains a 3+ input operator node.)
  3. Inspect the returned DSL for the operator node.
  4. If any extra input (B/C/D…) is actually wired in the Blueprint editor, none of the inputs after the second one is reflected in the output.

Concrete minimal reproduction (observed on this machine):

  • A Multiply node (Utilities | Operators | Multiply) with three wired inputs (A, B, C).

Expected Result

Please provide what you think the expected behavior would be when following the steps listed above:

  • read_graph_dsl should emit every wired input of an n-ary operator node.
  • For the three-input Multiply example, the expected DSL is:
    (* A B C)
    
    i.e. all three operands present, and in a form that write_graph_dsl can round-trip back to the same three-input node.

Observed Result

Please provide the actual result of the steps listed above:

  • Only the first two inputs are emitted; the 3rd+ input is silently dropped.
  • For the three-input Multiply example, the actual output (before the fix) is:
    (* A B)
    
  • The third operand is missing with no warning or error, and the same failure affects BooleanAND/BooleanOR, Add, Subtract, and Multiply — i.e. the whole n-ary operator family.

Note: verified against a real UE 5.8 Blueprint — a 3-input Multiply node. Before the fix it decompiled to (* A B) and dropped the third operand; after the fix it correctly returns (* A B C).

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

Two places hard-code the assumption that every operator node is strictly binary (exactly two inputs):

1. Decompiler (read_graph_dsl direction) — _emit_binop_expr

def _emit_binop_expr(self, type_id, src_ni, bindings, _visiting) -> str:
    op = _TYPE_ID_TO_OP[type_id]
    ins = _data_ins(src_ni)                 # returns ALL non-exec input pins
    left  = self._emit_expr(ins[0], ...) if len(ins) > 0 else '_'
    right = self._emit_expr(ins[1], ...) if len(ins) > 1 else '_'
    ...
    return f'({op} {left} {right})'

_data_ins() already returns every non-exec input pin, but _emit_binop_expr only ever reads ins[0] / ins[1], so ins[2:] (the 3rd+ inputs) are discarded.

2. Transpiler (write_graph_dsl direction) — _eval_binop

if len(form) != 3:
    raise RuntimeError(f'({op} ...) requires exactly 2 arguments, got {len(form) - 1}')
...
if len(ins) > 1:
    self._wire_with_pre(form[2], ins[1], pre2, bindings, ctx)

The transpiler rejects any form with more than two arguments, so even if you hand-write (* a b c), it raises. This also breaks round-tripping (read_graph_dslwrite_graph_dsl).


Fix

The operator family in _BIN_OPS is n-ary in Unreal — many of these nodes support arbitrary input count via add_node_pin/remove_node_pin. The fix makes both directions handle any number of inputs:

Decompiler (_emit_binop_expr)

Emit every connected input, while preserving the existing unary-minus folding (0 - x) → (- x):

def _emit_binop_expr(self, type_id, src_ni, bindings, _visiting) -> str:
    op = _TYPE_ID_TO_OP[type_id]
    ins = _data_ins(src_ni)
    args = [self._emit_expr(p, bindings, _visiting) for p in ins]
    args = args if args else ['_']
    if op == '-' and len(args) == 2 and args[0] == '0':
        return f'(- {args[1]})'          # preserve unary minus folding
    return f'({op} {" ".join(args)})'

Transpiler (_eval_binop)

Accept any number of arguments (≥2); wire the first two into the node’s initial pins, and use add_node_pin for each extra argument to dynamically add an input pin and wire it:

# accept any argument count >= 2 (old: `if len(form) != 3: raise ...`)
if len(form) < 3:
    raise RuntimeError(f'({op} ...) requires at least 2 arguments, got {len(form) - 1}')
...
# after wiring ins[0] and ins[1]:
if len(form) > 3:
    for extra in form[3:]:
        pre = self._eval_expr(extra, bindings, ctx) if isinstance(extra, list) else None
        new_pin = self._add_node_pin(node)          # dynamic input pin
        pin_info = PinInfo()
        pin_info.pin_id = new_pin
        self._wire_with_pre(extra, pin_info, pre, bindings, ctx)

Wiring add_node_pin into the transpiler

Transpiler.__init__ gains an optional add_node_pin_fn callback, and BlueprintTools.write_graph_dsl passes it:

blueprint_dsl.Transpiler(
    graph,
    BlueprintTools.create_node,
    BlueprintTools.connect_pins,
    BlueprintTools._get_node_info,
    BlueprintTools.set_pin_value,
    lambda g: BlueprintTools.find_nodes(g),
    delete_node_fn=BlueprintTools.delete_node,
    find_node_types_fn=lambda f: BlueprintTools.find_node_types(graph, f),
    add_node_pin_fn=BlueprintTools.add_node_pin,     # <-- added
).transpile(code)

Note: BlueprintTools.add_node_pin returns a raw PinID; it is wrapped in a PinInfo before being handed to the shared _wire_with_pre helper.

Because _BIN_OPS in UE includes all extensible operators (Add, Subtract, Multiply, BooleanAND/OR/XOR, …), this covers the whole family — not just booleans. Operators that are genuinely fixed-two-input (e.g. comparisons) are unaffected because they never have >2 arguments.


Verification

After applying the fix (as a plugin fork — see below) and restarting the editor, the same three-input Multiply node from the reproduction case now decompiles correctly:

(fn TestFn ()
  (return (* A B C)))

All three operands are present. A standalone smoke test over _emit_binop_expr covering 2- and 3-input +, -, *, and, or (plus the 0 - x → (- x) unary case) passes.


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.