DOCUMENTATION

Losing work

What destroys work when you drive Unreal through MifBridge, and what ok:false does not promise: failed calls that still changed something, batches, taken asset names and paths, unsaved maps, deletes that do not delete. Each entry says what you see, why, and what to do.

Back up what matters first. delete_asset takes no backup, and a content folder has no version control of its own. backup_blueprint exists, so call it yourself before a risky Blueprint edit. The default write mode already refuses saves and other calls that persist; see safety-and-write-modes.

ok:false does not mean nothing changed

What you see: a call answers ok:false, and the world is different anyway.

Why: a transacted call is wrapped in an undo transaction, and when the handler reports failure the bridge cancels that transaction. Cancelling discards the undo entry. It does not roll anything back. Handlers that must be atomic validate before they create, and report outcome: "preflight-rejected-nothing-created". Everything else may have written something before it failed.

Do instead: always re-read the target after a failed call. Never assume failure means no-op.

A call refused only for a wrong-typed parameter

The number and boolean readers record a violation and return a default instead of stopping the handler. So a call can write with a default you never sent, and then fail. set_actor_transform with {location:{x:100}, relative:2} answered ok:false, and the actor had moved from x=500 to x=100, an absolute move rather than the relative one intended.

This case keeps its undo entry on purpose. The answer says its change stands and that it is on the undo stack as Mif Bridge: set_actor_transform, and undo_transactions with count:1 reverts exactly that call. It is not rolled back automatically, because an undo can itself fail on some assets, and an undo you ask for is a different risk from one run blind after every handler.

A bulk commit whose rollback could not finish

A commit that reads back different from its preview rolls itself back. When that rollback cannot finish (rollbackComplete:false, for example because it removed an actor it cannot restore, or a patch retyped a wildcard pin), the answer says undoKept:true. The transaction holds every object as it was before the commit, so undo_transactions with count:1 takes the whole commit back, the rollback's own partial writes included. A complete rollback is cancelled as before, with no undo entry for a change that did not happen.

batch is not a unit of work

What you see: op 1 of three failed, and ops 0 and 2 both wrote.

Why: batch runs every op. A failing op does not stop the ones after it, and the transaction commits, so everything that succeeded, before and after the failure, is one undo step. The answer is ok:false with every op's result.

Do instead: preview it. batch with dryRun:true returns a previewId. Sending the previewId alone commits all the ops, or rolls every one back. A batch preview plans its set_property ops and every op whose endpoint has a preview of its own. Any other op is refused by name.

Compile-heavy endpoints cannot be nested in a batch. See mistakes-blueprints.

apply_graph_patch is the one real rollback

It keeps an explicit inverse for every operation it applies and replays them in reverse when one fails, so its rollback is real. That is why its operation set is small: connect_pins, disconnect_pin and set_pin_default can be exactly inverted, and node creation is not offered, because undoing it means deleting nodes. Create nodes first with the add_* calls, then wire them in one patch.

  • It resolves everything first. Every GUID and pin is resolved and every connection is checked before anything is touched, so a bad op is refused with the graph untouched.
  • It reports displaced links. Connecting to a wired exec output or data input breaks the old link. The result says replaced N existing link(s), and the rollback restores those too.
  • allowPartial:true opts back into batch-like behavior and adds a warning.
  • A rollback that cannot restore a node's pin shape says rollbackComplete:false and rollbackReshapedNodes instead of claiming success.

Minting an asset at a name that is taken

What you see: a create call at a path that already holds something, and the editor closes, or the existing asset changes under its users.

Why: the engine's object creation looks the name up first, and what happens next depends only on the class already there:

same class, or a child of the new one   -> rebuilt in place: same address, new contents
any other class                          -> fatal error: the editor is gone
a Blueprint over anything                -> assertion failure
a path containing "//"                   -> fatal error

It never asks whether the existing object is still an asset. A deleted object that is still in memory counts as taken, and one the undo history holds stays at its path (delete_asset answers pathFreed:false with stillResident). Measured, one call each:

create_material_instance at an instance's path, another parent   ok:true, rebuilt; its parent changed under its users
create_material_instance over a Material in memory                editor gone in 3.5 s
create_material_instance over a saved Material, not loaded       ok:true; the registry then called the Material an instance
create_blueprint over a Material, saved or never saved            editor gone in 0.9 s
create_* at "/Game/X//Y"                                          editor gone

What the bridge does: every call that makes an asset asks first whether the path is free. It checks the name, then memory, the disk (a loose file) and the asset registry, and loads nothing. It refuses naming the class it found, with an existingAsset block saying where. This covers create_blueprint, create_material, create_material_instance, create_asset, create_struct, create_enum, create_datatable, duplicate_asset, the mesh, sound and Niagara creators, and the create branch of import_texture. An empty package left behind by a failed read is not a taken name.

A path with // is refused at the door. The engine's own importers load an object path found in any text value. // in the package part of any path-like string, in any parameter, of any call (inside a batch too) is refused before a handler runs, naming the parameter, for example ops[0].value. https:// and drive letters are not affected.

Do instead: use a fresh path for each run, or check with find_assets first. When a create answers that the path is taken, read existingAsset before you reach for any "overwrite" option.

Saving a level onto a taken path

What you see: save_level_as answers ok:true, and a level you did not mean to touch has lost files.

Why: the engine's save does not ask what it writes over. With nothing loaded at the path, it empties the target's external actor and external object folders (every asset file in them, recursively) and writes the map. Measured: a saved World Partition level at the path, not loaded, lost all four external actor files and had its map overwritten, and a Material saved at the path had its file replaced by the map. Both answered ok:true.

What the bridge does: a taken path is refused, naming what is there. Taken means an asset of any class, the package in memory, external files saved for that name, or its built data. An empty external folder is not taken.

overwrite:true replaces a level that is not loaded. It never replaces another class and never a loaded package. replaced{} lists every external file the old level had, read back from disk after the save. The open level's own path is a plain re-save, as ever.

world in the reply is the open world. When the open level was already saved, the editor saves a copy at the path and keeps the source open, so packagePath is where the level went.

new_level and load_level with unsaved edits

What you see: you switch maps and your edits are either lost or written to disk unasked.

Why: the engine's two ways of replacing the open map treat unsaved edits oppositely, and neither can prompt here, since a "save changes?" dialog would freeze the editor and the bridge. Measured before the refusal existed:

The open map had unsaved edits and was new_level load_level
Saved (it has a .umap) Wrote them to its files, unasked Threw them away
Untitled (never saved) Threw them away Threw them away

What the bridge does: both refuse and change nothing while list_dirty_packages with kind:"world" names anything. That includes the open map, a partitioned map's actor packages and an untitled map. The refusal names the packages. To keep the edits, save first (save_package, or save_level_as for an untitled map). To throw them away, pass discardUnsaved:true. The answer reports the switch: result.map is the map open now, replaced the one it closed and discarded[] what was thrown away.

Loading the same map you have open is a revert. reload_packages refuses the open level for the same reason.

delete_asset says yes and the blueprint stays

What you see: delete_asset answers ok:true on a modified Blueprint (one add_variable is enough), and the path is stuck for the rest of the editor session. The calls then disagree:

Asked Answer
find_assets with pathPrefix not listed
delete_asset no asset found at package
save_package asset not found
create_blueprint an asset already exists in memory but is no longer an asset; delete_asset cannot remove it, and an editor restart frees the path (existingAsset.isAsset:false)
list_variables ok, and lists its variables

A plain Blueprint created and deleted can be re-created, but the same Blueprint with a variable added cannot. Nothing was written to disk, so saving first and deleting second does not help either.

What it costs: a script with a fixed fixture path passes once and fails the second time at its first call, with an error about the fixture and not about the thing under test.

Do instead: use a fresh path per run, for example one that includes a run number. When an existence check and a delete disagree, believe neither until a third reader breaks the tie. delete_asset also needs confirm=true and only works under /Game/. rename_asset needs confirm=true as well. duplicate_asset does not.

Deletes and renames right after the editor starts

What you see: delete_asset answers numDeleted:0, with a diagnosis naming open editors or referencers that hold nothing.

Why: right after a start, the asset registry runs its first scan. Until it ends, the editor refuses every asset delete and rename, and puts off redirector fix-ups, without telling the caller. It is long only on a project with no cached registry (a fresh project or a fresh clone), and longer under load. One fresh 5.3.2 project took 99.1 seconds beside a build.

What the bridge does: during the scan these refuse before anything is attempted, with registryStillScanning:true and "NOTHING was deleted" (or "renamed", or "changed"): delete_asset, rename_asset (single and renames[]), fix_up_redirectors (a dry run too), fixup_redirectors and list_redirectors. find_assets and list_blueprints answer registryStillScanning on every answer. register_landscape_layer with layerInfoPath registers the layer and skips the move, and layerInfoMoveNote says why.

Do instead: poll find_assets until registryStillScanning is false before you delete or rename anything. The scan runs once per editor session.

A map cannot be deleted in the session on 5.3 to 5.6 once it is loaded back. delete_asset on a map loads it, and a Niagara system manager then holds a reference inside it. The delete answers numDeleted 0 with blockedBy.engineReferencers naming GCObjectReferencer, and the editor keeps the file open. 5.7 and 5.8 delete it. A throwaway map is best removed after leaving it, clearing undo and running obj gc, before anything loads it again.

Editing a placed actor's component

set_property on a component of a placed actor reruns the actor's construction scripts, which replaces the component. The bridge re-resolves the new one and reports reconstructed, retargetedTo and verifiedOn. See mistakes-calling. Edit the component template instead when you want the change on every instance.

Calls with no safety net

  • run_console executes arbitrary engine console commands, by design with no list of forbidden commands, because a list by name would be theatre. It does not get the undo transaction either. Whatever the command does is outside every guarantee on this page. The default write mode refuses it. See safety-and-write-modes.
  • Editor Python never sends a network call. Python runs with the editor's script mode on, so a Server or Client RPC called from Python runs locally on whichever copy it was called on. In a two-player play session, a client's Server_* request ran on the client's own copy. To exercise the real network path, run a console command in that instance's world with run_console and world:"pie:N" (N is the play instance, 0 the server in listen mode). call_function on a target in a play world checks the actor's call space first and answers callspace, ranHere, sentOverNetwork and deliveredTo. A sent RPC runs on a later network tick, so the reply comes back before it ran: poll the receiving world's copy.
  • Standalone play always has authority, so it makes authority-gated replication code look like it works.

Where to go next