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();