Editor integration helper that bootstraps and repairs the Prism editor addon after assembly hotloads. It ensures one-time initialization, refreshes menus, flushes various Prism caches, drops stale delegates and windows, and raises a Reinitialized event so other components can rebuild.
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Preview;
using Editor.Prism.Serialization;
using Editor.Prism.Toolchain;
namespace Editor.Prism.Integration;
/// <summary>
/// Bootstraps Prism inside the editor and puts it back together after every hotload.
/// <para>
/// <c>EditorCompiler.WatchForChanges()</c> means this assembly is swapped every time a <c>.cs</c> file
/// under <c>Editor/</c> is saved, which for the people building Prism is dozens of times an hour. Four
/// things break every single time and all four are repaired here:
/// </para>
/// <list type="number">
/// <item><description>Static caches keyed by <see cref="Type"/> hand out metadata from the outgoing
/// assembly. <c>PrismHotload</c> already drops the core ones; the rest are dropped below.</description></item>
/// <item><description>Delegates stored on long-lived engine objects — the asset browser's
/// <c>OnFileSelected</c>, our own static events — point into the old assembly.</description></item>
/// <item><description>Cached window instances are zombies: the native widget is alive so
/// <c>IsValid</c> says true, but every handler on it is dangling.</description></item>
/// <item><description><c>IAssetEditor</c>'s two static maps keep stale entries that make
/// double-clicking an asset silently do nothing.</description></item>
/// </list>
/// <para>
/// Menu and toolbar options are the one thing we do <b>not</b> have to rebuild: <c>MenuBar</c> keeps a
/// registry and tears the whole thing down and back up on the <c>refresh</c> event, which the engine
/// raises the frame after any assembly changes.
/// </para>
/// </summary>
public static class PrismHotloadIntegration
{
static bool s_initialized;
/// <summary>
/// Raised after Prism has been re-armed following a hotload, so anything holding integration state
/// can rebuild. Cleared and re-subscribed by whoever needs it — it does not survive the reload.
/// </summary>
public static event Action Reinitialized;
/// <summary>True once the one-time startup work has run in this assembly instance.</summary>
public static bool IsInitialized => s_initialized;
// ---- startup -----------------------------------------------------------
/// <summary>
/// One-time startup, driven from the frame tick because there is no assembly-load callback an
/// editor addon can hook. Cheap to call and safe to call every frame.
/// </summary>
public static void EnsureInitialized()
{
if ( s_initialized ) return;
s_initialized = true;
PrismLog.Guard( "Starting Prism", () =>
{
PrismLog.Verbose = PrismCookies.VerboseLogging;
// Deliberately no NodeRegistry.EnsureBuilt() here. Reflecting over ~300 node types is a
// cost every editor session would pay whether or not Prism is ever opened; the registry
// builds itself the first time something asks for a node type.
CodeFileEditor.ApplyCodeEditorPreference();
// Flipping the toggle in the preferences page has to take effect without a restart.
PrismCookies.Changed -= CodeFileEditor.ReconcileCodeEditorPreference;
PrismCookies.Changed += CodeFileEditor.ReconcileCodeEditorPreference;
PrismAssetEditor.PruneStaleEditors();
// Scratch shaders from sessions that never got to clean up after themselves.
TempWorkspace.CollectGarbage();
PrismAutosave.ScanForRecovery();
PrismLog.Trace( $"Prism {PrismConstants.EditorVersion} integration ready" );
} );
}
/// <summary>
/// Per-frame upkeep: the parts of integration that depend on editor widgets which may not exist
/// yet, or which something else may have overwritten since.
/// </summary>
[EditorEvent.Frame]
static void Frame()
{
EnsureInitialized();
// Twice a second is plenty for "has a dock been rebuilt underneath us", and it keeps the
// per-frame cost of having Prism installed at one integer compare.
if ( ++s_ticks < 30 ) return;
s_ticks = 0;
EnsureMenusRegistered();
if ( PrismCookies.ClaimShaderFiles ) CodeFileEditor.EnsureAssetBrowserRouting();
}
static int s_ticks;
static bool s_menusRefreshed;
/// <summary>
/// Ask the editor to rebuild its menus once, shortly after this assembly comes up.
/// <para>
/// Both <c>[Menu]</c> and <c>[EditorApp]</c> entries are built by handlers of the engine's
/// <c>refresh</c> event — <c>MenuBar.RegisterAll</c> and <c>EditorMainWindow.RebuildApps</c>. That
/// event fires from <c>ManagedTools</c> when <c>AssembliesDirty</c> is set, and the tools enroller
/// only sets that flag in <c>OnAssemblyRemoved</c>, never in <c>OnAssemblyAdded</c>. Bootstrap sets
/// it once, then consumes it; the current project and its libraries are enrolled *after* that, so on
/// a cold start our assembly joins the type library with no refresh behind it and neither entry is
/// ever built. Every subsequent hotload works, because a reload removes the outgoing assembly and
/// sets the flag — which is exactly why this only ever looks broken on a fresh editor launch.
/// </para>
/// <para>
/// So we raise it ourselves, once per assembly instance, a beat after the frame loop starts (by
/// which point the main window and its menu bar exist). <c>RegisterAll</c> tears down and rebuilds
/// the whole registry, so doing it a second time after a hotload is idempotent and costs microseconds.
/// </para>
/// </summary>
static void EnsureMenusRegistered()
{
if ( s_menusRefreshed ) return;
s_menusRefreshed = true;
PrismLog.Guard( "Refreshing the editor menus so Prism's entries appear",
() => EditorEvent.Run( "refresh" ) );
}
// ---- hotload -----------------------------------------------------------
/// <summary>
/// Runs after <c>PrismHotload.FlushAll</c> — hence the priority — and repairs everything the core
/// flush does not know about.
/// </summary>
[EditorEvent.Hotload( Priority = 100 )]
static void OnHotload()
{
PrismLog.Guard( "Rebuilding Prism after a hotload", () =>
{
FlushRemainingCaches();
DropStaleDelegates();
DropStaleWindows();
PrismAssetEditor.PruneStaleEditors();
// The frame tick re-arms routing and re-runs startup against the new assembly.
s_initialized = false;
PrismLog.Guard( "Raising PrismHotloadIntegration.Reinitialized", () => Reinitialized?.Invoke() );
} );
}
/// <summary>
/// Caches <c>PrismHotload.FlushAll</c> does not reach. It owns the node registry, port reflection,
/// node properties, backends, the subgraph cache and the legacy import table; these are the rest.
/// </summary>
static void FlushRemainingCaches()
{
PrismLog.Guard( "Flushing schema migrations", SchemaMigrations.Reset );
PrismLog.Guard( "Flushing node descriptors", NodeDescriptors.Flush );
PrismLog.Guard( "Flushing preview meshes", PreviewMeshes.Flush );
// The lexers, language database, intrinsic tables, s&box symbol table, include resolver and
// document-symbol index. PrismHotload.FlushAll already calls this and runs first — it is
// priority 0 against this handler's 100 — but the two are independent registrations and the
// ordering guarantee is the event system's, not ours. TextCaches.Flush is idempotent by
// construction, so calling it twice costs a few null assignments and removes the ordering
// dependency entirely.
PrismLog.Guard( "Flushing the text caches", Text.TextCaches.Flush );
// Asynchronous on purpose. Probing spawns slangc and waits for it, and this runs inside an
// [EditorEvent.Hotload] handler on the main thread — so with a toolchain installed every single
// hotload used to block the editor on a process start. Nothing reads the result synchronously;
// SlangToolchain.Changed is what the status chip and the preferences page listen to.
PrismLog.Guard( "Flushing the Slang toolchain probe", () => _ = SlangToolchain.ProbeAsync( true ) );
}
/// <summary>
/// Hand back or drop every delegate we installed on something that outlives the assembly.
/// <para>
/// The asset browser's <c>OnFileSelected</c> is the dangerous one: it is a public field on a widget
/// that survives the reload, so the handler sitting in it belongs to the outgoing assembly. It is
/// re-armed by the frame tick; what matters here is forgetting the chain we captured, or the next
/// install would chain a dead delegate onto a live one and grow a link on every reload.
/// </para>
/// <para>
/// There is nothing to do about <c>FileWatch</c>: Prism deliberately owns none. Change detection
/// goes through the engine's <c>content.changed</c> event instead, which already watches every
/// active project's <c>Assets/</c> folder and hands us a path rather than holding a lambda it will
/// outlive.
/// </para>
/// </summary>
static void DropStaleDelegates()
{
PrismLog.Guard( "Dropping stale Prism delegates", () =>
{
CodeFileEditor.ForgetRouting();
CodeFileEditor.FlushFallback();
AssetHooks.ClearSubscribers();
PrismCookies.ClearSubscribers();
PrismCookies.FlushCache();
PrismAutosave.ClearSubscribers();
} );
}
/// <summary>
/// Re-check every window Prism holds a reference to.
/// <para>
/// Deliberately not a reset. The hotload system replaces instances of swapped types in place and
/// fixes up the fields that point at them, so a window the user had open is still open and still
/// theirs — throwing the reference away would open a duplicate next time and silently stop
/// autosaving the document in the old one. Only entries that failed to survive are dropped.
/// </para>
/// </summary>
static void DropStaleWindows()
{
PrismLog.Guard( "Revalidating Prism windows", () =>
{
PrismDocumentation.Revalidate();
PrismPreferences.Revalidate();
PrismAutosave.Revalidate();
} );
}
}