diff --git a/Robust.Client/ClientIoC.cs b/Robust.Client/ClientIoC.cs index 47061d5b7ce..b5f243ed55f 100644 --- a/Robust.Client/ClientIoC.cs +++ b/Robust.Client/ClientIoC.cs @@ -3,6 +3,7 @@ using Robust.Client.Audio.Midi; using Robust.Client.Configuration; using Robust.Client.Console; +using Robust.Client.ContentPack; using Robust.Client.Debugging; using Robust.Client.GameObjects; using Robust.Client.GameStates; @@ -174,6 +175,7 @@ public static void RegisterIoC(GameController.DisplayMode mode, IDependencyColle deps.Register(); deps.Register(); deps.Register(); + deps.Register(); // DevaStation - hot-reload } } } diff --git a/Robust.Client/ContentPack/ClientHotReloadManager.cs b/Robust.Client/ContentPack/ClientHotReloadManager.cs new file mode 100644 index 00000000000..f9db2d0cdd8 --- /dev/null +++ b/Robust.Client/ContentPack/ClientHotReloadManager.cs @@ -0,0 +1,31 @@ +using Robust.Shared; +using Robust.Shared.Configuration; +using Robust.Shared.ContentPack; +using Robust.Shared.IoC; +using Robust.Shared.Network; +using Robust.Shared.Network.Messages; + +namespace Robust.Client.ContentPack; + +internal sealed class ClientHotReloadManager : HotReloadManager +{ + [Dependency] private readonly INetManager _netManager = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + + public override void Initialize() + { + base.Initialize(); + + _netManager.RegisterNetMessage(HandleHotReloadMessage); + } + + private void HandleHotReloadMessage(MsgHotReload msg) + { + if (!_cfg.GetCVar(CVars.HotReload)) + { + return; + } + + TriggerReload(msg.AssemblyName); + } +} diff --git a/Robust.Client/GameController/GameController.cs b/Robust.Client/GameController/GameController.cs index 02cbb5cdeba..ca9a2475843 100644 --- a/Robust.Client/GameController/GameController.cs +++ b/Robust.Client/GameController/GameController.cs @@ -98,6 +98,7 @@ internal sealed partial class GameController : IGameControllerInternal [Dependency] private readonly ILocalizationManager _loc = default!; [Dependency] private readonly ISystemFontManagerInternal _systemFontManager = default!; [Dependency] private readonly LoadingScreenManager _loadscr = default!; + [Dependency] private readonly IHotReloadManager _hotReloadManager = default!; // DevaStation - hot-reload private IWebViewManagerHook? _webViewHook; @@ -244,6 +245,9 @@ internal bool StartupContinue(DisplayMode displayMode) _loadscr.LoadingStep(() => _modLoader.BroadcastRunLevel(ModRunLevel.PostInit), "Content PostInit"); + // DevaStation start - hot-reload + _hotReloadManager.Initialize(); + _loadscr.Finish(); if (_commandLineArgs?.Username != null) @@ -347,6 +351,11 @@ private bool LoadModules() return false; } + // DevaStation start - hot-reload + _hotReloadManager.AssemblyDirectory = assemblyDir; + _hotReloadManager.FilterPrefix = assemblyPrefix; + // DevaStation end + return true; } diff --git a/Robust.Server/BaseServer.cs b/Robust.Server/BaseServer.cs index f6e70299d53..ac5877150c9 100644 --- a/Robust.Server/BaseServer.cs +++ b/Robust.Server/BaseServer.cs @@ -107,6 +107,7 @@ internal sealed class BaseServer : IBaseServerInternal, IPostInjectInit [Dependency] private readonly UploadedContentManager _uploadedContMan = default!; [Dependency] private readonly NetworkResourceManager _netResMan = default!; [Dependency] private readonly IReflectionManager _refMan = default!; + [Dependency] private readonly IHotReloadManager _hotReloadManager = default!; // DevaStation - hot-reload private readonly Stopwatch _uptimeStopwatch = new(); @@ -326,6 +327,11 @@ public bool Start(ServerOptions options, Func? logHandlerFactory = return true; } + // DevaStation start - hot-reload + _hotReloadManager.AssemblyDirectory = Options.AssemblyDirectory; + _hotReloadManager.FilterPrefix = resourceManifest.AssemblyPrefix ?? Options.ContentModulePrefix; + // DevaStation end + foreach (var loadedModule in _modLoader.LoadedModules) { _config.LoadCVarsFromAssembly(loadedModule); @@ -411,6 +417,8 @@ public bool Start(ServerOptions options, Func? logHandlerFactory = _modLoader.BroadcastRunLevel(ModRunLevel.PostInit); + _hotReloadManager.Initialize(); // DevaStation - hot-reload + _statusHost.Start(); _hubManager.Start(); diff --git a/Robust.Server/ContentPack/ServerHotReloadManager.cs b/Robust.Server/ContentPack/ServerHotReloadManager.cs new file mode 100644 index 00000000000..63a7c241f8f --- /dev/null +++ b/Robust.Server/ContentPack/ServerHotReloadManager.cs @@ -0,0 +1,40 @@ +using Robust.Shared; +using Robust.Shared.Configuration; +using Robust.Shared.ContentPack; +using Robust.Shared.IoC; +using Robust.Shared.Network; +using Robust.Shared.Network.Messages; + +namespace Robust.Server.ContentPack; + +internal sealed class ServerHotReloadManager : HotReloadManager +{ + [Dependency] private readonly INetManager _netManager = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + + private AssemblyFileWatcher? _fileWatcher; + + public override void Initialize() + { + base.Initialize(); + + _netManager.RegisterNetMessage(); + + if (!_cfg.GetCVar(CVars.HotReload)) + return; + + _fileWatcher = new AssemblyFileWatcher(); + IoCManager.InjectDependencies(_fileWatcher); + _fileWatcher.Initialize(AssemblyDirectory, FilterPrefix, TriggerReloadForAssembly); + } + + private void TriggerReloadForAssembly(string assemblyName) + { + if (!_cfg.GetCVar(CVars.HotReload)) + return; + + _netManager.ServerSendToAll(new MsgHotReload { AssemblyName = assemblyName }); + + TriggerReload(assemblyName); + } +} diff --git a/Robust.Server/GameObjects/ServerEntityManager.cs b/Robust.Server/GameObjects/ServerEntityManager.cs index 1f47b2100b3..2c786e5fc54 100644 --- a/Robust.Server/GameObjects/ServerEntityManager.cs +++ b/Robust.Server/GameObjects/ServerEntityManager.cs @@ -271,7 +271,7 @@ private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs args) switch (args.NewStatus) { case SessionStatus.Connected: - _lastProcessedSequencesCmd.Add(args.Session, 0); + _lastProcessedSequencesCmd[args.Session] = 0; // DevaStation - hot-reload: use indexing break; case SessionStatus.Disconnected: diff --git a/Robust.Server/GameStates/PvsSystem.Dirty.cs b/Robust.Server/GameStates/PvsSystem.Dirty.cs index c7a180ccb79..f57f6de3962 100644 --- a/Robust.Server/GameStates/PvsSystem.Dirty.cs +++ b/Robust.Server/GameStates/PvsSystem.Dirty.cs @@ -30,6 +30,10 @@ private void InitializeDirty() _addEntities[i] = new HashSet(32); _dirtyEntities[i] = new HashSet(32); } + // DevaStation start - hot-reload + // Sync _currentIndex to current tick so the assertion in OnEntityAdd holds after reinit + _currentIndex = (int)(_gameTiming.CurTick.Value % DirtyBufferSize); + // DevaStation end EntityManager.EntityAdded += OnEntityAdd; EntityManager.EntityDirtied += OnEntityDirty; } @@ -42,6 +46,19 @@ private void ShutdownDirty() private void OnEntityAdd(Entity e) { + // DevaStation start - hot-reload + // Auto-correct _currentIndex when stale. CleanupDirty() only runs from + // AfterSerializeStates() which requires connected players, so after a + // hot-reload with no clients _currentIndex drifts from the actual tick. + var expectedIndex = (int)(_gameTiming.CurTick.Value % DirtyBufferSize); + if (_currentIndex != expectedIndex + && _gameTiming.GetType().Name != "IGameTimingProxy") + { + _currentIndex = expectedIndex; + _addEntities[_currentIndex].Clear(); + _dirtyEntities[_currentIndex].Clear(); + } + // DevaStation end DebugTools.Assert(_currentIndex == _gameTiming.CurTick.Value % DirtyBufferSize || _gameTiming.GetType().Name == "IGameTimingProxy");// Look I have NFI how best to excuse this assert if the game timing isn't real (a Mock). _addEntities[_currentIndex].Add(e); @@ -55,6 +72,17 @@ private void OnEntityDirty(Entity uid) meta.LastModifiedTick = uid.Comp.EntityLastModifiedTick; } + // DevaStation start - hot-reload + // OH MY STALE _currentIndex! + var expectedIndex = (int)(_gameTiming.CurTick.Value % DirtyBufferSize); + if (_currentIndex != expectedIndex) + { + _currentIndex = expectedIndex; + _addEntities[_currentIndex].Clear(); + _dirtyEntities[_currentIndex].Clear(); + } + // DevaStation end + if (!_addEntities[_currentIndex].Contains(uid)) _dirtyEntities[_currentIndex].Add(uid); } diff --git a/Robust.Server/ServerIoC.cs b/Robust.Server/ServerIoC.cs index 382596902f7..9dd666d4624 100644 --- a/Robust.Server/ServerIoC.cs +++ b/Robust.Server/ServerIoC.cs @@ -1,6 +1,7 @@ using System.Diagnostics.Metrics; using Robust.Server.Configuration; using Robust.Server.Console; +using Robust.Server.ContentPack; using Robust.Server.DataMetrics; using Robust.Server.GameObjects; using Robust.Server.GameStates; @@ -102,6 +103,7 @@ internal static void RegisterIoC(IDependencyCollection deps) deps.Register(); deps.Register(); deps.Register(); + deps.Register(); // DevaStation - hot-reload } } } diff --git a/Robust.Shared.Testing/TestingModLoader.cs b/Robust.Shared.Testing/TestingModLoader.cs index ae605eff39a..cbf884b904e 100644 --- a/Robust.Shared.Testing/TestingModLoader.cs +++ b/Robust.Shared.Testing/TestingModLoader.cs @@ -54,6 +54,13 @@ public void AddEngineModuleDirectory(string dir) { // Only used for ILVerify, not necessary. } + // DevaStation - hot-reload + public (Assembly oldAssembly, Assembly newAssembly)? ReloadSingleAssembly(string assemblyName, ResPath assemblyDirectory, string filterPrefix) + { + // nop + return null; + } + #pragma warning disable CS0067 // Needed by interface public event ExtraModuleLoad? ExtraModuleLoaders; #pragma warning restore CS0067 diff --git a/Robust.Shared/CVars.cs b/Robust.Shared/CVars.cs index c987fbf9f1e..fc36ac8d618 100644 --- a/Robust.Shared/CVars.cs +++ b/Robust.Shared/CVars.cs @@ -1525,6 +1525,16 @@ protected CVars() public static readonly CVarDef DebugTargetFps = CVarDef.Create("debug.target_fps", 60, CVar.CLIENTONLY | CVar.ARCHIVE); + // DevaStation start + /// + /// Whether to enable assembly hot-reloading for content assemblies. + /// When enabled, content DLLs marked with [assembly: AssemblyMetadata("HotReloadable", "true")] + /// will be watched for changes and reloaded at runtime + /// + public static readonly CVarDef HotReload = + CVarDef.Create("devaStation.hot_reload", true); + // DevaStation end + /* * MIDI */ diff --git a/Robust.Shared/Console/ConsoleHost.cs b/Robust.Shared/Console/ConsoleHost.cs index b26f246604a..e3288bb1fc2 100644 --- a/Robust.Shared/Console/ConsoleHost.cs +++ b/Robust.Shared/Console/ConsoleHost.cs @@ -70,10 +70,16 @@ public void LoadConsoleCommands() continue; var instance = (IConsoleCommand)_typeFactory.CreateInstanceUnchecked(type, true); - if (AvailableCommands.TryGetValue(instance.Command, out var duplicate)) + + // DevaStation start - hot-reload + if (AvailableCommands.TryGetValue(instance.Command, out var existing)) { + // Allow re-registration of the same command type during hot-reload + if (existing.GetType().FullName == instance.GetType().FullName) + continue; + throw new InvalidImplementationException(instance.GetType(), typeof(IConsoleCommand), - $"Command name already registered: {instance.Command}, previous: {duplicate.GetType()}"); + $"Command name already registered: {instance.Command}, previous: {existing.GetType()}"); } RegisteredCommands[instance.Command] = instance; @@ -110,9 +116,6 @@ public void RegisterCommand( ConCommandCallback callback, bool requireServerOrSingleplayer = false) { - if (RegisteredCommands.ContainsKey(command)) - throw new InvalidOperationException($"Command already registered: {command}"); - var newCmd = new RegisteredCommand(command, description, help, callback, requireServerOrSingleplayer); RegisterCommand(newCmd); } @@ -125,9 +128,6 @@ public void RegisterCommand( ConCommandCompletionCallback completionCallback, bool requireServerOrSingleplayer = false) { - if (RegisteredCommands.ContainsKey(command)) - throw new InvalidOperationException($"Command already registered: {command}"); - var newCmd = new RegisteredCommand(command, description, help, callback, completionCallback, requireServerOrSingleplayer); RegisterCommand(newCmd); } @@ -140,8 +140,6 @@ public void RegisterCommand( ConCommandCompletionAsyncCallback completionCallback, bool requireServerOrSingleplayer = false) { - if (RegisteredCommands.ContainsKey(command)) - throw new InvalidOperationException($"Command already registered: {command}"); var newCmd = new RegisteredCommand(command, description, help, callback, completionCallback, requireServerOrSingleplayer); RegisterCommand(newCmd); @@ -179,7 +177,9 @@ public void RegisterCommand( public void RegisterCommand(IConsoleCommand command) { - RegisteredCommands.Add(command.Command, command); + // DevaStation start - hot-reload: use indexer for safe overwrite + RegisteredCommands[command.Command] = command; + // DevaStation end if (!_isInRegistrationRegion) UpdateAvailableCommands(); @@ -187,6 +187,16 @@ public void RegisterCommand(IConsoleCommand command) #endregion + /// + /// Clears all registered commands and auto-registration tracking. + /// Used during hot-reload teardown to remove commands from unloaded content assemblies. + /// + public void ClearAllCommands() + { + RegisteredCommands.Clear(); + _autoRegisteredCommands.Clear(); + } + /// public void UnregisterCommand(string command) { diff --git a/Robust.Shared/Console/IConsoleHost.cs b/Robust.Shared/Console/IConsoleHost.cs index 66b86419260..d76c5ce5db9 100644 --- a/Robust.Shared/Console/IConsoleHost.cs +++ b/Robust.Shared/Console/IConsoleHost.cs @@ -295,6 +295,13 @@ void RegisterCommand( /// Removes all text from the local console. /// void ClearLocalConsole(); + + // DevaStation start - hot-reload + /// + /// Clears all registered commands and auto-registration tracking. + /// Used during hot-reload teardown to remove commands from unloaded content assemblies. + /// + void ClearAllCommands(); } internal interface IConsoleHostInternal : IConsoleHost diff --git a/Robust.Shared/ContentPack/AssemblyFileWatcher.cs b/Robust.Shared/ContentPack/AssemblyFileWatcher.cs new file mode 100644 index 00000000000..c16cb6581f9 --- /dev/null +++ b/Robust.Shared/ContentPack/AssemblyFileWatcher.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Robust.Shared.Asynchronous; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Utility; + +namespace Robust.Shared.ContentPack; + +internal sealed class AssemblyFileWatcher : IDisposable +{ + [Dependency] private readonly IResourceManagerInternal _res = default!; + [Dependency] private readonly ITaskManager _taskManager = default!; + [Dependency] private readonly ILogManager _logManager = default!; + + private readonly List _watchers = new(); + private ISawmill _sawmill = default!; + private CancellationTokenSource? _debounceCts; + private Action? _onAssemblyChanged; + private string? _pendingChangedAssembly; + + private static readonly TimeSpan DebounceDelay = TimeSpan.FromMilliseconds(500); + + /// VFS path where assemblies are mounted (e.g. /Assemblies/). + /// DLL filter prefix (e.g. "Content."). + /// Callback invoked when assembly files change (after debounce). + /// Assembly path resolution is done via VFS + public void Initialize(ResPath assemblyDirectory, string filterPrefix, Action onAssemblyChanged) + { + _sawmill = _logManager.GetSawmill("hotreload.watcher"); + _onAssemblyChanged = onAssemblyChanged; + var watchDirs = new HashSet(); + + foreach (var filePath in _res.ContentFindRelativeFiles(assemblyDirectory) + .Where(p => p.Filename.StartsWith(filterPrefix) && p.Extension == "dll")) + { + var fullVfsPath = assemblyDirectory / filePath; + if (_res.TryGetDiskFilePath(fullVfsPath, out var diskPath)) + { + var dir = Path.GetDirectoryName(diskPath); + if (dir != null) + watchDirs.Add(dir); + } + } + + foreach (var dir in watchDirs) + { + try + { + var watcher = new FileSystemWatcher(dir, "*.dll") + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, + EnableRaisingEvents = true + }; + + watcher.Changed += OnDllChanged; + watcher.Created += OnDllChanged; + _watchers.Add(watcher); + + } + catch (Exception e) + { + _sawmill.Error($"Failed to create watcher for {dir}: {e}"); + } + } + + if (_watchers.Count == 0) + { + _sawmill.Info("No assembly directories found to watch. Hot-reload file watcher is snoozing."); + } + } + + private void OnDllChanged(object sender, FileSystemEventArgs e) + { + // Debounce in case dlls change multiple times, multiple places + _debounceCts?.Cancel(); + _debounceCts?.Dispose(); + _debounceCts = new CancellationTokenSource(); + + _pendingChangedAssembly = Path.GetFileNameWithoutExtension(e.FullPath); + + var token = _debounceCts.Token; + + Task.Run(async () => + { + try + { + await Task.Delay(DebounceDelay, token); + } + catch (TaskCanceledException) + { + return; // I rebuilt pls restart + } + + var assemblyName = _pendingChangedAssembly ?? "Unknown"; + _sawmill.Info($"Assembly file changed (after debounce): {e.FullPath} (assembly: {assemblyName})"); + _sawmill.Info("Scheduling hot-reload"); + + _taskManager.RunOnMainThread(() => + { + try + { + _onAssemblyChanged?.Invoke(assemblyName); + } + catch (Exception ex) + { + _sawmill.Fatal($"Hot-reload callback failed: {ex}"); + } + }); + }, CancellationToken.None); + } + + public void Dispose() + { + _debounceCts?.Cancel(); + _debounceCts?.Dispose(); + + foreach (var watcher in _watchers) + { + watcher.EnableRaisingEvents = false; + watcher.Changed -= OnDllChanged; + watcher.Created -= OnDllChanged; + watcher.Dispose(); + } + + _watchers.Clear(); + } +} diff --git a/Robust.Shared/ContentPack/BaseModLoader.cs b/Robust.Shared/ContentPack/BaseModLoader.cs index 830ba56b20c..e0b75b43d7d 100644 --- a/Robust.Shared/ContentPack/BaseModLoader.cs +++ b/Robust.Shared/ContentPack/BaseModLoader.cs @@ -67,6 +67,73 @@ public bool IsContentAssembly(Assembly typeAssembly) return false; } + // DevaStation start - hot-reload + internal void ClearMods() + { + Mods.Clear(); + } + + /// + /// Removes a single mod by assembly name. Shuts down and disposes its entry points. + /// Returns the old assembly, or null if not found. + /// + internal Assembly? RemoveMod(string assemblyName) + { + for (int i = 0; i < Mods.Count; i++) + { + var mod = Mods[i]; + if (mod.GameAssembly.GetName().Name != assemblyName) + continue; + + // Shutdown and dispose entry points for this mod only + foreach (var entry in mod.EntryPoints) + { + entry.Shutdown(); + } + foreach (var entry in mod.EntryPoints) + { + entry.Dispose(); + } + + Mods.RemoveAt(i); + return mod.GameAssembly; + } + + return null; + } + + /// + /// Broadcasts a run level change to entry points of a specific assembly only. + /// + public void BroadcastRunLevelForAssembly(ModRunLevel level, Assembly assembly) + { + foreach (var mod in Mods) + { + if (mod.GameAssembly != assembly) + continue; + + foreach (var entry in mod.EntryPoints) + { + switch (level) + { + case ModRunLevel.PreInit: + entry.PreInit(); + break; + case ModRunLevel.Init: + entry.Init(); + break; + case ModRunLevel.PostInit: + entry.PostInit(); + break; + default: + Sawmill.Error($"Unknown RunLevel: {level}"); + break; + } + } + } + } + // DevaStation end + public void BroadcastRunLevel(ModRunLevel level) { foreach (var mod in Mods) diff --git a/Robust.Shared/ContentPack/HotReloadException.cs b/Robust.Shared/ContentPack/HotReloadException.cs new file mode 100644 index 00000000000..54fd21eb5bf --- /dev/null +++ b/Robust.Shared/ContentPack/HotReloadException.cs @@ -0,0 +1,17 @@ +using System; + +namespace Robust.Shared.ContentPack; + +/// +/// Exception thrown when assembly hot-reload fails at any stage. +/// +public sealed class HotReloadException : Exception +{ + public HotReloadException(string message) : base(message) + { + } + + public HotReloadException(string message, Exception inner) : base(message, inner) + { + } +} diff --git a/Robust.Shared/ContentPack/HotReloadManager.cs b/Robust.Shared/ContentPack/HotReloadManager.cs new file mode 100644 index 00000000000..7c69a0e1475 --- /dev/null +++ b/Robust.Shared/ContentPack/HotReloadManager.cs @@ -0,0 +1,180 @@ +using System; +using System.Diagnostics; +using System.Reflection; +using Robust.Shared.Configuration; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Network; +using Robust.Shared.Player; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager; +using Robust.Shared.Reflection; +using Robust.Shared.Utility; + +namespace Robust.Shared.ContentPack; + +/// +/// Does... does the thing. +/// +[Virtual] +internal class HotReloadManager : IHotReloadManager +{ + [Dependency] private readonly IModLoaderInternal _modLoader = default!; + [Dependency] private readonly IEntityManager _entityManager = default!; + [Dependency] private readonly IComponentFactory _componentFactory = default!; + [Dependency] private readonly ISerializationManager _serializationManager = default!; + [Dependency] private readonly IPrototypeManager _prototypeManager = default!; + [Dependency] private readonly INetManager _netManager = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly ISharedPlayerManager _playerManager = default!; + [Dependency] private readonly ILogManager _logManager = default!; + + protected ISawmill Sawmill = default!; + + public bool IsReloading { get; private set; } + public ResPath AssemblyDirectory { get; set; } + public string FilterPrefix { get; set; } = "Content."; + + public event Action? HotReloadPreparing; + public event Action? HotReloadComplete; + + public virtual void Initialize() + { + Sawmill = _logManager.GetSawmill("hotreload"); + } + + protected virtual void TriggerReload(string assemblyName) + { + if (!_cfg.GetCVar(CVars.HotReload)) + { + Sawmill.Warning("Hot reload triggered but dev.hot_reload CVar is disabled. Ignoring."); + return; + } + + if (IsReloading) + { + Sawmill.Warning("Hot reload already in progress. Ignoring re-entrant trigger."); + return; + } + + IsReloading = true; + var totalSw = Stopwatch.StartNew(); + Sawmill.Info($"Hot-reloading {assemblyName}"); + + try + { + // Before reloading maybe have to check if we have that assembly + Assembly? oldAssembly = null; + foreach (var asm in _modLoader.LoadedModules) + { + if (asm.GetName().Name == assemblyName) + { + oldAssembly = asm; + break; + } + } + + if (oldAssembly == null) + { + Sawmill.Warning($"{assemblyName} wasn't present. Skipping reload"); + return; + } + + Sawmill.Info("Tearing everything down"); + HotReloadPreparing?.Invoke(); + + var stepSw = Stopwatch.StartNew(); + _entityManager.FlushEntities(); + Sawmill.Info($"Entities flushed in {stepSw.ElapsedMilliseconds}ms"); + + TeardownAssembly(oldAssembly); + + stepSw.Restart(); + var result = _modLoader.ReloadSingleAssembly(assemblyName, AssemblyDirectory, FilterPrefix); + if (result == null) + { + throw new HotReloadException($"Failed to reload assembly '{assemblyName}'."); + } + var newAssembly = result.Value.newAssembly; + Sawmill.Info($"Assembly reloaded in {stepSw.ElapsedMilliseconds}ms"); + + ReinitializeAssembly(newAssembly); + + HotReloadComplete?.Invoke(); + Sawmill.Info($"Finished hot-reloading in {totalSw.ElapsedMilliseconds}ms"); + } + catch (Exception e) + { + Sawmill.Fatal("Hot-reload failed:"); + Sawmill.Fatal($"{e}"); + throw new HotReloadException("Assembly hot-reload failed. See log for details.", e); + } + finally + { + IsReloading = false; + } + } + + /// + /// Removes registrations from a single old assembly. + /// + private void TeardownAssembly(Assembly oldAssembly) + { + var sw = Stopwatch.StartNew(); + Sawmill.Info($"Tearing down {oldAssembly.GetName().Name}"); + + _playerManager.ClearContentEventSubscribers(oldAssembly); + foreach (var sessionData in _playerManager.GetAllPlayerData()) + { + sessionData.ContentDataUncast = null; + } + + _entityManager.EntitySysManager.RemoveContentSystems(oldAssembly); + _componentFactory.RemoveComponentsByAssembly(oldAssembly); + _serializationManager.RemoveContentTypes(oldAssembly); + _prototypeManager.RemoveKindsByAssembly(oldAssembly); + IoCManager.RemoveRegistrations(oldAssembly); + _netManager.RemoveNetMessages(oldAssembly); + + // But chuddha, where's the reflectionmanager? + // ReflectionManager is handled in ReloadSingleAssembly. + + Sawmill.Info($"Teardown for {oldAssembly.GetName()} done in {sw.ElapsedMilliseconds}ms"); + } + + private void ReinitializeAssembly(Assembly newAssembly) + { + var asm = newAssembly.GetName().Name!; + + var sw = Stopwatch.StartNew(); + + var stepSw = Stopwatch.StartNew(); + _modLoader.BroadcastRunLevelForAssembly(ModRunLevel.PreInit, newAssembly); + Sawmill.Info($"[{asm}] PreInit done in {stepSw.ElapsedMilliseconds}ms"); + + stepSw.Restart(); + _serializationManager.RegisterContentTypes(); + + stepSw.Restart(); + _modLoader.BroadcastRunLevelForAssembly(ModRunLevel.Init, newAssembly); + Sawmill.Info($"[{asm}] Init done in {stepSw.ElapsedMilliseconds}ms"); + + _componentFactory.GenerateNetIds(); + + // This takes a trillion years TODO find a way to skip this + stepSw.Restart(); + _prototypeManager.Reset(); + Sawmill.Info($"[{asm}] Prototypes done in {stepSw.ElapsedMilliseconds}ms"); + + stepSw.Restart(); + _entityManager.EntitySysManager.AddContentSystems(); + Sawmill.Info($"[{asm}] Entity systems done in {stepSw.ElapsedMilliseconds}ms"); + + stepSw.Restart(); + _modLoader.BroadcastRunLevelForAssembly(ModRunLevel.PostInit, newAssembly); + Sawmill.Info($"[{asm}] PostInit done in {stepSw.ElapsedMilliseconds}ms"); + + Sawmill.Info($"[{asm}] Reloading done in {sw.ElapsedMilliseconds}ms"); + } +} diff --git a/Robust.Shared/ContentPack/IHotReloadManager.cs b/Robust.Shared/ContentPack/IHotReloadManager.cs new file mode 100644 index 00000000000..cea069e6231 --- /dev/null +++ b/Robust.Shared/ContentPack/IHotReloadManager.cs @@ -0,0 +1,34 @@ +using System; +using Robust.Shared.Utility; + +namespace Robust.Shared.ContentPack; + +public interface IHotReloadManager +{ + /// + /// Whether a hot-reload is currently in progress. + /// + bool IsReloading { get; } + + /// + /// The assembly directory used for the initial load. + /// + ResPath AssemblyDirectory { get; set; } + + /// + /// The filter prefix used for the initial load (e.g., "Content."). + /// + string FilterPrefix { get; set; } + + /// + /// Raised before hot-reload teardown begins. Content can subscribe to clean up static state. + /// + event Action? HotReloadPreparing; + + /// + /// Raised after hot-reload completes successfully. Content can subscribe to restart rounds, etc. + /// + event Action? HotReloadComplete; + + void Initialize(); +} diff --git a/Robust.Shared/ContentPack/IModLoader.cs b/Robust.Shared/ContentPack/IModLoader.cs index 1d4e64b844d..71082511714 100644 --- a/Robust.Shared/ContentPack/IModLoader.cs +++ b/Robust.Shared/ContentPack/IModLoader.cs @@ -84,5 +84,24 @@ internal interface IModLoaderInternal : IModLoader void Shutdown(); event ExtraModuleLoad ExtraModuleLoaders; + + // DevaStation start - hot-reload + + /// + /// Broadcasts a run level change to entry points of a specific assembly only. + /// + void BroadcastRunLevelForAssembly(ModRunLevel level, Assembly assembly); + + /// + /// Reloads a single content assembly by loading the new version from disk into + /// a fresh collectible ALC. The old assembly version remains loaded + /// Other content assemblies are untouched. + /// + /// Name of the assembly to reload (e.g., "Content.Server"). + /// VFS directory containing the DLL. + /// Assembly filter prefix (e.g., "Content."). + /// Tuple of (oldAssembly, newAssembly), or null on failure. + (Assembly oldAssembly, Assembly newAssembly)? ReloadSingleAssembly(string assemblyName, ResPath assemblyDirectory, string filterPrefix); + // DevaStation end } } diff --git a/Robust.Shared/ContentPack/ModLoader.cs b/Robust.Shared/ContentPack/ModLoader.cs index 64820e1cc3b..00c517a199d 100644 --- a/Robust.Shared/ContentPack/ModLoader.cs +++ b/Robust.Shared/ContentPack/ModLoader.cs @@ -25,7 +25,7 @@ internal sealed class ModLoader : BaseModLoader, IModLoaderInternal, IDisposable // List of extra assemblies side-loaded from the /Assemblies/ mounted path. private readonly List _sideModules = new(); - private readonly AssemblyLoadContext _loadContext; + private AssemblyLoadContext _loadContext; // DevaStation - removed readonly for hot-reload ALC swap private readonly object _lock = new(); @@ -46,7 +46,7 @@ public event ExtraModuleLoad ExtraModuleLoaders public ModLoader() { var id = Interlocked.Increment(ref _modLoaderId); - _loadContext = new AssemblyLoadContext($"ModLoader-{id}"); + _loadContext = new AssemblyLoadContext($"ModLoader-{id}", isCollectible: true); // DevaStation - collectible for hot-reload _loadContext.Resolving += ResolvingAssembly; @@ -65,6 +65,22 @@ public void SetEnableSandboxing(bool sandboxing) Sawmill.Debug("{0} sandboxing", sandboxing ? "ENABLING" : "DISABLING"); } + // DevaStation start - hot-reload assembly metadata check + /// + /// Checks if an assembly is marked as hot-reloadable via AssemblyMetadata. + /// Uses [assembly: AssemblyMetadata("HotReloadable", "true")] - no new types needed. + /// + internal static bool IsHotReloadable(Assembly assembly) + { + foreach (var attr in assembly.GetCustomAttributes()) + { + if (attr.Key == "HotReloadable" && attr.Value == "true") + return true; + } + return false; + } + // DevaStation end + public Func? VerifierExtraLoadHandler { get; set; } public void AddEngineModuleDirectory(string dir) @@ -324,6 +340,91 @@ public bool TryLoadAssembly(string assemblyName) return false; } + // DevaStation start - hot-reload + /// + /// Reloads a single content assembly by loading the new version from disk into + /// a fresh collectible ALC. The old version remains loaded. + /// + public (Assembly oldAssembly, Assembly newAssembly)? ReloadSingleAssembly( + string assemblyName, ResPath assemblyDirectory, string filterPrefix) + { + Sawmill.Info($"Reloading assembly: {assemblyName}"); + + var oldAssembly = RemoveMod(assemblyName); + if (oldAssembly == null) + { + Sawmill.Error($"{assemblyName} wasn't there when trying to remove it."); + return null; + } + + ReflectionManager.RemoveAssembly(oldAssembly); + + string? diskPath = null; + foreach (var filePath in _res.ContentFindRelativeFiles(assemblyDirectory) + .Where(p => p.Filename.StartsWith(filterPrefix) && p.Extension == "dll")) + { + var fullVfsPath = assemblyDirectory / filePath; + var name = Path.GetFileNameWithoutExtension(filePath.Filename); + if (name == assemblyName && _res.TryGetDiskFilePath(fullVfsPath, out var dp)) + { + diskPath = dp; + break; + } + } + + if (diskPath == null) + { + Sawmill.Error($"Could not find DLL on disk for '{assemblyName}'."); + return null; + } + + var id = Interlocked.Increment(ref _modLoaderId); + var reloadContext = new AssemblyLoadContext($"HotReload-{assemblyName}-{id}", isCollectible: true); + + reloadContext.Resolving += (ctx, asmName) => + { + foreach (var mod in Mods) + { + if (mod.GameAssembly.GetName().Name == asmName.Name) + return mod.GameAssembly; + } + + foreach (var sideAsm in _sideModules) + { + if (sideAsm.GetName().Name == asmName.Name) + return sideAsm; + } + + try { return _loadContext.LoadFromAssemblyName(asmName); } + catch { /* fall through */ } + + // Just go to default whatever + return null; + }; + + // LoadFromStream is used here because CLR caches paths. + var oldMvid = oldAssembly.ManifestModule.ModuleVersionId; + var dllBytes = File.ReadAllBytes(diskPath); + var dllStream = new MemoryStream(dllBytes); + + MemoryStream? pdbStream = null; + var pdbPath = Path.ChangeExtension(diskPath, ".pdb"); + if (File.Exists(pdbPath)) + { + pdbStream = new MemoryStream(File.ReadAllBytes(pdbPath)); + } + + var newAssembly = reloadContext.LoadFromStream(dllStream, pdbStream); + var newMvid = newAssembly.ManifestModule.ModuleVersionId; + if (oldMvid == newMvid) + Sawmill.Warning($"{newAssembly.GetName().Name} old and new assemblies have the same MVID, assembly was not rebuilt!"); + + InitMod(newAssembly); + + return (oldAssembly, newAssembly); + } + // DevaStation end + private Assembly? ResolvingAssembly(AssemblyLoadContext context, AssemblyName name) { try diff --git a/Robust.Shared/GameObjects/ComponentFactory.cs b/Robust.Shared/GameObjects/ComponentFactory.cs index 20bedc1d21e..756b972d787 100644 --- a/Robust.Shared/GameObjects/ComponentFactory.cs +++ b/Robust.Shared/GameObjects/ComponentFactory.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Reflection; using JetBrains.Annotations; using Robust.Shared.GameStates; using Robust.Shared.IoC; @@ -96,8 +97,9 @@ private ComponentRegistration Register(Type type, if (_networkedComponents is not null) throw new ComponentRegistrationLockException(); - if (types.ContainsKey(type)) - throw new InvalidOperationException($"Type is already registered: {type}"); + // DevaStation - hot-reload + if (types.TryGetValue(type, out var register)) + return register; if (!type.IsSubclassOf(typeof(Component))) throw new InvalidOperationException($"Type is not derived from component: {type}"); @@ -555,6 +557,64 @@ public void GenerateNetIds() public Type IdxToType(CompIdx idx) => _idxToType[idx]; + // DevaStation start + internal void Reset() + { + _names = FrozenDictionary.Empty; + _lowerCaseNames = FrozenDictionary.Empty; + _networkedComponents = null; // Unlock registration + _types = FrozenDictionary.Empty; + _array = Array.Empty(); + _idxToType = FrozenDictionary.Empty; + _typeToIdx = FrozenDictionary.Empty; + _ignoreMissingComponentPostfix = null; + } + + + /// _ignored and _array being untouched is fine, and holes in the latter are acceptable + public void RemoveComponentsByAssembly(Assembly oldAssembly) + { + // We got the shoes + var contentRegs = new List(); + foreach (var reg in _types.Values) + { + if (reg.Type.Assembly == oldAssembly) + contentRegs.Add(reg); + } + + if (contentRegs.Count == 0) + { + return; + } + + // We got the money + var names = _names.ToDictionary(); + var lowerCaseNames = _lowerCaseNames.ToDictionary(); + var types = _types.ToDictionary(); + var idxToType = _idxToType.ToDictionary(); + var typeToIdx = _typeToIdx.ToDictionary(); + + foreach (var reg in contentRegs) + { + names.Remove(reg.Name); + lowerCaseNames.Remove(reg.Name.ToLowerInvariant()); + types.Remove(reg.Type); + idxToType.Remove(reg.Idx); + typeToIdx.Remove(reg.Type); + } + + _names = names.ToFrozenDictionary(); + _lowerCaseNames = lowerCaseNames.ToFrozenDictionary(); + _types = types.ToFrozenDictionary(); + _idxToType = idxToType.ToFrozenDictionary(); + _typeToIdx = typeToIdx.ToFrozenDictionary(); + + _networkedComponents = null; + + _sawmill.Info($"Removed {contentRegs.Count} components."); + } + // DevaStation end + public byte[] GetHash(bool networkedOnly) { if (_networkedComponents is null) diff --git a/Robust.Shared/GameObjects/EntityEventBus.Common.cs b/Robust.Shared/GameObjects/EntityEventBus.Common.cs index b076d269bb8..d96f2c8cb6f 100644 --- a/Robust.Shared/GameObjects/EntityEventBus.Common.cs +++ b/Robust.Shared/GameObjects/EntityEventBus.Common.cs @@ -54,7 +54,7 @@ private readonly Dictionary>.Empty; + _componentFactory.ComponentsAdded -= OnComponentsAdded; + // DevaStation end } private void RegisterComponents(IEnumerable components) @@ -81,6 +86,15 @@ private void RegisterComponents(IEnumerable components) var traitDict = _entTraitDict.ToDictionary(); foreach (var reg in components) { + // DevaStation start - hot-reload + // On first init _entTraitArray is Array.Empty so this check is always false. + if (reg.Idx.Value < _entTraitArray.Length && _entTraitArray[reg.Idx.Value] is { } existing) + { + traitDict[reg.Type] = existing; + continue; + } + // DevaStation end + var dict = new Dictionary(); traitDict.Add(reg.Type, dict); CompIdx.AssignArray(ref _entTraitArray, reg.Idx, dict); @@ -1722,7 +1736,6 @@ public bool CanGetComponentState(IComponent component, ICommonSession player) private void FillComponentDict() { _entTraitDict = FrozenDictionary>.Empty; - Array.Fill(_entTraitArray, null); RegisterComponents(_componentFactory.GetAllRegistrations()); } } diff --git a/Robust.Shared/GameObjects/EntityManager.cs b/Robust.Shared/GameObjects/EntityManager.cs index 349e648fc6f..233e732dca3 100644 --- a/Robust.Shared/GameObjects/EntityManager.cs +++ b/Robust.Shared/GameObjects/EntityManager.cs @@ -226,8 +226,12 @@ public bool IsDefault(EntityUid uid, ICollection? ignoredComps = null) public virtual void Startup() { - if(!Initialized) - throw new InvalidOperationException("Startup() called without Initialized"); + if (!Initialized) + { + // DevaStation - hot-reload: re-initialize after Shutdown() reset + InitializeComponents(); + Initialized = true; + } if (Started) throw new InvalidOperationException("Startup() called multiple times"); @@ -254,6 +258,12 @@ public virtual void Shutdown() ClearComponents(); ShuttingDown = false; Started = false; + + // DevaStation start - hot-reload + _componentFactory.ComponentsAdded -= OnComponentsAdded; + Initialized = false; + // DevaStation end + _entityConsoleHost.Shutdown(); } @@ -847,7 +857,11 @@ private void FlushEntitiesInternal() // First, we directly delete all maps. This will delete most entities while reducing the number of component // lookups - var maps = _entTraitDict[typeof(MapComponent)].Keys.ToArray(); + // No map dict no entities to kill + if (!_entTraitDict.TryGetValue(typeof(MapComponent), out var mapDict)) + return; + + var maps = mapDict.Keys.ToArray(); foreach (var map in maps) { try @@ -865,7 +879,10 @@ private void FlushEntitiesInternal() } // Then delete all other entities. - var ents = _entTraitDict[typeof(MetaDataComponent)].ToArray(); + if (!_entTraitDict.TryGetValue(typeof(MetaDataComponent), out var metaDict)) + return; + + var ents = metaDict.ToArray(); DebugTools.Assert(ents.Length == Entities.Count); foreach (var (uid, comp) in ents) { diff --git a/Robust.Shared/GameObjects/EntitySystemManager.cs b/Robust.Shared/GameObjects/EntitySystemManager.cs index db09a486e94..06135440b07 100644 --- a/Robust.Shared/GameObjects/EntitySystemManager.cs +++ b/Robust.Shared/GameObjects/EntitySystemManager.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Reflection; using System.Runtime.CompilerServices; using Prometheus; using Robust.Shared.IoC; @@ -272,6 +273,233 @@ private static IEnumerable GetBaseTypes(Type type) { .Concat(GetBaseTypes(type.BaseType)); } + // DevaStation start - hot-reload + + /// + /// Okay so we decide to unfuck systems at runtime, what does it take? + /// For one, we have to find out what a system is, and where it is. + /// For two, we have to fuck up EntityManager to unlock subscriptions because ofc you cant unsub from events EVEN IF there's a public API method just for that! + /// For three, we shut it all down + /// For four, we unperson them, they never happened, and redo update order + /// And at last we have removed a system from the game + /// + public void RemoveContentSystems(Assembly oldAssembly) + { + _sawmill.Info("Killing systems"); + + var contentTypes = new List(); + foreach (var systemType in _systemTypes) + { + if (systemType.Assembly == oldAssembly) + { + contentTypes.Add(systemType); + } + } + + _sawmill.Info($"Found {contentTypes.Count} content systems to kill, just completely eviscerate, maim, gore even"); + + if (_entityManager is EntityManager entManEarly) + { + entManEarly.EventBusInternal.UnlockSubscriptions(); + } + + foreach (var systemType in contentTypes) + { + try + { + var system = (IEntitySystem)SystemDependencyCollection.ResolveType(systemType); + + SystemUnloaded?.Invoke(this, new SystemChangedArgs(system)); + + system.Shutdown(); + + _entityManager.EventBus.UnsubscribeEvents(system); + } + catch (Exception e) + { + _sawmill.Error($"Error removing system {systemType.Name}: {e}"); + } + } + + var contentTypeSet = new HashSet(contentTypes); + _systemTypes.RemoveAll(t => contentTypeSet.Contains(t)); + + SystemDependencyCollection.RemoveRegistrations(oldAssembly); + + var excludedTypes = new HashSet(); + var subTypes = new Dictionary(); + + foreach (var type in _systemTypes) + { + excludedTypes.Add(type); + subTypes.Remove(type); + + foreach (var baseType in GetBaseTypes(type)) + { + if (excludedTypes.Contains(baseType)) continue; + + if (subTypes.Remove(baseType)) + { + excludedTypes.Add(baseType); + } + else + { + subTypes.Add(baseType, type); + } + } + } + + var (fUpdate, update) = CalculateUpdateOrder(_systemTypes, subTypes, SystemDependencyCollection); + + _frameUpdateOrder = fUpdate.ToArray(); + _updateOrder = update + .Select(s => new UpdateReg + { + System = s, + Monitor = _tickUsageHistogram.WithLabels(s.GetType().Name) + }) + .ToArray(); + _sawmill.Info("Killed systems"); + } + + // Basically above but GOOD this time + public void AddContentSystems() + { + _sawmill.Info("Reanimating systems"); + + var allSystemTypes = _reflectionManager.GetAllChildren(); + + var existingTypes = new HashSet(_systemTypes); + var newTypes = allSystemTypes + .Where(type => !existingTypes.Contains(type)) + .Where(type => !SystemDependencyCollection.TryResolveType(type, out _)) + .ToList(); + + _sawmill.Info($"Found {newTypes.Count} systems to reanimate."); + + if (newTypes.Count == 0) + { + return; + } + + var excludedTypes = new HashSet(_systemTypes); + var subTypes = new Dictionary(); + + foreach (var type in _systemTypes) + { + foreach (var baseType in GetBaseTypes(type)) + { + if (excludedTypes.Contains(baseType)) continue; + + if (subTypes.Remove(baseType)) + { + excludedTypes.Add(baseType); + } + else + { + subTypes.Add(baseType, type); + } + } + } + + foreach (var type in newTypes) + { + _sawmill.Debug($"Registering system {type.Name}"); + + SystemDependencyCollection.Register(type); + _systemTypes.Add(type); + + excludedTypes.Add(type); + subTypes.Remove(type); + + // Also register under supertypes + foreach (var baseType in GetBaseTypes(type)) + { + if (excludedTypes.Contains(baseType)) continue; + + if (subTypes.Remove(baseType)) + { + excludedTypes.Add(baseType); + } + else + { + subTypes.Add(baseType, type); + } + } + } + + foreach (var (baseType, type) in subTypes) + { + // Skip anything that survived + if (SystemDependencyCollection.TryResolveType(baseType, out _)) + { + _systemTypes.Remove(baseType); + continue; + } + + SystemDependencyCollection.Register(baseType, type, overwrite: true); + _systemTypes.Remove(baseType); + } + + SystemDependencyCollection.BuildGraph(); + + foreach (var systemType in newTypes) + { + try + { + var system = (IEntitySystem)SystemDependencyCollection.ResolveType(systemType); + system.Initialize(); + SystemLoaded?.Invoke(this, new SystemChangedArgs(system)); + } + catch (Exception e) + { + _sawmill.Error($"Error initializing system {systemType.Name}: {e}"); + } + } + + var allSubTypes = new Dictionary(); + var allExcluded = new HashSet(); + + foreach (var type in _systemTypes) + { + allExcluded.Add(type); + allSubTypes.Remove(type); + + foreach (var baseType in GetBaseTypes(type)) + { + if (allExcluded.Contains(baseType)) continue; + + if (allSubTypes.Remove(baseType)) + { + allExcluded.Add(baseType); + } + else + { + allSubTypes.Add(baseType, type); + } + } + } + + var (fUpdate, update) = CalculateUpdateOrder(_systemTypes, allSubTypes, SystemDependencyCollection); + + _frameUpdateOrder = fUpdate.ToArray(); + _updateOrder = update + .Select(s => new UpdateReg + { + System = s, + Monitor = _tickUsageHistogram.WithLabels(s.GetType().Name) + }) + .ToArray(); + + if (_entityManager is EntityManager entMan) + { + entMan.EventBusInternal.LockSubscriptions(); + } + + _sawmill.Info("Systems do be reanimated tho"); + } + // DevaStation end + /// public void Shutdown() { @@ -281,7 +509,18 @@ public void Shutdown() if(SystemDependencyCollection == null) continue; var system = (IEntitySystem)SystemDependencyCollection.ResolveType(systemType); SystemUnloaded?.Invoke(this, new SystemChangedArgs(system)); - system.Shutdown(); + + // DevaStation start - hot-reload + try + { + system.Shutdown(); + } + catch (Exception e) + { + _sawmill.Error($"Caught exception shutting down system {systemType.Name}: {e}"); + } + // DevaStation end + _entityManager.EventBus.UnsubscribeEvents(system); } diff --git a/Robust.Shared/GameObjects/IComponentFactory.cs b/Robust.Shared/GameObjects/IComponentFactory.cs index 219826c6dbf..35b6ff2738e 100644 --- a/Robust.Shared/GameObjects/IComponentFactory.cs +++ b/Robust.Shared/GameObjects/IComponentFactory.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using JetBrains.Annotations; using Robust.Shared.GameStates; using Robust.Shared.Prototypes; @@ -307,5 +308,8 @@ public interface IComponentFactory /// /// Whether to include all components or only networked ones. byte[] GetHash(bool networkedOnly); + + // DevaStation - hot-reload + void RemoveComponentsByAssembly(Assembly oldAssembly); } } diff --git a/Robust.Shared/GameObjects/IEntitySystemManager.cs b/Robust.Shared/GameObjects/IEntitySystemManager.cs index d4424483cbe..5aee8422e87 100644 --- a/Robust.Shared/GameObjects/IEntitySystemManager.cs +++ b/Robust.Shared/GameObjects/IEntitySystemManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using Robust.Shared.IoC; using Robust.Shared.IoC.Exceptions; @@ -137,5 +138,10 @@ void Resolve([NotNull] ref T1? instance1, [NotNull] ref T2? inst /// Dependency collection that contains all the loaded systems. /// public IDependencyCollection DependencyCollection { get; } + + // DevaStation start - hot-reload + void RemoveContentSystems(Assembly oldAssembly); + void AddContentSystems(); + // DevaStation end } } diff --git a/Robust.Shared/IoC/DependencyCollection.cs b/Robust.Shared/IoC/DependencyCollection.cs index 6a0c3f2256c..5c0dbde209b 100644 --- a/Robust.Shared/IoC/DependencyCollection.cs +++ b/Robust.Shared/IoC/DependencyCollection.cs @@ -267,7 +267,7 @@ object DefaultFactory(IReadOnlyDictionary services) _pendingResolves.Enqueue(interfaceType); } } - + private void CheckRegisterInterface(Type interfaceType, Type implementationType, bool overwrite) { lock (_serviceBuildLock) @@ -335,6 +335,48 @@ public void Clear() } } + // DevaStation start + /// + /// Removes all registrations where the implementation type belongs to the given assembly. + /// Also removes any built service instances and invalidates the injector cache. + /// Used during hot-reload to clear content IoC registrations before re-registration. + /// + internal void RemoveRegistrations(Assembly oldAssembly) + { + var toRemove = new List(); + + lock (_serviceBuildLock) + { + foreach (var (iface, impl) in _resolveTypes) + { + if (impl.Assembly == oldAssembly) + toRemove.Add(iface); + } + + foreach (var iface in toRemove) + { + _resolveTypes.Remove(iface); + _resolveFactories.Remove(iface); + } + } + + if (toRemove.Count > 0) + { + // We can rebuild him + var toRemoveSet = new HashSet(toRemove); + var remaining = _services + .Where(kv => !toRemoveSet.Contains(kv.Key)) + .ToDictionary(kv => kv.Key, kv => kv.Value); + _services = remaining.ToFrozenDictionary(); + + using (_injectorCacheLock.WriteGuard()) + { + _injectorCache.Clear(); + } + } + } + // DevaStation end + /// [System.Diagnostics.Contracts.Pure] public T Resolve() diff --git a/Robust.Shared/IoC/IoCManager.cs b/Robust.Shared/IoC/IoCManager.cs index 1eca24a00ba..7f04d534669 100644 --- a/Robust.Shared/IoC/IoCManager.cs +++ b/Robust.Shared/IoC/IoCManager.cs @@ -1,6 +1,7 @@ using Robust.Shared.IoC.Exceptions; using System; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -184,6 +185,17 @@ public static void Clear() _container.Value!.Clear(); } + // DevaStation start - hot-reload + /// + /// Removes all IoC registrations where the implementation type belongs to the given assembly. + /// + internal static void RemoveRegistrations(Assembly oldAssembly) + { + if (_container.IsValueCreated && _container.Value is DependencyCollection dc) + dc.RemoveRegistrations(oldAssembly); + } + // DevaStation end + /// /// Resolve a dependency manually. /// diff --git a/Robust.Shared/Localization/LocalizationManager.cs b/Robust.Shared/Localization/LocalizationManager.cs index cc719c6202e..99604d8926c 100644 --- a/Robust.Shared/Localization/LocalizationManager.cs +++ b/Robust.Shared/Localization/LocalizationManager.cs @@ -374,9 +374,11 @@ public bool HasCulture(CultureInfo culture) public void LoadCulture(CultureInfo culture) { - // Attempting to load an already loaded culture + // DevaStation start - hot-reload + // Localization resources come from the VFS, not content assemblies, so they're still valid. if (HasCulture(culture)) - throw new InvalidOperationException("Culture is already loaded"); + return; + // DevaStation end var bundle = LinguiniBuilder.Builder() .CultureInfo(culture) diff --git a/Robust.Shared/Map/TileDefinitionManager.cs b/Robust.Shared/Map/TileDefinitionManager.cs index 3b14440fca1..9257a46e3f1 100644 --- a/Robust.Shared/Map/TileDefinitionManager.cs +++ b/Robust.Shared/Map/TileDefinitionManager.cs @@ -29,10 +29,15 @@ public virtual void Initialize() public virtual void Register(ITileDefinition tileDef) { var name = tileDef.ID; - if (_tileNames.ContainsKey(name)) + // DevaStation start - hot-reload: Keep tiledefs in place + if (_tileNames.TryGetValue(name, out var existing)) { - throw new ArgumentException("Another tile definition or alias with the same name has already been registered.", nameof(tileDef)); + tileDef.AssignTileId(existing.TileId); + TileDefs[existing.TileId] = tileDef; + _tileNames[name] = tileDef; + return; } + // DevaStation end var id = checked((ushort) TileDefs.Count); tileDef.AssignTileId(id); diff --git a/Robust.Shared/Network/INetManager.cs b/Robust.Shared/Network/INetManager.cs index 89b407e9568..8d0e714e6dc 100644 --- a/Robust.Shared/Network/INetManager.cs +++ b/Robust.Shared/Network/INetManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Reflection; using System.Threading.Tasks; namespace Robust.Shared.Network @@ -141,5 +142,8 @@ void RegisterNetMessage(ProcessMessage? rxCallback = null, /// Instance of the NetMessage. [Obsolete("Just new NetMessage directly")] T CreateNetMessage() where T : NetMessage, new(); + + // DevaStation start - hot-reload + void RemoveNetMessages(Assembly oldAssembly); } } diff --git a/Robust.Shared/Network/Messages/MsgHotReload.cs b/Robust.Shared/Network/Messages/MsgHotReload.cs new file mode 100644 index 00000000000..4540983b922 --- /dev/null +++ b/Robust.Shared/Network/Messages/MsgHotReload.cs @@ -0,0 +1,21 @@ +using Lidgren.Network; +using Robust.Shared.Serialization; + +namespace Robust.Shared.Network.Messages; + +public sealed class MsgHotReload : NetMessage +{ + public override MsgGroups MsgGroup => MsgGroups.Command; + + public string AssemblyName { get; set; } = string.Empty; + + public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer) + { + AssemblyName = buffer.ReadString(); + } + + public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer) + { + buffer.Write(AssemblyName); + } +} diff --git a/Robust.Shared/Network/NetManager.cs b/Robust.Shared/Network/NetManager.cs index 7d9474fc95d..5e6e9bef928 100644 --- a/Robust.Shared/Network/NetManager.cs +++ b/Robust.Shared/Network/NetManager.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net; using System.Net.Sockets; +using System.Reflection; using System.Runtime.Serialization; using System.Threading; using System.Threading.Tasks; @@ -429,6 +430,21 @@ public void Reset(string reason) ClientConnectState = ClientConnectionState.NotConnecting; } + // DevaStation start - hot-reload + public void RemoveNetMessages(Assembly oldAssembly) + { + var toRemove = _messages + .Where(kv => kv.Value.Type.Assembly == oldAssembly) + .Select(kv => kv.Key) + .ToList(); + + foreach (var key in toRemove) + { + _messages.Remove(key); + } + } + // DevaStation end + /// public void Shutdown(string reason) { @@ -1015,7 +1031,7 @@ public void RegisterNetMessage(ProcessMessage? rxCallback = null, IsHandshake = (accept & NetMessageAccept.Handshake) != 0 }; - _messages.Add(name, data); + _messages[name] = data; // DevaStation start - hot-reload: use index var thisSide = IsServer ? NetMessageAccept.Server : NetMessageAccept.Client; diff --git a/Robust.Shared/Player/ISharedPlayerManager.cs b/Robust.Shared/Player/ISharedPlayerManager.cs index ece64cdab5f..6d2883fefea 100644 --- a/Robust.Shared/Player/ISharedPlayerManager.cs +++ b/Robust.Shared/Player/ISharedPlayerManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using Robust.Shared.Enums; using Robust.Shared.GameObjects; using Robust.Shared.GameStates; @@ -218,4 +219,9 @@ bool SetAttachedEntity([NotNullWhen(true)] ICommonSession? session, EntityUid? e /// Set the session's status to . /// void JoinGame(ICommonSession session); + + /// + /// Removes event subscribers whose target belongs to a content assembly. + /// + void ClearContentEventSubscribers(Assembly oldAssembly); } diff --git a/Robust.Shared/Player/SharedPlayerManager.cs b/Robust.Shared/Player/SharedPlayerManager.cs index 54ab6686fd9..8c072516f62 100644 --- a/Robust.Shared/Player/SharedPlayerManager.cs +++ b/Robust.Shared/Player/SharedPlayerManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Reflection; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Log; @@ -58,6 +59,20 @@ public virtual void Shutdown() PlayerData.Clear(); } + // DevaStation - hot-reload + public void ClearContentEventSubscribers(Assembly oldAssembly) + { + if (PlayerStatusChanged == null) + return; + + foreach (var d in PlayerStatusChanged.GetInvocationList()) + { + if (d.Target != null && d.Target.GetType().Assembly == oldAssembly) + PlayerStatusChanged -= (EventHandler)d; + } + } + // DevaStation end + public bool TryGetUserId(string userName, out NetUserId userId) { return UserIdMap.TryGetValue(userName, out userId); diff --git a/Robust.Shared/Prototypes/IPrototypeManager.cs b/Robust.Shared/Prototypes/IPrototypeManager.cs index fcb5cedb18e..71962a1511d 100644 --- a/Robust.Shared/Prototypes/IPrototypeManager.cs +++ b/Robust.Shared/Prototypes/IPrototypeManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Reflection; using Robust.Shared.Random; using Robust.Shared.Reflection; using Robust.Shared.Serialization.Manager; @@ -620,6 +621,9 @@ void ReloadPrototypes( /// Entity prototypes grouped by their categories. /// FrozenDictionary, IReadOnlyList> Categories { get; } + + // DevaStation - hot-reload + void RemoveKindsByAssembly(Assembly oldAssembly); } internal interface IPrototypeManagerInternal : IPrototypeManager diff --git a/Robust.Shared/Prototypes/PrototypeManager.cs b/Robust.Shared/Prototypes/PrototypeManager.cs index c727ff6cee9..cd4781ee585 100644 --- a/Robust.Shared/Prototypes/PrototypeManager.cs +++ b/Robust.Shared/Prototypes/PrototypeManager.cs @@ -293,7 +293,17 @@ public IPrototype Index(Type kind, string id) public void Clear() { _kindNames.Clear(); + _kindPriorities.Clear(); // DevaStation - hot-reload _kinds = FrozenDictionary.Empty; + + // DevaStation start - hot-reload + // Reset initialized state so Initialize() can re-run and rediscover prototype kinds + if (_initialized) + { + PrototypesReloaded -= OnReload; + _initialized = false; + } + // DevaStation end } /// @@ -1143,6 +1153,62 @@ private void RegisterKind(Type kind, Dictionary kinds) /// public event Action? PrototypesReloaded; + // DevaStation start + public void RemoveKindsByAssembly(Assembly oldAssembly) + { + var kindsToRemove = new List(); + var namesToRemove = new List(); + + foreach (var (type, kindData) in _kinds) + { + if (type.Assembly == oldAssembly) + { + kindsToRemove.Add(type); + namesToRemove.Add(kindData.Name); + } + } + + foreach (var name in namesToRemove) + { + _kindNames.Remove(name); + } + + foreach (var type in kindsToRemove) + { + _kindPriorities.Remove(type); + } + + var mutableKinds = _kinds.ToDictionary(); + foreach (var type in kindsToRemove) + { + mutableKinds.Remove(type); + } + + Freeze(mutableKinds); + } + + internal void AddContentKinds() + { + var allPrototypeTypes = _reflectionManager.GetAllChildren(); + + var newTypes = allPrototypeTypes.Where(t => !_kinds.ContainsKey(t)).ToList(); + + if (newTypes.Count == 0) + return; + + var mutableKinds = _kinds.ToDictionary(); + + foreach (var type in newTypes) + { + RegisterKind(type, mutableKinds); + } + + Freeze(mutableKinds); + + Sawmill.Info($"Added {newTypes.Count} new prototype kinds"); + } + // DevaStation end + private sealed class KindData(Type kind, string name) { public Dictionary? UnfrozenInstances; diff --git a/Robust.Shared/Reflection/IReflectionManager.cs b/Robust.Shared/Reflection/IReflectionManager.cs index d2519b1b770..08b29b5e969 100644 --- a/Robust.Shared/Reflection/IReflectionManager.cs +++ b/Robust.Shared/Reflection/IReflectionManager.cs @@ -122,5 +122,11 @@ public interface IReflectionManager IEnumerable FindAllTypes(); void Initialize(); + + // DevaStation - hot-reload + /// + /// Removes a specific assembly and invalidates all type caches. + /// + void RemoveAssembly(Assembly assembly); } } diff --git a/Robust.Shared/Reflection/ReflectionManager.cs b/Robust.Shared/Reflection/ReflectionManager.cs index 112ca726b52..72414198e5d 100644 --- a/Robust.Shared/Reflection/ReflectionManager.cs +++ b/Robust.Shared/Reflection/ReflectionManager.cs @@ -117,6 +117,33 @@ public void LoadAssemblies(IEnumerable assemblies) OnAssemblyAdded?.Invoke(this, new ReflectionUpdateEventArgs(this)); } + // DevaStation start - hot-reload + public void RemoveAssembly(Assembly assembly) + { + if (!assemblies.Remove(assembly)) + return; + + _getAllTypesCache.Clear(); + + lock (_looseTypeCache) + { + _looseTypeCache.Clear(); + } + + using (_yamlTypeTagCacheLock.WriteGuard()) + { + _yamlTypeTagCache.Clear(); + } + + using (_enumCacheLock.WriteGuard()) + { + _enumCache.Clear(); + _reverseEnumCache.Clear(); + } + } + + // DevaStation end + /// public Type? GetType(string name) { diff --git a/Robust.Shared/Serialization/Manager/ISerializationManager.cs b/Robust.Shared/Serialization/Manager/ISerializationManager.cs index 4abe1d444bb..bb192f18c65 100644 --- a/Robust.Shared/Serialization/Manager/ISerializationManager.cs +++ b/Robust.Shared/Serialization/Manager/ISerializationManager.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using JetBrains.Annotations; using Robust.Shared.Reflection; using Robust.Shared.Serialization.Markdown; @@ -464,5 +465,10 @@ public TNode PushCompositionWithGenericNode(Type type, TNode parent, TNod #endregion public bool TryGetVariableType(Type type, string variableName, [NotNullWhen(true)] out Type? variableType); + + // DevaStation start - hot-reload + void RemoveContentTypes(Assembly oldAssembly); + void RegisterContentTypes(); + // DevaStation end } } diff --git a/Robust.Shared/Serialization/Manager/SerializationManager.cs b/Robust.Shared/Serialization/Manager/SerializationManager.cs index 60895b6220f..28a7be9805f 100644 --- a/Robust.Shared/Serialization/Manager/SerializationManager.cs +++ b/Robust.Shared/Serialization/Manager/SerializationManager.cs @@ -283,6 +283,165 @@ private DataDefinition CreateDataDefinition(Type t, bool isRecord) .Invoke(new object[]{this, isRecord}); } + // DevaStation start - hot-reload + public void RemoveContentTypes(Assembly oldAssembly) + { + foreach (var key in _dataDefinitions.Keys) + { + if (key.Assembly == oldAssembly) + _dataDefinitions.TryRemove(key, out _); + } + + foreach (var key in _copyByRefRegistrations.Keys) + { + if (key.Assembly == oldAssembly) + _copyByRefRegistrations.TryRemove(key, out _); + } + + foreach (var key in _flagsMapping.Keys.ToArray()) + { + if (key.Assembly == oldAssembly) + _flagsMapping.Remove(key); + } + + foreach (var key in _constantsMapping.Keys.ToArray()) + { + if (key.Assembly == oldAssembly) + _constantsMapping.Remove(key); + } + + foreach (var key in _highestFlagBit.Keys.ToArray()) + { + if (key.Assembly == oldAssembly) + _highestFlagBit.Remove(key); + } + + _readBoxingDelegates.Clear(); + _readGenericBaseDelegates.Clear(); + _readGenericDelegates.Clear(); + + _writeBoxingDelegates.Clear(); + _writeGenericBaseDelegates.Clear(); + _writeGenericDelegates.Clear(); + + _copyToGenericDelegates.Clear(); + _copyToGenericBaseDelegates.Clear(); + _copyToBoxingDelegates.Clear(); + _createCopyGenericDelegates.Clear(); + _createCopyBoxingDelegates.Clear(); + + _validationDelegates.Clear(); + _compositionPushers.Clear(); + _customTypeSerializers.Clear(); + + _instantiators.Clear(); + } + + public void RegisterContentTypes() + { + var flagsTypes = new ConcurrentBag(); + var constantsTypes = new ConcurrentBag(); + var typeSerializers = new ConcurrentBag(); + var meansDataDef = new ConcurrentBag(); + var meansDataRecord = new ConcurrentBag(); + var implicitDataDef = new ConcurrentBag(); + var implicitDataRecord = new ConcurrentBag(); + + CollectAttributedTypes(flagsTypes, constantsTypes, typeSerializers, meansDataDef, meansDataRecord, implicitDataDef, implicitDataRecord); + + // InitializeFlagsAndConstants uses Add which throws on duplicates, so filter to only new entries + var newFlags = flagsTypes.Where(t => + { + var attrs = t.GetCustomAttributes(true); + return attrs.Any(a => !_flagsMapping.ContainsKey(a.Tag)); + }); + var newConstants = constantsTypes.Where(t => + { + var attrs = t.GetCustomAttributes(true); + return attrs.Any(a => !_constantsMapping.ContainsKey(a.Tag)); + }); + InitializeFlagsAndConstants(newFlags, newConstants); + + InitializeTypeSerializers(typeSerializers); + + var registrations = new ConcurrentBag(); + var records = new ConcurrentDictionary(); + + IEnumerable GetImplicitTypes(Type type) + { + if (type.IsInterface) + { + foreach (var child in _reflectionManager.GetAllChildren(type)) + { + if (child.IsAbstract || child.IsGenericTypeDefinition || child.IsInterface) + continue; + + yield return child; + } + } + else if (!type.IsAbstract && !type.IsGenericTypeDefinition) + { + yield return type; + } + } + + foreach (var baseType in implicitDataDef) + { + foreach (var type in GetImplicitTypes(baseType)) + { + registrations.Add(type); + } + } + + foreach (var baseType in implicitDataRecord) + { + foreach (var type in GetImplicitTypes(baseType)) + { + records.TryAdd(type, 0); + } + } + + Parallel.ForEach(_reflectionManager.FindAllTypes(), type => + { + if (meansDataDef.Any(type.IsDefined)) + registrations.Add(type); + + if (type.IsDefined(typeof(DataRecordAttribute)) || meansDataRecord.Any(type.IsDefined)) + records[type] = 0; + + if (type.IsDefined(typeof(CopyByRefAttribute))) + _copyByRefRegistrations[type] = 0; + }); + + var sawmill = Logger.GetSawmill(LogCategory); + + Parallel.ForEach(registrations, type => + { + if (_dataDefinitions.ContainsKey(type)) + return; + + if (type.IsAbstract || type.IsInterface || type.IsGenericTypeDefinition) + { + sawmill.Debug( + $"Skipping registering data definition for type {type} since it is abstract or an interface"); + return; + } + + var isRecord = records.ContainsKey(type); + if (!type.IsValueType && !isRecord && !type.HasParameterlessConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + sawmill.Warning( + $"Skipping registering data definition for type {type} since it has no parameterless ctor"); + return; + } + + _dataDefinitions.GetOrAdd(type, static (t, s) => s.Item1.CreateDataDefinition(t, s.isRecord), (this, isRecord)); + }); + + _copyByRefRegistrations[typeof(Type)] = 0; + } + // DevaStation end + public void Shutdown() { _constantsMapping.Clear(); diff --git a/Robust.Shared/ViewVariables/ViewVariablesTypeHandler.cs b/Robust.Shared/ViewVariables/ViewVariablesTypeHandler.cs index 3532a53a834..7d8de65e7ba 100644 --- a/Robust.Shared/ViewVariables/ViewVariablesTypeHandler.cs +++ b/Robust.Shared/ViewVariables/ViewVariablesTypeHandler.cs @@ -229,7 +229,7 @@ public ViewVariablesTypeHandler AddPath(string path, ComponentPropert /// public ViewVariablesTypeHandler AddPath(string path, PathHandler handler) { - _paths.Add(path, handler); + _paths[path] = handler; // DevaStation start - hot-reload: use indexing return this; } diff --git a/Robust.UnitTesting/RobustIntegrationTest.NetManager.cs b/Robust.UnitTesting/RobustIntegrationTest.NetManager.cs index e830ae15203..105d1d55df0 100644 --- a/Robust.UnitTesting/RobustIntegrationTest.NetManager.cs +++ b/Robust.UnitTesting/RobustIntegrationTest.NetManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Reflection; using System.Net; using System.Threading.Channels; using System.Threading.Tasks; @@ -303,6 +304,9 @@ public event Func Connecting _callbacks.Add(typeof(T), msg => rxCallback((T) msg)); } + // DevaStation start - hot-reload + public void RemoveNetMessages(Assembly oldAssembly) { } + public T CreateNetMessage() where T : NetMessage, new() { var type = typeof(T);