DOCUMENTATION

Editor hazards for agents

What can crash a live Unreal editor, freeze the bridge or destroy unsaved work when an AI agent drives it through MifBridge. A person can point their agent at this page, and it is written as instructions the agent can follow directly.

MifBridge drives a live editor. There is no sandbox, and a crash has no undo. Most of the cases below have a guard in the plugin. The guards cover the shapes already found, and the next one will look like these and may not be guarded yet, so know the shapes. For how to work correctly, read Driving the bridge.

Hazards when driving the editor

1. A modal dialog

Handlers run synchronously on the editor's game thread. An engine call that opens a modal dialog stops that thread until someone clicks it. From 1.2 the bridge keeps answering while a dialog is open: every other call is refused at once with modalActive: true and the dialog's title, text and buttons, and get_modal_dialog reads it. See MifBridge.

Two things still hold:

  • A "no dialog" flag does not guarantee no dialog. Several engine functions take a bShowDialog or bPromptUser parameter and still raise one on a path that ignores it. There are also two separate classes of dialog with two separate suppression mechanisms, and silencing one does nothing about the other.
  • If calls fail and the editor looks alive, look for a window behind the editor before you assume a crash.

2. Cooked assets keep runtime data and lose editor data

A cooked game's assets have had their editor-only data stripped. The runtime side is intact, which is why they load and look normal. Reading a cooked asset's editor data does not return empty. It reads something that was removed. Three cases that crash instead of failing:

  • Duplicating a cooked Niagara system. The plugin refuses this now, and the refusal is the feature.
  • ReloadPackage on a cooked Blueprint. This is what "Force Reload" in the Content Browser does. The bridge did not call it and it still took the editor down.
  • Material expressions. The expression class is optional in the engine and may not exist at all in a cooked build, so code that walks a material graph has to handle its absence.

See Cooked projects for what works on cooked content.

3. A refused call and a failed call look alike

Before you decide an endpoint is broken, read writeMode from self_audit. A call the safety gate refused is not a defect. The refusal names the gate and says what to do. See Safety and Write Modes.

4. Endpoints that reach a save without being a save

The gate's list of unsafe calls was first built by asking whether an endpoint changes anything. Two endpoints that honestly answer no could still reach Save:

  • send_editor_key with {"key": "S", "modifiers": {"ctrl": true}} delivers a real key event to whatever has focus.
  • invoke_editor_command with {"context": "LevelEditor", "command": "Save"} runs any registered editor command.

Both are gated now. The question to ask of any call is not whether it writes but whether it can reach something that writes.

If you change the plugin's source

These are for an agent or a developer editing the plugin itself, which a GitHub checkout allows.

A pin pointer is invalid after almost anything

A graph pin pointer does not survive a graph mutation. In particular it does not survive BreakPinLinks, which sounds as if it leaves the pins alone. It does not: the pin-connection callback runs on both ends and can remove an orphaned pin outright. Capture pin identities and look the pin up again on each use, never hold the pointer across a call:

for (const FMifPinRef& Ref : CapturePins(Matches))   // snapshot of identities
{
    if (UEdGraphPin* Live = ResolvePin(Ref))         // re-resolved every iteration
    { ... }
}

This is the most repeated defect in the codebase.

An unchecked cast terminates the process

CastChecked does not raise an exception or answer ok: false. The editor exits, with anything unsaved. Any engine call reached through a cast you did not verify can do this, and engine code uses CastChecked internally on arguments you supply. Use Cast<T> and a null check.

A canceled transaction undoes nothing

Canceling a scoped transaction after a mutation does not roll the mutation back. It only keeps the entry off the undo stack, which is worse than useless: the change stays and the user cannot Ctrl-Z it. A failed call once permanently added a component override this way, because its failure path had canceled the transaction and treated that as undone. An endpoint that needs rollback has to undo its own work explicitly.

Engine "add" functions that allocate without initializing

A public engine Add... function can return a valid-looking object whose members are uninitialized, with the crash landing two lines later somewhere unrelated. The engine's own UI code calls an initialize step afterwards that is not part of the add. Mirror the engine's full path, not the one call that looks like the operation. The same lesson applies to deletion: a create path mirrored correctly and a delete path that was not led to a hard crash.

Two dispatchers: a guard on one is not a guard

RunEndpoint is not the only place a handler is invoked. batch dispatches straight from the handler table without going back through it. That was a complete bypass of the safety gate: save_package was refused, and {"op": "save_package"} inside a batch ran. It is fixed, and the general form matters more than the instance:

A control enforced at one choke point is only as good as the claim that there is one choke point.

Audit every place a handler can be called whenever you add a dispatcher.

A mutation without a read-back is not done

Every silent-success bug has the same shape: the endpoint reports what it asked for rather than what happened. It shows up two ways:

  1. The endpoint never checks, for example an engine call that returns void, or whose bool result was discarded.
  2. The endpoint checks, reports the right numbers and returns ok: true anyway. The response contains the truth in a field nobody reads.

The second is worth a rule of its own:

An endpoint that computes an outcome count must decide what that count means, rather than reporting it and leaving the caller to notice.

Real cases: delete_material_expression returned correct deleted and remaining counts beside ok: true while leaving three expressions behind. spawn_many reported spawned: 0, failed: 50 with ok: true. add_simplified_collision reported added: 0 for a primitive that was never created. These are fixed.

Do not trust a build

Build.bat returns exit code 0 on a build that prints Result: Failed. It also returns 0 when Live Coding blocked the build and nothing happened. Search the log for Result: Failed and for error, and check that the binary's modified time moved. A "successful" build that took under a second did nothing.