From 531d359174d56d6dd9d7977e9c7551c7a2fb3c4c Mon Sep 17 00:00:00 2001 From: Paolo Date: Tue, 16 Jun 2026 12:32:04 +0200 Subject: [PATCH 01/13] Added StaticFieldCrawler to clean up filled static fields --- Prowl.Editor/Utils/StaticFieldCrawler.cs | 75 ++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 Prowl.Editor/Utils/StaticFieldCrawler.cs diff --git a/Prowl.Editor/Utils/StaticFieldCrawler.cs b/Prowl.Editor/Utils/StaticFieldCrawler.cs new file mode 100644 index 000000000..b2fc90105 --- /dev/null +++ b/Prowl.Editor/Utils/StaticFieldCrawler.cs @@ -0,0 +1,75 @@ +// 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.Reflection; +using System.Collections.Generic; + +namespace Prowl.Editor; + +/// +/// Snapshots and restores static field values across play-mode sessions. +/// Instead of resetting all static fields to their defaults (which destroys editor state), +/// we capture values before entering play mode and restore them when exiting. +/// +public static class StaticFieldCrawler +{ + private static readonly Dictionary s_snapshot = []; + + /// + /// Captures the current values of all mutable static fields in the given assembly. + /// Call this before entering play mode. + /// + public static void SnapshotStaticFields(Assembly assembly) + { + foreach (Type type in assembly.GetTypes()) + { + var staticFields = type.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + + foreach (FieldInfo field in staticFields) + { + if (field.IsLiteral || field.IsInitOnly) + continue; + + try + { + s_snapshot[field] = field.GetValue(null); + } + catch (Exception ex) + { + //Runtime.Debug.LogWarning($"[StaticFieldCrawler] Could not snapshot '{type.Name}.{field.Name}': {ex.Message}"); + } + } + } + } + + /// + /// Restores all previously snapshotted static fields to their pre-play-mode values. + /// Call this after exiting play mode. + /// + public static void RestoreStaticFields() + { + foreach (var (field, value) in s_snapshot) + { + try + { + field.SetValue(null, value); + } + catch (Exception ex) + { + Runtime.Debug.LogWarning($"[StaticFieldCrawler] Could not restore '{field.DeclaringType?.Name}.{field.Name}': {ex.Message}"); + } + } + + s_snapshot.Clear(); + } + + /// + /// Discards the snapshot without restoring it. The snapshot holds + /// handles (which pin their declaring user types) and captured values (which may be user + /// instances), so it must be cleared before unloading the script AssemblyLoadContext. + /// Only relevant if a reload is somehow attempted with a live snapshot; normally the + /// snapshot is already empty outside of play mode. + /// + public static void Clear() => s_snapshot.Clear(); +} From d2e440e7d8faefd71a9696e77146a461357c99e7 Mon Sep 17 00:00:00 2001 From: Paolo Date: Tue, 16 Jun 2026 12:32:46 +0200 Subject: [PATCH 02/13] Fixed to prevent candidates from Builds path within the project --- Prowl.Editor/Core/IScriptReloadCleanup.cs | 20 +++++++++++++++++++ .../Projects/Scripting/ScriptCompiler.cs | 10 +++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 Prowl.Editor/Core/IScriptReloadCleanup.cs 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/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}"); From 4c8143b17fd8b8f122736a80bb0ea31c4414d8b6 Mon Sep 17 00:00:00 2001 From: Paolo Date: Tue, 16 Jun 2026 12:33:47 +0200 Subject: [PATCH 03/13] Moved Build to another thread now that rendering is using its own thread --- Prowl.Editor/Projects/Build/ProjectBuilder.cs | 53 +++++++++---------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/Prowl.Editor/Projects/Build/ProjectBuilder.cs b/Prowl.Editor/Projects/Build/ProjectBuilder.cs index e9ced9994..ef41ad461 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,38 +194,30 @@ 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 () => - //{ - try + // 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(async () => { - Console.WriteLine($"[BEGIN]{projectPath}[END]"); - var result = pipeline.BuildAsync( - projectPath, settings, outputPath, progress).GetAwaiter().GetResult(); - progress.Complete(result); + try + { + Console.WriteLine($"[BEGIN]{projectPath}[END]"); + var result = pipeline.BuildAsync( + projectPath, settings, outputPath, progress).GetAwaiter().GetResult(); + progress.Complete(result); - HandleBuildResult(pipeline, result, settings, andRun); - } - catch (Exception ex) - { - progress.Log($"FATAL: {ex.Message}", Runtime.LogSeverity.Error); - progress.Complete(new BuildResult + HandleBuildResult(pipeline, result, settings, andRun); + } + catch (Exception ex) { - Success = false, - Errors = ex.ToString(), - }); - } - //}); + progress.Log($"FATAL: {ex.Message}", Runtime.LogSeverity.Error); + progress.Complete(new BuildResult { Success = false, Errors = ex.ToString(), }); + } + }); - //if (Program.BuildMode) - //{ - // task.Wait(); - //} + if (Program.BuildMode) + { + task.Wait(); + } return progress; } From 8403d80c6556fafeb0165faa102626053562db2e Mon Sep 17 00:00:00 2001 From: Paolo Date: Tue, 16 Jun 2026 12:43:28 +0200 Subject: [PATCH 04/13] Added Unload Hint, removed reassigned reference to s_scriptContext and streamlined unload flow --- .../Scripting/ScriptAssemblyManager.cs | 68 +++++++++++-------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs index 419a8f638..93b271ca1 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 = []; @@ -213,10 +213,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 +224,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; } // ================================================================ @@ -303,7 +313,9 @@ private static bool TryHotReload(Project project) 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) From 35b314a37f4a6e63787cd51524655c1047513df6 Mon Sep 17 00:00:00 2001 From: Paolo Date: Tue, 16 Jun 2026 12:48:15 +0200 Subject: [PATCH 05/13] Editor references cleanup --- .../Importers/ImporterRegistry.cs | 8 ++ .../Thumbnails/ThumbnailGeneratorRegistry.cs | 7 + Prowl.Editor/Core/EditorApplication.cs | 127 ++++++++++++++++-- Prowl.Editor/GUI/CustomEditor.cs | 11 ++ .../GUI/CustomEditors/AssetImporterEditor.cs | 8 ++ .../GUI/GraphTools/NodePreviewRegistry.cs | 8 ++ Prowl.Editor/GUI/GraphTools/NodeRenderer.cs | 8 ++ Prowl.Editor/GUI/Panels/HierarchyPanel.cs | 10 +- Prowl.Editor/GUI/Panels/InspectorPanel.cs | 6 +- Prowl.Editor/GUI/Panels/SceneViewPanel.cs | 6 +- Prowl.Editor/GUI/Popups/AddComponentPopup.cs | 6 + Prowl.Editor/GUI/PropertyEditor.cs | 11 ++ .../Registries/AssetDoubleClickRegistry.cs | 7 + .../GUI/Registries/ComponentIconRegistry.cs | 6 + .../GUI/Registries/CreateAssetMenuRegistry.cs | 12 ++ .../CreateGameObjectMenuRegistry.cs | 7 + .../GUI/Registries/FileIconRegistry.cs | 7 + .../GUI/Registries/ScriptTemplateRegistry.cs | 7 + Prowl.Editor/GUI/SceneView/EditorCamera.cs | 12 ++ .../GUI/SceneView/SceneDropHandlerRegistry.cs | 7 + .../GUI/SceneView/SceneViewEditorRegistry.cs | 14 ++ .../Scripting/ScriptAssemblyManager.cs | 21 +-- .../Projects/Settings/ProjectSettings.cs | 27 +++- .../Components/UI/Input/UIEventSystem.cs | 15 +++ Prowl.Runtime/GameObject/GameObject.cs | 7 + Prowl.Runtime/GraphTools/GraphValidator.cs | 11 ++ .../MeshFeatures/MeshFeatureRegistry.cs | 12 ++ 27 files changed, 356 insertions(+), 32 deletions(-) diff --git a/Prowl.Editor/AssetsDatabase/Importers/ImporterRegistry.cs b/Prowl.Editor/AssetsDatabase/Importers/ImporterRegistry.cs index caec81169..4542e0e2d 100644 --- a/Prowl.Editor/AssetsDatabase/Importers/ImporterRegistry.cs +++ b/Prowl.Editor/AssetsDatabase/Importers/ImporterRegistry.cs @@ -16,6 +16,14 @@ public static class ImporterRegistry public static void Reinitialize() { _initialized = false; Initialize(); } + /// Drop cached importer type maps so the script AssemblyLoadContext can be collected. + 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..5bc95cdf9 100644 --- a/Prowl.Editor/AssetsDatabase/Thumbnails/ThumbnailGeneratorRegistry.cs +++ b/Prowl.Editor/AssetsDatabase/Thumbnails/ThumbnailGeneratorRegistry.cs @@ -43,6 +43,13 @@ public static class ThumbnailGeneratorRegistry public static void Reinitialize() { _initialized = false; Initialize(); } + /// Drop cached generators (keyed by user ) so the script AssemblyLoadContext can be collected. + 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..c0e609390 100644 --- a/Prowl.Editor/Core/EditorApplication.cs +++ b/Prowl.Editor/Core/EditorApplication.cs @@ -1116,6 +1116,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,8 +1291,80 @@ 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(); + + // Re-create settings singletons against the new types, then reload the values that + // SaveProjectState() persisted to disk just before the reload so authoring survives. + ProjectSettingsRegistry.OnProjectOpened(); + } + + /// + /// 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() + { + // 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(); + Runtime.UI.UIEventSystem.ResetState(); + SceneViewEditorRegistry.ClearCache(); + + // 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 { } + + // 2. Play-mode leftovers (normally empty outside play mode; cleared defensively). + _savedEditorScene = null; + _savedEditorTime = null; + StaticFieldCrawler.Clear(); + + // 3. Menu closures: Window/* and GameObject/* items capture user Types. Drop the whole + // menu (rebuilt wholesale by ReinitializeRegistries) plus the panel type list. + MenuRegistry.Clear(); + _registeredPanels.Clear(); + + // 4. Editor registry caches (Type maps, cached editor/drawer/generator instances, ...). + PropertyEditorRegistry.ClearCache(); + CustomEditorRegistry.ClearCache(); + GraphTools.NodeRendererRegistry.ClearCache(); + GraphTools.NodePreviewRegistry.ClearCache(); + Inspector.AssetImporterEditorRegistry.ClearCache(); + GUI.Popups.AddComponentPopup.ClearCache(); + Importers.ImporterRegistry.ClearCache(); + ProjectSettingsRegistry.ClearCache(); + CreateAssetMenuRegistry.ClearCache(); + ThumbnailGeneratorRegistry.ClearCache(); + SceneDropHandlerRegistry.ClearCache(); + CreateGameObjectMenuRegistry.ClearCache(); + FileIconRegistry.ClearCache(); + AssetDoubleClickRegistry.ClearCache(); + ScriptTemplateRegistry.ClearCache(); + ComponentIconRegistry.ClearCache(); + + // 5. Runtime-side caches that also reflect over user assemblies. + Echo.Serializer.ClearCache(); + RuntimeUtils.ClearCache(); + Runtime.GraphTools.NodeRegistry.Reinitialize(); // clear-only; rebuilds lazily + Runtime.GraphTools.GraphValidatorRegistry.ClearCache(); + Runtime.MeshFeatures.MeshFeatureRegistry.ClearCache(); + } private void ReinitializeRegistries() { @@ -1293,16 +1389,11 @@ private void ReinitializeRegistries() AssetDoubleClickRegistry.Reinitialize(); ScriptTemplateRegistry.Reinitialize(); - // Re-register Window menu items for any new panels from user assemblies - foreach (var (type, path) in _registeredPanels) - { - var capturedType = type; - MenuRegistry.Register($"Window/{path}", () => OpenPanel(capturedType), - isChecked: () => IsPanelOpen(capturedType)); - } - - // Re-register GameObject menu items for any new creators from user assemblies - CreateGameObjectMenuRegistry.RegisterMenuBarItems(); + // Rebuild the menu bar from scratch. A hot-reload clears it in ReleaseScriptReferences() + // (dropping closures that captured user Types); rebuild the full set so removed/renamed + // user windows and creators don't linger. Cheap and idempotent on the normal open path. + MenuRegistry.Clear(); + RegisterMenus(); } public void RestoreAutoSavedScene(string path) @@ -1463,6 +1554,15 @@ private void EnterPlayMode() return; } + // Snapshot static fields before play mode so we can restore them on exit + foreach (Assembly assembly in ScriptAssemblyManager.GetAllRelevantAssemblies()) + { + if (!assembly.FullName.StartsWith("System.") && !assembly.FullName.StartsWith("Microsoft.")) + { + StaticFieldCrawler.SnapshotStaticFields(assembly); + } + } + // Clear selection (references will be invalid) Selection.Clear(); @@ -1520,6 +1620,9 @@ private void ExitPlayMode() // Unload the play scene Runtime.Resources.Scene.Unload(); + // Restore static fields to their pre-play-mode values + StaticFieldCrawler.RestoreStaticFields(); + // Restore the editor scene WITHOUT lifecycle callbacks if (_savedEditorScene != null) { diff --git a/Prowl.Editor/GUI/CustomEditor.cs b/Prowl.Editor/GUI/CustomEditor.cs index 038bc1ef2..875469935 100644 --- a/Prowl.Editor/GUI/CustomEditor.cs +++ b/Prowl.Editor/GUI/CustomEditor.cs @@ -60,6 +60,17 @@ public static void Reinitialize() Initialize(); } + /// + /// Drop all cached references and editor instances so the script + /// AssemblyLoadContext can be collected. Caches rebuild on the next . + /// + 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..64f74cbda 100644 --- a/Prowl.Editor/GUI/CustomEditors/AssetImporterEditor.cs +++ b/Prowl.Editor/GUI/CustomEditors/AssetImporterEditor.cs @@ -37,6 +37,14 @@ public static class AssetImporterEditorRegistry public static void Reinitialize() { _initialized = false; Initialize(); } + /// Drop cached type maps and editor instances so the script AssemblyLoadContext can be collected. + 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..5a40687c6 100644 --- a/Prowl.Editor/GUI/GraphTools/NodePreviewRegistry.cs +++ b/Prowl.Editor/GUI/GraphTools/NodePreviewRegistry.cs @@ -50,6 +50,14 @@ public static void Reinitialize() Initialize(); } + /// Drop cached type maps so the script AssemblyLoadContext can be collected. + 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..1e679fae8 100644 --- a/Prowl.Editor/GUI/GraphTools/NodeRenderer.cs +++ b/Prowl.Editor/GUI/GraphTools/NodeRenderer.cs @@ -75,6 +75,14 @@ public static void Reinitialize() Initialize(); } + /// Drop cached type maps so the script AssemblyLoadContext can be collected. + public static void ClearCache() + { + _initialized = false; + _typeToRenderer.Clear(); + _cache.Clear(); + } + public static void Initialize() { if (_initialized) return; 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..b869cc73f 100644 --- a/Prowl.Editor/GUI/Popups/AddComponentPopup.cs +++ b/Prowl.Editor/GUI/Popups/AddComponentPopup.cs @@ -37,6 +37,12 @@ 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. + /// + 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..620e5d13b 100644 --- a/Prowl.Editor/GUI/PropertyEditor.cs +++ b/Prowl.Editor/GUI/PropertyEditor.cs @@ -45,6 +45,17 @@ public static class PropertyEditorRegistry 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 . + /// + 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..0963c8b0f 100644 --- a/Prowl.Editor/GUI/Registries/AssetDoubleClickRegistry.cs +++ b/Prowl.Editor/GUI/Registries/AssetDoubleClickRegistry.cs @@ -37,6 +37,13 @@ public static class AssetDoubleClickRegistry public static void Reinitialize() { _initialized = false; Initialize(); } + /// Drop cached handler delegates (which may bind user code) so the script AssemblyLoadContext can be collected. + 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..2c4feb48c 100644 --- a/Prowl.Editor/GUI/Registries/ComponentIconRegistry.cs +++ b/Prowl.Editor/GUI/Registries/ComponentIconRegistry.cs @@ -19,6 +19,12 @@ 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. + /// + 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..7202b04b1 100644 --- a/Prowl.Editor/GUI/Registries/CreateAssetMenuRegistry.cs +++ b/Prowl.Editor/GUI/Registries/CreateAssetMenuRegistry.cs @@ -64,6 +64,18 @@ public static void RemoveManualEntriesByPrefix(string prefix) 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()). + /// + 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..3745850a7 100644 --- a/Prowl.Editor/GUI/Registries/CreateGameObjectMenuRegistry.cs +++ b/Prowl.Editor/GUI/Registries/CreateGameObjectMenuRegistry.cs @@ -56,6 +56,13 @@ internal struct MenuEntry public static void Reinitialize() { _initialized = false; Initialize(); } + /// Drop cached menu entries (which capture user s) so the script AssemblyLoadContext can be collected. + 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..cb3f66d53 100644 --- a/Prowl.Editor/GUI/Registries/FileIconRegistry.cs +++ b/Prowl.Editor/GUI/Registries/FileIconRegistry.cs @@ -40,6 +40,13 @@ public static class FileIconRegistry 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. + 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..62d699b1c 100644 --- a/Prowl.Editor/GUI/Registries/ScriptTemplateRegistry.cs +++ b/Prowl.Editor/GUI/Registries/ScriptTemplateRegistry.cs @@ -56,6 +56,13 @@ public static class ScriptTemplateRegistry public static void Reinitialize() { _initialized = false; Initialize(); } + /// Drop cached templates so the script AssemblyLoadContext can be collected. + 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..64b9cb3fd 100644 --- a/Prowl.Editor/GUI/SceneView/SceneDropHandlerRegistry.cs +++ b/Prowl.Editor/GUI/SceneView/SceneDropHandlerRegistry.cs @@ -65,6 +65,13 @@ private struct HandlerEntry public static void Reinitialize() { _initialized = false; Initialize(); } + /// Drop cached handlers (which may bind user code) so the script AssemblyLoadContext can be collected. + 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..08dc0770b 100644 --- a/Prowl.Editor/GUI/SceneView/SceneViewEditorRegistry.cs +++ b/Prowl.Editor/GUI/SceneView/SceneViewEditorRegistry.cs @@ -45,6 +45,20 @@ 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. + /// + public static void ClearCache() + { + Deactivate(); + _entries = []; + _editorCache.Clear(); + _initialized = false; + } + public static void Initialize() { if (_initialized) return; diff --git a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs index 93b271ca1..96f6bc802 100644 --- a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs +++ b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs @@ -294,22 +294,11 @@ 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; diff --git a/Prowl.Editor/Projects/Settings/ProjectSettings.cs b/Prowl.Editor/Projects/Settings/ProjectSettings.cs index ea42ae2d7..3ab7e63cc 100644 --- a/Prowl.Editor/Projects/Settings/ProjectSettings.cs +++ b/Prowl.Editor/Projects/Settings/ProjectSettings.cs @@ -77,6 +77,18 @@ 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. + /// + public static void ClearCache() + { + _initialized = false; + _entries.Clear(); + } + public static void Initialize() { if (_initialized) return; @@ -114,7 +126,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.Runtime/Components/UI/Input/UIEventSystem.cs b/Prowl.Runtime/Components/UI/Input/UIEventSystem.cs index 868b2a77b..bed0fb1a9 100644 --- a/Prowl.Runtime/Components/UI/Input/UIEventSystem.cs +++ b/Prowl.Runtime/Components/UI/Input/UIEventSystem.cs @@ -61,6 +61,21 @@ 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. + /// + 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/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..63c5f1742 100644 --- a/Prowl.Runtime/GraphTools/GraphValidator.cs +++ b/Prowl.Runtime/GraphTools/GraphValidator.cs @@ -57,6 +57,17 @@ 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. + /// + 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/Resources/MeshFeatures/MeshFeatureRegistry.cs b/Prowl.Runtime/Resources/MeshFeatures/MeshFeatureRegistry.cs index 1af82e520..b0f844b6e 100644 --- a/Prowl.Runtime/Resources/MeshFeatures/MeshFeatureRegistry.cs +++ b/Prowl.Runtime/Resources/MeshFeatures/MeshFeatureRegistry.cs @@ -46,6 +46,18 @@ 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. + /// + 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", From 122810708faf08e2f304ce376dab7823b4b3c5d1 Mon Sep 17 00:00:00 2001 From: Paolo Date: Tue, 16 Jun 2026 13:11:37 +0200 Subject: [PATCH 06/13] Added different Build process based on AssetSettings --- Prowl.Editor/Projects/Build/ProjectBuilder.cs | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/Prowl.Editor/Projects/Build/ProjectBuilder.cs b/Prowl.Editor/Projects/Build/ProjectBuilder.cs index ef41ad461..c89a113aa 100644 --- a/Prowl.Editor/Projects/Build/ProjectBuilder.cs +++ b/Prowl.Editor/Projects/Build/ProjectBuilder.cs @@ -194,34 +194,47 @@ public static BuildProgress StartBuildAsync(bool andRun, string? outputPath) var progress = new BuildProgress(); var projectPath = Project.Current?.RootPath ?? ""; - // 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(async () => + var assetSettings = ProjectSettingsRegistry.Get(); + if (assetSettings != null && assetSettings.AsyncAssetLoading) { - try + // 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(() => { - Console.WriteLine($"[BEGIN]{projectPath}[END]"); - var result = pipeline.BuildAsync( - projectPath, settings, outputPath, progress).GetAwaiter().GetResult(); - progress.Complete(result); + ProcessBuild(projectPath, pipeline, settings, outputPath, progress, andRun); + }); - HandleBuildResult(pipeline, result, settings, andRun); - } - catch (Exception ex) + if (Program.BuildMode) { - progress.Log($"FATAL: {ex.Message}", Runtime.LogSeverity.Error); - progress.Complete(new BuildResult { Success = false, Errors = ex.ToString(), }); + task.Wait(); } - }); - - if (Program.BuildMode) + } + else { - task.Wait(); + 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]"); + var result = pipeline.BuildAsync( + projectPath, settings, outputPath, progress).GetAwaiter().GetResult(); + progress.Complete(result); + + HandleBuildResult(pipeline, result, settings, andRun); + } + catch (Exception ex) + { + progress.Log($"FATAL: {ex.Message}", Runtime.LogSeverity.Error); + progress.Complete(new BuildResult { Success = false, Errors = ex.ToString(), }); + } + } + private static void HandleBuildResult(BuildPipeline pipeline, BuildResult result, BuildSettings settings, bool andRun) { if (result.Success) From 4a84e716b22a0b8738286ce59f632ebe7ea2a70b Mon Sep 17 00:00:00 2001 From: Paolo Date: Tue, 16 Jun 2026 16:46:17 +0200 Subject: [PATCH 07/13] Removed locks from Debug and Paper --- Prowl.Editor/Core/EditorApplication.cs | 26 ++++++++++++++++++++++++++ Prowl.Runtime/Debug.cs | 12 ++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/Prowl.Editor/Core/EditorApplication.cs b/Prowl.Editor/Core/EditorApplication.cs index c0e609390..fbc696abe 100644 --- a/Prowl.Editor/Core/EditorApplication.cs +++ b/Prowl.Editor/Core/EditorApplication.cs @@ -1330,6 +1330,9 @@ public void ReleaseScriptReferences() 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; @@ -1366,6 +1369,29 @@ public void ReleaseScriptReferences() Runtime.MeshFeatures.MeshFeatureRegistry.ClearCache(); } + private void ReleasePaperRetainedCallbacks() + { + try + { + 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}"); + } + } + private void ReinitializeRegistries() { _registeredPanels.Clear(); 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); From fe8cd3a5b3abe6a171910fb5ff7051ac8d39e7e5 Mon Sep 17 00:00:00 2001 From: Paolo Date: Tue, 16 Jun 2026 17:04:58 +0200 Subject: [PATCH 08/13] Added Selection saving/restoration via tokenization --- Prowl.Editor/Core/EditorApplication.cs | 110 ++++++++++++++++++ .../Scripting/ScriptAssemblyManager.cs | 2 + Prowl.Editor/Utils/SelectionToken.cs | 11 ++ 3 files changed, 123 insertions(+) create mode 100644 Prowl.Editor/Utils/SelectionToken.cs diff --git a/Prowl.Editor/Core/EditorApplication.cs b/Prowl.Editor/Core/EditorApplication.cs index fbc696abe..e7cce5911 100644 --- a/Prowl.Editor/Core/EditorApplication.cs +++ b/Prowl.Editor/Core/EditorApplication.cs @@ -1315,6 +1315,8 @@ public void ReinitializeAfterReload() /// 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(); @@ -1392,6 +1394,114 @@ private void ReleasePaperRetainedCallbacks() } } + // ================================================================ + // 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() { _registeredPanels.Clear(); diff --git a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs index 96f6bc802..74ebb81fc 100644 --- a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs +++ b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs @@ -315,6 +315,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/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 } From 625068ca2488a292be5be0542834f07556a73e40 Mon Sep 17 00:00:00 2001 From: Paolo Date: Wed, 17 Jun 2026 11:49:00 +0200 Subject: [PATCH 09/13] Added OnScriptCompile, OnAssemblyUnload and OnAssemblyLoad --- .../Importers/ImporterRegistry.cs | 2 + .../Thumbnails/ThumbnailGeneratorRegistry.cs | 2 + Prowl.Editor/Core/EditorApplication.cs | 69 +++------------ Prowl.Editor/Core/ScriptReloadCallbacks.cs | 88 +++++++++++++++++++ Prowl.Editor/GUI/CustomEditor.cs | 2 + .../GUI/CustomEditors/AssetImporterEditor.cs | 2 + .../GUI/GraphTools/NodePreviewRegistry.cs | 2 + Prowl.Editor/GUI/GraphTools/NodeRenderer.cs | 2 + .../Editors/ShaderTypeCreateMenu.cs | 3 + Prowl.Editor/GUI/Popups/AddComponentPopup.cs | 1 + Prowl.Editor/GUI/PropertyEditor.cs | 2 + .../Registries/AssetDoubleClickRegistry.cs | 2 + .../GUI/Registries/ComponentIconRegistry.cs | 1 + .../GUI/Registries/CreateAssetMenuRegistry.cs | 2 + .../CreateGameObjectMenuRegistry.cs | 2 + .../GUI/Registries/FileIconRegistry.cs | 2 + .../GUI/Registries/ScriptTemplateRegistry.cs | 2 + .../GUI/SceneView/SceneDropHandlerRegistry.cs | 2 + .../GUI/SceneView/SceneViewEditorRegistry.cs | 1 + .../Scripting/ScriptAssemblyManager.cs | 3 + .../Projects/Settings/ProjectSettings.cs | 12 +++ .../Utils/InitializeOnLoadRegistry.cs | 1 + Prowl.Editor/Utils/StaticFieldCrawler.cs | 1 + .../Components/UI/Input/UIEventSystem.cs | 1 + Prowl.Runtime/GraphTools/GraphValidator.cs | 2 + Prowl.Runtime/GraphTools/NodeRegistry.cs | 2 + .../MeshFeatures/MeshFeatureRegistry.cs | 1 + Prowl.Runtime/ScriptLifecycleAttributes.cs | 32 +++++++ Prowl.Runtime/Utils/RuntimeUtils.cs | 1 + 29 files changed, 187 insertions(+), 58 deletions(-) create mode 100644 Prowl.Editor/Core/ScriptReloadCallbacks.cs create mode 100644 Prowl.Runtime/ScriptLifecycleAttributes.cs diff --git a/Prowl.Editor/AssetsDatabase/Importers/ImporterRegistry.cs b/Prowl.Editor/AssetsDatabase/Importers/ImporterRegistry.cs index 4542e0e2d..1048c26b4 100644 --- a/Prowl.Editor/AssetsDatabase/Importers/ImporterRegistry.cs +++ b/Prowl.Editor/AssetsDatabase/Importers/ImporterRegistry.cs @@ -14,9 +14,11 @@ 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; diff --git a/Prowl.Editor/AssetsDatabase/Thumbnails/ThumbnailGeneratorRegistry.cs b/Prowl.Editor/AssetsDatabase/Thumbnails/ThumbnailGeneratorRegistry.cs index 5bc95cdf9..50660f656 100644 --- a/Prowl.Editor/AssetsDatabase/Thumbnails/ThumbnailGeneratorRegistry.cs +++ b/Prowl.Editor/AssetsDatabase/Thumbnails/ThumbnailGeneratorRegistry.cs @@ -41,9 +41,11 @@ 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; diff --git a/Prowl.Editor/Core/EditorApplication.cs b/Prowl.Editor/Core/EditorApplication.cs index e7cce5911..01c2d1873 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(); @@ -1298,10 +1297,6 @@ private void RegisterMenus() public void ReinitializeAfterReload() { ReinitializeRegistries(); - - // Re-create settings singletons against the new types, then reload the values that - // SaveProjectState() persisted to disk just before the reload so authoring survives. - ProjectSettingsRegistry.OnProjectOpened(); } /// @@ -1322,8 +1317,6 @@ public void ReleaseScriptReferences() Selection.Clear(); Undo.Clear(); Runtime.Resources.Scene.Unload(); - Runtime.UI.UIEventSystem.ResetState(); - SceneViewEditorRegistry.ClearCache(); // 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. @@ -1338,37 +1331,14 @@ public void ReleaseScriptReferences() // 2. Play-mode leftovers (normally empty outside play mode; cleared defensively). _savedEditorScene = null; _savedEditorTime = null; - StaticFieldCrawler.Clear(); - - // 3. Menu closures: Window/* and GameObject/* items capture user Types. Drop the whole - // menu (rebuilt wholesale by ReinitializeRegistries) plus the panel type list. MenuRegistry.Clear(); _registeredPanels.Clear(); - // 4. Editor registry caches (Type maps, cached editor/drawer/generator instances, ...). - PropertyEditorRegistry.ClearCache(); - CustomEditorRegistry.ClearCache(); - GraphTools.NodeRendererRegistry.ClearCache(); - GraphTools.NodePreviewRegistry.ClearCache(); - Inspector.AssetImporterEditorRegistry.ClearCache(); - GUI.Popups.AddComponentPopup.ClearCache(); - Importers.ImporterRegistry.ClearCache(); - ProjectSettingsRegistry.ClearCache(); - CreateAssetMenuRegistry.ClearCache(); - ThumbnailGeneratorRegistry.ClearCache(); - SceneDropHandlerRegistry.ClearCache(); - CreateGameObjectMenuRegistry.ClearCache(); - FileIconRegistry.ClearCache(); - AssetDoubleClickRegistry.ClearCache(); - ScriptTemplateRegistry.ClearCache(); - ComponentIconRegistry.ClearCache(); - - // 5. Runtime-side caches that also reflect over user assemblies. + // 3. The Echo serializer cache lives in an external package so we can't call OnAssemblyUnload there. Echo.Serializer.ClearCache(); - RuntimeUtils.ClearCache(); - Runtime.GraphTools.NodeRegistry.Reinitialize(); // clear-only; rebuilds lazily - Runtime.GraphTools.GraphValidatorRegistry.ClearCache(); - Runtime.MeshFeatures.MeshFeatureRegistry.ClearCache(); + + // 4. Everything tagged [OnAssemblyUnload] + ScriptReloadCallbacks.InvokeAssemblyUnload(); } private void ReleasePaperRetainedCallbacks() @@ -1504,30 +1474,13 @@ public void RestoreSelectionAfterReload() private void ReinitializeRegistries() { + // Panel scan is an editor-instance step (needed before the menu rebuild reads the panel list). _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(); - - // Rebuild the menu bar from scratch. A hot-reload clears it in ReleaseScriptReferences() - // (dropping closures that captured user Types); rebuild the full set so removed/renamed - // user windows and creators don't linger. Cheap and idempotent on the normal open path. + + // Run every [OnAssemblyLoad] hook + ScriptReloadCallbacks.InvokeAssemblyLoad(); + MenuRegistry.Clear(); RegisterMenus(); } 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 875469935..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; @@ -64,6 +65,7 @@ public static void Reinitialize() /// 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; diff --git a/Prowl.Editor/GUI/CustomEditors/AssetImporterEditor.cs b/Prowl.Editor/GUI/CustomEditors/AssetImporterEditor.cs index 64f74cbda..ed48b8cd1 100644 --- a/Prowl.Editor/GUI/CustomEditors/AssetImporterEditor.cs +++ b/Prowl.Editor/GUI/CustomEditors/AssetImporterEditor.cs @@ -35,9 +35,11 @@ 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; diff --git a/Prowl.Editor/GUI/GraphTools/NodePreviewRegistry.cs b/Prowl.Editor/GUI/GraphTools/NodePreviewRegistry.cs index 5a40687c6..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; @@ -51,6 +52,7 @@ public static void Reinitialize() } /// Drop cached type maps so the script AssemblyLoadContext can be collected. + [Runtime.OnAssemblyUnload] public static void ClearCache() { _initialized = false; diff --git a/Prowl.Editor/GUI/GraphTools/NodeRenderer.cs b/Prowl.Editor/GUI/GraphTools/NodeRenderer.cs index 1e679fae8..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; @@ -76,6 +77,7 @@ public static void Reinitialize() } /// Drop cached type maps so the script AssemblyLoadContext can be collected. + [Runtime.OnAssemblyUnload] public static void ClearCache() { _initialized = false; 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/Popups/AddComponentPopup.cs b/Prowl.Editor/GUI/Popups/AddComponentPopup.cs index b869cc73f..2ceae64a6 100644 --- a/Prowl.Editor/GUI/Popups/AddComponentPopup.cs +++ b/Prowl.Editor/GUI/Popups/AddComponentPopup.cs @@ -41,6 +41,7 @@ private struct ComponentEntry /// 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; diff --git a/Prowl.Editor/GUI/PropertyEditor.cs b/Prowl.Editor/GUI/PropertyEditor.cs index 620e5d13b..8f5601d9b 100644 --- a/Prowl.Editor/GUI/PropertyEditor.cs +++ b/Prowl.Editor/GUI/PropertyEditor.cs @@ -43,12 +43,14 @@ 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; diff --git a/Prowl.Editor/GUI/Registries/AssetDoubleClickRegistry.cs b/Prowl.Editor/GUI/Registries/AssetDoubleClickRegistry.cs index 0963c8b0f..3baa1d5d1 100644 --- a/Prowl.Editor/GUI/Registries/AssetDoubleClickRegistry.cs +++ b/Prowl.Editor/GUI/Registries/AssetDoubleClickRegistry.cs @@ -35,9 +35,11 @@ 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; diff --git a/Prowl.Editor/GUI/Registries/ComponentIconRegistry.cs b/Prowl.Editor/GUI/Registries/ComponentIconRegistry.cs index 2c4feb48c..6cb1ca0e5 100644 --- a/Prowl.Editor/GUI/Registries/ComponentIconRegistry.cs +++ b/Prowl.Editor/GUI/Registries/ComponentIconRegistry.cs @@ -23,6 +23,7 @@ public static class ComponentIconRegistry /// 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()); diff --git a/Prowl.Editor/GUI/Registries/CreateAssetMenuRegistry.cs b/Prowl.Editor/GUI/Registries/CreateAssetMenuRegistry.cs index 7202b04b1..6e8064139 100644 --- a/Prowl.Editor/GUI/Registries/CreateAssetMenuRegistry.cs +++ b/Prowl.Editor/GUI/Registries/CreateAssetMenuRegistry.cs @@ -62,6 +62,7 @@ public static void RemoveManualEntriesByPrefix(string prefix) public static IReadOnlyList Entries => _entries; + [Runtime.OnAssemblyLoad] public static void Reinitialize() { _initialized = false; Initialize(); } /// @@ -70,6 +71,7 @@ public static void RemoveManualEntriesByPrefix(string prefix) /// collected. Manual entries are re-registered after reload by their owners (e.g. /// ShaderTypeCreateMenu.Register()). /// + [Runtime.OnAssemblyUnload] public static void ClearCache() { _initialized = false; diff --git a/Prowl.Editor/GUI/Registries/CreateGameObjectMenuRegistry.cs b/Prowl.Editor/GUI/Registries/CreateGameObjectMenuRegistry.cs index 3745850a7..52951cf85 100644 --- a/Prowl.Editor/GUI/Registries/CreateGameObjectMenuRegistry.cs +++ b/Prowl.Editor/GUI/Registries/CreateGameObjectMenuRegistry.cs @@ -54,9 +54,11 @@ 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; diff --git a/Prowl.Editor/GUI/Registries/FileIconRegistry.cs b/Prowl.Editor/GUI/Registries/FileIconRegistry.cs index cb3f66d53..aa917a3a8 100644 --- a/Prowl.Editor/GUI/Registries/FileIconRegistry.cs +++ b/Prowl.Editor/GUI/Registries/FileIconRegistry.cs @@ -38,9 +38,11 @@ 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; diff --git a/Prowl.Editor/GUI/Registries/ScriptTemplateRegistry.cs b/Prowl.Editor/GUI/Registries/ScriptTemplateRegistry.cs index 62d699b1c..0548dd2e3 100644 --- a/Prowl.Editor/GUI/Registries/ScriptTemplateRegistry.cs +++ b/Prowl.Editor/GUI/Registries/ScriptTemplateRegistry.cs @@ -54,9 +54,11 @@ 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; diff --git a/Prowl.Editor/GUI/SceneView/SceneDropHandlerRegistry.cs b/Prowl.Editor/GUI/SceneView/SceneDropHandlerRegistry.cs index 64b9cb3fd..ec4ce71d4 100644 --- a/Prowl.Editor/GUI/SceneView/SceneDropHandlerRegistry.cs +++ b/Prowl.Editor/GUI/SceneView/SceneDropHandlerRegistry.cs @@ -63,9 +63,11 @@ 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; diff --git a/Prowl.Editor/GUI/SceneView/SceneViewEditorRegistry.cs b/Prowl.Editor/GUI/SceneView/SceneViewEditorRegistry.cs index 08dc0770b..6dd03769f 100644 --- a/Prowl.Editor/GUI/SceneView/SceneViewEditorRegistry.cs +++ b/Prowl.Editor/GUI/SceneView/SceneViewEditorRegistry.cs @@ -51,6 +51,7 @@ private struct Entry /// 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(); diff --git a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs index 74ebb81fc..2178229a0 100644 --- a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs +++ b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs @@ -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(() => { diff --git a/Prowl.Editor/Projects/Settings/ProjectSettings.cs b/Prowl.Editor/Projects/Settings/ProjectSettings.cs index 3ab7e63cc..a67a942d8 100644 --- a/Prowl.Editor/Projects/Settings/ProjectSettings.cs +++ b/Prowl.Editor/Projects/Settings/ProjectSettings.cs @@ -83,12 +83,24 @@ public struct SettingsEntry /// 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; 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/StaticFieldCrawler.cs b/Prowl.Editor/Utils/StaticFieldCrawler.cs index b2fc90105..0ece85b8d 100644 --- a/Prowl.Editor/Utils/StaticFieldCrawler.cs +++ b/Prowl.Editor/Utils/StaticFieldCrawler.cs @@ -71,5 +71,6 @@ public static void RestoreStaticFields() /// Only relevant if a reload is somehow attempted with a live snapshot; normally the /// snapshot is already empty outside of play mode. /// + [Runtime.OnAssemblyUnload] public static void Clear() => s_snapshot.Clear(); } diff --git a/Prowl.Runtime/Components/UI/Input/UIEventSystem.cs b/Prowl.Runtime/Components/UI/Input/UIEventSystem.cs index bed0fb1a9..f844c0bea 100644 --- a/Prowl.Runtime/Components/UI/Input/UIEventSystem.cs +++ b/Prowl.Runtime/Components/UI/Input/UIEventSystem.cs @@ -66,6 +66,7 @@ public struct HostViewport /// 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; diff --git a/Prowl.Runtime/GraphTools/GraphValidator.cs b/Prowl.Runtime/GraphTools/GraphValidator.cs index 63c5f1742..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; @@ -62,6 +63,7 @@ public static void Reinitialize() /// 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; 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 b0f844b6e..99936f687 100644 --- a/Prowl.Runtime/Resources/MeshFeatures/MeshFeatureRegistry.cs +++ b/Prowl.Runtime/Resources/MeshFeatures/MeshFeatureRegistry.cs @@ -51,6 +51,7 @@ public static void Reinitialize() /// user types can be unloaded. Specs rebuild on the next /// / after the new assemblies are loaded. /// + [OnAssemblyUnload] public static void ClearCache() { _initialized = false; 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(); From 4c4cafd441618f97f232bce11d1a8624113965c9 Mon Sep 17 00:00:00 2001 From: Paolo Date: Fri, 19 Jun 2026 12:45:51 +0200 Subject: [PATCH 10/13] Added Static field crawler clearing before restoring states --- Prowl.Editor/Core/EditorApplication.cs | 9 ++++++++ Prowl.Editor/Utils/StaticFieldCrawler.cs | 27 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/Prowl.Editor/Core/EditorApplication.cs b/Prowl.Editor/Core/EditorApplication.cs index 01c2d1873..42ce1207b 100644 --- a/Prowl.Editor/Core/EditorApplication.cs +++ b/Prowl.Editor/Core/EditorApplication.cs @@ -1709,6 +1709,15 @@ private void ExitPlayMode() // Unload the play scene Runtime.Resources.Scene.Unload(); + // Clear all static fields + foreach (Assembly assembly in ScriptAssemblyManager.GetAllRelevantAssemblies()) + { + if (!assembly.FullName.StartsWith("System.") && !assembly.FullName.StartsWith("Microsoft.")) + { + StaticFieldCrawler.ClearAllStaticFields(assembly); + } + } + // Restore static fields to their pre-play-mode values StaticFieldCrawler.RestoreStaticFields(); diff --git a/Prowl.Editor/Utils/StaticFieldCrawler.cs b/Prowl.Editor/Utils/StaticFieldCrawler.cs index 0ece85b8d..ce5dec429 100644 --- a/Prowl.Editor/Utils/StaticFieldCrawler.cs +++ b/Prowl.Editor/Utils/StaticFieldCrawler.cs @@ -43,6 +43,33 @@ public static void SnapshotStaticFields(Assembly assembly) } } + /// + /// Clears all the current values of all mutable static fields in the given assembly. + /// Call this before restoring the static values when exiting play mode. + /// + public static void ClearAllStaticFields(Assembly assembly) + { + foreach (Type type in assembly.GetTypes()) + { + var staticFields = type.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + + foreach (FieldInfo field in staticFields) + { + if (field.IsLiteral || field.IsInitOnly) + continue; + + try + { + field.SetValue(null, null); + } + catch (Exception ex) + { + //Runtime.Debug.LogWarning($"[StaticFieldCrawler] Could not snapshot '{type.Name}.{field.Name}': {ex.Message}"); + } + } + } + } + /// /// Restores all previously snapshotted static fields to their pre-play-mode values. /// Call this after exiting play mode. From 19fe91ff95426d2a4d9ab59c87b90e8ea39d40ce Mon Sep 17 00:00:00 2001 From: Paolo Date: Fri, 19 Jun 2026 12:46:28 +0200 Subject: [PATCH 11/13] Clear before snapshot to store accurate state --- Prowl.Editor/Core/EditorApplication.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Prowl.Editor/Core/EditorApplication.cs b/Prowl.Editor/Core/EditorApplication.cs index 42ce1207b..4eba5d169 100644 --- a/Prowl.Editor/Core/EditorApplication.cs +++ b/Prowl.Editor/Core/EditorApplication.cs @@ -1643,6 +1643,8 @@ private void EnterPlayMode() return; } + StaticFieldCrawler.Clear(); + // Snapshot static fields before play mode so we can restore them on exit foreach (Assembly assembly in ScriptAssemblyManager.GetAllRelevantAssemblies()) { From 31d1f8838148c16cfeac7e41c72e2ce90cbad2bc Mon Sep 17 00:00:00 2001 From: Paolo Date: Fri, 19 Jun 2026 21:23:15 +0200 Subject: [PATCH 12/13] Removed StaticFieldCrawler --- Prowl.Editor/Core/EditorApplication.cs | 23 ----- Prowl.Editor/Utils/StaticFieldCrawler.cs | 103 ----------------------- 2 files changed, 126 deletions(-) delete mode 100644 Prowl.Editor/Utils/StaticFieldCrawler.cs diff --git a/Prowl.Editor/Core/EditorApplication.cs b/Prowl.Editor/Core/EditorApplication.cs index 4eba5d169..b20476df1 100644 --- a/Prowl.Editor/Core/EditorApplication.cs +++ b/Prowl.Editor/Core/EditorApplication.cs @@ -1643,17 +1643,6 @@ private void EnterPlayMode() return; } - StaticFieldCrawler.Clear(); - - // Snapshot static fields before play mode so we can restore them on exit - foreach (Assembly assembly in ScriptAssemblyManager.GetAllRelevantAssemblies()) - { - if (!assembly.FullName.StartsWith("System.") && !assembly.FullName.StartsWith("Microsoft.")) - { - StaticFieldCrawler.SnapshotStaticFields(assembly); - } - } - // Clear selection (references will be invalid) Selection.Clear(); @@ -1711,18 +1700,6 @@ private void ExitPlayMode() // Unload the play scene Runtime.Resources.Scene.Unload(); - // Clear all static fields - foreach (Assembly assembly in ScriptAssemblyManager.GetAllRelevantAssemblies()) - { - if (!assembly.FullName.StartsWith("System.") && !assembly.FullName.StartsWith("Microsoft.")) - { - StaticFieldCrawler.ClearAllStaticFields(assembly); - } - } - - // Restore static fields to their pre-play-mode values - StaticFieldCrawler.RestoreStaticFields(); - // Restore the editor scene WITHOUT lifecycle callbacks if (_savedEditorScene != null) { diff --git a/Prowl.Editor/Utils/StaticFieldCrawler.cs b/Prowl.Editor/Utils/StaticFieldCrawler.cs deleted file mode 100644 index ce5dec429..000000000 --- a/Prowl.Editor/Utils/StaticFieldCrawler.cs +++ /dev/null @@ -1,103 +0,0 @@ -// 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.Reflection; -using System.Collections.Generic; - -namespace Prowl.Editor; - -/// -/// Snapshots and restores static field values across play-mode sessions. -/// Instead of resetting all static fields to their defaults (which destroys editor state), -/// we capture values before entering play mode and restore them when exiting. -/// -public static class StaticFieldCrawler -{ - private static readonly Dictionary s_snapshot = []; - - /// - /// Captures the current values of all mutable static fields in the given assembly. - /// Call this before entering play mode. - /// - public static void SnapshotStaticFields(Assembly assembly) - { - foreach (Type type in assembly.GetTypes()) - { - var staticFields = type.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); - - foreach (FieldInfo field in staticFields) - { - if (field.IsLiteral || field.IsInitOnly) - continue; - - try - { - s_snapshot[field] = field.GetValue(null); - } - catch (Exception ex) - { - //Runtime.Debug.LogWarning($"[StaticFieldCrawler] Could not snapshot '{type.Name}.{field.Name}': {ex.Message}"); - } - } - } - } - - /// - /// Clears all the current values of all mutable static fields in the given assembly. - /// Call this before restoring the static values when exiting play mode. - /// - public static void ClearAllStaticFields(Assembly assembly) - { - foreach (Type type in assembly.GetTypes()) - { - var staticFields = type.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); - - foreach (FieldInfo field in staticFields) - { - if (field.IsLiteral || field.IsInitOnly) - continue; - - try - { - field.SetValue(null, null); - } - catch (Exception ex) - { - //Runtime.Debug.LogWarning($"[StaticFieldCrawler] Could not snapshot '{type.Name}.{field.Name}': {ex.Message}"); - } - } - } - } - - /// - /// Restores all previously snapshotted static fields to their pre-play-mode values. - /// Call this after exiting play mode. - /// - public static void RestoreStaticFields() - { - foreach (var (field, value) in s_snapshot) - { - try - { - field.SetValue(null, value); - } - catch (Exception ex) - { - Runtime.Debug.LogWarning($"[StaticFieldCrawler] Could not restore '{field.DeclaringType?.Name}.{field.Name}': {ex.Message}"); - } - } - - s_snapshot.Clear(); - } - - /// - /// Discards the snapshot without restoring it. The snapshot holds - /// handles (which pin their declaring user types) and captured values (which may be user - /// instances), so it must be cleared before unloading the script AssemblyLoadContext. - /// Only relevant if a reload is somehow attempted with a live snapshot; normally the - /// snapshot is already empty outside of play mode. - /// - [Runtime.OnAssemblyUnload] - public static void Clear() => s_snapshot.Clear(); -} From e6f1311e8ce6975b5d54a74a061b1e9d6e556acd Mon Sep 17 00:00:00 2001 From: Paolo Date: Fri, 19 Jun 2026 21:33:42 +0200 Subject: [PATCH 13/13] Added ClearScenesAndPrefabForReload The method clears the loaded scenes/prefabs when reloading the alc, so that even if a scene/prefab is holding a reference to a user-defined script it won't stop the ALC from reloading --- .../AssetsDatabase/EditorAssetDatabase.cs | 35 +++++++++++++++++++ Prowl.Runtime/Resources/Scene.cs | 7 ++++ 2 files changed, 42 insertions(+) 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.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()