Bulk preview
A bulk preview shows exactly what a large change would do before it does it. Read this if an assistant is about to change many actors, assets or rows at once, or if a call came back with capped: true and a previewId.
The idea
Any write that touches many things can be asked to preview first. A preview lists every change, each with its value before, and writes nothing. A commit takes the preview's id, refuses if the world changed since, and compares what it wrote against what the preview promised. If anything does not match, the commit puts everything back.
It is two calls:
- Preview. The endpoint you want, with
dryRun: true. It resolves every target, reads every value before, computes every value after, runs every refusal check, and answers with apreviewIdand the full change list. - Commit. The same endpoint with the
previewIdand nothing else. It plans the same request again. If the new plan differs from the preview in any line, it refuses, writes nothing, and names each difference indrift[]. Otherwise it applies exactly that plan, reads every target back, and compares each with the preview.
A plain call with neither key behaves as it always did. Inside, the preview, the commit and the plain call share one code path per endpoint, so a preview cannot describe something the real call would not do.
The same handshake covers the change cap: a call that would change more than 25 things answers a preview instead of writing, and only a commit carrying that preview's id applies it.
Parameters
| Key | Meaning |
|---|---|
dryRun |
true asks for a preview. Nothing is written |
previewTtlSeconds |
how long the preview stays valid: 1 to 3600, default 600. Only alongside dryRun |
previewId |
a commit. Send it alone, with no other parameter |
list_previews lists the live previews (id, endpoint, age, expiry, counts). Give it a previewId to page that preview's changes. A preview answer carries at most 200 changes inline, and changesTotal says how many exist.
What comes back
A preview:
{ok, dryRun:true, previewId, expiresInSeconds, wouldCommit, summary{create,modify,delete,unchanged},
changes[{i, kind, op, target, label?, field?, before?, after?}], changesTotal, changesShown,
context[], refused[], note}
A commit:
{ok, committed:true, previewId, summary, changes[{..., readBack, matches}], matchesPreview, alsoChanged[]}
A refused commit because the world changed:
{ok:false, committed:false, nothingWritten:true, previewId,
drift[{i?, target, field?, previewed{before,after}, now{before,after} | change}], error}
A commit that failed or read back differently, and was rolled back:
{ok:false, committed:false, rolledBack:true, rollbackComplete, failedAt | mismatches[], error}
A selector that matches nothing fails the preview, as it fails the plain call. A preview in which every target already holds its value answers ok with wouldCommit: false and no id, because there is nothing to commit.
Over the cap
A previewable endpoint over the change cap answers its own preview, with ok:false on purpose so an agent that reads ok alone does not mistake a list for a write:
this would change 30 items, over the default cap of 25, so nothing was written
The message goes on to say to review the list (changes[], which list_previews pages), then commit with {"previewId":"pv-44184-3-1a2b"} on spawn_many, or to send at most 25 items per call to do it in smaller steps.
The answer also has capped: true, nothingWritten: true and changeCap {cap, source, targets}. The commit that follows is not capped again.
A call that cannot be previewed is refused over the cap instead:
this would change 27 items, over the default cap of 25, and it cannot be previewed (ops[0] is 'set_actor_label', which
takes no preview of its own ...), so nothing was written. Split it into calls of at most 25 items, or ask the user to
raise the project's cap (mif_project_mode changeCap).
What "the world changed" means
The world changed if planning the same request again gives a different plan. A plan is an ordered list of exact lines: one per change (operation, kind, target, field, before, after), plus context lines (the world's package, each resolved class, mesh or asset, any random seed). Every line the commit compares is a line the preview showed, so a refusal can always say what changed.
Planning again, instead of keeping pointers to live objects, means:
- Nothing stored can go stale across garbage collection, a Blueprint recompile, a Live Coding patch or a map change.
- The check and the writes happen in one game-thread call, so nothing can change between them.
- The commit cannot do anything the preview did not show, because it applies the plan it has just re-derived and compared.
Randomness is fixed at preview time. An unseeded variation has its seed written into the stored request, so planning again is deterministic.
A map change, Play-In-Editor, a recompile or an undo does not end a preview. Each shows up at commit time as a difference in the plan.
A commit, step by step
- Look up the id. If it is unknown, expired, already used, from another editor, or made by a different endpoint, the commit is refused. Nothing is written, and the id is not spent.
- Plan again. If the plan differs, the commit is refused with
drift[]. Nothing is written, and the id is spent. - Apply in plan order. In-memory changes apply inside one undo transaction, and each applied change records its inverse.
- Read every change back and compare it with the preview.
- If a step fails, or any previewed field reads back different, roll back. The recorded inverses run in reverse order, then the request is planned a third time and must equal the preview's lines.
rollbackComplete: truemeans the world is back to exactly what the preview saw.falseis reported loudly, with what differs, and an incomplete rollback keeps its undo entry (undoKept: true), soundo_transactionswithcount: 1takes the whole commit back. - Changes that reach disk cannot roll back. They report each target's outcome, and a fresh preview lists only what is left, so a partial commit can be resumed.
- The call is journaled.
A successful commit is one undo step.
Side effects. set_property can write a second field to satisfy an edit condition. The plan predicts that write and lists it as its own change. A write to a placed Blueprint actor reruns its construction script, which can change anything, so the preview marks that change rerunsConstructionScript: true. The commit also compares every other property of each object it wrote, and reports anything the preview did not list in alsoChanged[]. That is reported and kept: rerunning a construction script is how the engine works, and the preview warned of it.
A previewId also stands in for confirm: true on the endpoints that otherwise require it (write_datatable_rows, delete_datatable_rows, rename_asset, consolidate_assets, the redirector fixers, remove_foliage_instances and move_actors_to_level), because a preview looks harder before writing than a flag does.
The preview id
- Its format is
pv-<editor pid>-<n>-<4 hex>. - It lives in the editor process's memory only. An editor restart loses every preview, and a commit refused for that reason says so.
- It expires after 10 minutes by default, or up to one hour on request.
- It is single use. The first commit that reaches the plan comparison spends it, whatever happens after.
- It is bound to its endpoint: a
spawn_manypreview commits only onspawn_many. - At most 64 previews are live per editor. The oldest is evicted, and the preview that evicted it says which one.
- The pid lets a commit sent to the wrong editor name the editor the preview came from.
- Previews and commits act on the editor world. A request that names a Play-In-Editor object is refused.
Safety rules for previews
- A preview passes the safety gate in every write mode, because it writes nothing. This replaces splitting a read-only twin off each gated endpoint.
- A commit is gated exactly like a plain call of the same endpoint. A gated endpoint's preview answers in
scratchmode, and its commit is refused. - A tripwire watches every preview. For the length of the plan the bridge watches for any modification and any newly dirty package. If a preview changes anything, it fails loudly as a MifBridge defect and names the object, so "a preview writes nothing" is checked on every call.
Which endpoints preview
On Unreal, these endpoints take dryRun, previewId and previewTtlSeconds: batch, set_property_many, spawn_many, duplicate_actors, snap_actors, snap_actors_to_ground, group_actors, ungroup_actors, modify_actor_layers, add_foliage_instances, remove_foliage_instances, write_datatable_rows, delete_datatable_rows, convert_light_units, calibrate_lights, convert_atmospheric_fog, apply_graph_patch, assign_materials_by_slot_prefix, apply_level_snapshot, move_actors_to_level, save_dirty_packages, reload_packages, rename_asset, fix_up_redirectors, fixup_redirectors, consolidate_assets, add_to_collection and remove_from_collection. describe_endpoint shows previewable in an endpoint's safety block.
The three light and fog endpoints preview by default and write only with apply: true. check_consolidate_assets and list_redirectors stay as read-only twins and now answer with a previewId that the acting endpoint accepts.
What each kind of plan reads, and how it rolls back
| Kind | Endpoints | The plan reads | Rollback |
|---|---|---|---|
| property | batch (set_property ops), convert_light_units, calibrate_lights |
each target resolved to an object and class, and each field's text. The "after" value is imported into a scratch copy of the property, never the object | the before text is imported back |
| actor create | spawn_many, duplicate_actors, convert_atmospheric_fog |
the world, each input (class, mesh, material), and each new actor's class, label as it will be made unique, transform, folder and parent | destroy the new actor |
| actor transform | snap_actors, snap_actors_to_ground |
the resolved set and each ground trace's hit | set the before transform |
| row | write_datatable_rows, delete_datatable_rows |
the table and its row struct, and each named row's text, or its absence. In replace mode, every row | remove an added row, restore an updated one, add a deleted one back at its old position |
| membership | group_actors, ungroup_actors, modify_actor_layers, add_to_collection, remove_from_collection |
each actor's group, each actor and layer pair, each asset in the collection | toggle back |
| foliage | add_foliage_instances, remove_foliage_instances |
the type's instance list. Instances are named by transform, because indices shift on removal | re-add or remove by transform |
| graph | apply_graph_patch |
its own preflight | its own inverse journal |
| package on disk | save_dirty_packages, reload_packages |
the dirty set, and each package's outcome as the real run would classify it | none: it is on disk |
| asset on disk | move_actors_to_level |
each asset's path and class, its referencers and open asset editors | none: reported per target |
| package file | rename_asset, fix_up_redirectors, fixup_redirectors |
each file the engine writes or deletes, with its time on disk | none: reported per file |
| asset in memory | consolidate_assets |
every object in memory that references a source, and the registry's referencers | none: the engine's consolidation is not undoable |
| level snapshot | apply_level_snapshot |
the engine's own diff of the snapshot against the world: each object's selected properties, actors deleted and added, components added and removed | properties go back; a respawned actor is destroyed; a destroyed actor or a component change is not rolled back here, and undo takes them back |
The asset registry lags the object it describes. A plan that reads the registry at both ends can report drift that is only the registry catching up. That fails safe: preview again.
Blender
The Blender add-on speaks the same handshake with the same keys and answers (dryRun, previewTtlSeconds, previewId alone), with ids of the form bpv-<Blender pid>-<n> and 64 live at once. list_previews works there too.
Mesh operations plan on a scratch copy. A bevel, a skirt, a decimate, an unwrap, a modifier applied: each computes its result as it writes, and several only through a Blender operator, which writes. Their plan runs the op's ordinary code on a temporary copy of the object and mesh, reads the copy, then removes it and every data-block the run made and restores the selection, active object and frame. The rows it reports are the moved vertices, edge marks, vertex weights and face material indices while the topology holds. When it does not hold they are one geometry row {vertices, edges, faces, digest}. They always include the vertex groups, UVs, shape keys and modifiers.
| Op | Change kind | Inverse |
|---|---|---|
bl_mark_seams, bl_bevel_edges, bl_extrude_skirt, bl_uv_unwrap, bl_decimate_mesh, bl_push_out_of, bl_cloth_filter, bl_blend_weights, bl_garment_reshape, bl_wrap_to_target, bl_clear_envelope, bl_push_out_for_poses, bl_apply_modifier |
the mesh rows above | the mesh copy remapped back, groups and modifier stack restored |
bl_fit_mesh |
a transform per object moved, including carried objects and descendants | the matrix set back |
bl_set_bone_properties |
one row per bone per property | the properties back; for a parent or connected change in edit mode, the parent, each bone's matrix and length |
bl_generate_rigify_rig |
the rig to be created | every data-block the generation made removed, the metarig's target and base name back |
bl_transform_objects |
a transform per object: location, rotationEuler, scale | the matrix set back |
bl_rename_bones |
one row per bone name | renamed back through temporary names |
bl_set_material_slots |
one row per slot, and any material to create | slot count, links, materials and polygon material indices; made materials removed |
bl_transform_selection |
one row per moved vertex | every vertex of the mesh and of each shape key |
bl_purge_orphans |
one row per data-block deleted | none: reported as partial |
Refused at preview, with nothing written: bl_apply_modifier on a mesh other objects share, or of a simulation modifier (its shape comes from a cache the copy does not share); a Rigify regenerate (it rewrites the target's bones in place, and only its backup brings them back); and a Rigify metarig that fails a generate check. The two Rigify refusals carry the dry-run report beside the error (checks[], limbBends, generateReady: false, regenerates), so the dry run still diagnoses the metarig.
Previewing bl_batch. bl_batch with dryRun: true plans each op with its own plan against the file as it is. Each change carries step and endpoint, and steps[] holds each op's own dry-run answer. It is refused, with refused[] and no previewId, if an op has no plan or no inverse can take it back (bl_purge_orphans), or if two ops change one target, because the later op's preview of it could never read back as shown. continueOnError and the budget keys are refused, because a previewed batch commits every op or rolls every op back. The commit runs each op's code in order, reads every change back, and on a mismatch or refusal restores every op, the last first.
Differences from Unreal, so nobody reads them as passes. No tripwire watches what a plan modifies. Each plan is instead verified to write nothing by comparing a whole-file snapshot before and after. alsoChanged[] is not measured. A purge commit takes exactly the previewed data-blocks, so an orphan type outside the add-on's purgeable list is not taken, and a recursive purge cannot be previewed. A drift, or a complete rollback, pushes no undo step. A change that no inverse takes back answers {partial, rollbackPossible: false, landed}.
A Blender preview forced by the change cap is smaller. The add-on has no plan for those ops, so the preview lists the targets (targets[], perOp[]), and a commit re-checks what it can: every named data-block's existence and type, and each object's world matrix for bl_transform_objects. It refuses with drift[], and the preview is spent, when any differs. The commit runs the stored request through the op's ordinary code as one undo step.
Using previews in a flow
A workflow dry run calls each write's own preview and lists the ids. Running the flow again with commits committing those ids applies exactly what you read.