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.
Unreleased
Section titled “Unreleased”OpenAI Codex in the chat panel
Section titled “OpenAI Codex in the chat panel”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.
A merged duplicate keeps every one of its links
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.
A save that did not happen now says why
Section titled “A save that did not happen now says why”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.
New asset & gameplay domains
Section titled “New asset & gameplay domains”- 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.
Deeper graph & stack authoring
Section titled “Deeper graph & stack authoring”- 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.
World Partition & Levels
Section titled “World Partition & Levels”- 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 & debugging tooling
Section titled “Profiling & debugging tooling”- 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_defaultandget_componentsansweredokwith no data. Each ended inunreal.log(...), which puts the answer in the engine log — where the caller ofapplycannot reach it.get_component_propertyneeded more than a wire: the C++ returns""for a missing component, a missing property and an empty value alike, so it now goes throughdescribe_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_functioncould not override a parent Blueprint function. It routed onFUNC_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, anddescribe_function_overridesays 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; anArrayParamfunction needsUK2Node_CallArrayFunction, which is the only class that propagates a type into a wildcard. SoArray_IsEmptystayedArray<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’soutputaccepts the words it documented.true/false,loop/done,cast/failedand 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 --stdinno longer throws away its own answer. A top-level JSON array killed the annotation step with aTypeErrorafter the editor had done the work.list_actorsreported every actor at the origin — 4459 actors, one distinct location, on a map that was loaded rather than opened.ComponentToWorldis not serialised, so a world with unregistered components has none; the query resolves it the way the engine does.get_actor_infoandduplicate_actorhad the same bug, the latter silently placing copies at the origin.delete_assetleft the file on disk and reportedok, 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/statecapture the active editor or PIE viewport to a Motion JPEG AVI a person can play, with arecording.jsonbeside it holding the real per-frame timing. Frames come off the back buffer through the engine’s ownFFrameGrabberand are encoded on a worker thread, so a recording does not create the stutter it would then appear to have recorded.marknames an instant so it can be found again.record summaryis 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
truncatedand hands back everything before the tear. - Dropped frames are counted and reported rather than silently closing the gap, and
frames_pendingsays the encoder is falling behind before anything is lost.max_secondsis capped, with no unlimited setting — a recorder nobody stopped fills a disk.
Pin literals of every type
Section titled “Pin literals of every type”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,
/Scriptpath, Blueprint asset path (/Game/UI/BP_Hud, no_C), generated class path or export-text wrapper. Empty orNoneclears the pin.Add Component by Classand 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.0on anFLinearColorpin used to compile to black, andX=1 Y=2 Z=3on anFVectorpin 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_nodewas the only op that could feed a Bind node’sEventpin, so every binding grew a node that is not the handler.add_custom_event_for_delegatenow 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 — andbind_event_to_delegatebinds 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_typeretype 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.
One type vocabulary for every variable op
Section titled “One type vocabulary for every variable op”add_variabletakes 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 andset_variable_typealready use.- The C++ spelling works everywhere a type is named. Reflection strips the prefix a header
carries, so
FVector,FHitResult,AActorandUSceneComponentused to name nothing. A single leadingF/U/A/Iis now retried after the exact name misses — never before, so a realFFloatRangecannot be shadowed — andint32,integer64,uint8,FString,FNameandFTextjoined the primitives. Enums are deliberately left alone: aUEnumkeeps itsE. - The typed-ref ops take a short name.
add_class_variable,add_enum_variable,add_soft_object_variableandadd_soft_class_variablerequired a/Scriptpath;add_class_variable’s own shipped example,/Script/Engine.APawn, resolved to nothing. A short name, a/Scriptpath, a/GameBlueprint 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_typepair as the retype ops, soTArray<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
wikiskill is installed besideunrealandintegrations, 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 inschema, 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
schemalists 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.jsonhad 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.ActorComponentbefore,/Script/SampleGame.Skillafter, 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 awiki-index-whyline 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/.hunderSource/: 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 aUFUNCTIONnamedDescribe*/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 eighteenDescribe*(names —DescribeInputs,DescribeGlyphs,DescribeFailure— and not one of them describes an asset. - A plugin installed under a directory called
Testshashed 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
Pathobjects, 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.csis 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.
Inspection tests that were never run
Section titled “Inspection tests that were never run”- 148 tests covering the inspection dispatcher ran nowhere. The unit runner skipped every
directory whose name begins with
_, in the all-domains shard too, sotests/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_cleanlooked upunreal.KumoAssetOpsLibraryunguarded, 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.sourcesused 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
2d5b0f0eand a headlesspython -m wiki_helpersrecordeda097ec41for 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
absentorunreadablenow; 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
entitiesstage, 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)— twoPathobjects 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_indexhas a knob that says so — which the store builder never turned. Every restricted share resolvedwiki-categories.jsonfrom 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.
The wiki graph draws the links you wrote
Section titled “The wiki graph draws the links you wrote”- 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.mdwas looked up under that literal text and matched nothing, and even a same-foldersibling.mdmissed the stemsiblingover 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-targetis a typo;outside-vaultis 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 updatefetches 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.
Documentation
Section titled “Documentation”- 54 broken links in the shipped docs now resolve, and a guard holds the tree at zero.
Splitting
Docs/api/intograph/,asset/,editor/andworld/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 intools/, and the monetization notes cited thirteen source files by a path from the UE project root rather than from themselves.
v1.0: Initial release
Section titled “v1.0: Initial release”The first public version of kumo ships with the full surface described in the rest of these docs.
Editor operations
Section titled “Editor operations”- 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.
AI assistant integration
Section titled “AI assistant integration”- 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.
Panel & sessions
Section titled “Panel & sessions”- 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.
Headless & CI
Section titled “Headless & CI”- 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 validatefor engine-free type-checks on every push.
Configuration
Section titled “Configuration”- 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.mdat the project root.
Known limitations
Section titled “Known limitations”- 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.