Skip to content

Changelog

Releases are listed newest first. Each entry covers what shipped in that version of the plugin, new domains, panel features, behavior changes, and breaking changes when they happen.

New asset domains and editor tooling on top of the v1.0 surface, each with the same validated-JSON, auto-layout and full-test treatment.

UE 5.8 is supported, and has a download of its own

Section titled “UE 5.8 is supported, and has a download of its own”

Blueprint AI now builds and tests on Unreal Engine 5.7 and 5.8, and the listing carries one package per engine rather than one package that hoped to fit both. Install the one that matches your project; nothing else changes, and a project on 5.7 is not being left behind — the same source tree produces both, so every fix from here lands in both downloads at once with no backporting step in between.

What 5.8 asked for was mostly invisible. FJsonObject’s keys changed type, three engine headers moved, a handful of APIs were renamed, and three compiler warnings became errors; all of it is behind version guards that keep 5.7 compiling exactly as before. Two things are worth knowing:

  • the coverage report is now measured against 5.8, which is a bigger engine: it ships plugins and modules 5.7 does not, so the denominator moved. The figures in the engine-coverage report are about the newest supported engine, which is the one they should be about;
  • Chaos Visual Debugger’s mode is gone on both engines — see the entry below. That is the only place a 5.8 change reached the op surface.

The Chaos Visual Debugger reports transport instead of mode

Section titled “The Chaos Visual Debugger reports transport instead of mode”

get_chaos_vd_status and start_chaos_vd_recording used to return a mode of Live or File. UE 5.8 removed the recording mode outright — its own header deprecates the field as “no longer valid and will not be replaced” — so that value has nowhere left to come from.

It is not recoverable by inference either, which is why it was replaced rather than mapped. The obvious guess is that a file-system transport means File and everything else means Live, but UE 5.7’s own StartRecording accepts RecordingMode::File together with TransportMode::TraceServer and starts a network trace for it. The two axes were always independent, so any mapping would have reported a confident wrong answer on exactly the configuration where it matters.

So the ops now report transport, which every supported engine really does carry: FileSystem, TraceServer, Direct, Relay, or Invalid when there is no live connection. It says strictly more than Live/File did — where the data is going, not just whether it is going somewhere.

If you were reading mode, read transport: FileSystem is the old File, and the other three are what used to be flattened into Live.

Screenshots are where your screenshots are, and Saved/BlueprintAI/ is gone

Section titled “Screenshots are where your screenshots are, and Saved/BlueprintAI/ is gone”

Ten writers used to put files in Saved/BlueprintAI/ — every capture, the editor/build hand-off, the crash reporter’s queue, a wiki cache, the engine tool snapshot and the CLI’s own scratch. A folder named after the plugin, inside Unreal’s directory, and three things followed from it. A “clear cached data” empties Saved/ — an unsent crash report is not cached data, and neither is a hand-off a detached build process is about to read. Nothing in .blueprintai/ could see any of it: not resolved by any accessor, not swept, not migrated, so the plugin’s state had two homes and one of them was documented. And FPaths::ProjectSavedDir() is relative to the working directory, which for a launched editor is the engine’s binaries folder — so a path handed to a child process was relative to a directory that child did not share.

Seven of the ten are the plugin’s own state and now live under .blueprintai/local/, in a directory per lifecycle: editor/ for what a running editor keeps, runs/ for what an in-flight run holds, engine/ for what this engine build provides, reports/ for what a tool wrote to be read.

The three captures went the other way, and that is the part you will notice. A screenshot is not the plugin’s private state, so it goes where the editor has always put screenshots:

capture_viewport, capture_widget, capture_sequenceSaved/Screenshots/<platform>/FPaths::ScreenShotDir(), beside High Res Screenshot’s output
start_recordingSaved/VideoCaptures/FPaths::VideoCaptureDir(), beside Sequencer’s Render Movie output

A frame the agent grabbed is the same kind of file as a frame you grabbed, so it is in the same folder. Bursts sit with the stills rather than with the video because that is what they are — N screenshots on a timer, written by the same capture call — and because a burst and a recording started in the same second with the same label would otherwise land in one folder.

Existing installs are moved on the next editor start, contents intact, once, with anything the move does not recognise left exactly where it is rather than relocated on a guess. A relative out_folder still resolves against the project, so record summary <folder> and friends are unchanged apart from the default path they print.

Unreal’s own editor tools are ops, and stay current by themselves

Section titled “Unreal’s own editor tools are ops, and stay current by themselves”

UE 5.8 ships its editor tools as UFUNCTION(meta = (AICallable)) — 276 of them across 20 modules at 5.8.0, and more each release. Where BlueprintAI has no operation for one, it now registers an op whose entire body is a reflected call into Epic’s function: Epic maintains the behaviour, BlueprintAI keeps the interface, and the schema and documentation are generated from reflection including Epic’s own @param text.

Neither ordinary route reaches those functions, measured rather than assumed: 22 of the 27 toolset modules declare them in Private/ headers so nothing can include them, and only 9 of the ~276 are additionally BlueprintCallable so the Python unreal module does not expose the rest. Reflection is unaffected by both.

Nothing has to be re-run when the engine changes. The list those ops come from is rebuilt by the editor itself, on the first launch after the engine stops matching it — hotfixes included, which is the case nobody would think to re-bake for. Every launch fingerprints the engine build and the toolset plugins it has, compares that against the file on disk, and stops there in single-digit milliseconds when they agree. A refresh that replaces a list logs what arrived and what left, because a renamed tool is an op whose callers must change.

A delegated op is registered only where BlueprintAI has no op of that name, so nothing tested is replaced by something unverified. Engine tools get their own domains (engine_umg, engine_niagara) rather than joining BlueprintAI’s, because the validator applies content-path rules per domain and an engine tool’s path may be an export path or a config section, carrying none of the promises /Game/... does.

On UE 5.7 and earlier there are no such tools, and zero delegated ops is the answer rather than a degraded one.

An unsupported engine now costs one domain, not the whole editor

Section titled “An unsupported engine now costs one domain, not the whole editor”

A changed engine API used to have exactly one outcome: UnrealBuildTool fails the target on one bad module, so there was no editor at all until every break was fixed. Two mechanisms replace that, and which one applies is decided by how a module reaches the engine — measured, not assumed. Of the 116 gated modules, 81 link no engine plugin module whatsoever: they resolve types by /Script/<Module>.<Class> string, so a renamed class is a runtime lookup miss. Only 35 hard-link engine plugin internals, and only those can fail to compile.

The 81 were already resistant and could not say so. Every miss reported class '...' is not registered — enable the owning optional plugin. On an engine that had merely renamed the class that is a confident, wrong instruction: the plugin is already on, and enabling it again does nothing. The verdict is now decided from live plugin state — IPluginManager maps a module to its owning plugin for every DISCOVERED plugin, enabled or not — and separates “switch it on”, “install it”, and “this engine does not have it, and BlueprintAI is not updated for it”. Six diagnostic sites carried the old wording; none does now.

An op from an excluded domain read as Unknown operation — indistinguishable from a typo, so the caller would retry it and then work around a capability the product has and only this build lacks. It now names the domain, the cause and the engine, and offers no spelling suggestions, because suggesting a different op steers away from the real answer.

The 35 get a build-time quarantine. Config/EngineCompatibility/quarantine.json is keyed by engine and read by three things that must never disagree: the gates in Build.cs, Python’s reason lookup, and the probe that writes it. A quarantined module compiles as its stub set, so the plugin loads and exactly one domain excludes itself. An engine with no entry compiles in full — optimistic, because a pessimistic default would stub 130 domains to protect a handful — and a newer engine inherits nothing, since 5.9 may have fixed what 5.8 broke. python Scripts/dev/engine_compat/probe.py fills it by building, reading the compiler’s own errors, and rebuilding; it refuses to quarantine a module with no stub path, refuses to guess at an error naming no module of ours, and stops if a quarantined module fails again.

Two things this does not do, both measured rather than hedged. Of the four modules the 5.7-to-5.8 port actually broke, two cannot be excludedBlueprintAIMesh and BlueprintAIWidget are always-on, and a stubbed Mesh domain answering every op with a safe default in a build where meshes exist is worse than a build that stops. And compile-time resistance is not behavioural resistance: a module that still compiles but whose engine semantics moved fails at runtime, which only the per-domain tests can catch.

Along the way: CreateByClassPath never gave a lazily-loaded module its chance to load, so it failed the first op of a commandlet session for exactly the modules the factory paths already worked around by hand — fixed for every caller at once. The committed stub-mode include map had gone stale and was failing BlueprintAIControlRig; regenerating it added Misc/EngineVersionComparison.h, the header every version guard needs.

Seven more known limitations closed, and two were not what the report said

Section titled “Seven more known limitations closed, and two were not what the report said”

The register opened on 2026-09-02 — moving a gameplay ability into a plugin, auditing every plugin in a project for /Game/ coupling, then asking whether the moved ability still resolves its camera graph on a real character. Every entry is now a closed one, and two were the same mistake twice: a mechanism that existed and could not reach the case at hand, recorded as a mechanism that did not exist. Checking the op list answers “is there an op”; it does not answer “does it reach my case”.

inspect’s dependencies answered clean for assets that were not. It came from the asset registry, and the registry’s dependency data is a package’s IMPORT TABLE — so a TSubclassOf held on a CDO and an Open Level (by Name) FName literal are simply not in it. Across nine plugins the registry named 5 offending assets where the package bytes named 30, and the step that missed them is the one the workflow tells you to trust. It answers from three routes now — the registry, a walk of every object in the package, and the graph pin literals — and dependency_sources says which found each path, because “the registry says no” and “the registry was never told” are different facts and only one is evidence. It does not over-report to compensate: a pin literal counts only when it names a package that EXISTS, a bare name only on a LevelName pin, and an ambiguous map name goes to notes.

Nine assets could not be moved together, and moving them one at a time is a different operation: RenameAssets rewrites the references BETWEEN the assets in one batch, so a loop leaves each pointing at the previous one’s old path — invisible until something fails to load. move_assets_with_references takes the whole set, and refuses in full before the first asset moves.

Every cast-promoted variable was unusable. set_variable_type and find_variable_references refused As BP Character as an asset name; UE spells a Blueprint member with FKismetNameValidator, whose entire rule is “not empty, at most 100 characters, no .”. Param.name_kind replaces the old asset_name boolean with three kinds, validated at registration so a typo raises at import rather than silently restoring the strict rule.

A variable on ANOTHER object could not be authored at all, which had already cost a design change — AC_MatchEndView could not hand its MainMenuLevel to WBP_MatchEnd the way AC_Pause hands its own to WBP_Pause. The external-property path that should have covered it resolved classes with FindFirstObject plus a /Script/Engine fallback, so a BLUEPRINT class was never resolvable however it was spelled. target on add_variable_get_node / add_variable_set_node takes the project’s one class vocabulary, and the refusal now says which half of the lookup missed.

An AnimBlueprint could not be made a Template but could be corrupted. bIsTemplate is AssetRegistrySearchable and nothing else, so Python cannot see it, while TargetSkeleton writes freely — and bIsTemplate is what makes a null skeleton legal. The pair is set together now and neither alone, and the generic setter refuses both halves and points at the op, so the corrupt intermediate state has no expression anywhere in the surface.

An AnimGraph node’s Tag was read and written on a field the engine deprecated. It read as “write-only”, and the write did not work either. Tag moved to UAnimGraphNode_Base, which is what the compiler fills FAnimSubsystem_Tag from and therefore what GetLinkedAnimGraphInstanceByTag resolves; the FAnimNode struct keeps Tag_DEPRECATED.

Adding the node layer would not have been enough, and that is the part worth knowing: UHT registers a property under its engine name and strips _DEPRECATED to build it, so the struct really does answer to Tag — in the layer searched first. The write went there and reported success. So both passes skip retired fields, a name only a retired field answers to is refused (naming the C++ member, so the reader can see why it resolved), and the reader lists such fields under retired rather than beside the live ones.

A Linked Anim Graph node’s exposed pins could not be hidden, and an exposed pin is copied into the linked instance on every update whether or not it is wired — so while it is shown, gameplay code cannot write that variable at all. set_anim_node_pin_exposed performs the engine’s own SetCustomPinVisibility sequence, and is idempotent on purpose: asking for the state a pin is already in does not reconstruct the node and drop every other pin.

Three ops also stopped raising NameError on a named AnimGraph. They emitted a call to _find_graph, which nothing defined — and nothing could have, since from blueprint_helpers import * never binds an underscore-prefixed name. Anything in an animation LAYER was unreachable through an op batch; find_anim_graph exists, is exported, and refuses with the graph names it does have.

compile_and_save answered “The Blueprint is only in memory - nothing was written to disk”. That is true, and it describes the OUTCOME, so every reader who met it went looking at the Blueprint. The causes are all on the file system and need opposite responses: a read-only bit is checked out, a full volume is cleared, and a .uasset held open by a second editor is fixed by closing that editor and by nothing else. An afternoon went into the third one.

The engine knows. SavePackageUtilities.cpp logs the real reason — :465 for read-only, :492/:513 for a rename another process’s handle is blocking — and the public API discards it: FileHelpers.cpp:5840 collects FailedPackages and :5914 drops them, returning a bare bool. So the save-path log is captured for the duration of the save (seven categories, Warning and worse, capped) and quoted back attributed to the engine.

Then the file itself is probed, and on Windows the Restart Manager names the processes holding it, by name and PID. The probe runs AFTER the failure on purpose: ResetLoaders releases our own handle first, so probing early would have blamed a bystander. rstrtmgr.dll is loaded through GetDllHandle, so this adds no link dependency; POSIX answers “not asked” rather than “nobody holds it”, because an advisory lock there does not block the rename a save performs.

Every non-default replication condition was silently discarded

Section titled “Every non-default replication condition was silently discarded”

set_variable_replication resolved the condition in Python with getattr(unreal.ELifetimeCondition, "COND_OwnerOnly", 0). UE pythonizes every enum entry to UPPER_SNAKE — PyGenUtil.cpp:3156 runs each through PythonizeName(..., Upper), so the real spelling is COND_OWNER_ONLY — and that getattr therefore matched nothing and took the 0 default. Ask for owner-only replication, get everyone-replication, and apply answers success: true.

Nothing could observe it. get_variable_replication reported "Replicated" whatever the condition was, so the one reading available agreed with the value that had been thrown away. Fixing only the setter would have left the defect exactly as undetectable, so both halves moved: the condition travels BY NAME and resolves against the enum’s own reflection in C++, an unknown name is refused with the valid ones listed rather than defaulted, and the getter reports what is actually set — Replicated (COND_OwnerOnly), RepNotify (OnRep_HP, COND_None).

Ten known limitations closed, and three of them were already fixed

Section titled “Ten known limitations closed, and three of them were already fixed”

Every open entry in Docs/dev/known-limitations.md was re-measured against a live editor before anything was changed, and a quarter of the register turned out to be describing behaviour that no longer existed. get_variable_default read 250.000000 across a compile boundary, search IsMoveInputIgnored returned all three overloads including AController’s, and a run --stdin script that raised answered ops_executed: 0 rather than the previous batch’s count. Each is now a regression guard instead of an entry: an entry that outlives its defect costs the next reader a fix they do not need to make.

Measuring the first of those found a real neighbour. GetVariableDefaultValue returns an FString and returns "" for a Blueprint with no such variable, for a default that really IS empty, and for a class that has not compiled — so the op answered ok: true, value: "" for a misspelt name. DescribeVariableDefault tells the three apart and reports which of the value’s two homes answered.

A local variable was unreachable by every node op. get_local_variables listed it and both node ops refused it, asking whether the variable was added and the Blueprint compiled — a question whose two halves were both true. The lookup resolved member variables only; a local is not a class property at all. Both node ops resolve either now, and a miss lists the graph’s locals instead of blaming a compile that already happened.

The Tick event was a ghost, and there were two of them. Reported as “no op reaches bCanEverTick”; measuring it found something else. The compiler recomputes that flag on every compile from whether a connected ReceiveTick event exists, so a component authored through ops does arm its tick. What killed the graph was that add_event_node produced a DISABLED node — AddDefaultEventNode always ends in MakeAutomaticallyPlacedGhostNode(), and the compiler prunes a ghost — and that its idempotency guard searched node titles for "ReceiveTick" while a Tick node’s title is "Event Tick", so every call added a second node for the same function. The op adopts the node already there, promotes a ghost the way a connection does, and creates an enabled one when there is none.

The class-defaults gap was real and is closed on its own terms: set_class_default / get_class_default / get_class_defaults reach any reflected property on the class default object, and set_tick_defaults / get_tick_defaults reach the tick struct, resolving PrimaryActorTick vs PrimaryComponentTick from the class rather than from the caller. bCanEverTick has no Edit specifier, so it is absent from the Details panel and from Python’s reflection; text import through the struct property is the only route to it.

One field at a time would have silently reset the others. FBlueprintEditorUtils::PropertyValueFromString re-initialises a struct before importing, so (TickInterval=0.5) over a tick struct carrying bCanEverTick=True left it false — “change the interval” would have disarmed the tick. Caught while building the ops, before shipping, by reading the value back rather than trusting the write. Both setters use ImportText_Direct, which assigns only the members present.

Custom-event replication could only touch an event created in the same batch. Both ops took a batch-local node id and nothing else, so an event from any earlier run answered Node ID '8CCD258F-...' not found. Available: [] — which is exactly when the question is asked, because an RPC that is not arriving is diagnosed by checking whether the event really carries Client. They take asset + graph now and resolve an id, a GUID or the event’s name. The getter also surfaced nothing at all (it ended in unreal.log); it answers what the COMPILED UFunction carries beside the node’s own flags, with matches — the two disagree until a compile, and that gap is the diagnosis.

PIE broke every batch and the diagnostic named a destructive fix. With a play session running, every op answered “Asset not found … try force_delete_asset” for an asset intact on disk, and every save answered “only in memory”. Following that suggestion destroys the asset the caller was editing, to fix a condition that clears itself the moment play stops. A batch that cannot save must not edit either, so apply now refuses before the first op, with one sentence that says what is running, that the assets are intact, and what to do instead.

pie start reported success into a process with no viewport. With no interactive editor, execution fell through to a -NullRHI commandlet, so the session was started somewhere nothing could see or record and the following pie state reached a different process. pie, screenshot, sequence, record and input require a live editor and refuse with a reason; logs deliberately still works headlessly.

A batch left its assets bound in the namespace it ran in, so the next batch could not re-create them. Found while writing the re-run tests for the nine above: applying the same batch twice failed at op 0 with “registry entry still present … (force_delete_asset didn’t clear it — possibly locked or referenced by a loaded package)”. Every clause of that is wrong. Measured after the failing delete, the object was resident, the package was resident, and the .uasset was still on disk — nothing had been removed at all. The registry row existed BECAUSE the object did (GetAssetByObjectPath synthesises an FAssetData from a live UObject, AssetRegistry.cpp:3185), and AssetTools::CanCreateAsset refuses on exactly one thing: a StaticFindObject hit (AssetTools.cpp:4839).

The holder was ours. Every op binds its result to a name — _bp_0, _graph_1 — and each is an unreal.Object wrapper that UE’s Python plugin roots through an FGCObject. Both hosts keep the namespace afterwards: UE’s remote execution execs into the editor’s persistent console globals, and the local server’s per-command dict is self-referential through the functions defined in it, so only a full cyclic GC frees it and the next command runs first. ObjectTools::ForceDeleteObjects was therefore refusing a live reference — correctly.

It was not only the batch. A try: is not a scope, and every snippet the CLI sends was merely indented into one — so cli.py describe, a READ, bound bp and graphs at module level and broke the next WRITE: create, describe, re-create, and the third failed. cli.py inspect did the same. Both now succeed.

The batch body runs inside a function now, and so does every snippet (_cli/_execution_setup.wrap_snippet, which the test framework’s runner also uses). The two are not redundant: a batch reaches the editor through execution.py from cli.py apply, and directly from run_in_editor.py in the integration suites, so each has to hold on its own. Returning releases the frame and its wrappers, structurally rather than by a cleanup list a future op could forget. The four batch-state names stay global, where EXCEPT_BLOCK reads partial progress. Proven by A/B in one editor: the same work bound at module level made the next apply fail, and bound inside a function it succeeded. Cost: +2.45% generated tokens, re-baselined against the codegen budgets.

The postmortem was rewritten with it — it now tells a resident object from a stale registry row from a real name collision, and names the holder rather than guessing at it, so a Python wrapper still holding the asset reads as FPyReferenceCollector instead of [[native reference]]. Re-running a whole batch, create_blueprint included, is now the tested path; the suites that had to split the create out no longer do.

  • Gameplay Ability System (gas): ability / effect / cue Blueprints, attribute sets and the gameplay-tag registry.
  • Data Tables & Data Assets (data): row CRUD, search / sort / batch, CSV + JSON import-export, composite tables with override / conflict inspection, structured Data Asset values.
  • IK Rigs & Retargeting (ik) and Pose Search / Motion Matching (pose_search): solvers, goals, retarget chains, schemas, databases and interaction assets.
  • Chooser Tables (chooser): data-driven selection tables and proxy-table indirection.
  • Textures (texture): render targets, collections, sub-UV and media textures, plus property tuning on any of them.
  • Physics (physics + physical_material): collision channels / profiles, physics settings, the physical-surface registry and physical-material assets.
  • Landscape, Groom & Hair, Paper2D, Fonts, and Touch Interfaces.
  • Niagara emitter-stack modules and script graphs, PCG graphs, Cinematics / Sequencer (bindings, tracks, keyframes, camera cuts, sub-sequences) and Gameplay Cameras (variable collections, assets, rigs, shakes), all with node search and auto-layout. Materials, Audio and Meshes gained full coverage too.
  • Per-level World Settings + GameMode override, World Partition Data Layers, runtime-grid configuration and streaming toggles, and Levels-window state (current level, visibility, lock, lighting scenario, move-actors-between-levels) — each with a diagnostic when no World Partition map is open.
  • Profiling (profiling): Unreal Insights trace capture + headless TraceServices analysis (frame stats, percentiles, bottleneck timers, counters), the CSV profiler and a live perf snapshot.
  • Debugging (debug): console / cvar / log-verbosity control, the Visual Logger (record + headless analysis), and replay / gameplay-debugger / debug-draw / audio-debug / Chaos Visual Debugger tooling, per tool.

A declared default is now the one the editor applies

Section titled “A declared default is now the one the editor applies”

Param.default had exactly one reader: the schema formatter, which renders it as the =256 column an agent sees. What an op actually did when a caller omitted the param was a second, independent copy hard-coded in its codegen, and nothing compared them — so the schema could promise one number while the editor used another, and only running it would tell you.

Measured before changing anything: 823 params declared a default, 17 of them read it back as None because the .get carried no fallback, and 42 more applied a real default the schema never mentioned. An agent was shown (optional) and had no way to learn what omitting the param would do.

The declared value is now filled in at the single point where codegen runs, so the promise and the behaviour cannot drift. Adopting it was proved, not assumed: it changes not one generated byte across 2,564 ops, and that proof is kept as a test. It paid for itself on the way in — set_numeric_text_config writes its interpolation settings only when the caller asks for one of them, so giving those params defaults would have made every call overwrite a widget’s interpolation config, including calls that only meant to set the numeric type. They carry no default, and the code says why.

The 42 invisible defaults are declared now, so the schema shows them. 78 descriptions that restated a default the column already renders were trimmed: a restatement is a second copy that can disagree, which is the whole defect in miniature.

Two documents, because there are two audiences

Section titled “Two documents, because there are two audiences”

Publishing to Fab had been impossible for some time. The listing read its “what’s new” field from the repo-root CHANGELOG.md, whose newest section had reached 41,701 characters against a 4,000-character limit — so assembling the listing failed before it began, and took the upload flow and its regression test down with it.

Condensing this file would have lost the record and regrown by the next release, because a development log grows without bound by design. So the store note is its own document now. This one goes on saying what changed for whoever maintains the plugin.

The guard meant to catch this had hard-coded the file it measured rather than reading which file the listing configuration named, so it was watching a document that no longer fed Fab. It follows the configuration now, and a separate test fails if the two are ever pointed at each other again.

A self-hosted licence server could not have answered a single query

Section titled “A self-hosted licence server could not have answered a single query”

The entitlement path imports the simulation module before it can establish there is no simulation to honour, and the payload half does the same. Neither was in the manifest that decides what a deployment copies — nor was the module that provides the plugin directory, which the payload module imports at import time. A studio’s own box would have raised ModuleNotFoundError on the first question anybody asked it, and the self-hosting guide’s minimal-tree list was missing a file too, so following the documented procedure produced exactly that box. Both are declared now, with the reason, and the guide moves with them.

The two entries those eight left open, and the four things measuring them found

Section titled “The two entries those eight left open, and the four things measuring them found”

Both are closed. Neither closed the way the report predicted, and that is most of what this entry is about: every count in it was checked before it was acted on, and half the first one dissolved.

Twenty ops that documented a return they never wrote — ten of which were fine. Nine spread a parsed C++ payload (_op_extra.update(...)), so their key names live on the other side of the language boundary and reading only the Python called every one of them silent; a tenth emits its second key only when a parameter asks for it, and says so. The scan asks the C++ now (tests/framework/cpp_json_keys.py) and generates each op under more than one parameterisation. Two more corrections came out of the same check: ctx.add_result IS a real channel — it writes the batch-level results array — but it is written at codegen time, so it can carry a static key and never a discovered one; and produces_asset is NOT one, though every create_* op set it and it looked like one.

Then the blind spot: 64 ops document returns and ship no example, so a fifth of the contract had never been generated at all. Ten of the fifteen real defects were in there. They are scanned from synthesised parameters now, and there is no baseline left — the registry is checked whole and the answer is zero. With it empty, the rule moved out of a test and into OpDef.__post_init__, where a documented return with no way to surface it fails at import.

Three ops could not run at all. read_document, write_document and describe_document_formats were written as codegen(op) returning a dict — a shape the runner has never called. validate accepted them; apply raised TypeError before the editor did anything; and every one of their unit tests called the functions directly and passed.

Eleven ops printed their own result envelope, and that was worse than the missing data. parse_result returns the FIRST BLUEPRINT_RESULT: payload, and the batch prints its own last — so an op that printed one won, and the batch’s success, ops_executed, results and op_results were parsed away with it. A batch containing any of the seven find_* queries, check_dependency, get_dependencies, get_referencers or reveal_file reported that op’s hard-coded success: True even when a later op failed. Thirteen more in the anim domain were the same. One envelope per batch is now an invariant checked against every op, not a list.

Ten call sites that saved through the interactive path and wrote nothing under a headless editor: PromptForCheckoutAndSave delegates for an unattended script (a commandlet) and returns PR_Cancelled for an unattended editor (every automation run, and the panel under an unattended boot). All ten discarded the return code, and four were void UFUNCTIONs that had no way to report a failure at all — SaveEnum, SaveStruct, SaveDataAsset and SaveDataTable return FString now. There is one save left in the plugin, BlueprintAI::SavePackageChecked, and it verifies the FILE afterwards, because the engine returns success for a package it skipped.

490 ops sent their answer to unreal.log and surfaced nothing. Written down first as 432, which is what a sweep for unreal.log(<call>) could see; three further shapes appeared as the detector improved — 103 emissions built inside describe_* factories, 54 values interpolated into a log message, and 23 written with print, which is the same stream BLUEPRINT_RESULT: is parsed from and so was the find_* envelope defect above reached by another road. Every read op now answers under one key, result, parsed when the payload is JSON and kept as text when it is not; one key rather than a per-key map because a describe_* payload’s keys are the asset’s own property names, walked at runtime with TFieldIterator, so a static map naming them would be fiction.

Both halves of the contract are structural now, in OpDef.__post_init__: an op that surfaces must document what (396 → 0 violators), and an op that documents must declare a mechanism to surface it (26 → 0). A violator raises at import instead of being counted by a test. Registry-wide: 2591 ops, 338 → 848 documenting returns — 510, twenty more than were rewritten, because the rest already surfaced something and had simply never documented it. 0 unsurfaced keys, 0 ops printing their own envelope. 270 op modules import the emitter, and 99 assertions across 81 files that pinned the defect were retargeted.

Four ops generated Python that did not parse, so every batch containing one failed before any op in it ran — including the ops that were fine. load_behavior_tree and load_blackboard put a quoted path inside a quoted assert message and had been dead since they were written; get_bt_node_property and get_actor_material_slots were broken by this campaign’s own sweep. A per-domain codegen test asserts on substrings of the generated text, and a substring is found just as happily inside source that cannot execute — so test_generated_python_parses now parses every op’s output, and emit_answer takes an indent= rather than leaving callers to prefix the interpolation (which indents one line of four).

A describe_* of an asset that is not there no longer answers ok: true. The C++ returns {"error": ...} for a missing asset or a class mismatch; surfacing the payload put the message where a caller could see it but left the verdict wrong. It fails the op now — on the exact one-key shape, so an asset owning a property called Error is unaffected.

One tool had the defect it was used to measure: the token-budget regenerator rewrote _token_helpers.py, which only imports the totals, and its brace tracking treated DOMAIN_TOKEN_BUDGETS = {} as an opening brace — so the totals were never reached and every regeneration reported “no change (idempotent)” over a stale number.

Eight reported op-surface defects, and what they had in common

Section titled “Eight reported op-surface defects, and what they had in common”

Found in one afternoon building match-start components in a real game, and written up in Docs/dev/known-limitations.md before being fixed. Seven of the eight shared a shape: the op reported success for something that did not happen, so validate --stdin accepted every one of them — it resolves ops, it does not predict what the editor will do with them.

  • get_component_property, get_variable_default and get_components answered ok with no data. Each ended in unreal.log(...), which puts the answer in the engine log — where the caller of apply cannot reach it. get_component_property needed more than a wire: the C++ returns "" for a missing component, a missing property and an empty value alike, so it now goes through describe_component_property, which tells the three apart and names what DOES exist. A new registry-wide test runs every op’s codegen and compares what it writes against what its schema promises; 20 more ops are in the same state and are pinned as a ratchet.
  • override_function could not override a parent Blueprint function. It routed on FUNC_BlueprintEvent, which a Blueprint function carries — that flag is how Blueprint overriding works — so it added a disabled ghost event node instead of the override graph. The editor’s own decision refuses any function with an output parameter; that is the call now. A ghost node is promoted rather than returned as-is, an existing override is never duplicated, and describe_function_override says what an override would do before you do it.
  • Wildcard array pins could never be typed. Every call node was spawned as a plain UK2Node_CallFunction; an ArrayParam function needs UK2Node_CallArrayFunction, which is the only class that propagates a type into a wildcard. So Array_IsEmpty stayed Array<wildcard> and the Blueprint refused to compile, and re-making the connection could not help. Nodes come from the engine’s own spawner now, so the class is the editor’s.
  • Node ids identify one node. A re-run of a partly-failed batch used to create every node again beside the first set; claiming an id now takes it, and says so.
  • connect_exec’s output accepts the words it documented. true/false, loop/done, cast/failed and a bare index all resolve to the pin that means them — while a literal pin name still wins, so no correct call changes behaviour.
  • cli.py run --stdin no longer throws away its own answer. A top-level JSON array killed the annotation step with a TypeError after the editor had done the work.
  • list_actors reported every actor at the origin — 4459 actors, one distinct location, on a map that was loaded rather than opened. ComponentToWorld is not serialised, so a world with unregistered components has none; the query resolves it the way the engine does. get_actor_info and duplicate_actor had the same bug, the latter silently placing copies at the origin.
  • delete_asset left the file on disk and reported ok, so the asset came back on the next editor start. The engine’s delete returns TRUE for a loader-pinned package it skipped; the op read that bool. It verifies the package now.

Two things surfaced while fixing the last one and are fixed with it: save_blueprint could not save under a headless editor at all (the interactive save path returns PR_Cancelled when unattended), and 117 of the 146 functions in the Blueprint API reference named the wrong library, with two naming functions that exist nowhere. A test now checks every row against the headers — a wrong attribution is an AttributeError inside the editor, which nothing else catches.

Recording the viewport, and being able to ask it questions

Section titled “Recording the viewport, and being able to ask it questions”

The verify loop could take a screenshot, and a burst of them — both of which need the moment decided in advance. Recording removes that: start it, do the thing (or let somebody play), stop it, and then ask the recording when anything changed.

  • record start / mark / stop / state capture the active editor or PIE viewport to a Motion JPEG AVI a person can play, with a recording.json beside it holding the real per-frame timing. Frames come off the back buffer through the engine’s own FFrameGrabber and are encoded on a worker thread, so a recording does not create the stutter it would then appear to have recorded. mark names an instant so it can be found again.
  • record summary is the step that makes a recording answerable. A minute at 10 fps is 600 stills nobody can read; the summary is one PNG grid of timestamped stills, spent on the frames that changed rather than on an even sweep of the clock — so a minute where the camera never moved does not fill every cell with the same picture. Marked seconds are always kept and their labels printed on the cell.
  • record frames --at <seconds> returns that second at full size, as the exact JPEG bytes the capture stored — no decode, no re-encode, nothing between the reader and what the viewport showed.
  • A recording that ends in a crash is still readable, which is usually the one worth looking at: the manifest is flushed while recording rather than only at the end, and the video is walked chunk by chunk instead of trusting an index that was never written. A file torn mid-frame reports truncated and hands back everything before the tear.
  • Dropped frames are counted and reported rather than silently closing the gap, and frames_pending says the encoder is falling behind before anything is lost. max_seconds is capped, with no unlimited setting — a recorder nobody stopped fills a disk.

set_pin_default and the defaults on node ops author a literal of any pin category. A failure names the pin, its full type, the node, the value and the spellings that category accepts, and resolution probes no longer log engine warnings on a miss.

  • Class, object, interface and soft-reference pins can be set at all, and a class can be named the way you have it: short name, /Script path, Blueprint asset path (/Game/UI/BP_Hud, no _C), generated class path or export-text wrapper. Empty or None clears the pin. Add Component by Class and every other class-pin node were unreachable before this.
  • Text pins work.
  • Struct literals are proved against the compiler: R=1.0 G=0.5 B=0.0 A=1.0 on an FLinearColor pin used to compile to black, and X=1 Y=2 Z=3 on an FVector pin was refused. Both are now rewritten to export text and imported through the backend’s own call.
  • Enum pins take the name you have seen: the internal NewEnumerator0, the display name the editor actually shows, or the numeric value. A refusal lists every enumerator in both spellings.
  • Split struct pins are refused with the names of the sub-pins to set instead of silently swallowing the write.
  • Reading a literal back (get_pin_default_value, the graph description) reports class, object and text literals instead of showing them unset.

Delegate handlers, and retyping a variable

Section titled “Delegate handlers, and retyping a variable”
  • A dispatcher’s handler is a custom event again. add_create_event_node was the only op that could feed a Bind node’s Event pin, so every binding grew a node that is not the handler. add_custom_event_for_delegate now adds a custom event carrying the dispatcher’s exact parameters and wires it straight in — the editor’s own “Add Custom Event” from a delegate pin — and bind_event_to_delegate binds an event node the graph already has. Create Event is documented as the last resort, for a handler that must be a function graph.
  • add_assign_delegate_node’s custom event is reachable. It spawned one and gave the caller no way to name it, so execution had nowhere to flow from; it is now addressable as <id>_event.
  • A mismatched handler is refused with both signatures. The schema cannot do this on its own — it allows any delegate connection whose signature does not resolve, which every uncompiled custom event’s does not. A custom event carrying no parameters of its own still takes the delegate’s, which is what the engine does at the next reconstruct anyway.
  • set_variable_type / set_local_variable_type retype an existing variable, re-resolving every Get and Set node that reads it. remove_variable + re-add orphans them, which is a re-authoring rather than a migration; this was the last open entry in the known-limitations list. The engine’s modal “this could break connections” confirmation is answered in advance through its own ini opt-out and restored afterwards, so the op neither blocks an interactive editor nor silently does nothing under -unattended.
  • add_variable takes any type the schema advertises. It promised structs, classes and enums and then screened the name against a nine-entry primitive table in Python, so the op refused the very types its own description named. That gate is gone; every add op resolves through the same call that pins, parameters and set_variable_type already use.
  • The C++ spelling works everywhere a type is named. Reflection strips the prefix a header carries, so FVector, FHitResult, AActor and USceneComponent used to name nothing. A single leading F/U/A/I is now retried after the exact name misses — never before, so a real FFloatRange cannot be shadowed — and int32, integer64, uint8, FString, FName and FText joined the primitives. Enums are deliberately left alone: a UEnum keeps its E.
  • The typed-ref ops take a short name. add_class_variable, add_enum_variable, add_soft_object_variable and add_soft_class_variable required a /Script path; add_class_variable’s own shipped example, /Script/Engine.APawn, resolved to nothing. A short name, a /Script path, a /Game Blueprint path and the C++ spelling all work now.
  • A container is part of the declaration. All eight add ops — including the four typed refs — take the same container / value_type pair as the retype ops, so TArray<TSubclassOf<AActor>> is one call rather than a create-then-retype pair.
  • Re-adding a variable is idempotent, and a type conflict is refused. The same name with a different type used to report success and quietly keep the old type; it now names both types and points at set_variable_type, which preserves the Get/Set nodes.

The Project Wiki is a skill your agent has, not one it has to find

Section titled “The Project Wiki is a skill your agent has, not one it has to find”
  • A wiki skill is installed beside unreal and integrations, in every configured agent’s skill directory — .claude/skills, .agents/skills, .cursor/rules, and the rest. It is the wiki as an agent needs it, and it is read-only: retrieve grounded passages before answering anything about the project and answer from what comes back; what an empty result means, and how that differs from a share this machine cannot reach; how to attribute a passage that gives you a heading and an opaque id rather than a filename. Until now the retrieval operations were reachable only by an agent that thought to go looking for them in schema, and nothing anywhere said that answering a design question out of its own memory was the wrong move.
  • It says which operations are not the agent’s, by name. The wiki indexes itself — on editor open and on every document change — so there is nothing for a model to build, and schema lists the authoring, indexing and curation ops right beside the two that answer questions. The skill names all nine and says why each belongs to BlueprintAI or to the people who write the documents; a wiki op added later fails a test until it is classified one way or the other.
  • It arrives and leaves with the feature. The skill is installed exactly while this machine may use the Project Wiki — a source checkout, or a subscription that grants it and a payload that is installed — and removed when it may not. That second direction is the one that matters: a subscription that ends leaves the file where it was and leaves the payload installed on purpose, so without this an agent would go on reading a skill for a feature whose every tool call now comes back refused. When it is withheld, the run says which of the three reasons it was, because a paying subscriber whose download broke is the one state that is impossible to diagnose from the outside.
  • A licence that changes mid-session is picked up mid-session. Activating a subscription used to unlock the panel immediately and leave the agent unaware of the wiki until the next editor launch. The feature-gate transition now re-runs the installer, in both directions.

Opening the editor no longer re-indexes the wiki

Section titled “Opening the editor no longer re-indexes the wiki”
  • The wiki re-derived itself on every editor open, on a project nobody had touched. The freshness check was right every time — it reported that wiki-asset-types.json had changed, and it had. The map is written by the editor from the asset registry, and three separate things in that pass read live editor state rather than what is on disk, so its bytes depended on which assets somebody happened to have open: an asset’s native parent class is re-derived by the engine the moment a package loads (/Script/Engine.ActorComponent before, /Script/SampleGame.Skill after, from the same registry, in the same session); a class chain was resolved only for classes already in memory; and a renamed class (UserDefinedStruct, ControlRigGizmoLibrary) was recorded under its old name until a load applied the redirect. Measured on a real project, opening two dozen Blueprints between two passes moved 28 of 201 class chains and 8 asset entries. Every one of those is a change to a 2.6 MB committed file and an input of the index — so concepts, entities, the retrieval store and twenty-one restricted shares were rebuilt on every single open, for ever. The map is now derived entirely from tags a load cannot move, with class renames applied always rather than only when something is loaded, so two sessions agree. It is also more accurate than it was: a Blueprint four steps down a chain of Blueprints now reports the C++ class it actually derives from, which the engine’s own saved tag did not.
  • A package’s two registry rows no longer race. A Blueprint package holds a row for the Blueprint and one for its generated class; the map is keyed by package, and which row won was decided by an unstable sort. The order is total now, and the package’s own asset wins.
  • A pass with nothing new to say leaves the file alone, instead of rewriting 2.6 MB and showing up as a modified file in version control on every open.
  • A rebuild now says what moved. The reason names the file behind the input (asset_types (project:.blueprintai/wiki/wiki-asset-types.json) changed), and a wiki-index-why line per moved input carries both identities — the before and the after. Finding the defect above took a measurement rather than a reading, because no line ever said more than the name of a fingerprint.

The wiki no longer throws away every asset description when the plugin is edited

Section titled “The wiki no longer throws away every asset description when the plugin is edited”
  • A stored description was discarded whenever anybody edited the plugin’s C++ — any of it. Descriptions are kept only while the thing that wrote them has not changed, and that identity was computed over every .cpp/.h under Source/: 3,039 files, of which 929 are the panel’s Slate widgets and 106 the wiki’s own retrieval code. So saving a widget discarded every stored description, and the pass that rebuilds them — which takes minutes — began again from zero. Measured on a real project, from the sidecar’s own diagnostics: described: 1456, remaining: 1344, reused: 0. That pass had therefore never once finished: each session re-described about half the assets and lost them again, so a bit under half the project had prose and the rest never did. The identity is now derived: a module counts if it declares a UFUNCTION named Describe*/Inspect* — the same convention the dispatch reflects over at runtime — or if such a module names it in its .Build.cs. 2,023 files across 131 of 142 modules, and the two modules a person edits while working on the wiki are out of it.
  • It is not a speed change. The whole computation went from 680 ms to 671: deciding the scope costs what the smaller stat and hash give back. What it buys is that the answer stops moving.
  • Every header is read, not only the public ones. UHT parses a module’s whole source directory, so a describer declared in a private header is ordinary and dispatchable; reading only Public/ would have been 442 headers of 1,007 and an assumption with no rule behind it, in the direction that fails silently.
  • The match is anchored on UFUNCTION. The panel alone spells eighteen Describe*( names — DescribeInputs, DescribeGlyphs, DescribeFailure — and not one of them describes an asset.
  • A plugin installed under a directory called Tests hashed no C++ at all. The packaged-plugin exclusion was tested against the whole absolute path rather than the path inside the plugin, so such an install silently stopped watching its describers.
  • The same tree no longer hashes in two different orders on two platforms. File paths were sorted as Path objects, whose comparison is case-folded on Windows and left alone everywhere else — the two orders diverge at the second file here. The note is shared, so that was a full re-describe for a teammate on another OS, every time either of them indexed. The order is now the POSIX path relative to the plugin, through one function that both the one-shot listing and the editor’s resumable build call.
  • A module’s .Build.cs is hashed with its sources, because it now decides which modules are in scope — and a define added there always did change what the compiled describer does.
  • 148 tests covering the inspection dispatcher ran nowhere. The unit runner skipped every directory whose name begins with _, in the all-domains shard too, so tests/unit/_inspect/ — the dispatcher, the fallback, auto-registration, priority bands and the four stub-contract tiers — was collected by nothing. The rule was spelled twice and both copies had it; there is one spelling now, and it excludes __pycache__ and dot-directories rather than every underscore.
  • 22 of them had rotted unnoticed, all from one cause: project_left_clean looked up unreal.BlueprintAIAssetOpsLibrary unguarded, while its own contract is to do nothing against a binary that predates the two functions it wants. That skew can be wide enough that the library is absent entirely, in which case an inspection raised rather than skipping its tidying up.
  • A new guard states which describer covers each domain. Every C++ module registered as a domain either declares an asset describer, or is recorded with the describer that covers what it authors — and the record carries its own proof, so deleting that describer fails the guard instead of leaving a domain unreadable. It complements the live-engine routing test, which measures per asset TYPE and cannot see a type that is imported rather than created.

Syncing documents now indexes them fully, or says what it could not

Section titled “Syncing documents now indexes them fully, or says what it could not”
  • A sync brought the documents down and then ran one sixth of the indexing. “Indexing” a wiki is six passes in dependency order — the asset-type map, the descriptions, the index itself, the store fill, and the two link passes — and a sync ran the index and stopped. So a document that arrived was in the graph and findable by NAME, with its passages left unembedded (only a process holding the encoder can embed them) and the link sidecars still describing the corpus as it was before the sync. Findable by meaning, it was not there at all. The sync drives the whole cycle now.
  • Five of those six passes need the editor, and a sync has none — so they are DECLINED rather than attempted. That distinction is the whole of the change, because each of those passes has a graceful answer for “there is no editor here” and each answer was being WRITTEN DOWN as though it were a fact about the project: a description stored as “could not be described”, which is a settled answer nothing ever retries; an empty link sidecar written over the links the editor derived, carrying the identity that stops anything later noticing they had gone (measured on a real store: 6 semantic links to 0, 24 concept links to 0); and the asset-content map deleted, which sent the freshness gate back to modification times and had it disagree with itself on every alternate run — a full enrichment cycle every fifteen minutes, on a project nobody had touched.
  • “There is no editor here” is not a reason to leave the work undone. A sync that cannot enrich in-process now reaches an engine that can — it defers to a live editor, whose pump runs the passes a slice at a time, and otherwise boots the headless commandlet itself. Measured on a real project: nothing declined, settled in one cycle, 29.5 s warm and inside a 549.9 s cold re-index of 21 store indexes.
  • What it cannot finish is OWED rather than half-done, and it says so. The report carries which passes did not run, which engine finished them, and why if none did; the terminal says one sentence about it. It is not reported as a failure: the documents and the index agree, which is what the step is for. When no engine answers, the error now carries the execution layer’s own diagnosis — which server, which script, what the engine said — instead of the bare “did not answer” a discarded capture used to leave behind.
  • The editor missed every sync that only DELETED documents. It decided whether to re-fold its corpus by counting arrivals and merges, while the CLI decided by asking all six of its steps — two answers to one question, and a withdrawal matched neither counter. So the wiki went on answering from documents that had left every machine, and the enrichment was never told. The panel reads the CLI’s own answer now.

A committed wiki index is reusable by the team that committed it

Section titled “A committed wiki index is reusable by the team that committed it”
  • The freshness note was fingerprinted with one machine’s absolute paths, so no teammate could ever match it. Every path in that note is named by its relationship to a root — project:.blueprintai/local/team/wiki-backend — except one. access.sources used each source’s declared root verbatim, on the reasoning that a declaration is committed text and therefore the same sentence everywhere. True of the root a studio writes (${SHARE}/wiki); false of the one the team layer synthesises for a project that declares none, which is an absolute path on the machine that built it. On such a project — twenty-two sources on the game’s own — the note named a directory nobody else has, so a teammate who received a fully-built, committed index re-derived the entire wiki on every open. That is the exact failure the feature exists to prevent, sitting in the one input exempt from the work that prevented it everywhere else.
  • It did not match on one machine either. A verbatim string skips the normalisation every other path goes through, so the four ways one machine can spell a directory are four identities. Measured: the editor’s build recorded 2d5b0f0e and a headless python -m wiki_helpers recorded a097ec41 for the same twenty-two sources, each invalidating the other’s note — reproduced by settling the index, booting an editor over it, and watching the note change in sixty seconds.
  • A retrieval store that cannot be read is named by its KIND, not by the error. The identity recorded for an unreadable store was the failure sentence — which quotes an absolute path and an OS message. It only appears when something is already wrong (a store that has not synced yet, a fill interrupted half way, a locked file), which is the machine least likely to report it, and it made that machine’s note match nobody. It records absent or unreadable now; the sentence is still reported, as a diagnostic, which is where it was always useful.
  • A root that genuinely cannot be named portably is now reported, alongside the build parameters that already were. A share on a drive letter each teammate picks is a real thing, and until now the only evidence of it was a wiki everybody rebuilt.

The project is walked once per re-index, not once per corpus

Section titled “The project is walked once per re-index, not once per corpus”
  • A wiki open re-derived twenty-two corpora and walked the project for every one of them. A run indexes the project’s own documents plus one store per restricted share, and every store draws the PROJECT’s assets so a restricted note can still say what it is about — so each of them walked the whole project and read every source file in it. Measured on a real project from the store indexes’ own performance records: 21 shares holding 13 documents and 237 entity nodes between them spent 13.51 s of their 14.45 s in the entities stage, for an answer that was the same every time. The vocabulary and the 2.6 MB asset-type map were parsed twenty-two times beside it. All three are read once for the run now (13.51 s → 1.99 s measured on the same project), and each index records whether it paid for that walk or was handed it — a fact in the artifact, so a regression is visible without a stopwatch.
  • Reading the type map once is also one ANSWER. The editor’s enrichment cycle rewrites that file while the indexer runs, so twenty-two separate reads could describe the same project two ways inside one set of indexes.
  • The walk itself is a third faster. It named every file with Path(current).joinpath(name).relative_to(root) — two Path objects built and their parts compared, per file. Over 20,761 files that was 1,470 ms of pure string arithmetic. The directory is the same for every file in it, so the relationship is computed there and the name appended: byte-for-byte the same answer, 3.3× faster. This walk also runs on the editor’s game thread in 8 ms slices, so it buys frames as well as seconds.
  • Every corpus is filed under the project’s categories. A wiki has many corpora and one chip row, and write_index has a knob that says so — which the store builder never turned. Every restricted share resolved wiki-categories.json from a share root that has none and was filed under the built-in twelve, so a studio that switches a category off saw it come back on a share’s documents, and one that declares a category of its own never saw it there at all.
  • A markdown link now resolves as a path. The wiki resolved both link forms through one flat name table — title, alias, filename stem, vault-relative path — but a markdown href is none of those: it is a path relative to the document that wrote it. So ../../hub.md was looked up under that literal text and matched nothing, and even a same-folder sibling.md missed the stem sibling over the extension alone. Measured on this plugin’s own docs: 156 links reported unresolved, 155 of which pointed at documents that were right there, every one of them silently missing from the graph — so the API hub, the most linked page in the reference, was drawn as an orphan. A [[wikilink]] still resolves by name first and a markdown href by path first, each falling back to the other, so nothing that resolved before resolves less now.
  • An unresolved link says which failure it was. no-such-target is a typo; outside-vault is a good link to a real file that has no node in the graph (../../../Skills/unreal/SKILL.md). They are not the same problem and only one is a defect.

A background operation can no longer look stuck

Section titled “A background operation can no longer look stuck”
  • The Claude CLI update says what it is doing. claude update fetches a binary of over 300 MB and prints nothing at all while it does — a run measured here spent 10 minutes 8 seconds in that silence — and the panel was throwing away the few lines it does print. The banner sat on one unchanging title for the whole run, which is indistinguishable from a hang. It now follows the updater’s own stages and names the version being fetched (“Downloading Claude CLI 2.1.241”).
  • An operation that cannot be counted now says how long it has been running. Every long-running operation with no measurable rate — a CLI update, a native install — used to show one still line under a sweeping bar for as long as it lasted. It now carries an elapsed time, which is the one number that is always measured and never guessed. It appears only where there is no remaining-time estimate: the reader asked how much longer, and a second duration beside the answer is a puzzle. (The hub’s own clock also used to stop for exactly these operations, so even a correct line would have frozen at its first value.)
  • A failed update is reported, and stops repeating. A non-zero exit took the banner down in silence — indistinguishable from success — and left the 24-hour marker untouched, so the ten-minute download was re-paid on every editor launch, forever, for a CLI that already worked. A failure now surfaces the updater’s own diagnosis and backs off for four hours; a run that did nothing because another Claude process held the update lock no longer counts as a day’s work done; and an update that never exits is cancelled at a ceiling of its own rather than holding a banner for the rest of the session.
  • The progress bar has room to breathe. It ran edge to edge across the panel; it is now inset to the same gutter as the header above it, in both the minimized rule and the expanded banner.
  • The wiki’s progress no longer skips ahead and comes back. Building a wiki runs several passes at once, and the banner picked which one to show afresh on every report — so a pass that was counting could lose the banner to a shorter one that had just started, and get it back when that finished. Measured here: “Reading the project’s assets” counted to 1,535, was replaced for 49 seconds by “Preparing the wiki for search”, and came back at 1,630. Two counts of different things, read as one series that stopped, jumped a step ahead and went backwards. A pass now keeps the banner for as long as it is running, and hands it on only when it genuinely ends — so the numbers in front of the reader are one series, and the remaining-time estimate (which is discarded whenever the unit changes) survives the interruption instead of being thrown away twice. The re-index the pass sets off is not hidden work: it is another part of the same build the banner is already reporting, and the log now names every handover and how many passes were live at it.
  • “Linking notes to the project’s files” has a bar and a remaining time. A link pass spends its first minutes building itself — reading the index, loading a multi-megabyte search store, walking the project, encoding the corpus, building the search table — and none of that has anything a reader would count. So it reported nothing, the bar swept, and the shared progress layer, which derives “about two minutes left” from whatever the bar is drawing, had nothing to derive one from. Measured here: 36 seconds in a single one of those steps, and 87 seconds once, under “Loading the wiki’s search index — nothing to count yet — running for about 20 minutes”. Each pass now places itself in its own whole, weighting every step by what that step cost the last time it ran on this corpus and this machine — a measurement the previous run left behind, not a guess; a project with no such record weights its steps equally and leaves the next run a real one. The preparing and the asking are ONE bar rather than two, so it fills once instead of filling, resetting and filling again; the count it gives up (“42 of 300”) moves to the line under it, in the same words as before. The four passes that genuinely cannot place themselves are untouched, and the second line for a preparing link pass no longer says “nothing to count yet” — with a bar that moves and a countdown beside it, that clause was arguing with both.
  • 54 broken links in the shipped docs now resolve, and a guard holds the tree at zero. Splitting Docs/api/ into graph/, asset/, editor/ and world/ broke links in both directions and the repair was only half applied: 26 moved pages still opened with [API Quick Reference](../api-quick-reference.md) while the hub sits two levels up, and the hub itself still pointed 14 rows at the pre-split flat paths. 18 siblings already had it right, which is exactly the shape review does not catch. The same thing had happened twice more: the Agent SDK reference linked four pages that live one folder over in tools/, and the monetization notes cited thirteen source files by a path from the UE project root rather than from themselves.

The first public version of Blueprint AI ships with the full surface described in the rest of these docs.

  • The initial asset-authoring surface, covered end-to-end: Blueprints, Animation Blueprints, Behavior Trees, State Trees, Enhanced Input, Control Rig, UMG Widget Blueprints, Common UI, Curves, Environment Queries, Static / Skeletal Meshes, Materials, Niagara, Sound, and MetaSound.
  • Validated JSON operations: every change goes through the engine’s own schemas before any Python runs, so what lands in the Content Browser compiles cleanly.
  • Auto-layout on every graph mutation: deterministic six-phase algorithm, reroute knots for long wires, pure-getter dedup. No spaghetti graphs after an AI edit.
  • Declarative specs for higher-level assets (Behavior Trees, State Trees, Animation state machines), the AI describes the structure once and Blueprint AI decomposes it into the underlying ops.
  • Auto-installed skill files for five assistants on first editor launch: Claude, Cursor, GitHub Copilot, Windsurf, Codex.
  • Bundled in-editor chat panel (Claude), dockable tab with chat, embedded terminal, attachments, slash commands, @-mentions, and a live context-usage ring.
  • Bring-your-own path via the self-documenting CLI for any other assistant that can shell out.
  • Embedded terminal backed by xterm.js + ConPTY / POSIX PTY, ANSI colors, scrollback, native shell history, curses-app compatible.
  • Session store as plain JSONL on disk with a file-watcher-backed Session Drawer, search across titles / content / tool names, and time-grouped history.
  • Replay engine restores message bubbles, tool-use status, attachments, mode, and context-ring state when switching sessions.
  • Five attachment types: files, images, viewport captures, editor selection, folder snapshots, with picker, drag-and-drop, paste, and live-selection toggle entry points.
  • Multi-account login with a credentials locker at ~/.claude/BlueprintAI/<email>/ for fast switching.
  • Editor-not-required operation: every op runs through a short-lived Unreal commandlet. Drive Blueprint AI from your IDE, terminal, or CI pipeline without an open editor.
  • cli.py validate for engine-free type-checks on every push.
  • CSS-flavored theming via JSON stylesheet at Resources/Theme/, hot-reloads on save, no editor restart.
  • 23-culture localization (English + 22 translations) covering the panel UI and Editor Preferences. Editor language is honored automatically.
  • Settings split between developer-local Editor Preferences and team-shared Project Settings.
  • Privacy-first telemetry, opt-in only, anonymous error reports via Sentry, no source code / chat / asset content ever leaves the machine.
  • Project conventions layered on top of bundled defaults via CLAUDE.md / AGENTS.md at the project root.
  • The bundled chat panel is currently Claude-only. Cursor, Copilot, Windsurf, and Codex get the skill auto-installed for them but drive Blueprint AI from their own UI today; bringing them into the panel’s Agent dropdown directly is on the roadmap.
  • A handful of operations need a live editor (some PIE-specific assets, certain Niagara workflows). The CLI detects these and prints a clear “this op needs an open editor” diagnostic instead of failing silently.