diff --git a/DMCompiler/DMStandard/Types/World.dm b/DMCompiler/DMStandard/Types/World.dm index f7ca1d1e6c..7d29fb9782 100644 --- a/DMCompiler/DMStandard/Types/World.dm +++ b/DMCompiler/DMStandard/Types/World.dm @@ -114,4 +114,6 @@ set opendream_unimplemented = TRUE return 0 - proc/ODHotReloadInterface() \ No newline at end of file + proc/ODHotReloadInterface() + + proc/ODHotReloadResource(var/file_name) diff --git a/OpenDreamClient/Interface/DreamInterfaceManager.cs b/OpenDreamClient/Interface/DreamInterfaceManager.cs index 7b0510e9ab..5ea4919edc 100644 --- a/OpenDreamClient/Interface/DreamInterfaceManager.cs +++ b/OpenDreamClient/Interface/DreamInterfaceManager.cs @@ -794,22 +794,21 @@ public void WinClone(string controlId, string cloneId) { private void Reset() { _userInterfaceManager.MainViewport.Visible = false; - //close windows if they're open, and clear all child uielements foreach (var window in Windows.Values){ window.CloseChildWindow(); window.UIElement.RemoveAllChildren(); } - + Windows.Clear(); Menus.Clear(); MacroSets.Clear(); - + //close popups if they're open foreach (var popup in _popupWindows.Values) { popup.Close(); } - + _popupWindows.Clear(); _inputManager.ResetAllBindings(); } diff --git a/OpenDreamClient/Rendering/DreamIcon.cs b/OpenDreamClient/Rendering/DreamIcon.cs index 7ca796ec60..b331e94125 100644 --- a/OpenDreamClient/Rendering/DreamIcon.cs +++ b/OpenDreamClient/Rendering/DreamIcon.cs @@ -17,6 +17,7 @@ internal sealed class DreamIcon(IGameTiming gameTiming, IClyde clyde, ClientAppe public DMIResource? DMI { get => _dmi; private set { + _dmi?.OnUpdateCallbacks.Remove(DirtyTexture); _dmi = value; CheckSizeChange(); } @@ -62,6 +63,7 @@ public DreamIcon(IGameTiming gameTiming, IClyde clyde, ClientAppearanceSystem ap public void Dispose() { _ping?.Dispose(); _pong?.Dispose(); + DMI = null; //triggers the removal of the onUpdateCallback } public Texture? GetTexture(DreamViewOverlay viewOverlay, DrawingHandleWorld handle, RendererMetaData iconMetaData, Texture? textureOverride = null) { @@ -410,7 +412,7 @@ private void UpdateIcon() { } else { IoCManager.Resolve().LoadResourceAsync(Appearance.Icon.Value, dmi => { if (dmi.Id != Appearance.Icon) return; //Icon changed while resource was loading - + dmi.OnUpdateCallbacks.Add(DirtyTexture); DMI = dmi; _animationFrame = 0; _animationFrameTime = gameTiming.CurTime; diff --git a/OpenDreamClient/Resources/DreamResourceManager.cs b/OpenDreamClient/Resources/DreamResourceManager.cs index f99a127edf..a7837fc4e9 100644 --- a/OpenDreamClient/Resources/DreamResourceManager.cs +++ b/OpenDreamClient/Resources/DreamResourceManager.cs @@ -48,6 +48,7 @@ public void Initialize() { _netManager.RegisterNetMessage(RxBrowseResourceResponse); _netManager.RegisterNetMessage(); _netManager.RegisterNetMessage(RxResource); + _netManager.RegisterNetMessage(RxResourceUpdateNotification); } public void Shutdown() { @@ -91,12 +92,19 @@ private void RxBrowseResourceResponse(MsgBrowseResourceResponse message) { private void RxResource(MsgResource message) { if (_loadingResources.ContainsKey(message.ResourceId)) { LoadingResourceEntry entry = _loadingResources[message.ResourceId]; - DreamResource resource = LoadResourceFromData( - entry.ResourceType, - message.ResourceId, - message.ResourceData); + DreamResource resource; + if(_resourceCache.ContainsKey(message.ResourceId)){ + _resourceCache[message.ResourceId].UpdateData(message.ResourceData); //we update instead of replacing so we don't have to replace the handle in everything that uses it + _resourceCache[message.ResourceId].OnUpdateCallbacks.ForEach(cb => cb.Invoke()); + resource = _resourceCache[message.ResourceId]; + } else { + resource = LoadResourceFromData( + entry.ResourceType, + message.ResourceId, + message.ResourceData); + _resourceCache[message.ResourceId] = resource; + } - _resourceCache[message.ResourceId] = resource; foreach (Action callback in entry.LoadCallbacks) { try { callback.Invoke(resource); @@ -111,6 +119,15 @@ private void RxResource(MsgResource message) { } } + private void RxResourceUpdateNotification(MsgNotifyResourceUpdate message) { + if (!_loadingResources.ContainsKey(message.ResourceId) && _resourceCache.TryGetValue(message.ResourceId, out var cached)) { //either we're already requesting it, or we don't have it so don't need to update + _sawmill.Debug($"Resource id {message.ResourceId} was updated, reloading"); + _loadingResources[message.ResourceId] = new LoadingResourceEntry(cached.GetType()); + var msg = new MsgRequestResource() { ResourceId = message.ResourceId }; + _netManager.ClientSendMessage(msg); + } + } + public void LoadResourceAsync(int resourceId, Action onLoadCallback) where T:DreamResource { DreamResource? resource = GetCachedResource(resourceId); diff --git a/OpenDreamClient/Resources/ResourceTypes/DMIResource.cs b/OpenDreamClient/Resources/ResourceTypes/DMIResource.cs index c40fb20a2a..d67638cc28 100644 --- a/OpenDreamClient/Resources/ResourceTypes/DMIResource.cs +++ b/OpenDreamClient/Resources/ResourceTypes/DMIResource.cs @@ -8,7 +8,7 @@ namespace OpenDreamClient.Resources.ResourceTypes; public sealed class DMIResource : DreamResource { - private readonly byte[] _pngHeader = { 0x89, 0x50, 0x4E, 0x47, 0xD, 0xA, 0x1A, 0xA }; + private static readonly byte[] PngHeader = [0x89, 0x50, 0x4E, 0x47, 0xD, 0xA, 0x1A, 0xA]; public Texture Texture; public Vector2i IconSize; @@ -17,9 +17,19 @@ public sealed class DMIResource : DreamResource { private readonly Dictionary _states; public DMIResource(int id, byte[] data) : base(id, data) { + _states = new Dictionary(); + ProcessDMIData(); + } + + public override void UpdateData(byte[] data) { + base.UpdateData(data); + ProcessDMIData(); + } + + private void ProcessDMIData() { if (!IsValidPNG()) throw new Exception("Attempted to create a DMI using an invalid PNG"); - using Stream dmiStream = new MemoryStream(data); + using Stream dmiStream = new MemoryStream(Data); DMIParser.ParsedDMIDescription description = DMIParser.ParseDMI(dmiStream); dmiStream.Seek(0, SeekOrigin.Begin); @@ -29,7 +39,7 @@ public DMIResource(int id, byte[] data) : base(id, data) { IconSize = new Vector2i(description.Width, description.Height); Description = description; - _states = new Dictionary(); + _states.Clear(); foreach (DMIParser.ParsedDMIState parsedState in description.States.Values) { State state = new State(Texture, parsedState, description.Width, description.Height); @@ -45,10 +55,10 @@ public DMIResource(int id, byte[] data) : base(id, data) { } private bool IsValidPNG() { - if (Data.Length < _pngHeader.Length) return false; + if (Data.Length < PngHeader.Length) return false; - for (int i=0; i<_pngHeader.Length; i++) { - if (Data[i] != _pngHeader[i]) return false; + for (int i=0; i OnUpdateCallbacks = new(); - protected readonly byte[] Data; + protected byte[] Data; [UsedImplicitly] public DreamResource(int id, byte[] data) { @@ -18,4 +19,8 @@ public DreamResource(int id, byte[] data) { public void WriteTo(Stream stream) { stream.Write(Data); } + + public virtual void UpdateData(byte[] data) { + Data = data; + } } diff --git a/OpenDreamRuntime/DreamManager.Connections.cs b/OpenDreamRuntime/DreamManager.Connections.cs index 55ab12cf43..64a13c3d69 100644 --- a/OpenDreamRuntime/DreamManager.Connections.cs +++ b/OpenDreamRuntime/DreamManager.Connections.cs @@ -296,6 +296,16 @@ public void HotReloadInterface() { connection.Session?.Channel.SendMessage(msgLoadInterface); } } + + public void HotReloadResource(string fileName){ + var resource = _dreamResourceManager.LoadResource(fileName, forceReload:true); + var msgBrowseResource = new MsgNotifyResourceUpdate() { //send a message that this resource id has been updated, let the clients handle re-requesting it + ResourceId = resource.Id + }; + foreach (var connection in _connections.Values) { + connection.Session?.Channel.SendMessage(msgBrowseResource); + } + } } } @@ -322,3 +332,27 @@ public void Execute(IConsoleShell shell, string argStr, string[] args) { shell.WriteLine("Reloading interface"); } } + +public sealed class HotReloadResourceCommand : IConsoleCommand { + // ReSharper disable once StringLiteralTypo + public string Command => "hotreloadresource"; + public string Description => "Reload a specified resource and send the update to all clients who have the old version already"; + public string Help => ""; + public bool RequireServerOrSingleplayer => true; + + public void Execute(IConsoleShell shell, string argStr, string[] args) { + if(!shell.IsLocal) { + shell.WriteError("You cannot use this command as a client. Execute it on the server console."); + return; + } + + if (args.Length != 1) { + shell.WriteError("This command requires a file path to reload as an argument! Example: hotreloadresource ./path/to/resource.dmi"); + return; + } + + DreamManager dreamManager = IoCManager.Resolve(); + shell.WriteLine($"Reloading {args[0]}"); + dreamManager.HotReloadResource(args[0]); + } +} diff --git a/OpenDreamRuntime/Procs/Native/DreamProcNative.cs b/OpenDreamRuntime/Procs/Native/DreamProcNative.cs index 5c3a3e84ae..0f6f1776a7 100644 --- a/OpenDreamRuntime/Procs/Native/DreamProcNative.cs +++ b/OpenDreamRuntime/Procs/Native/DreamProcNative.cs @@ -148,6 +148,7 @@ public static void SetupNativeProcs(DreamObjectTree objectTree) { objectTree.SetNativeProc(objectTree.World, DreamProcNativeWorld.NativeProc_Profile); objectTree.SetNativeProc(objectTree.World, DreamProcNativeWorld.NativeProc_SetConfig); objectTree.SetNativeProc(objectTree.World, DreamProcNativeWorld.NativeProc_ODHotReloadInterface); + objectTree.SetNativeProc(objectTree.World, DreamProcNativeWorld.NativeProc_ODHotReloadResource); SetOverridableNativeProc(objectTree, objectTree.World, DreamProcNativeWorld.NativeProc_Reboot); } diff --git a/OpenDreamRuntime/Procs/Native/DreamProcNativeWorld.cs b/OpenDreamRuntime/Procs/Native/DreamProcNativeWorld.cs index bc8aa1762d..efa08612ee 100644 --- a/OpenDreamRuntime/Procs/Native/DreamProcNativeWorld.cs +++ b/OpenDreamRuntime/Procs/Native/DreamProcNativeWorld.cs @@ -192,6 +192,16 @@ public static DreamValue NativeProc_ODHotReloadInterface(NativeProc.Bundle bundl return DreamValue.Null; } + [DreamProc("ODHotReloadResource")] + [DreamProcParameter("file_name", Type = DreamValue.DreamValueTypeFlag.String)] + public static DreamValue NativeProc_ODHotReloadResource(NativeProc.Bundle bundle, DreamObject? src, DreamObject? usr) { + if(!bundle.GetArgument(0, "file_name").TryGetValueAsString(out var fileName)) + throw new ArgumentException("file_name must be a string"); + var dreamManager = IoCManager.Resolve(); + dreamManager.HotReloadResource(fileName); + return DreamValue.Null; + } + /// /// Determines the specified configuration space and configuration set in a config_set argument /// diff --git a/OpenDreamRuntime/Resources/DreamResourceManager.cs b/OpenDreamRuntime/Resources/DreamResourceManager.cs index a8402fb862..f2870dfbe8 100644 --- a/OpenDreamRuntime/Resources/DreamResourceManager.cs +++ b/OpenDreamRuntime/Resources/DreamResourceManager.cs @@ -23,6 +23,7 @@ public void PreInitialize() { _sawmill = Logger.GetSawmill("opendream.res"); _netManager.RegisterNetMessage(RxRequestResource); _netManager.RegisterNetMessage(); + _netManager.RegisterNetMessage(); } public void Initialize(string rootPath, string[] resources) { @@ -75,7 +76,6 @@ DreamResource GetResource() { resource = new DreamResource(resourceId, resourcePath, resourcePath); break; } - return resource; } diff --git a/OpenDreamShared/Network/Messages/MsgNotifyResourceUpdate.cs b/OpenDreamShared/Network/Messages/MsgNotifyResourceUpdate.cs new file mode 100644 index 0000000000..afb31dc67b --- /dev/null +++ b/OpenDreamShared/Network/Messages/MsgNotifyResourceUpdate.cs @@ -0,0 +1,20 @@ +using Lidgren.Network; +using Robust.Shared.Network; +using Robust.Shared.Serialization; + +namespace OpenDreamShared.Network.Messages; + +public sealed class MsgNotifyResourceUpdate : NetMessage { + public override MsgGroups MsgGroup => MsgGroups.EntityEvent; + + public int ResourceId; + + public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer) { + ResourceId = buffer.ReadInt32(); + } + + public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer) { + buffer.Write(ResourceId); + } +} + diff --git a/TestGame/TestInterface.dmf b/TestGame/TestInterface.dmf index 5fa3064637..21ce7cec35 100644 --- a/TestGame/TestInterface.dmf +++ b/TestGame/TestInterface.dmf @@ -209,4 +209,3 @@ window "wingetwindow" size = 0,20 text = "as raw" command = "wingettextverb \"raw: [[wingetinput.text as raw]]\"" - diff --git a/TestGame/code.dm b/TestGame/code.dm index e9811a0147..afb536f3ae 100644 --- a/TestGame/code.dm +++ b/TestGame/code.dm @@ -201,12 +201,18 @@ set name = "wingettextverb" world << "recieved: [rawtext]" - verb/test_hot_reload() + verb/test_hot_reload_interface() set category = "Test" src << "trying hot reload of interface..." world.ODHotReloadInterface() src << "done hot reload of interface!" + verb/test_hot_reload_icon() + set category = "Test" + src << "trying hot reload of icon..." + world.ODHotReloadResource("icons/turf.dmi") + src << "done hot reload of icon!" + verb/manipulate_world_size() var/x = input("New World X?", "World Size", 0) as num|null var/y = input("New World Y?", "World Size", 0) as num|null