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.

Codex is now a full chat agent beside Claude, in the editor’s panel and in the desktop app alike. Sign in with an OpenAI API key from the sign-in screen (or Switch account); the first sign-in installs the Codex runtime, and kumo installs its skill into the project’s .agents/skills/ as you sign in, so Codex can work in the editor from its first message. The Agent selector switches between the agents you are signed in to and carries the conversation on screen across; the history lists both agents’ sessions and opens each in the agent it belongs to. A request OpenAI refuses (a wrong key, no credit) now shows OpenAI’s message instead of ending silently, and a failed command is marked as failed. Saving the agents list no longer erases the sign-in method stored next to it.

A solo developer’s projects live in their own studio, and no studio needs a subscription

Section titled “A solo developer’s projects live in their own studio, and no studio needs a subscription”

A person’s first cloud project creates their studio of one (POST /studio/own, host only): one per account, idempotent, the creator its owner and an active member with the owner role. A solo checkout buys into that same studio. A studio subscription is only ever a convenience: a studio whose members each pay for themselves works exactly like one paying through seats, and paid features are the person’s licence OR a paid seat. /studio/link now takes the identity token rather than a licence, so a member who pays for nothing still links to their studio, and a self-hosted studio with no subscription still gets its key into its service pointer.

Your host can list every hosted studio you are in

Section titled “Your host can list every hosted studio you are in”

GET /memberships, with the identity token as a Bearer, answers one signed membership per hosted studio where you are an active member: your per-studio name, the studio’s name, your project and roles when you are in one project (none when in several), and whether a seat is paid. Memberships are signed with the service’s own key, so every build verifies them offline; a self-hosted studio’s box keeps issuing its own. Studios are found through a small central index (studio_presence, migration 0024) kept by the cloud’s admission path, and each is then read in its own scope; an account in no studio gets an empty answer.

A studio buys, assigns and vouches for its own people

Section titled “A studio buys, assigns and vouches for its own people”

A studio’s owner buys its subscription through a studio checkout: POST /checkout with a studio names it, only an owner the vendor appointed (deploy.seat --appoint-owner) gets a ticket, and the payment is filed in the studio’s scope, where its seats are counted. A self-hosted studio assigns those seats from its own box (python -m deploy.seats), authenticated by its tenant credential, and every answer names people by a per-studio pseudonym. A person links their roster entry to their central account by carrying a vendor-signed link to their studio’s box when they enrol; admitting them confirms it. From then on the box’s redeem returns, beside the licence, a membership the studio signs — roles, project, and whether the seat is paid — which editors verify with the studio key the vendor’s service pointer now carries. Also fixed: the first renewal of a paid subscription re-filed it under Paddle’s customer id, so the buyer lost it a month after paying.

A studio that does not exist yet can be bought too: POST /checkout with new_studio (its display name) creates it when the payment arrives, through one audited, idempotent database function, and makes the buyer its owner. Nothing is created by a checkout nobody pays for. A studio’s box keeps the last seat state the vendor confirmed for a day through an outage, and the membership says when that was (seat_checked_at).

One account per person, and a studio can pay for it

Section titled “One account per person, and a studio can pay for it”

Accounts on the licence service are now central: a sign-in, a password and a verified address reach the same account whichever studio a person works for, where they used to belong to one tenant each. A studio pays for seats, and the operator assigns a seat to a person’s account with python -m deploy.seat; the person’s licence is then funded by their own subscription or by any seat assigned to them, and is issued and refreshed only by the central service. The token carries no new claim and never names the studio that pays. Migration 0021_global_accounts folds the existing accounts: a sign-in recorded twice for one account is kept once, an address verified on two accounts stays verified on the one that proved it first (recorded in account_fold), and a sign-in that reaches two different accounts stops the migration with the rows named.

A key binding can be changed or removed, in one op

Section titled “A key binding can be changed or removed, in one op”

remove_context_mapping unbinds one key from an action in an Input Mapping Context, and set_context_mapping_key rebinds one key to another in a single op, keeping the mapping’s triggers and modifiers. Both refuse, naming the keys the action is actually bound to, when the mapping does not exist. Until now a mapping could only be added, so a rebind left the old key bound and still consuming input.

Screenshots during Play in Editor show the game, and say which viewport they read

Section titled “Screenshots during Play in Editor show the game, and say which viewport they read”

capture_viewport / screenshot and capture_sequence / sequence read the level editor’s viewport even while a game ran in its own window, and said nothing about it; so did the panel’s viewport chip. They now pick the play window the way record start does: the first client with a window, then any play window, then the editor. pie_instance (--pie-instance N) names one, and a named instance with no window is refused rather than answered with another world. Replies carry viewport, pie_instance, pie_net_mode and world, and a burst whose viewport changes mid-run stops instead of mixing two worlds. CaptureViewport and CaptureSequence gain a trailing PieInstance argument; pass -1 for the default.

input key says which actions a key triggered, not only that it was sent

Section titled “input key says which actions a key triggered, not only that it was sent”

handled only ever meant “this key is mapped”, so it read true whether or not the bound action fired. input key --hold now waits for the release and reports key_accepted, action_triggered and triggered_actions, read from Enhanced Input’s own state each tick, with each mapped action’s trigger events, its contexts, whether anything listens to it, and a one-line diagnosis when it did not trigger. input effect <id> and get_input_key_effect read the report later. A hold also waits for the player to have evaluated the press before letting go, so a hitching editor no longer swallows a short hold. The action half needs the Enhanced Input plugin; without it the reply reports key state only and says why.

DynamicWind and compute graphs can be filled in on UE 5.8

Section titled “DynamicWind and compute graphs can be filled in on UE 5.8”

On UE 5.8 a wind setup and a compute graph could be created but not filled in. DynamicWind skeletal data can now be attached to a skeletal mesh and its enable flag, ground-cover flag, gust attenuation and simulation groups set (add_dynamic_wind_skeletal_data, set_dynamic_wind_skeletal_data_property, plus get and describe). A compute-graph kernel’s HLSL is set by name or index with set_compute_graph_kernel_source, which adds the kernel if needed and recompiles the graph. UE 5.7 behaviour is unchanged.

A property set by a dotted path tells the object which of its members changed

Section titled “A property set by a dotted path tells the object which of its members changed”

Setting a nested value such as RelativeLocation.X notified the object with the nested field as the changed member, where the engine’s own details panel names RelativeLocation. Change handlers that key on the member never ran: a component did not announce its moved transform, and a compute graph did not rebuild. The notification now names the member the object holds.

Moving an old install’s layout no longer kills a process by a recycled PID

Section titled “Moving an old install’s layout no longer kills a process by a recycled PID”

Bringing a pre-migration install up to date stopped the old commandlet server by force-killing whatever PID its leftover state file named. Windows reuses PIDs, so that number could by then belong to anything, an open editor included, which would vanish with no log line and no crash report. The server is now stopped only when the running process is still the program the file recorded.

Edits to an existing asset are saved even when the op does not call it asset

Section titled “Edits to an existing asset are saved even when the op does not call it asset”

apply saves every asset a batch changed, but it found that asset only under an op’s asset key. Ops that named it anything else changed the asset in memory and never saved it, so the change was gone after the next editor restart: the input mapping ops (context_asset), every CommonUI widget op (blueprint), the skeleton, string table, material instance and data table ops, and the style ops that set a class’s defaults by class path — 161 in all. Several create ops (CommonUI styles, action domains and tables, physics assets, LOD settings, duplicate_material) never saved what they made. Every op now declares which asset it changes, or why there is none to save, and a new op cannot be added without doing so.

Thirty asset libraries set properties the same way, and a refused value changes nothing

Section titled “Thirty asset libraries set properties the same way, and a refused value changes nothing”

The property setters of thirty asset libraries (textures, audio, Paper2D, landscape layers, HLOD layers, physical materials and more) each carried their own copy of the same code, and had drifted apart. They now share one. For every one of them, writing () to a struct is refused instead of reporting a change that did not happen; a retired (deprecated) property is refused by name; a class property such as a TSubclassOf is set from its class path in all of them, where thirteen used to try to load it as an asset; and a value the engine rejects leaves the property as it was, with the engine’s own reason in the error. The data-asset, anim-node, material, mesh user data, State Tree condition and data-layer setters refuse () too.

build_level no longer reports a navigation build that did not happen

Section titled “build_level no longer reports a navigation build that did not happen”

build_level with BuildAIPaths or a Build All answered ok even when the engine skipped the navigation build, and a save_map after it kept the navmesh as it was. It now fails with the reason: nowhere to build (no NavMeshBoundsVolume), a map that is not the open level, a play session, or another lock. A map opened in the same call is first let finish loading and compiling, which is what the engine was waiting for, so its build runs.

inspect reads assets while a play session runs

Section titled “inspect reads assets while a play session runs”

During Play In Editor, inspect answered “Asset not found” for every asset, because the editor’s asset library refuses every call while a game is running. It now asks the asset registry and loads the asset directly in that case, so an asset can be inspected while the game runs.

A placed actor’s properties can be read back

Section titled “A placed actor’s properties can be read back”

set_actor_property_value and set_actor_component_property had no way to read back what they wrote. get_actor_property_value and get_actor_component_property read a placed actor’s property, or one of its components’, as the level holds it, in the same text the setters take, and name the component that was read. get_component_property still reads a Blueprint’s component template, which a placed instance may override.

Writing () to a struct no longer reports a change that did not happen

Section titled “Writing () to a struct no longer reports a change that did not happen”

A struct value sets only the members it names, so () names none. Writing it to a struct answered success and left the struct as it was, so a tag or vector meant to be cleared stayed set. set_class_default, set_tick_defaults, set_component_property, set_asset_property, set_cdo_property and the two placed-actor setters now refuse it, and the refusal gives the text that does empty the struct. () on a struct that is already empty is still accepted.

A math operator placed by name takes its defaults straight away

Section titled “A math operator placed by name takes its defaults straight away”

add_function_call_node with a typed operator such as Subtract_IntInt and a defaults value failed with “Unsupported type Wildcard”: UE 5 places these as promotable operators whose pins stay untyped until something is connected, and in a busy graph the node could even come out as a different operator. The node is now typed as the function you named, so its defaults apply at once.

A recording plays at the speed it was captured

Section titled “A recording plays at the speed it was captured”

A recording’s AVI header named the frame rate it asked for, not the one it achieved, so a capture that fell behind played too fast in every player: 47 frames over 15.44 seconds at a requested 15 fps played in 3.13. The header now carries the rate the frames were really taken at, measured from their capture times; a recording with fewer than two frames keeps the requested rate. Recordings made before this still carry the old rate, and their recording.json timestamps are right.

inspect answers the types UE 5.8 renamed with their own describers

Section titled “inspect answers the types UE 5.8 renamed with their own describers”

On UE 5.8, inspect answered a UAF system, UAF shared variables, a UAF animation graph, an editable compute graph or a MetaHuman instance with a generic dump of every property instead of the description written for that type. UE 5.8 renamed all five classes, and inspect picks a describer by the class name it was written for, which is the 5.7 one. It now knows each class’s 5.8 name too, so the same describer answers on both engines.

An op’s own imports no longer break the ops before it

Section titled “An op’s own imports no longer break the ops before it”

The whole batch runs as one Python function, and an import anywhere in a function makes that name local to all of it. 100 ops import json in their own snippet, so an earlier op in the same batch that read its answer through json — get_class_default is one — failed with “cannot access local variable ‘json’”. A read-modify-write of a class default took three separate calls; it is one batch now. apply --profile failed the same way on any batch holding live_coding_compile, which imports time, and now times every op.

A value with quotes in it is a value, not code

Section titled “A value with quotes in it is a value, not code”

apply generates one Python script for the whole batch, and ops pasted the values they were given into it as source. A " in a Print String message, a tooltip, a map default such as (("Key", 1.0)), or the object array get_class_default answers ended a string in that script, so the batch did not parse and none of its ops ran — reported only as “No structured result returned from editor”, while validate passed the same batch. A { could become an f-string field instead. Every value now reaches the script as data. apply compiles the script before sending it and names the op a failure is in, and validate runs the same compile, so the two no longer disagree.

read_document and write_document get through apply

Section titled “read_document and write_document get through apply”

Validation held both ops’ path to the content-path rule, so since the documents domain arrived it refused their own examples (Docs/Design.docx), every project-relative path and, on Windows, every absolute one. They pass now, and a relative path is the project’s: the editor’s working directory is the engine’s binaries folder, so a relative path handed straight through would have been read from and written to there.

A server that crashes on your request says so

Section titled “A server that crashes on your request says so”

When the headless editor server crashed while running a request, the request was sent again to a fresh server. A crash the second run did not repeat came back as a success, one it did repeat cost a second engine boot, and a server that had died in three seconds was reported as timed out after 120. The engine’s own crash report now decides: a crash on your request is answered with the engine’s message and the report’s path, and is not run again. Where the engine writes no report — a server a rebuild closed, say — the request is still sent again.

Actor folders go through the editor’s folder API

Section titled “Actor folders go through the editor’s folder API”

The actor-folder ops create, rename, move and delete through FActorFolders, the API the Outliner itself uses — UE 5.8 reserves the class they called for engine use — so the Outliner’s rows and undo stay in step. Deleting a folder moves its subfolders up to its parent, as the Outliner does. A rename onto another existing folder, which the engine’s rename asserts against, and a move into the folder’s own subtree are refused with a reason; a rename that only changes case is allowed. list_actor_folders changed shape: each folder reports expanded, whether the Outliner shows it open, instead of initially_expanded, and no longer reports guid — neither has a public reader.

MetaHuman instances are authored again on UE 5.8

Section titled “MetaHuman instances are authored again on UE 5.8”

create_meta_human_character_instance and describe_meta_human_character_instance resolved nothing on UE 5.8. The engine renamed UMetaHumanCharacterInstance to UMetaHumanInstance inside the same MetaHumanCharacterPalette module — its own MetaHumanCharacterInstance.h is now a deprecated header that declares nothing and includes the new one — and the ops named only the 5.7 spelling.

Nothing could see it. The plugin is present on both engines and still ships the other three MetaHuman types, so the gate stayed on and the op reported the whole domain as “not provided by this engine version” — the one thing that was not true. Both ops now ask the engine which spelling it registers, as the media-framework, compute-graph and UAF renames already do, and a miss on both names both roots rather than sending you after a plugin that is installed and enabled. The op ids are unchanged.

State Trees take Blueprint nodes, and any node’s property is reachable

Section titled “State Trees take Blueprint nodes, and any node’s property is reachable”

add_task, add_global_task, add_evaluator, add_enter_condition, add_transition_condition and add_consideration accept a Blueprint node class (/Game/AI/STT_Foo) as well as a struct path. A Blueprint node is not a struct with a class inside: the engine places its WRAPPER struct with the class set and instances the class as an object, and AddEditorNode only ever allocated a struct — so a Blueprint task added by struct path had nothing to bind to, and there was no op that could set the wrapper’s class afterwards. The adds now go through FStateTreeEditorNode::InitializeAs, the editor’s own initialiser, and a Blueprint class is written onto the wrapper and the instance re-allocated, exactly as picking it does.

set_node_property sets a property on ANY node by GUID — evaluator, global task, task, condition or consideration — trying instance data first and the node struct second, which is where TaskClass / EvaluatorClass live; writing a class there re-allocates the instance. set_task_property with an empty state_path searches the whole tree, so global tasks are reachable. Every add_* registers its id, and every op that names a node — set_task_property, set_node_property, add_property_binding, remove_task, add_transition_condition and the condition property ops — takes {{id}} for the GUID it produced, as add_transition already did.

Interface graphs are listed, and an interface call is a Message node

Section titled “Interface graphs are listed, and an interface call is a Message node”

get_all_graphs now includes the graphs of implemented interface functions, which the engine keeps on the interface description rather than in FunctionGraphs; every graph op that resolves by name — describe, node adds — answered “not found” for a freshly implemented GetHealth. add_function_call_node resolves a Blueprint class from its asset name or content path (BPI_Foo is the class BPI_Foo_C), and places an interface’s function as the Message node the palette places, which resolves the implementer at run time. add_cast_node accepts a Blueprint content path.

A move saves the referencers the rename manager repointed

Section titled “A move saves the referencers the rename manager repointed”

move_asset_with_references, rename_asset_with_references and the batch move save every dirty package holding an in-memory reference to the moved asset before fixing up redirectors. The rename manager repoints every LOADED referencer but saves only those the asset registry listed, and the registry lags a neighbour moved moments earlier; a BehaviorTree kept its Blackboard’s old path on disk while the editor showed the new one, and the fixup then deleted the redirector that could still have resolved it. Memory is asked instead of the registry, and a referencer that will not save fails the op.

A delete is refused only for a referencer the disk still has

Section titled “A delete is refused only for a referencer the disk still has”

get_asset_referencers, get_asset_dependencies and asset_depends_on re-read the package headers in question before answering, so delete_asset no longer refuses an asset because of a reference that was removed and saved moments earlier. The registry learns a package’s imports from its file, and a save only queues the file for the background gatherer, which runs on the core ticker — which nothing ticks between two ops of the headless server. Two abilities had their cooldown effect cleared and saved, the packages on disk no longer named it, and the delete of the effect was still refused for both.

A component the parent class created is a component too

Section titled “A component the parent class created is a component too”

set_component_property, get_component_property, set_component_transform and set_component_mobility reach a component the Blueprint inherits — one a C++ parent made with CreateDefaultSubobject, or one a parent Blueprint added — and not only one added in this Blueprint’s own construction script. A Blueprint whose native parent owns a StateTree component was told Component 'StateTree' not found, for the component its own details panel edits; the per-Blueprint defaults of such a component live on the class default object’s subobject, and that is what is written now.

Section titled “A merged duplicate keeps every one of its links”

layout_graph with optimize merges duplicate pure getters, and the merge moved links consumer by consumer, looking each consumer’s pin up as “the first of its inputs linked to the duplicate”. A consumer fed twice by one duplicate had one link moved and the other silently severed when the duplicate was removed, and a duplicate with two outputs into one consumer could land an output on the wrong input. Links move per link now, and a duplicate whose canonical lacks a pin some link needs is left alone rather than half-merged.

Several play-in-editor clients, tiled into one recording

Section titled “Several play-in-editor clients, tiled into one recording”

start_recording takes pie_instances, a list of play-in-editor instances to film at once, composited into a single video. One file, one timeline: what a multi-client recording is for is the relationship between the clients — a shot fired here landing there, a menu choice on one machine deciding the map on the other — and two files with independent clocks cannot show it. Every pane is asked for its capture under one frame index and one timestamp, in one place, which is the only place that can guarantee they agree.

The grid is the squarest one that holds the panes, every cell is the same size, and each client is fitted into its cell preserving aspect, never magnified, and centred — so a client smaller than its cell is letterboxed rather than stretched. panes[] in the state and the manifest carries both rectangles: where each client’s pixels are in the frame, and what that client was captured at.

The refusals are the substance, because each mistake they catch produces a working recording of the wrong thing. Naming one instance twice tiles a client beside itself, and is judged before anything is resolved so the diagnostic is the same in a commandlet as in a live editor — resolving first answered “there is no editor” to a caller whose real mistake was the list. One unusable instance refuses the whole recording, because a two-pane video where three were asked for looks exactly like a three-pane video of a session where one client never rendered. And pane_slack_frames may not be zero: zero releases every frame before its panes have arrived, giving a video of the right length, the right size, and black.

A pane whose window closes mid-recording is retired: its cell stays black, a pane_lost note says when, and the other clients carry on without waiting out the slack on every later frame. The check is the viewport’s widget rather than the viewport itself, because the recorder holds the viewport and so the old check could never fire. A frame that one grabber skipped is released as soon as every live pane has moved past it, rather than after pane_slack_frames.

A frame grabber cannot rescale, and every reduced recording was mostly uninitialised

Section titled “A frame grabber cannot rescale, and every reduced recording was mostly uninitialised”

FFrameGrabber fixes its capture rectangle from the viewport widget at construction and normalises its draw by that rectangle rather than by the buffer it was asked for. Ask it for any other size and it fills exactly Buffer/CaptureRect of the way across and down, leaving the rest uninitialised. Measured: a 1280x724 viewport asked for a 640x362 buffer filled exactly 320x181 — a quarter of the frame, with the remainder reading as black because it happened to.

The recorder had always assumed the grabber would scale, so every recording whose max_long_edge forced a reduction had been three-quarters undefined, silently, since long before tiling existed. It surfaced only because tiling makes the reduction the normal case rather than the rare one, and because one test capture happened to be of a bright room.

Each pane is now captured at its own size — the only size a grabber fills — and any reduction to the frame budget is a box average done while compositing. A single-pane capture at --max-long-edge 640 now fills its frame; it used to fill a quarter of it.

What made this expensive to find is that nothing in the recorder said what sizes it had used: the delivered buffer is always the size that was asked for, so a size check passes and tells you nothing, and only the pixels show it. Recordings now carry source_width / source_height per pane, and the recorder logs renders WxH, captured at WxH, drawn WxH for each pane at start — the render size included precisely because it is the number that looks like the right one to capture at and is not.

A recording can be cut into a clip you can send

Section titled “A recording can be cut into a clip you can send”

clip_recording (record clip) cuts a window out of a recording and compresses it, optionally under a size ceiling: a real 129-second capture went from 71.8 MB to 7.4 MB under --target-mb 8. Its seconds are the contact sheet’s seconds — the clip is retimed from the manifest’s own frame times, never the AVI header, which in older recordings carries the fps that was requested. ffmpeg_status (record ffmpeg) says which ffmpeg will be used, found by which rule, and every path that was searched, so “not found” is something to act on. Recording itself still needs no codec: only these two touch ffmpeg, and with none installed only the cut refuses.

The ffmpeg the plugin fetches is our own build, LGPL-2.1-or-later on every platform — Windows, Linux, and macOS as one universal binary — built by .github/workflows/ffmpeg-build.yml from the signature-verified FFmpeg source with one configure line and no GPL, non-free or version-3 component. No software H.264 encoder ships: a clip is H.264 in an .mp4 only through the operating system’s own encoder (Media Foundation, VideoToolbox), and VP9 in a .webm from libvpx everywhere else — always on Linux. The result names the codec and container. The exact corresponding source — the FFmpeg and libvpx tarballs, the build scripts and the configure line — ships in the plugin beside the binaries (Resources/ThirdParty/ffmpeg/Source/), and packaging refuses a binary that is not provably built from it. third-party.json pins every archive by SHA-256. The installer now reads .tar.xz as well as .zip, keeps going past a failed download to try the rest, and treats an optional dependency that cannot be fetched as a warning rather than a failed install.

v1.1: Read operations answer, and a much larger build surface

Section titled “v1.1: Read operations answer, and a much larger build surface”

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.

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, Kumo::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 kumo 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:.kumo/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.KumoAssetOpsLibrary 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:.kumo/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 kumo 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 kumo 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/Kumo/<email>/ for fast switching.
  • Editor-not-required operation: every op runs through a short-lived Unreal commandlet. Drive kumo 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 kumo 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.