diff --git a/Prowl.Editor/AssetsDatabase/EditorAssetDatabase.cs b/Prowl.Editor/AssetsDatabase/EditorAssetDatabase.cs
index aaa1d0d39..7c7553698 100644
--- a/Prowl.Editor/AssetsDatabase/EditorAssetDatabase.cs
+++ b/Prowl.Editor/AssetsDatabase/EditorAssetDatabase.cs
@@ -695,6 +695,41 @@ public string[] GetAllAssetPaths()
/// Get an already-loaded asset from memory without triggering import. Returns null if not loaded.
public EngineObject? GetLoadedAsset(Guid guid) => _loadedAssets.GetValueOrDefault(guid);
+ ///
+ /// Clear the cache for on assembly reload so that scenes/prefabs that might hold
+ /// user-defined scripts won't stop the ALC from reloading
+ ///
+ [OnAssemblyUnload]
+ internal static void ClearScenesAndPrefabForReload()
+ {
+ var db = Instance;
+ if (db == null) return;
+
+ foreach (var kv in db._loadedAssets.ToArray())
+ {
+ EngineObject? asset = kv.Value;
+ if (asset is null) continue;
+
+ bool sensitive = asset is Runtime.Resources.Scene
+ || asset is Runtime.Resources.PrefabAsset
+ || asset.GetType().Assembly.IsCollectible;
+
+ if (!sensitive) continue;
+
+ if (db._loadedAssets.TryRemove(kv.Key, out var removed))
+ {
+ try
+ {
+ removed?.Dispose();
+ }
+ catch(Exception e)
+ {
+ Debug.LogException(e);
+ }
+ }
+ }
+ }
+
/// Load a cached thumbnail for an asset. Returns (width, height, pixels) or null.
public (int width, int height, byte[] pixels)? LoadThumbnail(Guid guid) => ThumbnailGenerator.LoadThumbnail(guid, _project.ThumbnailsPath);
diff --git a/Prowl.Editor/AssetsDatabase/Importers/ImporterRegistry.cs b/Prowl.Editor/AssetsDatabase/Importers/ImporterRegistry.cs
index caec81169..1048c26b4 100644
--- a/Prowl.Editor/AssetsDatabase/Importers/ImporterRegistry.cs
+++ b/Prowl.Editor/AssetsDatabase/Importers/ImporterRegistry.cs
@@ -14,8 +14,18 @@ public static class ImporterRegistry
private static readonly Dictionary _nameToImporter = new(StringComparer.OrdinalIgnoreCase);
private static bool _initialized;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize() { _initialized = false; Initialize(); }
+ /// Drop cached importer type maps so the script AssemblyLoadContext can be collected.
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _extensionToImporter.Clear();
+ _nameToImporter.Clear();
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/AssetsDatabase/Thumbnails/ThumbnailGeneratorRegistry.cs b/Prowl.Editor/AssetsDatabase/Thumbnails/ThumbnailGeneratorRegistry.cs
index dbc847a3e..50660f656 100644
--- a/Prowl.Editor/AssetsDatabase/Thumbnails/ThumbnailGeneratorRegistry.cs
+++ b/Prowl.Editor/AssetsDatabase/Thumbnails/ThumbnailGeneratorRegistry.cs
@@ -41,8 +41,17 @@ public static class ThumbnailGeneratorRegistry
private static readonly Dictionary _generators = new();
private static bool _initialized;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize() { _initialized = false; Initialize(); }
+ /// Drop cached generators (keyed by user ) so the script AssemblyLoadContext can be collected.
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _generators.Clear();
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/Core/EditorApplication.cs b/Prowl.Editor/Core/EditorApplication.cs
index acbcd5b53..b20476df1 100644
--- a/Prowl.Editor/Core/EditorApplication.cs
+++ b/Prowl.Editor/Core/EditorApplication.cs
@@ -427,10 +427,9 @@ public override void BeginGui(Paper paper)
// Load user script assemblies and re-register all types
ScriptAssemblyManager.LoadAssemblies(Project.Current);
- ReinitializeRegistries();
- // Load project settings
- ProjectSettingsRegistry.OnProjectOpened();
+ // ReinitializeRegistries() runs the [OnAssemblyLoad] hooks, which include the project-settings reload.
+ ReinitializeRegistries();
// Restore layout from project (or use default)
var savedLayout = LoadDockLayout();
@@ -1116,6 +1115,30 @@ private void ScanAndRegisterPanels()
return FindInNode(node.ChildA, panelType) ?? FindInNode(node.ChildB, panelType);
}
+ /// Enumerate every open panel across the docked tree and all floating windows.
+ private IEnumerable EnumerateAllPanels()
+ {
+ foreach (var p in EnumerateNodePanels(_dockSpace.Root))
+ yield return p;
+ foreach (var fw in _dockSpace.FloatingWindows)
+ foreach (var p in EnumerateNodePanels(fw.Node))
+ yield return p;
+ }
+
+ private static IEnumerable EnumerateNodePanels(DockNode? node)
+ {
+ if (node == null) yield break;
+ if (node.IsLeaf)
+ {
+ if (node.Tabs != null)
+ foreach (var tab in node.Tabs)
+ yield return tab;
+ yield break;
+ }
+ foreach (var p in EnumerateNodePanels(node.ChildA)) yield return p;
+ foreach (var p in EnumerateNodePanels(node.ChildB)) yield return p;
+ }
+
///
/// Open a panel. If it's already open, focus it. Otherwise create a new instance as a floating window.
///
@@ -1267,42 +1290,199 @@ private void RegisterMenus()
// Script Compilation
// ================================================================
- /// Called by ScriptAssemblyManager after hot-reload to re-scan all registries.
- public void ReinitializeAfterReload() => ReinitializeRegistries();
+ ///
+ /// Called by right after the new script assemblies are
+ /// loaded. Re-scans every registry against the fresh assemblies and reloads project settings.
+ ///
+ public void ReinitializeAfterReload()
+ {
+ ReinitializeRegistries();
+ }
- private void ReinitializeRegistries()
+ ///
+ /// Drops every strong reference the editor holds into the script
+ /// so it can actually be collected when unloaded. This is the counterpart to
+ /// : tear everything down here, rebuild it there.
+ ///
+ /// Anything that survives this call and transitively reaches a user type (a live instance, a
+ /// handle, a delegate bound to user code, a ) pins
+ /// the old context and forces a full editor restart instead of a hot-reload.
+ ///
+ public void ReleaseScriptReferences()
{
+ CaptureSelectionForReload();
+
+ // 1. Live object graph: the scene's GameObjects hold user MonoBehaviour instances.
+ // The scene was already serialized to disk by SaveSceneForRestart().
+ Selection.Clear();
+ Undo.Clear();
+ Runtime.Resources.Scene.Unload();
+
+ // 1b. Long-lived editor panels cache scene objects (e.g. the Inspector's last target,
+ // the Hierarchy's drag target). Let each drop its references before the unload.
+ if (_dockSpace != null)
+ foreach (var panel in EnumerateAllPanels())
+ if (panel is IScriptReloadCleanup cleanup)
+ try { cleanup.OnScriptReloadCleanup(); } catch { }
+
+ // Release Paper callbacks as they might otherwise pin ALC types across a reload.
+ ReleasePaperRetainedCallbacks();
+
+ // 2. Play-mode leftovers (normally empty outside play mode; cleared defensively).
+ _savedEditorScene = null;
+ _savedEditorTime = null;
+ MenuRegistry.Clear();
_registeredPanels.Clear();
- ScanAndRegisterPanels();
- InitializeOnLoadRegistry.Reinitialize();
- PropertyEditorRegistry.Reinitialize();
- CustomEditorRegistry.Reinitialize();
- GraphTools.NodeRendererRegistry.Reinitialize();
- GraphTools.NodePreviewRegistry.Reinitialize();
- Runtime.GraphTools.GraphValidatorRegistry.Reinitialize();
- Inspector.AssetImporterEditorRegistry.Reinitialize();
- GUI.Popups.AddComponentPopup.Reinitialize();
- Importers.ImporterRegistry.Reinitialize();
- ProjectSettingsRegistry.Reinitialize();
- CreateAssetMenuRegistry.Reinitialize();
- ShaderTypeCreateMenu.Register();
- ThumbnailGeneratorRegistry.Reinitialize();
- SceneDropHandlerRegistry.Reinitialize();
- CreateGameObjectMenuRegistry.Reinitialize();
- FileIconRegistry.Reinitialize();
- AssetDoubleClickRegistry.Reinitialize();
- ScriptTemplateRegistry.Reinitialize();
-
- // Re-register Window menu items for any new panels from user assemblies
- foreach (var (type, path) in _registeredPanels)
+
+ // 3. The Echo serializer cache lives in an external package so we can't call OnAssemblyUnload there.
+ Echo.Serializer.ClearCache();
+
+ // 4. Everything tagged [OnAssemblyUnload]
+ ScriptReloadCallbacks.InvokeAssemblyUnload();
+ }
+
+ private void ReleasePaperRetainedCallbacks()
+ {
+ try
{
- var capturedType = type;
- MenuRegistry.Register($"Window/{path}", () => OpenPanel(capturedType),
- isChecked: () => IsPanelOpen(capturedType));
+ var paper = PaperInstance;
+ if (paper == null) return;
+
+ Type t = paper.GetType();
+ const BindingFlags BF = BindingFlags.NonPublic | BindingFlags.Instance;
+
+ if (t.GetField("_elements", BF)?.GetValue(paper) is not Array elements) return;
+
+ int count = t.GetField("_elementCount", BF)?.GetValue(paper) is int c ? c : 0;
+ count = Math.Clamp(count, 0, elements.Length);
+ if (count < elements.Length)
+ Array.Clear(elements, count, elements.Length - count);
+ }
+ catch (Exception ex)
+ {
+ Runtime.Debug.LogWarning($"[EditorApplication] Could not reset PaperUI retained callbacks: {ex.Message}");
}
+ }
- // Re-register GameObject menu items for any new creators from user assemblies
- CreateGameObjectMenuRegistry.RegisterMenuBarItems();
+ // ================================================================
+ // Selection preserve/restore across a hot-reload
+ // ================================================================
+
+ private List? _reloadSelection;
+ private SelectionToken _reloadActive;
+ private bool _hasReloadActive;
+
+ ///
+ /// Snapshot the current selection as identifier tokens (called before the selection is cleared).
+ ///
+ private void CaptureSelectionForReload()
+ {
+ _reloadSelection = new List();
+ _hasReloadActive = false;
+
+ foreach (var obj in Selection.Selected)
+ {
+ if (!TryMakeSelectionToken(obj, out var token))
+ continue;
+
+ _reloadSelection.Add(token);
+ if (ReferenceEquals(obj, Selection.ActiveObject))
+ {
+ _reloadActive = token;
+ _hasReloadActive = true;
+ }
+ }
+ }
+
+ ///
+ /// Tries to create a selection token to then restore the selection after script reload.
+ ///
+ private static bool TryMakeSelectionToken(object obj, out SelectionToken token)
+ {
+ switch (obj)
+ {
+ // Scene GameObject - restore by stable scene identifier.
+ case GameObject go:
+ token = new SelectionToken(SelKind.GameObject, go.Identifier, Guid.Empty, "", "", false);
+ return true;
+ // Scene component - restore by owning GameObject + component identifier.
+ case MonoBehaviour mb when mb.GameObject.IsValid():
+ token = new SelectionToken(SelKind.Component, mb.GameObject.Identifier, mb.Identifier, "", "", false);
+ return true;
+ // Project asset - restore by AssetID via the asset database.
+ case EngineObject eo when eo.AssetID != Guid.Empty:
+ token = new SelectionToken(SelKind.Asset, eo.AssetID, Guid.Empty, "", "", false);
+ return true;
+ // Project browser item - identifier-only, rebuilt from its path/guid.
+ case ContentItem ci:
+ token = new SelectionToken(SelKind.Content, ci.Guid, Guid.Empty, ci.RelativePath, ci.Name, ci.IsFolder);
+ return true;
+ default:
+ token = default;
+ return false;
+ }
+ }
+
+ ///
+ /// Re-resolve the captured selection tokens against the freshly reloaded scene/assets and re-select them.
+ ///
+ public void RestoreSelectionAfterReload()
+ {
+ if (_reloadSelection == null)
+ return;
+
+ var tokens = _reloadSelection;
+ _reloadSelection = null;
+
+ Selection.Clear();
+ object? active = null;
+
+ foreach (var token in tokens)
+ {
+ object? resolved = ResolveSelectionToken(token);
+ if (resolved == null)
+ continue;
+
+ Selection.AddToSelection(resolved);
+ if (_hasReloadActive && token.Equals(_reloadActive))
+ active = resolved;
+ }
+
+ if (active != null)
+ Selection.ActiveObject = active;
+
+ _hasReloadActive = false;
+ }
+
+ private static object? ResolveSelectionToken(SelectionToken token)
+ {
+ switch (token.Kind)
+ {
+ case SelKind.GameObject:
+ return Runtime.Resources.Scene.Current?.FindObjectByIdentifier(token.Id);
+ case SelKind.Component:
+ return Runtime.Resources.Scene.Current?.FindObjectByIdentifier(token.Id)?.GetComponentByIdentifier(token.CompId);
+ case SelKind.Asset:
+ return Runtime.AssetDatabase.Get(token.Id);
+ case SelKind.Content:
+ // ContentItem compares by Guid + RelativePath, so a rebuilt instance re-selects the same item.
+ return new ContentItem { Guid = token.Id, RelativePath = token.Path, Name = token.Name, IsFolder = token.IsFolder };
+ default:
+ return null;
+ }
+ }
+
+ private void ReinitializeRegistries()
+ {
+ // Panel scan is an editor-instance step (needed before the menu rebuild reads the panel list).
+ _registeredPanels.Clear();
+ ScanAndRegisterPanels();
+
+ // Run every [OnAssemblyLoad] hook
+ ScriptReloadCallbacks.InvokeAssemblyLoad();
+
+ MenuRegistry.Clear();
+ RegisterMenus();
}
public void RestoreAutoSavedScene(string path)
diff --git a/Prowl.Editor/Core/IScriptReloadCleanup.cs b/Prowl.Editor/Core/IScriptReloadCleanup.cs
new file mode 100644
index 000000000..afa545195
--- /dev/null
+++ b/Prowl.Editor/Core/IScriptReloadCleanup.cs
@@ -0,0 +1,20 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+namespace Prowl.Editor.Core;
+
+///
+/// Implemented by long-lived editor objects (typically dock panels) that cache references to
+/// scene objects or user-script types. Hot-reload tears the script
+/// down, and any surviving reference
+/// into it pins the old context and blocks the unload.
+///
+/// calls
+/// on every open panel right before the unload. Implementations must drop their cached
+/// scene/user references (set fields to null, clear collections). They do NOT need to dispose
+/// themselves — the panel instance lives on; only its references into the dying context go away.
+///
+public interface IScriptReloadCleanup
+{
+ void OnScriptReloadCleanup();
+}
diff --git a/Prowl.Editor/Core/ScriptReloadCallbacks.cs b/Prowl.Editor/Core/ScriptReloadCallbacks.cs
new file mode 100644
index 000000000..640abad0d
--- /dev/null
+++ b/Prowl.Editor/Core/ScriptReloadCallbacks.cs
@@ -0,0 +1,88 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+
+using Prowl.Runtime;
+
+namespace Prowl.Editor.Core;
+
+///
+/// Discovers and invokes the script hot-reload lifecycle hooks (,
+/// , ). Any static
+/// parameterless method tagged with one of those attributes (in the engine, the editor, or user
+/// scripts) is run automatically at the matching phase.
+///
+///
+/// It's important that the types are never cached: this avoids pinning the ALC down before a reload and
+/// allows for any script to subscribe at any point during the lifetime of the application.
+///
+public static class ScriptReloadCallbacks
+{
+ /// Run all hooks (compilation is starting).
+ public static void InvokeScriptCompile() => Invoke("OnScriptCompile");
+
+ /// Run all hooks (about to unload the script context).
+ public static void InvokeAssemblyUnload() => Invoke("OnAssemblyUnload");
+
+ /// Run all hooks (new script assemblies are loaded).
+ public static void InvokeAssemblyLoad() => Invoke("OnAssemblyLoad");
+
+ private static void Invoke(string phase) where TAttr : ScriptLifecycleAttribute
+ {
+ var hooks = new List<(int order, MethodInfo method)>();
+
+ foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ // Skip any .NET or System library call that couldn't possibly have any attributes we need
+ if (IsFrameworkAssembly(assembly)) continue;
+
+ Type[] types;
+ try { types = assembly.GetTypes(); }
+ catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t != null).ToArray()!; }
+ catch { continue; }
+
+ foreach (Type? type in types)
+ {
+ if (type == null) continue;
+ foreach (MethodInfo method in type.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
+ {
+ var attr = method.GetCustomAttribute();
+ if (attr == null) continue;
+
+ if (method.GetParameters().Length != 0)
+ {
+ Runtime.Debug.LogWarning($"[ScriptReloadCallbacks] [{phase}] {type.FullName}.{method.Name} must be static and parameterless; skipping.");
+ continue;
+ }
+
+ hooks.Add((attr.Order, method));
+ }
+ }
+ }
+
+ foreach (var (_, method) in hooks.OrderBy(h => h.order))
+ {
+ try
+ {
+ method.Invoke(null, null);
+ }
+ catch (Exception ex)
+ {
+ Runtime.Debug.LogError($"[ScriptReloadCallbacks] [{phase}] {method.DeclaringType?.Name}.{method.Name} threw: {ex.InnerException?.Message ?? ex.Message}");
+ }
+ }
+ }
+
+ private static bool IsFrameworkAssembly(Assembly assembly)
+ {
+ string? name = assembly.GetName().Name;
+ if (string.IsNullOrEmpty(name)) return true;
+ return name.StartsWith("System", StringComparison.Ordinal)
+ || name.StartsWith("Microsoft", StringComparison.Ordinal)
+ || name == "mscorlib" || name == "netstandard" || name == "WindowsBase";
+ }
+}
diff --git a/Prowl.Editor/GUI/CustomEditor.cs b/Prowl.Editor/GUI/CustomEditor.cs
index 038bc1ef2..0670dbc38 100644
--- a/Prowl.Editor/GUI/CustomEditor.cs
+++ b/Prowl.Editor/GUI/CustomEditor.cs
@@ -53,6 +53,7 @@ public static class CustomEditorRegistry
private static readonly Dictionary _editorCache = new();
private static bool _initialized;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize()
{
_initialized = false;
@@ -60,6 +61,18 @@ public static void Reinitialize()
Initialize();
}
+ ///
+ /// Drop all cached references and editor instances so the script
+ /// AssemblyLoadContext can be collected. Caches rebuild on the next .
+ ///
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _typeToEditor.Clear();
+ _editorCache.Clear();
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/GUI/CustomEditors/AssetImporterEditor.cs b/Prowl.Editor/GUI/CustomEditors/AssetImporterEditor.cs
index 61c40137c..ed48b8cd1 100644
--- a/Prowl.Editor/GUI/CustomEditors/AssetImporterEditor.cs
+++ b/Prowl.Editor/GUI/CustomEditors/AssetImporterEditor.cs
@@ -35,8 +35,18 @@ public static class AssetImporterEditorRegistry
private static readonly Dictionary _editorCache = new();
private static bool _initialized;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize() { _initialized = false; Initialize(); }
+ /// Drop cached type maps and editor instances so the script AssemblyLoadContext can be collected.
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _typeToEditor.Clear();
+ _editorCache.Clear();
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/GUI/GraphTools/NodePreviewRegistry.cs b/Prowl.Editor/GUI/GraphTools/NodePreviewRegistry.cs
index a2be8d53d..8dee1811e 100644
--- a/Prowl.Editor/GUI/GraphTools/NodePreviewRegistry.cs
+++ b/Prowl.Editor/GUI/GraphTools/NodePreviewRegistry.cs
@@ -43,6 +43,7 @@ public static class NodePreviewRegistry
private static readonly Dictionary _cache = new();
private static bool _initialized;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize()
{
_initialized = false;
@@ -50,6 +51,15 @@ public static void Reinitialize()
Initialize();
}
+ /// Drop cached type maps so the script AssemblyLoadContext can be collected.
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _typeToDrawer.Clear();
+ _cache.Clear();
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/GUI/GraphTools/NodeRenderer.cs b/Prowl.Editor/GUI/GraphTools/NodeRenderer.cs
index 8568e3d65..3868213c7 100644
--- a/Prowl.Editor/GUI/GraphTools/NodeRenderer.cs
+++ b/Prowl.Editor/GUI/GraphTools/NodeRenderer.cs
@@ -68,6 +68,7 @@ public static class NodeRendererRegistry
private static readonly NodeRenderer _default = new DefaultNodeRenderer();
private static bool _initialized;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize()
{
_initialized = false;
@@ -75,6 +76,15 @@ public static void Reinitialize()
Initialize();
}
+ /// Drop cached type maps so the script AssemblyLoadContext can be collected.
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _typeToRenderer.Clear();
+ _cache.Clear();
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/GUI/GraphTools/ShaderGraphs/Editors/ShaderTypeCreateMenu.cs b/Prowl.Editor/GUI/GraphTools/ShaderGraphs/Editors/ShaderTypeCreateMenu.cs
index 9a9a335c3..b3421b1e0 100644
--- a/Prowl.Editor/GUI/GraphTools/ShaderGraphs/Editors/ShaderTypeCreateMenu.cs
+++ b/Prowl.Editor/GUI/GraphTools/ShaderGraphs/Editors/ShaderTypeCreateMenu.cs
@@ -18,6 +18,9 @@ public static class ShaderTypeCreateMenu
public const string ShaderGraphExtension = ".shadergraph";
private const string MenuPrefix = "Shader Graph/";
+ // Order 10: runs after CreateAssetMenuRegistry (default order 0) has rescanned, since this
+ // appends manual shader-graph entries onto the create-asset menu.
+ [Runtime.OnAssemblyLoad(Order = 10)]
public static void Register()
{
// Rescan so plugin-defined types picked up after a recompile are visible.
diff --git a/Prowl.Editor/GUI/Panels/HierarchyPanel.cs b/Prowl.Editor/GUI/Panels/HierarchyPanel.cs
index 10c106d0c..46c71c0f7 100644
--- a/Prowl.Editor/GUI/Panels/HierarchyPanel.cs
+++ b/Prowl.Editor/GUI/Panels/HierarchyPanel.cs
@@ -19,11 +19,19 @@
namespace Prowl.Editor.GUI.Panels;
[EditorWindow("General/Hierarchy")]
-public class HierarchyPanel : DockPanel
+public class HierarchyPanel : DockPanel, IScriptReloadCleanup
{
public override string Title => Loc.Get("panel.hierarchy");
public override string Icon => EditorIcons.Sitemap;
+ // The drag-hover targets are scene GameObjects; drop them before a hot-reload unload so they
+ // don't pin the script AssemblyLoadContext. (Normally null outside an active drag.)
+ public void OnScriptReloadCleanup()
+ {
+ _dragHoverTarget = null;
+ _dragHoverTargetNext = null;
+ }
+
private string _searchText = "";
private Paper? _paper;
// Rename state is managed by RenameOverlay
diff --git a/Prowl.Editor/GUI/Panels/InspectorPanel.cs b/Prowl.Editor/GUI/Panels/InspectorPanel.cs
index ca83f5dd2..0f5ed9fba 100644
--- a/Prowl.Editor/GUI/Panels/InspectorPanel.cs
+++ b/Prowl.Editor/GUI/Panels/InspectorPanel.cs
@@ -17,7 +17,7 @@
namespace Prowl.Editor.GUI.Panels;
[EditorWindow("General/Inspector")]
-public class InspectorPanel : DockPanel
+public class InspectorPanel : DockPanel, IScriptReloadCleanup
{
public override string Title => Loc.Get("panel.inspector");
public override string Icon => EditorIcons.Sliders;
@@ -26,6 +26,10 @@ public class InspectorPanel : DockPanel
private object? _lastInspectable;
private bool _subscribed;
+ // The cached inspectable is usually a scene GameObject/component (a user type), which would
+ // pin the script AssemblyLoadContext across a hot-reload. Drop it before the unload.
+ public void OnScriptReloadCleanup() => _lastInspectable = null;
+
public override bool SerializeState(System.Text.Json.Nodes.JsonObject state)
{
// Selection is global, so the Inspector is the natural owner of its persistence.
diff --git a/Prowl.Editor/GUI/Panels/SceneViewPanel.cs b/Prowl.Editor/GUI/Panels/SceneViewPanel.cs
index c4b370992..801bc1d77 100644
--- a/Prowl.Editor/GUI/Panels/SceneViewPanel.cs
+++ b/Prowl.Editor/GUI/Panels/SceneViewPanel.cs
@@ -19,12 +19,16 @@
namespace Prowl.Editor.GUI.Panels;
[EditorWindow("General/Scene")]
-public class SceneViewPanel : DockPanel
+public class SceneViewPanel : DockPanel, IScriptReloadCleanup
{
public override string Title => Loc.Get("panel.scene");
public override string Icon => EditorIcons.Video;
private EditorCamera? _editorCamera;
+
+ // The editor camera caches the scene camera + cloned image effects (possibly user types).
+ // Release them before a hot-reload so they don't pin the script AssemblyLoadContext.
+ public void OnScriptReloadCleanup() => _editorCamera?.ReleaseSceneReferences();
private Gizmo.TransformGizmo? _transformGizmo;
private bool _wasGizmoActive;
private Gizmo.ViewManipulatorGizmo? _viewManipulator;
diff --git a/Prowl.Editor/GUI/Popups/AddComponentPopup.cs b/Prowl.Editor/GUI/Popups/AddComponentPopup.cs
index 30a017549..2ceae64a6 100644
--- a/Prowl.Editor/GUI/Popups/AddComponentPopup.cs
+++ b/Prowl.Editor/GUI/Popups/AddComponentPopup.cs
@@ -37,6 +37,13 @@ private struct ComponentEntry
/// Clear cached component list so it re-scans assemblies on next Open.
public static void Reinitialize() => _cachedComponents = null;
+ ///
+ /// Drop the cached component list (which holds every MonoBehaviour ,
+ /// including user ones) so the script AssemblyLoadContext can be collected.
+ ///
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache() => _cachedComponents = null;
+
public static bool IsOpen => _isOpen;
private static OrigamiUI.IModal? _modal;
diff --git a/Prowl.Editor/GUI/PropertyEditor.cs b/Prowl.Editor/GUI/PropertyEditor.cs
index a6ef45229..8f5601d9b 100644
--- a/Prowl.Editor/GUI/PropertyEditor.cs
+++ b/Prowl.Editor/GUI/PropertyEditor.cs
@@ -43,8 +43,21 @@ public static class PropertyEditorRegistry
private static readonly Dictionary _editorCache = new();
private static bool _initialized;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize() { _initialized = false; Initialize(); }
+ ///
+ /// Drop all cached references and editor instances so the script
+ /// AssemblyLoadContext can be collected. Caches rebuild on the next .
+ ///
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _typeToEditor.Clear();
+ _editorCache.Clear();
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/GUI/Registries/AssetDoubleClickRegistry.cs b/Prowl.Editor/GUI/Registries/AssetDoubleClickRegistry.cs
index 91dd542ac..3baa1d5d1 100644
--- a/Prowl.Editor/GUI/Registries/AssetDoubleClickRegistry.cs
+++ b/Prowl.Editor/GUI/Registries/AssetDoubleClickRegistry.cs
@@ -35,8 +35,17 @@ public static class AssetDoubleClickRegistry
private static readonly Dictionary _handlers = new(StringComparer.OrdinalIgnoreCase);
private static bool _initialized;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize() { _initialized = false; Initialize(); }
+ /// Drop cached handler delegates (which may bind user code) so the script AssemblyLoadContext can be collected.
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _handlers.Clear();
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/GUI/Registries/ComponentIconRegistry.cs b/Prowl.Editor/GUI/Registries/ComponentIconRegistry.cs
index ba5fbbf49..6cb1ca0e5 100644
--- a/Prowl.Editor/GUI/Registries/ComponentIconRegistry.cs
+++ b/Prowl.Editor/GUI/Registries/ComponentIconRegistry.cs
@@ -19,6 +19,13 @@ public static class ComponentIconRegistry
{
private static readonly ConcurrentDictionary _cache = new();
+ ///
+ /// Drop the per-type icon cache (keyed by component , including user
+ /// components) so the script AssemblyLoadContext can be collected.
+ ///
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache() => _cache.Clear();
+
public static string GetIcon(MonoBehaviour component) => GetIcon(component.GetType());
public static string GetIcon(Type componentType)
diff --git a/Prowl.Editor/GUI/Registries/CreateAssetMenuRegistry.cs b/Prowl.Editor/GUI/Registries/CreateAssetMenuRegistry.cs
index 1c2e98f7c..6e8064139 100644
--- a/Prowl.Editor/GUI/Registries/CreateAssetMenuRegistry.cs
+++ b/Prowl.Editor/GUI/Registries/CreateAssetMenuRegistry.cs
@@ -62,8 +62,22 @@ public static void RemoveManualEntriesByPrefix(string prefix)
public static IReadOnlyList Entries => _entries;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize() { _initialized = false; Initialize(); }
+ ///
+ /// Drop ALL cached entries (including manual factory entries, which hold a user-supplied
+ /// and factory delegate) so the script AssemblyLoadContext can be
+ /// collected. Manual entries are re-registered after reload by their owners (e.g.
+ /// ShaderTypeCreateMenu.Register()).
+ ///
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _entries.Clear();
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/GUI/Registries/CreateGameObjectMenuRegistry.cs b/Prowl.Editor/GUI/Registries/CreateGameObjectMenuRegistry.cs
index 69600247e..52951cf85 100644
--- a/Prowl.Editor/GUI/Registries/CreateGameObjectMenuRegistry.cs
+++ b/Prowl.Editor/GUI/Registries/CreateGameObjectMenuRegistry.cs
@@ -54,8 +54,17 @@ internal struct MenuEntry
private static readonly List _entries = [];
private static bool _initialized;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize() { _initialized = false; Initialize(); }
+ /// Drop cached menu entries (which capture user s) so the script AssemblyLoadContext can be collected.
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _entries.Clear();
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/GUI/Registries/FileIconRegistry.cs b/Prowl.Editor/GUI/Registries/FileIconRegistry.cs
index 899e138f7..aa917a3a8 100644
--- a/Prowl.Editor/GUI/Registries/FileIconRegistry.cs
+++ b/Prowl.Editor/GUI/Registries/FileIconRegistry.cs
@@ -38,8 +38,17 @@ public static class FileIconRegistry
private static string _defaultIcon = EditorIcons.File;
private static bool _initialized;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize() { _initialized = false; Initialize(); }
+ /// Reset to an uninitialized state so it rebuilds after a script reload. Kept symmetric with the other registries' teardown.
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _icons.Clear();
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/GUI/Registries/ScriptTemplateRegistry.cs b/Prowl.Editor/GUI/Registries/ScriptTemplateRegistry.cs
index 96ca88aee..0548dd2e3 100644
--- a/Prowl.Editor/GUI/Registries/ScriptTemplateRegistry.cs
+++ b/Prowl.Editor/GUI/Registries/ScriptTemplateRegistry.cs
@@ -54,8 +54,17 @@ public static class ScriptTemplateRegistry
public static IReadOnlyList Templates => _templates;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize() { _initialized = false; Initialize(); }
+ /// Drop cached templates so the script AssemblyLoadContext can be collected.
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _templates.Clear();
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/GUI/SceneView/EditorCamera.cs b/Prowl.Editor/GUI/SceneView/EditorCamera.cs
index 4e276ad44..50452160d 100644
--- a/Prowl.Editor/GUI/SceneView/EditorCamera.cs
+++ b/Prowl.Editor/GUI/SceneView/EditorCamera.cs
@@ -172,6 +172,18 @@ public void Render(Scene scene, bool drawUI = true)
private readonly List _clonedEffects = new();
private Camera? _lastSceneCamera;
+ ///
+ /// Drop references to scene-derived objects: the cached scene and the
+ /// cloned instances (which can be user-script types). These persist
+ /// across frames, so they must be released before a script hot-reload unloads the ALC.
+ ///
+ public void ReleaseSceneReferences()
+ {
+ DisposeClonedEffects();
+ _camera.Effects.Clear();
+ _lastSceneCamera = null;
+ }
+
///
/// Sync image effects from the scene's main camera. Clones effect instances on first
/// use or when the effect list changes, then uses DeserializeInto each frame to copy
diff --git a/Prowl.Editor/GUI/SceneView/SceneDropHandlerRegistry.cs b/Prowl.Editor/GUI/SceneView/SceneDropHandlerRegistry.cs
index 15a0fb54b..ec4ce71d4 100644
--- a/Prowl.Editor/GUI/SceneView/SceneDropHandlerRegistry.cs
+++ b/Prowl.Editor/GUI/SceneView/SceneDropHandlerRegistry.cs
@@ -63,8 +63,17 @@ private struct HandlerEntry
private static readonly List _handlers = [];
private static bool _initialized;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize() { _initialized = false; Initialize(); }
+ /// Drop cached handlers (which may bind user code) so the script AssemblyLoadContext can be collected.
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _handlers.Clear();
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/GUI/SceneView/SceneViewEditorRegistry.cs b/Prowl.Editor/GUI/SceneView/SceneViewEditorRegistry.cs
index 9ccf046f7..6dd03769f 100644
--- a/Prowl.Editor/GUI/SceneView/SceneViewEditorRegistry.cs
+++ b/Prowl.Editor/GUI/SceneView/SceneViewEditorRegistry.cs
@@ -45,6 +45,21 @@ private struct Entry
/// The GameObject the active editor is targeting.
public static GameObject? ActiveTarget { get; private set; }
+ ///
+ /// Deactivate the active editor and drop all cached entries. _entries holds target
+ /// component s (possibly user types), _editorCache holds editor
+ /// instances, and holds a live scene —
+ /// all of which pin the script AssemblyLoadContext. Re-scans lazily on the next access.
+ ///
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ Deactivate();
+ _entries = [];
+ _editorCache.Clear();
+ _initialized = false;
+ }
+
public static void Initialize()
{
if (_initialized) return;
diff --git a/Prowl.Editor/Projects/Build/ProjectBuilder.cs b/Prowl.Editor/Projects/Build/ProjectBuilder.cs
index e9ced9994..c89a113aa 100644
--- a/Prowl.Editor/Projects/Build/ProjectBuilder.cs
+++ b/Prowl.Editor/Projects/Build/ProjectBuilder.cs
@@ -171,7 +171,10 @@ public static BuildProgress StartBuildProcess(string? outputPath)
public static BuildProgress StartBuildAsync(bool andRun, string? outputPath)
{
BuildSettings? settings;
- try { settings = ProjectSettingsRegistry.Get(); }
+ try
+ {
+ settings = ProjectSettingsRegistry.Get();
+ }
catch
{
Runtime.Debug.LogError("BuildSettings not found.");
@@ -191,14 +194,31 @@ public static BuildProgress StartBuildAsync(bool andRun, string? outputPath)
var progress = new BuildProgress();
var projectPath = Project.Current?.RootPath ?? "";
- // THREADING DISABLED: OpenGL is thread-affine, and the build pipeline currently
- // touches GL during asset reimport (SceneImporter -> RenderTexture.Deserialize ->
- // GenTexture()), which crashes with 0xC0000005 when invoked from a ThreadPool
- // worker. Running the build inline on the main thread until GPU resource creation
- // is removed from the import path (or marshaled back to the GL thread).
- // TODO: restore the Task.Run wrapper once that's fixed.
- //Task task = Task.Run(async () =>
- //{
+ var assetSettings = ProjectSettingsRegistry.Get();
+ if (assetSettings != null && assetSettings.AsyncAssetLoading)
+ {
+ // Now that rendering is handled by a separate thread, we should be able to run
+ // the build in a separate thread as well without having any issues
+ System.Threading.Tasks.Task task = System.Threading.Tasks.Task.Run(() =>
+ {
+ ProcessBuild(projectPath, pipeline, settings, outputPath, progress, andRun);
+ });
+
+ if (Program.BuildMode)
+ {
+ task.Wait();
+ }
+ }
+ else
+ {
+ ProcessBuild(projectPath, pipeline, settings, outputPath, progress, andRun);
+ }
+
+ return progress;
+ }
+
+ public static void ProcessBuild(string projectPath, BuildPipeline pipeline, BuildSettings settings, string outputPath, BuildProgress progress, bool andRun)
+ {
try
{
Console.WriteLine($"[BEGIN]{projectPath}[END]");
@@ -211,20 +231,8 @@ public static BuildProgress StartBuildAsync(bool andRun, string? outputPath)
catch (Exception ex)
{
progress.Log($"FATAL: {ex.Message}", Runtime.LogSeverity.Error);
- progress.Complete(new BuildResult
- {
- Success = false,
- Errors = ex.ToString(),
- });
+ progress.Complete(new BuildResult { Success = false, Errors = ex.ToString(), });
}
- //});
-
- //if (Program.BuildMode)
- //{
- // task.Wait();
- //}
-
- return progress;
}
private static void HandleBuildResult(BuildPipeline pipeline, BuildResult result, BuildSettings settings, bool andRun)
diff --git a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs
index 419a8f638..2178229a0 100644
--- a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs
+++ b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs
@@ -28,7 +28,7 @@ public static class ScriptAssemblyManager
private static bool _isCompiling;
private static ScriptCompiler.CompileResult? _pendingResult;
private static bool _restartDeferred;
- private static readonly TimeSpan DebounceDelay = TimeSpan.FromSeconds(1);
+ private static TimeSpan DebounceDelay = TimeSpan.FromSeconds(1);
private static AssemblyLoadContext? s_scriptContext;
private static readonly List s_scriptAssemblies = [];
@@ -108,6 +108,9 @@ public static void Update()
_isCompiling = true;
Runtime.Debug.Log("[ScriptAssemblyManager] Starting compilation...");
+ // Notify anything tagged [OnScriptCompile] that a recompile is starting (main thread).
+ Core.ScriptReloadCallbacks.InvokeScriptCompile();
+
// Run on background thread result polled on main thread via _pendingResult
Task.Run(() =>
{
@@ -213,10 +216,10 @@ private static void LoadAssembly(string dllPath, string label)
// ================================================================
///
- /// Unload the script assembly context. Uses GC retries to verify the context
- /// is truly gone. Returns false if unloading fails (caller should restart).
- /// NoInlining prevents the JIT from keeping references on the stack that would
- /// prevent the GC from collecting the context.
+ /// Unload the script assembly context, then verify it is truly gone via a forced GC loop.
+ /// Returns false if it survives (caller should restart). The strong reference to the context
+ /// is confined entirely to : this method only ever holds the
+ /// , so no live local roots the context across the GC loop.
///
[MethodImpl(MethodImplOptions.NoInlining)]
private static bool UnloadScriptAssemblies()
@@ -224,43 +227,53 @@ private static bool UnloadScriptAssemblies()
if (s_scriptContext == null)
return true;
- // Clear framework-level type caches that hold references into the assembly
+ // Drop framework reflection caches that key off the user assemblies.
foreach (var asm in s_scriptAssemblies)
- {
- try { TypeDescriptor.Refresh(asm); } catch { }
- }
-
+ TypeDescriptor.Refresh(asm);
s_scriptAssemblies.Clear();
- // Unload in a separate method so the local reference to the context
- // doesn't stay on this method's stack frame during the GC loop.
- UnloadContextInternal(out WeakReference contextRef);
+ WeakReference weakCtx = UnloadContext();
- for (int i = 0; contextRef.IsAlive; i++)
+ // Forced GC loop. Collect, run finalizers, collect again so anything resurrected for
+ // finalization is reclaimed within the same iteration.
+ // In this section, it's useless to re-assign s_scriptContext as it pins down the ALC and, if it fails to reload,
+ // the editor will restart anyway so there's no need to have it here
+ for (int i = 0; i < MaxGCAttempts && weakCtx.IsAlive; i++)
{
- if (i >= MaxGCAttempts)
- {
- Runtime.Debug.LogError($"[ScriptAssemblyManager] Failed to unload script assemblies after {MaxGCAttempts} GC attempts.");
- // Context is still alive - recover the reference so we don't leak it
- s_scriptContext = contextRef.Target as AssemblyLoadContext;
- return false;
- }
-
- Runtime.Debug.Log($"[ScriptAssemblyManager] GC attempt ({i + 1}/{MaxGCAttempts})...");
GC.Collect();
GC.WaitForPendingFinalizers();
+ GC.Collect();
}
- Runtime.Debug.Log("[ScriptAssemblyManager] Script assemblies unloaded successfully.");
+ if (weakCtx.IsAlive)
+ {
+ string hint = System.Diagnostics.Debugger.IsAttached
+ ? " A DEBUGGER IS ATTACHED - the CLR keeps collectible assemblies alive for the whole debug " +
+ "session, so hot-reload cannot unload while debugging. Run without the debugger (Ctrl+F5 / " +
+ "launch the built exe) to get true hot-reload; under the debugger the editor restarts instead."
+ : " No debugger attached - the pin is a runtime type/method handle (reflection or JIT/emit cache " +
+ "of a script type), which a heap dump's managed graph can't show. Falling back to editor restart.";
+ Debug.LogError($"[ScriptAssemblyManager] Script context still alive after {MaxGCAttempts} GC attempts." + hint);
+ return false;
+ }
+
+ Debug.Log("[ScriptAssemblyManager] Unload successful.");
return true;
}
+ ///
+ /// Nulls the static field and unloads the context. The only strong reference to the context
+ /// lives in this method's ctx local, which goes out of scope on return — so the caller's
+ /// GC loop can collect it. NoInlining keeps that local out of the caller's stack frame.
+ ///
[MethodImpl(MethodImplOptions.NoInlining)]
- private static void UnloadContextInternal(out WeakReference contextRef)
+ private static WeakReference UnloadContext()
{
- s_scriptContext!.Unload();
- contextRef = new WeakReference(s_scriptContext);
+ AssemblyLoadContext ctx = s_scriptContext!;
s_scriptContext = null;
+ var weak = new WeakReference(ctx);
+ ctx.Unload();
+ return weak;
}
// ================================================================
@@ -284,26 +297,17 @@ private static bool TryHotReload(Project project)
SaveSceneForRestart(project);
EditorApplication.Instance?.SaveProjectState();
- // 2. Clear all caches that hold Type references or reflection data
- Echo.Serializer.ClearCache();
- Runtime.RuntimeUtils.ClearCache();
- Runtime.GraphTools.NodeRegistry.Reinitialize();
- Runtime.MeshFeatures.MeshFeatureRegistry.Reinitialize();
-
- // Editor ComponentIconRegistry cache
- typeof(ComponentIconRegistry)
- .GetField("_cache", BindingFlags.NonPublic | BindingFlags.Static)?
- .GetValue(null)
- ?.GetType().GetMethod("Clear")?
- .Invoke(typeof(ComponentIconRegistry)
- .GetField("_cache", BindingFlags.NonPublic | BindingFlags.Static)?
- .GetValue(null), null);
-
- // 3. Unload old assemblies - if this fails, caller will restart
+ // 2. Drop every editor/runtime strong reference into the old assemblies. Without this
+ // the collectible context stays rooted and the unload below fails, forcing a restart.
+ EditorApplication.Instance?.ReleaseScriptReferences();
+
+ // 3. Unload old assemblies - if this fails, caller will restart.
if (!UnloadScriptAssemblies())
return false;
- // 4. Load the new assemblies into a fresh context
+ DebounceDelay = TimeSpan.FromSeconds(1);
+
+ // 4. Load the new assemblies into a fresh context.
LoadAssemblies(project);
// 5. Reinitialize all editor registries (re-scans assemblies for attributes)
@@ -314,6 +318,8 @@ private static bool TryHotReload(Project project)
if (File.Exists(autoSavePath))
EditorApplication.Instance?.RestoreAutoSavedScene(autoSavePath);
+ EditorApplication.Instance?.RestoreSelectionAfterReload();
+
Runtime.Debug.LogSuccess("[ScriptAssemblyManager] Hot-reload successful!");
return true;
}
diff --git a/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs b/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs
index e1cacb057..118065943 100644
--- a/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs
+++ b/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs
@@ -118,7 +118,7 @@ internal static string GetVersionDefine()
int plus = version.IndexOf('+');
if (plus >= 0) version = version[..plus];
// Convert "0.0.1" to "PROWL_0_0_1"
- return "PROWL_" + version.Replace('.', '_');
+ return "PROWL_" + version.Replace('.', '_').Replace('-', '_').ToUpperInvariant();
}
private static void GenerateGameCsproj(Project project, List scripts)
@@ -133,7 +133,10 @@ private static void GenerateGameCsproj(Project project, List scripts)
sb.AppendLine("");
sb.AppendLine(" ");
sb.AppendLine(" net10.0");
- sb.AppendLine(" false");
+ // EnableDefaultItems=false prevents the CandidateAssemblyFiles to include Dlls
+ // in the Builds\ path, if it's in the same folder as the project. This won't cause any issues as other compile
+ // references are directly added below.
+ sb.AppendLine(" false");
sb.AppendLine(" true");
sb.AppendLine(" enable");
sb.AppendLine($" {outputDir}");
@@ -198,7 +201,8 @@ private static void GenerateEditorCsproj(Project project, List scripts)
sb.AppendLine("");
sb.AppendLine(" ");
sb.AppendLine(" net10.0");
- sb.AppendLine(" false");
+ // Same as above
+ sb.AppendLine(" false");
sb.AppendLine(" true");
sb.AppendLine(" enable");
sb.AppendLine($" {outputDir}");
diff --git a/Prowl.Editor/Projects/Settings/ProjectSettings.cs b/Prowl.Editor/Projects/Settings/ProjectSettings.cs
index ea42ae2d7..a67a942d8 100644
--- a/Prowl.Editor/Projects/Settings/ProjectSettings.cs
+++ b/Prowl.Editor/Projects/Settings/ProjectSettings.cs
@@ -77,6 +77,30 @@ public struct SettingsEntry
public static void Reinitialize() { _initialized = false; _entries.Clear(); Initialize(); }
+ ///
+ /// Drop cached settings entries (each holds a settings and a live
+ /// instance, which may be a user type) so the script AssemblyLoadContext can be collected.
+ /// Callers must first and reload via
+ /// after the new assemblies are in place so authored values survive the reload.
+ ///
+ [Runtime.OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _entries.Clear();
+ }
+
+ ///
+ /// Hook before scripts reload, reinizializes all the settings singletons against the values
+ /// written to disk just before by SaveAll.
+ ///
+ [Runtime.OnAssemblyLoad]
+ public static void OnScriptReload()
+ {
+ Reinitialize();
+ OnProjectOpened();
+ }
+
public static void Initialize()
{
if (_initialized) return;
@@ -114,7 +138,20 @@ public static T Get() where T : ProjectSettingsBase
{
foreach (var entry in _entries)
if (entry.Instance is T t) return t;
- throw new InvalidOperationException($"Settings type {typeof(T).Name} not registered.");
+
+ // Not registered. This normally only happens transiently if the registry was cleared for
+ // a script hot-reload that then failed to finish. Rebuild instead of hard-crashing the
+ // editor (a throw here turned a recoverable reload failure into a fatal exception during
+ // Scene.Load -> Settings.Apply).
+ if (!_initialized)
+ {
+ Initialize();
+ foreach (var entry in _entries)
+ if (entry.Instance is T t) return t;
+ }
+
+ Debug.LogWarning($"Settings type {typeof(T).Name} not registered; returning a transient default.");
+ return (T)Activator.CreateInstance(typeof(T))!;
}
/// Load all settings from the current project's ProjectSettings folder.
diff --git a/Prowl.Editor/Utils/InitializeOnLoadRegistry.cs b/Prowl.Editor/Utils/InitializeOnLoadRegistry.cs
index d12312888..31b9b229f 100644
--- a/Prowl.Editor/Utils/InitializeOnLoadRegistry.cs
+++ b/Prowl.Editor/Utils/InitializeOnLoadRegistry.cs
@@ -23,6 +23,7 @@ public static class InitializeOnLoadRegistry
{
private static bool _initialized;
+ [Runtime.OnAssemblyLoad]
public static void Reinitialize() { _initialized = false; Initialize(); }
public static void Initialize()
diff --git a/Prowl.Editor/Utils/SelectionToken.cs b/Prowl.Editor/Utils/SelectionToken.cs
new file mode 100644
index 000000000..20263fe2f
--- /dev/null
+++ b/Prowl.Editor/Utils/SelectionToken.cs
@@ -0,0 +1,11 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+using System;
+
+
+///
+/// A selection entry stored as identifiers only, so it never pins the script ALC.
+///
+public readonly record struct SelectionToken(SelKind Kind, Guid Id, Guid CompId, string Path, string Name, bool IsFolder);
+
+public enum SelKind { GameObject, Component, Asset, Content }
diff --git a/Prowl.Runtime/Components/UI/Input/UIEventSystem.cs b/Prowl.Runtime/Components/UI/Input/UIEventSystem.cs
index 868b2a77b..f844c0bea 100644
--- a/Prowl.Runtime/Components/UI/Input/UIEventSystem.cs
+++ b/Prowl.Runtime/Components/UI/Input/UIEventSystem.cs
@@ -61,6 +61,22 @@ public struct HostViewport
private static GameObject? s_lastHovered;
private static Float2 s_lastPointerPos;
+ ///
+ /// Clears all retained references (hover/selection/pointer targets).
+ /// These outlive a unload and would otherwise pin disposed
+ /// GameObjects — and, during script hot-reload, the collectible AssemblyLoadContext.
+ ///
+ [OnAssemblyUnload]
+ public static void ResetState()
+ {
+ CurrentHovered = null;
+ CurrentSelected = null;
+ s_lastHovered = null;
+ s_left.Reset();
+ s_right.Reset();
+ s_middle.Reset();
+ }
+
// ============================================================
// Public API
// ============================================================
diff --git a/Prowl.Runtime/Debug.cs b/Prowl.Runtime/Debug.cs
index 00a81226c..473a796b4 100644
--- a/Prowl.Runtime/Debug.cs
+++ b/Prowl.Runtime/Debug.cs
@@ -27,14 +27,14 @@ public enum LogSeverity
public delegate void OnLog(string message, DebugStackTrace? stackTrace, LogSeverity logSeverity);
-public record DebugStackFrame(string FileName, int? Line = null, int? Column = null, MethodBase? MethodBase = null)
+public record DebugStackFrame(string FileName, int? Line = null, int? Column = null, string? Method = null)
{
public override string ToString()
{
string locSuffix = Line != null ? Column != null ? $"({Line},{Column})" : $"({Line})" : "";
- if (MethodBase != null)
- return $"In {MethodBase.DeclaringType.Name}.{MethodBase.Name} at {FileName}{locSuffix}";
+ if (!string.IsNullOrEmpty(Method))
+ return $"In {Method} at {FileName}{locSuffix}";
else
return $"At {FileName}{locSuffix}";
}
@@ -53,7 +53,11 @@ public static explicit operator DebugStackTrace(StackTrace stackTrace)
for (int i = 0; i < stackFrames.Length; i++)
{
StackFrame srcFrame = stackTrace.GetFrame(i);
- stackFrames[i] = new DebugStackFrame(srcFrame.GetFileName(), srcFrame.GetFileLineNumber(), srcFrame.GetFileColumnNumber(), srcFrame.GetMethod());
+
+ MethodBase? m = srcFrame.GetMethod();
+ string? method = m != null ? $"{m.DeclaringType?.Name}.{m.Name}" : null;
+
+ stackFrames[i] = new DebugStackFrame(srcFrame.GetFileName(), srcFrame.GetFileLineNumber(), srcFrame.GetFileColumnNumber(), method);
}
return new DebugStackTrace(stackFrames);
diff --git a/Prowl.Runtime/GameObject/GameObject.cs b/Prowl.Runtime/GameObject/GameObject.cs
index a1492037a..260620712 100644
--- a/Prowl.Runtime/GameObject/GameObject.cs
+++ b/Prowl.Runtime/GameObject/GameObject.cs
@@ -998,6 +998,13 @@ public override void OnDispose()
}
_components.Clear();
+ // Sever every graph link so a disposed GameObject is a dead-end. Without this, anything
+ // still holding a reference to this GameObject (an editor panel, a render cache, a stray
+ // delegate) transitively keeps its components - and therefore their user-script types and
+ // the collectible AssemblyLoadContext - alive, which blocks script hot-reload.
+ _componentCache.Clear();
+ Children.Clear();
+
if (_parent.IsValid() && !_parent.IsDisposed)
SetParent(null);
}
diff --git a/Prowl.Runtime/GraphTools/GraphValidator.cs b/Prowl.Runtime/GraphTools/GraphValidator.cs
index a186cbf4a..dfcdba391 100644
--- a/Prowl.Runtime/GraphTools/GraphValidator.cs
+++ b/Prowl.Runtime/GraphTools/GraphValidator.cs
@@ -50,6 +50,7 @@ public static class GraphValidatorRegistry
private static readonly List<(Type? marker, GraphValidator instance)> _validators = new();
private static bool _initialized;
+ [OnAssemblyLoad]
public static void Reinitialize()
{
_initialized = false;
@@ -57,6 +58,18 @@ public static void Reinitialize()
Initialize();
}
+ ///
+ /// Drop cached validators (each holds a marker and a live instance,
+ /// possibly user types) without re-scanning, so a collectible AssemblyLoadContext can be
+ /// unloaded. Rebuilds on the next after reload.
+ ///
+ [OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _validators.Clear();
+ }
+
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
Justification = "Engine bootstrap: scans loaded assemblies for GraphValidator subclasses. Validator types must be preserved by the consuming application's trim configuration.")]
[UnconditionalSuppressMessage("Trimming", "IL2072:DynamicallyAccessedMembers",
diff --git a/Prowl.Runtime/GraphTools/NodeRegistry.cs b/Prowl.Runtime/GraphTools/NodeRegistry.cs
index eba1e1e51..39b871eae 100644
--- a/Prowl.Runtime/GraphTools/NodeRegistry.cs
+++ b/Prowl.Runtime/GraphTools/NodeRegistry.cs
@@ -140,6 +140,8 @@ public static IReadOnlyList GetForMarker(Type? markerInterface
/// Force a full rescan call after adding/removing assemblies at runtime (e.g. after
/// recompiling user scripts).
///
+ // Clear-only (rebuilds lazily on next access), so this is the unload hook, not a load hook.
+ [OnAssemblyUnload]
public static void Reinitialize()
{
lock (s_lock)
diff --git a/Prowl.Runtime/Resources/MeshFeatures/MeshFeatureRegistry.cs b/Prowl.Runtime/Resources/MeshFeatures/MeshFeatureRegistry.cs
index 1af82e520..99936f687 100644
--- a/Prowl.Runtime/Resources/MeshFeatures/MeshFeatureRegistry.cs
+++ b/Prowl.Runtime/Resources/MeshFeatures/MeshFeatureRegistry.cs
@@ -46,6 +46,19 @@ public static void Reinitialize()
Initialize();
}
+ ///
+ /// Drop cached specs without re-scanning, so a collectible AssemblyLoadContext holding
+ /// user types can be unloaded. Specs rebuild on the next
+ /// / after the new assemblies are loaded.
+ ///
+ [OnAssemblyUnload]
+ public static void ClearCache()
+ {
+ _initialized = false;
+ _specs.Clear();
+ _aggregateVersion = 0;
+ }
+
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
Justification = "Engine bootstrap: scans loaded assemblies for MeshFeatureSpec subclasses. Spec types must be preserved by the consuming application's trim configuration.")]
[UnconditionalSuppressMessage("Trimming", "IL2072:DynamicallyAccessedMembers",
diff --git a/Prowl.Runtime/Resources/Scene.cs b/Prowl.Runtime/Resources/Scene.cs
index d99469a34..6c3a69294 100644
--- a/Prowl.Runtime/Resources/Scene.cs
+++ b/Prowl.Runtime/Resources/Scene.cs
@@ -563,6 +563,13 @@ public override void OnDispose()
// Clear any remaining references
_allObj.Clear();
_allObjSet.Clear();
+
+ // Remove all identifiers and reference to any possible gameobject that could hold a
+ // user-defined script as it might leave the ALC alive
+ serializeObj = null;
+ _goIdentifiers = null;
+ _compIdentifiers = null;
+ _compIdOffsets = null;
}
public void OnBeforeSerialize()
diff --git a/Prowl.Runtime/ScriptLifecycleAttributes.cs b/Prowl.Runtime/ScriptLifecycleAttributes.cs
new file mode 100644
index 000000000..88cdc9dd0
--- /dev/null
+++ b/Prowl.Runtime/ScriptLifecycleAttributes.cs
@@ -0,0 +1,32 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+
+namespace Prowl.Runtime;
+
+public abstract class ScriptLifecycleAttribute : Attribute
+{
+ // Lower values run first. Methods with the same order run in an unspecified order.
+ public int Order { get; init; }
+}
+
+///
+/// Use on a static and parameterless method to run just before the editor unloads the ALC for a reload.
+///
+[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
+public sealed class OnAssemblyUnloadAttribute : ScriptLifecycleAttribute { }
+
+///
+/// Use on a static and parameterless method to run right after the new script assemblies are loaded
+/// during a reload (the counterpart to ).
+///
+[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
+public sealed class OnAssemblyLoadAttribute : ScriptLifecycleAttribute { }
+
+///
+/// Use on a static, parameterless method to run when script compilation begins (before the build
+/// kicks off).
+///
+[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
+public sealed class OnScriptCompileAttribute : ScriptLifecycleAttribute { }
diff --git a/Prowl.Runtime/Utils/RuntimeUtils.cs b/Prowl.Runtime/Utils/RuntimeUtils.cs
index 0ae907285..593794818 100644
--- a/Prowl.Runtime/Utils/RuntimeUtils.cs
+++ b/Prowl.Runtime/Utils/RuntimeUtils.cs
@@ -44,6 +44,7 @@ public static class RuntimeUtils
private static readonly Dictionary s_deepCopyByAssignmentCache = [];
private static readonly Dictionary s_executionOrderCache = [];
+ [OnAssemblyUnload]
public static void ClearCache()
{
s_deepCopyByAssignmentCache.Clear();