diff --git a/Torch.API/Torch.API.csproj b/Torch.API/Torch.API.csproj
index 9bc7e421..86656d18 100644
--- a/Torch.API/Torch.API.csproj
+++ b/Torch.API/Torch.API.csproj
@@ -44,9 +44,8 @@
..\GameBinaries\HavokWrapper.dll
False
-
- ..\packages\Newtonsoft.Json.12.0.2\lib\net45\Newtonsoft.Json.dll
- True
+
+ ..\packages\Newtonsoft.Json.13.0.4\lib\net45\Newtonsoft.Json.dll
..\packages\NLog.4.4.12\lib\net45\NLog.dll
diff --git a/Torch.API/packages.config b/Torch.API/packages.config
index fbd3f147..ea61ec50 100644
--- a/Torch.API/packages.config
+++ b/Torch.API/packages.config
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/Torch.Server/Initializer.cs b/Torch.Server/Initializer.cs
index f7524005..09b9f051 100644
--- a/Torch.Server/Initializer.cs
+++ b/Torch.Server/Initializer.cs
@@ -8,18 +8,11 @@
using System.Reflection;
using System.Text;
using System.Threading;
-using System.Threading.Tasks;
using System.Windows;
-using System.Windows.Threading;
+using Microsoft.Win32;
using NLog;
-using NLog.Targets;
using Sandbox;
-using Sandbox.Engine.Utils;
-using Torch.Utils;
using VRage;
-using VRage.FileSystem;
-using VRage.Scripting;
-using VRage.Utils;
namespace Torch.Server
{
@@ -35,13 +28,222 @@ public class Initializer
private static readonly string STEAMCMD_PATH = $"{STEAMCMD_DIR}\\steamcmd.exe";
private static readonly string RUNSCRIPT_PATH = $"{STEAMCMD_DIR}\\runscript.txt";
- private const string RUNSCRIPT = @"force_install_dir ../
+ private const string RUNSCRIPT = @"force_install_dir ../game
login anonymous
-app_update 298740
+app_update 298740
quit";
private TorchServer _server;
private string _basePath;
+ private static string GetDedicatedServer64Path(string basePath) => Path.Combine(basePath, "game", "DedicatedServer64");
+
+ private void EnsureDedicatedServer64Symlink()
+ {
+ var targetPath = GetDedicatedServer64Path(_basePath);
+ var linkPath = Path.Combine(_basePath, "DedicatedServer64");
+
+ if (!Directory.Exists(targetPath))
+ {
+ Log.Warn($"Target DedicatedServer64 folder does not exist at {targetPath}, skipping symlink creation.");
+ return;
+ }
+
+ if (Directory.Exists(linkPath))
+ {
+ // Check if it's already a junction pointing to the correct target
+ var attr = File.GetAttributes(linkPath);
+ if ((attr & FileAttributes.ReparsePoint) != 0)
+ {
+ // It's a junction/symlink, we assume it's correct (could verify target but skip for simplicity)
+ Log.Info($"DedicatedServer64 junction already exists at {linkPath}");
+ return;
+ }
+ else
+ {
+ // It's a regular directory - we shouldn't replace it
+ Log.Warn($"DedicatedServer64 already exists as a regular directory at {linkPath}, not creating junction.");
+ return;
+ }
+ }
+
+ // Create junction using mklink
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "cmd.exe",
+ Arguments = $"/c mklink /J \"{linkPath}\" \"{targetPath}\"",
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+ try
+ {
+ using (var process = Process.Start(startInfo))
+ {
+ process.WaitForExit();
+ if (process.ExitCode == 0)
+ {
+ Log.Info($"Created DedicatedServer64 junction at {linkPath} pointing to {targetPath}");
+ }
+ else
+ {
+ var output = process.StandardOutput.ReadToEnd();
+ var error = process.StandardError.ReadToEnd();
+ Log.Warn($"Failed to create junction: {output} {error}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"Failed to create DedicatedServer64 junction: {ex.Message}");
+ }
+ }
+
+ private void CheckPrerequisites()
+ {
+ if (Environment.OSVersion.Platform != PlatformID.Win32NT)
+ {
+ Log.Info("Skipping prerequisite checks on non-Windows platform.");
+ return;
+ }
+
+ bool allOk = true;
+
+ // .NET Framework 4.8
+ if (!IsNetFramework48Installed())
+ {
+ Log.Error(".NET Framework 4.8 is not installed. Please install it from https://go.microsoft.com/fwlink/?LinkId=2085155");
+ Log.Error(
+ "Please visit Torch's Wiki - Installation page for more information at https://wiki.torchapi.com/en/installing-torch");
+ allOk = false;
+ }
+
+ // VC++ 2013 Redistributable (x64)
+ if (!IsVcRedist2013Installed())
+ {
+ Log.Error("Visual C++ 2013 Redistributable (x64) is not installed. Please install it from https://aka.ms/highdpimfc2013x64enu");
+ Log.Error("Please visit Torch's Wiki - Installation page for more information at https://wiki.torchapi.com/en/installing-torch");
+ allOk = false;
+ }
+
+ // VC++ 2019 Redistributable (x64)
+ if (!IsVcRedist2019Installed())
+ {
+ Log.Error("Visual C++ 2019 Redistributable (x64) is not installed. Please install it from https://aka.ms/vc14/vc_redist.x64.exe");
+ Log.Error("Please visit Torch's Wiki - Installation page for more information at https://wiki.torchapi.com/en/installing-torch");
+ allOk = false;
+ }
+
+ if (allOk)
+ Log.Info("All prerequisites satisfied.");
+ else
+ {
+ Log.Error("Prerequisites not satisfied. Please install the missing components and try again.");
+ Log.Error("Press any key to exit...");
+ Console.ReadKey();
+ Environment.Exit(1);
+ }
+ }
+
+ private static bool IsNetFramework48Installed()
+ {
+ try
+ {
+ using (var ndpKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full"))
+ {
+ if (ndpKey?.GetValue("Release") is int release)
+ return release >= 528040; // .NET 4.8
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ return false;
+ }
+
+ private static bool IsVcRedist2013Installed()
+ {
+ // Check registry keys for x64 and x86
+ string[] registryPaths = new[]
+ {
+ @"SOFTWARE\Microsoft\VisualStudio\12.0\VC\Runtimes\x64",
+ @"SOFTWARE\WOW6432Node\Microsoft\VisualStudio\12.0\VC\Runtimes\x64",
+ @"SOFTWARE\Microsoft\VisualStudio\12.0\VC\Runtimes\x86",
+ @"SOFTWARE\WOW6432Node\Microsoft\VisualStudio\12.0\VC\Runtimes\x86"
+ };
+ foreach (var path in registryPaths)
+ {
+ try
+ {
+ using (var vcKey = Registry.LocalMachine.OpenSubKey(path))
+ {
+ if (vcKey?.GetValue("Installed") is int installed && installed == 1)
+ return true;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+
+ // Fallback: check for presence of msvcp120.dll in system directory
+ string systemDir = Environment.GetFolderPath(Environment.SpecialFolder.System);
+ string dllPath = Path.Combine(systemDir, "msvcp120.dll");
+ if (File.Exists(dllPath))
+ return true;
+
+ // Also check vcruntime120.dll
+ dllPath = Path.Combine(systemDir, "vcruntime120.dll");
+ if (File.Exists(dllPath))
+ return true;
+
+ return false;
+ }
+
+ private static bool IsVcRedist2019Installed()
+ {
+ // Check registry keys for x64 and x86
+ string[] registryPaths = new[]
+ {
+ @"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64",
+ @"SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64",
+ @"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x86",
+ @"SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x86",
+ @"SOFTWARE\Microsoft\VisualStudio\14.2\VC\Runtimes\x64",
+ @"SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.2\VC\Runtimes\x64"
+ };
+ foreach (var path in registryPaths)
+ {
+ try
+ {
+ using (var vcKey = Registry.LocalMachine.OpenSubKey(path))
+ {
+ if (vcKey?.GetValue("Installed") is int installed && installed == 1)
+ return true;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+
+ // Fallback: check for presence of vcruntime140.dll in system directory
+ string systemDir = Environment.GetFolderPath(Environment.SpecialFolder.System);
+ string dllPath = Path.Combine(systemDir, "vcruntime140.dll");
+ if (File.Exists(dllPath))
+ return true;
+
+ // Also check msvcp140.dll
+ dllPath = Path.Combine(systemDir, "msvcp140.dll");
+ if (File.Exists(dllPath))
+ return true;
+
+ return false;
+ }
+
internal Persistent ConfigPersistent { get; private set; }
public TorchConfig Config => ConfigPersistent?.Data;
public TorchServer Server => _server;
@@ -64,7 +266,7 @@ public bool Initialize(string[] args)
#if !DEBUG
AppDomain.CurrentDomain.UnhandledException += HandleException;
- LogManager.Configuration.AddRule(LogLevel.Info, LogLevel.Fatal, "console");
+ // LogManager.Configuration.AddRule(LogLevel.Info, LogLevel.Fatal, "console"); This is a duplicate rule which already exists in Nlog.conf
LogManager.ReconfigExistingLoggers();
#endif
@@ -79,11 +281,17 @@ public bool Initialize(string[] args)
#endif
// This is what happens when Keen is bad and puts extensions into the System namespace.
+ CheckPrerequisites();
+
if (!Enumerable.Contains(args, "-noupdate"))
RunSteamCmd();
+ // Legacy/Plugin Dev Support
+ EnsureDedicatedServer64Symlink();
+
var basePath = new FileInfo(typeof(Program).Assembly.Location).Directory.ToString();
- var apiSource = Path.Combine(basePath, "DedicatedServer64", "steam_api64.dll");
+ var dedicatedServerPath = GetDedicatedServer64Path(basePath);
+ var apiSource = Path.Combine(dedicatedServerPath, "steam_api64.dll");
var apiTarget = Path.Combine(basePath, "steam_api64.dll");
if (!File.Exists(apiTarget))
@@ -101,37 +309,37 @@ public bool Initialize(string[] args)
var requiredVersion = new Version(9, 86, 62, 31);
// Steam Client 64-bit DLL
- var clientSource64 = Path.Combine(basePath, "DedicatedServer64", "steamclient64.dll");
+ var clientSource64 = Path.Combine(dedicatedServerPath, "steamclient64.dll");
var clientTarget64 = Path.Combine(basePath, "steamclient64.dll");
CopyAndVerifyDll(clientSource64, clientTarget64, requiredVersion);
// Steam Client 32-bit DLL
- var clientSource = Path.Combine(basePath, "DedicatedServer64", "steamclient.dll");
+ var clientSource = Path.Combine(dedicatedServerPath, "steamclient.dll");
var clientTarget = Path.Combine(basePath, "steamclient.dll");
CopyAndVerifyDll(clientSource, clientTarget, requiredVersion);
// tier0 64-bit DLL
- var tier0Source64 = Path.Combine(basePath, "DedicatedServer64", "tier0_s64.dll");
+ var tier0Source64 = Path.Combine(dedicatedServerPath, "tier0_s64.dll");
var tier0Target64 = Path.Combine(basePath, "tier0_s64.dll");
CopyAndVerifyDll(tier0Source64, tier0Target64, requiredVersion);
// tier0 32-bit DLL
- var tier0Source = Path.Combine(basePath, "DedicatedServer64", "tier0_s.dll");
+ var tier0Source = Path.Combine(dedicatedServerPath, "tier0_s.dll");
var tier0Target = Path.Combine(basePath, "tier0_s.dll");
CopyAndVerifyDll(tier0Source, tier0Target, requiredVersion);
// vstdlib 64-bit DLL
- var vstdlibSource64 = Path.Combine(basePath, "DedicatedServer64", "vstdlib_s64.dll");
+ var vstdlibSource64 = Path.Combine(dedicatedServerPath, "vstdlib_s64.dll");
var vstdlibTarget64 = Path.Combine(basePath, "vstdlib_s64.dll");
CopyAndVerifyDll(vstdlibSource64, vstdlibTarget64, requiredVersion);
// vstdlib 32-bit DLL
- var vstdlibSource = Path.Combine(basePath, "DedicatedServer64", "vstdlib_s.dll");
+ var vstdlibSource = Path.Combine(dedicatedServerPath, "vstdlib_s.dll");
var vstdlibTarget = Path.Combine(basePath, "vstdlib_s.dll");
CopyAndVerifyDll(vstdlibSource, vstdlibTarget, requiredVersion);
- var havokSource = Path.Combine(basePath, "DedicatedServer64", "Havok.dll");
+ var havokSource = Path.Combine(dedicatedServerPath, "Havok.dll");
var havokTarget = Path.Combine(basePath, "Havok.dll");
if (!File.Exists(havokTarget))
@@ -257,20 +465,74 @@ public static void RunSteamCmd()
}
log.Info("Checking for DS updates.");
- var steamCmdProc = new ProcessStartInfo(STEAMCMD_PATH, "+runscript runscript.txt")
- {
- WorkingDirectory = Path.Combine(Directory.GetCurrentDirectory(), STEAMCMD_DIR),
- UseShellExecute = false,
- RedirectStandardOutput = true,
- StandardOutputEncoding = Encoding.ASCII
- };
- var cmd = Process.Start(steamCmdProc);
- // ReSharper disable once PossibleNullReferenceException
- while (!cmd.HasExited)
+ const int maxAttempts = 3;
+ for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
- log.Info(cmd.StandardOutput.ReadLine());
- Thread.Sleep(100);
+ if (attempt > 1)
+ {
+ log.Info($"Retrying SteamCMD update (attempt {attempt})...");
+ log.Info("This may take awhile depending on your internet connection, please be patient.");
+ Thread.Sleep(3000); // brief delay before retry
+ }
+
+ var steamCmdProc = new ProcessStartInfo(STEAMCMD_PATH, "+runscript runscript.txt")
+ {
+ WorkingDirectory = Path.Combine(Directory.GetCurrentDirectory(), STEAMCMD_DIR),
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ StandardOutputEncoding = Encoding.ASCII,
+ StandardErrorEncoding = Encoding.ASCII
+ };
+ var cmd = Process.Start(steamCmdProc);
+ if (cmd == null)
+ {
+ log.Error("Failed to start SteamCMD process.");
+ continue;
+ }
+
+ // Read output and error asynchronously to avoid deadlocks
+ var output = new StringBuilder();
+ var error = new StringBuilder();
+ cmd.OutputDataReceived += (_, args) =>
+ {
+ if (args.Data != null)
+ {
+ log.Info(args.Data);
+ output.AppendLine(args.Data);
+ }
+ };
+ cmd.ErrorDataReceived += (_, args) =>
+ {
+ if (args.Data != null)
+ {
+ log.Error($"SteamCMD stderr: {args.Data}");
+ error.AppendLine(args.Data);
+ }
+ };
+ cmd.BeginOutputReadLine();
+ cmd.BeginErrorReadLine();
+
+ cmd.WaitForExit();
+ int exitCode = cmd.ExitCode;
+
+ // Ensure all events are processed
+ Thread.Sleep(500);
+
+ if (exitCode == 0)
+ {
+ log.Info("SteamCMD update completed successfully.");
+ return;
+ }
+
+ log.Warn($"SteamCMD exited with code {exitCode}. Output: {output}");
+ if (error.Length > 0)
+ log.Error($"SteamCMD errors: {error}");
+
+ // If this was the last attempt, break and let the caller continue (copy will fail later)
+ if (attempt == maxAttempts)
+ log.Error("SteamCMD update failed after all attempts. The DS files may be missing.");
}
}
diff --git a/Torch.Server/Managers/EntityControlManager.cs b/Torch.Server/Managers/EntityControlManager.cs
index 65d1e8fb..cf22458f 100644
--- a/Torch.Server/Managers/EntityControlManager.cs
+++ b/Torch.Server/Managers/EntityControlManager.cs
@@ -1,16 +1,11 @@
-using System;
+using System;
using System.Collections.Generic;
-using System.Linq;
-using System.Reflection;
using System.Runtime.CompilerServices;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
using System.Windows.Controls;
using NLog;
-using NLog.Fluent;
using Torch.API;
using Torch.Collections;
+using Torch.Collections.Concurrent;
using Torch.Managers;
using Torch.Server.ViewModels.Entities;
using Torch.Utils;
@@ -87,8 +82,8 @@ protected override EntityControlViewModel Create(EntityViewModel evm)
private readonly List _modelFactories = new List();
private readonly List _controlFactories = new List();
- private readonly List> _boundEntityViewModels = new List>();
- private readonly ConditionalWeakTable> _boundViewModels = new ConditionalWeakTable>();
+ private readonly ObservableConcurrentList> _boundEntityViewModels = new ObservableConcurrentList>();
+ private readonly ConditionalWeakTable> _boundViewModels = new ConditionalWeakTable>();
///
/// This factory will be used to create component models for matching entity models.
@@ -109,14 +104,14 @@ public void RegisterModelFactory(Func components))
+ _boundViewModels.TryGetValue(target, out ObservableConcurrentList components))
{
if (target is TEntityBaseModel tent)
UpdateBinding(target, components);
i++;
}
else
- _boundEntityViewModels.RemoveAtFast(i);
+ _boundEntityViewModels.RemoveAt(i);
}
}
}
@@ -190,7 +185,7 @@ private void RefreshControls() where TEntityComponentMode
while (i < _boundEntityViewModels.Count)
{
if (_boundEntityViewModels[i].TryGetTarget(out EntityViewModel target) &&
- _boundViewModels.TryGetValue(target, out MtObservableList components))
+ _boundViewModels.TryGetValue(target, out ObservableConcurrentList components))
{
foreach (EntityControlViewModel component in components)
if (component is TEntityComponentModel)
@@ -198,7 +193,7 @@ private void RefreshControls() where TEntityComponentMode
i++;
}
else
- _boundEntityViewModels.RemoveAtFast(i);
+ _boundEntityViewModels.RemoveAt(i);
}
}
@@ -207,7 +202,7 @@ private void RefreshControls() where TEntityComponentMode
///
/// view model to query
///
- public MtObservableList BoundModels(EntityViewModel entity)
+ public ObservableConcurrentList BoundModels(EntityViewModel entity)
{
return _boundViewModels.GetValue(entity, CreateFreshBinding);
}
@@ -231,9 +226,9 @@ public Control CreateControl(EntityControlViewModel model)
return null;
}
- private MtObservableList CreateFreshBinding(EntityViewModel key)
+ private ObservableConcurrentList CreateFreshBinding(EntityViewModel key)
{
- var binding = new MtObservableList();
+ var binding = new ObservableConcurrentList();
lock (this)
{
_boundEntityViewModels.Add(new WeakReference(key));
@@ -246,7 +241,7 @@ private MtObservableList CreateFreshBinding(EntityViewMo
return binding;
}
- private void UpdateBinding(EntityViewModel key, MtObservableList binding)
+ private void UpdateBinding(EntityViewModel key, ObservableConcurrentList binding)
{
if (!binding.IsObserved)
return;
diff --git a/Torch.Server/Managers/InstanceManager.cs b/Torch.Server/Managers/InstanceManager.cs
index 9a36675f..80c00e61 100644
--- a/Torch.Server/Managers/InstanceManager.cs
+++ b/Torch.Server/Managers/InstanceManager.cs
@@ -1,33 +1,21 @@
using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.ComponentModel;
using System.IO;
using System.Linq;
-using System.Reflection;
-using System.Text;
using System.Threading.Tasks;
-using Havok;
using NLog;
using Sandbox;
-using Sandbox.Engine.Networking;
using Sandbox.Engine.Utils;
-using Sandbox.Game;
-using Sandbox.Game.Gui;
using Torch.API;
-using Torch.API.Managers;
-using Torch.Collections;
+using Torch.Collections.Concurrent;
using Torch.Managers;
using Torch.Mod;
using Torch.Server.ViewModels;
using Torch.Utils;
-using VRage;
using VRage.FileSystem;
using VRage.Game;
-using VRage.Game.ObjectBuilder;
+using VRage.Game.ModAPI;
using VRage.ObjectBuilders;
using VRage.ObjectBuilders.Private;
-using VRage.Plugins;
namespace Torch.Server.Managers
{
@@ -52,7 +40,7 @@ public void LoadInstance(string path, bool validate = true)
ValidateInstance(path);
MyFileSystem.Reset();
- MyFileSystem.Init("Content", path);
+ MyFileSystem.Init("game/Content", path);
//Initializes saves path. Why this isn't in Init() we may never know.
MyFileSystem.InitUserSpecific(null);
@@ -106,6 +94,7 @@ public void SelectWorld(string worldPath, bool modsOnly = true)
}
DedicatedConfig.SelectedWorld = worldInfo;
+ DedicatedConfig.RefreshModel();
if (DedicatedConfig.SelectedWorld?.Checkpoint != null)
{
DedicatedConfig.Mods.Clear();
@@ -114,6 +103,7 @@ public void SelectWorld(string worldPath, bool modsOnly = true)
foreach (var m in DedicatedConfig.SelectedWorld.WorldConfiguration.Mods)
DedicatedConfig.Mods.Add(new ModItemInfo(m));
Task.Run(() => DedicatedConfig.UpdateAllModInfosAsync());
+ DedicatedConfig.RefreshModel();
}
}
@@ -121,6 +111,7 @@ public void SelectWorld(WorldViewModel world, bool modsOnly = true)
{
DedicatedConfig.LoadWorld = world.WorldPath;
DedicatedConfig.SelectedWorld = world;
+ DedicatedConfig.RefreshModel();
if (DedicatedConfig.SelectedWorld?.Checkpoint != null)
{
DedicatedConfig.Mods.Clear();
@@ -129,6 +120,7 @@ public void SelectWorld(WorldViewModel world, bool modsOnly = true)
foreach (var m in DedicatedConfig.SelectedWorld.WorldConfiguration.Mods)
DedicatedConfig.Mods.Add(new ModItemInfo(m));
Task.Run(() => DedicatedConfig.UpdateAllModInfosAsync());
+ DedicatedConfig.RefreshModel();
}
}
@@ -139,16 +131,44 @@ public void ImportSelectedWorldConfig()
private void ImportWorldConfig(WorldViewModel world, bool modsOnly = true)
{
- var mods = new MtObservableList();
+
+ var mods = new ObservableConcurrentList();
foreach (var mod in world.WorldConfiguration.Mods)
mods.Add(new ModItemInfo(mod));
DedicatedConfig.Mods = mods;
-
Log.Debug("Loaded mod list from world");
if (!modsOnly)
DedicatedConfig.SessionSettings = world.WorldConfiguration.Settings;
+
+ // Update left pane fields from world checkpoint
+ if (world.Checkpoint != null)
+ {
+ DedicatedConfig.WorldName = world.Checkpoint.SessionName;
+ DedicatedConfig.ServerDescription = world.Checkpoint.Description;
+ DedicatedConfig.ServerName = world.Checkpoint.SessionName;
+ if (!string.IsNullOrEmpty(world.Checkpoint.Password))
+ DedicatedConfig.Password = world.Checkpoint.Password;
+ // Update administrators from promoted users
+ if (world.Checkpoint.PromotedUsers != null)
+ {
+ var adminIds = world.Checkpoint.PromotedUsers.Dictionary
+ .Where(kvp => kvp.Value >= MyPromoteLevel.Admin)
+ .Select(kvp => kvp.Key.ToString())
+ .ToList();
+ DedicatedConfig.Administrators = adminIds;
+ }
+ }
+
+ // Ensure LoadWorld is set to this world's path
+ DedicatedConfig.LoadWorld = world.WorldPath;
+
+ // Make sure the config uses LoadWorld instead of last session
+ if (DedicatedConfig.Model is MyConfigDedicated config)
+ config.IgnoreLastSession = true;
+
+ DedicatedConfig.RefreshModel();
}
private void ImportWorldConfig(bool modsOnly = true)
@@ -170,7 +190,7 @@ private void ImportWorldConfig(bool modsOnly = true)
return;
}
- var mods = new MtObservableList();
+ var mods = new ObservableConcurrentList();
foreach (var mod in checkpoint.Mods)
mods.Add(new ModItemInfo(mod));
DedicatedConfig.Mods = mods;
@@ -179,6 +199,28 @@ private void ImportWorldConfig(bool modsOnly = true)
if (!modsOnly)
DedicatedConfig.SessionSettings = new SessionSettingsViewModel(checkpoint.Settings);
+
+ // Update left pane fields from world checkpoint
+ DedicatedConfig.WorldName = checkpoint.SessionName;
+ DedicatedConfig.ServerDescription = checkpoint.Description;
+ DedicatedConfig.ServerName = checkpoint.SessionName;
+ if (!string.IsNullOrEmpty(checkpoint.Password))
+ DedicatedConfig.Password = checkpoint.Password;
+ // Update administrators from promoted users
+ if (checkpoint.PromotedUsers != null)
+ {
+ var adminIds = checkpoint.PromotedUsers.Dictionary
+ .Where(kvp => kvp.Value >= MyPromoteLevel.Admin)
+ .Select(kvp => kvp.Key.ToString())
+ .ToList();
+ DedicatedConfig.Administrators = adminIds;
+ }
+
+ // Make sure the config uses LoadWorld instead of last session
+ if (DedicatedConfig.Model is MyConfigDedicated config)
+ config.IgnoreLastSession = true;
+
+ DedicatedConfig.RefreshModel();
}
catch (Exception e)
{
diff --git a/Torch.Server/Program.cs b/Torch.Server/Program.cs
index 0f163f3e..9385ed34 100644
--- a/Torch.Server/Program.cs
+++ b/Torch.Server/Program.cs
@@ -27,7 +27,7 @@ public static void Main(string[] args)
Target.Register("FlowDocument");
//Ensures that all the files are downloaded in the Torch directory.
var workingDir = new FileInfo(typeof(Program).Assembly.Location).Directory.ToString();
- var binDir = Path.Combine(workingDir, "DedicatedServer64");
+ var binDir = Path.Combine(workingDir, "game", "DedicatedServer64");
Directory.SetCurrentDirectory(workingDir);
//HACK for block skins update
diff --git a/Torch.Server/Torch.Server.csproj b/Torch.Server/Torch.Server.csproj
index f33fad8e..88d45c05 100644
--- a/Torch.Server/Torch.Server.csproj
+++ b/Torch.Server/Torch.Server.csproj
@@ -92,9 +92,8 @@
..\GameBinaries\netstandard.dll
-
- ..\packages\Newtonsoft.Json.12.0.2\lib\net45\Newtonsoft.Json.dll
- True
+
+ ..\packages\Newtonsoft.Json.13.0.4\lib\net45\Newtonsoft.Json.dll
..\packages\NLog.4.4.12\lib\net45\NLog.dll
diff --git a/Torch.Server/ViewModels/ConfigDedicatedViewModel.cs b/Torch.Server/ViewModels/ConfigDedicatedViewModel.cs
index a417eef6..55ab235a 100644
--- a/Torch.Server/ViewModels/ConfigDedicatedViewModel.cs
+++ b/Torch.Server/ViewModels/ConfigDedicatedViewModel.cs
@@ -1,17 +1,13 @@
using System;
using System.Collections.Generic;
-using System.Collections.ObjectModel;
using System.Linq;
-using System.Text;
using System.Threading.Tasks;
using NLog;
using Sandbox.Engine.Utils;
-using Torch.Collections;
using Torch.Server.Managers;
using VRage.Game;
-using VRage.Game.ModAPI;
using Torch.Utils.SteamWorkshopTools;
-using Torch.Collections;
+using Torch.Collections.Concurrent;
namespace Torch.Server.ViewModels
{
@@ -64,7 +60,7 @@ public bool Validate()
private SessionSettingsViewModel _sessionSettings;
public SessionSettingsViewModel SessionSettings { get => _sessionSettings; set { _sessionSettings = value; OnPropertyChanged(); } }
- public MtObservableList Worlds { get; } = new MtObservableList();
+ public ObservableConcurrentList Worlds { get; } = new ObservableConcurrentList();
private WorldViewModel _selectedWorld;
public WorldViewModel SelectedWorld
{
@@ -118,8 +114,8 @@ public async Task UpdateAllModInfosAsync(Action messageHandler = null)
public List Banned { get => _config.Banned; set => SetValue(x => _config.Banned = x, value); }
- private MtObservableList _mods = new MtObservableList();
- public MtObservableList Mods
+ private ObservableConcurrentList _mods = new ObservableConcurrentList();
+ public ObservableConcurrentList Mods
{
get => _mods;
set
diff --git a/Torch.Server/ViewModels/Entities/Blocks/BlockViewModel.cs b/Torch.Server/ViewModels/Entities/Blocks/BlockViewModel.cs
index e93eaac1..7e6a454f 100644
--- a/Torch.Server/ViewModels/Entities/Blocks/BlockViewModel.cs
+++ b/Torch.Server/ViewModels/Entities/Blocks/BlockViewModel.cs
@@ -10,6 +10,7 @@
using Sandbox.ModAPI;
using Sandbox.ModAPI.Interfaces;
using Torch.Collections;
+using Torch.Collections.Concurrent;
using Torch.Server.ViewModels.Entities;
using VRage.Game.ModAPI;
@@ -18,7 +19,7 @@ namespace Torch.Server.ViewModels.Blocks
public class BlockViewModel : EntityViewModel
{
public IMyTerminalBlock Block => (IMyTerminalBlock) Entity;
- public MtObservableList Properties { get; } = new MtObservableList();
+ public ObservableConcurrentList Properties { get; } = new ObservableConcurrentList();
public string FullName => $"{Block?.CubeGrid.CustomName} - {Block?.CustomName}";
diff --git a/Torch.Server/ViewModels/Entities/EntityViewModel.cs b/Torch.Server/ViewModels/Entities/EntityViewModel.cs
index ada41ad6..0651b875 100644
--- a/Torch.Server/ViewModels/Entities/EntityViewModel.cs
+++ b/Torch.Server/ViewModels/Entities/EntityViewModel.cs
@@ -3,7 +3,7 @@
using NLog;
using Sandbox.Game.Entities;
using Torch.API.Managers;
-using Torch.Collections;
+using Torch.Collections.Concurrent;
using Torch.Server.Managers;
using Torch.Utils;
using VRage.ModAPI;
@@ -33,7 +33,7 @@ private set
public long Id => Entity?.EntityId ?? 0; // Throws null then gives entity id
- public MtObservableList EntityControls { get; private set; }
+ public ObservableConcurrentList EntityControls { get; private set; }
public virtual string Name
{
diff --git a/Torch.Server/ViewModels/Entities/GridViewModel.cs b/Torch.Server/ViewModels/Entities/GridViewModel.cs
index f6886544..1d914339 100644
--- a/Torch.Server/ViewModels/Entities/GridViewModel.cs
+++ b/Torch.Server/ViewModels/Entities/GridViewModel.cs
@@ -6,7 +6,7 @@
using Sandbox.Game.Entities.Cube;
using Sandbox.Game.World;
using SharpDX.Toolkit.Collections;
-using Torch.Collections;
+using Torch.Collections.Concurrent;
using Torch.Server.ViewModels.Blocks;
using VRage.Game;
@@ -45,10 +45,9 @@ public int Compare(MyCubeBlockDefinition x, MyCubeBlockDefinition y)
private MyCubeGrid Grid => (MyCubeGrid) Entity;
- public MtObservableSortedDictionary>
+ public ObservableConcurrentDictionary>
Blocks { get; } =
- new MtObservableSortedDictionary>(
- CubeBlockDefinitionComparer.Default);
+ new ObservableConcurrentDictionary>();
public GridViewModel()
{
@@ -58,7 +57,7 @@ public GridViewModel()
public GridViewModel(MyCubeGrid grid, EntityTreeViewModel tree) : base(grid, tree)
{
//DescriptiveName = $"{grid.DisplayName} ({grid.BlocksCount} blocks)";
- Blocks.Add(_fillerDefinition, new MtObservableSortedDictionary());
+ Blocks.Add(_fillerDefinition, new ObservableConcurrentDictionary());
}
private void Grid_OnBlockRemoved(MySlimBlock obj)
@@ -99,7 +98,7 @@ private void AddBlock(MyTerminalBlock block)
try
{
if (!Blocks.TryGetValue(block.BlockDefinition, out var group))
- group = Blocks[block.BlockDefinition] = new MtObservableSortedDictionary();
+ group = Blocks[block.BlockDefinition] = new ObservableConcurrentDictionary();
group.Add(block.EntityId, new BlockViewModel(block, Tree));
long ownerId = block.OwnerId;
diff --git a/Torch.Server/ViewModels/Entities/VoxelMapViewModel.cs b/Torch.Server/ViewModels/Entities/VoxelMapViewModel.cs
index bec7847e..5e0ab501 100644
--- a/Torch.Server/ViewModels/Entities/VoxelMapViewModel.cs
+++ b/Torch.Server/ViewModels/Entities/VoxelMapViewModel.cs
@@ -5,6 +5,7 @@
using VRage.Game.ModAPI;
using System.Threading.Tasks;
using Torch.Collections;
+using Torch.Collections.Concurrent;
namespace Torch.Server.ViewModels.Entities
{
@@ -16,7 +17,7 @@ public class VoxelMapViewModel : EntityViewModel
public override bool CanStop => false;
- public MtObservableList AttachedGrids { get; } = new MtObservableList();
+ public ObservableConcurrentList AttachedGrids { get; } = new ObservableConcurrentList();
public async Task UpdateAttachedGrids()
{
diff --git a/Torch.Server/ViewModels/EntityTreeViewModel.cs b/Torch.Server/ViewModels/EntityTreeViewModel.cs
index c3d34c64..46d328cf 100644
--- a/Torch.Server/ViewModels/EntityTreeViewModel.cs
+++ b/Torch.Server/ViewModels/EntityTreeViewModel.cs
@@ -11,7 +11,7 @@
using Torch.API;
using Torch.API.Managers;
using Torch.API.Session;
-using Torch.Collections;
+using Torch.Collections.Concurrent;
using VRage.Game.ModAPI;
using PlayerViewModel = Torch.Server.ViewModels.Entities.PlayerViewModel;
@@ -31,21 +31,21 @@ public enum SortEnum
private static readonly Logger _log = LogManager.GetCurrentClassLogger();
//TODO: these should be sorted sets for speed
- public MtObservableSortedDictionary Grids { get; set; } = new MtObservableSortedDictionary();
- public MtObservableSortedDictionary Characters { get; set; } = new MtObservableSortedDictionary();
- public MtObservableSortedDictionary FloatingObjects { get; set; } = new MtObservableSortedDictionary();
- public MtObservableSortedDictionary VoxelMaps { get; set; } = new MtObservableSortedDictionary();
- public MtObservableSortedDictionary Players { get; set; } = new MtObservableSortedDictionary();
- public MtObservableSortedDictionary Factions { get; set; } = new MtObservableSortedDictionary();
+ public ObservableConcurrentDictionary Grids { get; set; } = new ObservableConcurrentDictionary();
+ public ObservableConcurrentDictionary Characters { get; set; } = new ObservableConcurrentDictionary();
+ public ObservableConcurrentDictionary FloatingObjects { get; set; } = new ObservableConcurrentDictionary();
+ public ObservableConcurrentDictionary VoxelMaps { get; set; } = new ObservableConcurrentDictionary();
+ public ObservableConcurrentDictionary Players { get; set; } = new ObservableConcurrentDictionary();
+ public ObservableConcurrentDictionary Factions { get; set; } = new ObservableConcurrentDictionary();
public Dispatcher ControlDispatcher => _control.Dispatcher;
- public SortedView SortedGrids { get; }
- public SortedView FilteredSortedGrids { get; }
- public SortedView SortedCharacters { get; }
- public SortedView SortedFloatingObjects { get; }
- public SortedView SortedVoxelMaps { get; }
- public SortedView SortedPlayers { get; }
- public SortedView SortedFactions { get; }
+ public ObservableConcurrentSortedList SortedGrids { get; }
+ public ObservableConcurrentSortedList FilteredSortedGrids { get; }
+ public ObservableConcurrentSortedList SortedCharacters { get; }
+ public ObservableConcurrentSortedList SortedFloatingObjects { get; }
+ public ObservableConcurrentSortedList SortedVoxelMaps { get; }
+ public ObservableConcurrentSortedList SortedPlayers { get; }
+ public ObservableConcurrentSortedList SortedFactions { get; }
private EntityViewModel _currentEntity;
private SortEnum _currentSort;
@@ -60,7 +60,11 @@ public EntityViewModel CurrentEntity
public SortEnum CurrentSort
{
get => _currentSort;
- set => SetValue(ref _currentSort, value);
+ set
+ {
+ SetValue(ref _currentSort, value);
+ UpdateSortComparer();
+ }
}
// Westin miller still hates you today WPF
@@ -72,16 +76,16 @@ public EntityTreeViewModel(UserControl control, ITorchServer server)
{
_control = control;
var entityComparer = new EntityViewModel.Comparer(_currentSort);
- SortedGrids = new SortedView(Grids.Values, entityComparer);
- FilteredSortedGrids = new SortedView(Grids.Values, entityComparer);
- SortedCharacters = new SortedView(Characters.Values, entityComparer);
- SortedFloatingObjects = new SortedView(FloatingObjects.Values, entityComparer);
- SortedVoxelMaps = new SortedView(VoxelMaps.Values, entityComparer);
- SortedPlayers = new SortedView(Players.Values, Comparer
+ SortedGrids = new ObservableConcurrentSortedList(Grids.Values, entityComparer);
+ FilteredSortedGrids = new ObservableConcurrentSortedList(Grids.Values, entityComparer);
+ SortedCharacters = new ObservableConcurrentSortedList(Characters.Values, entityComparer);
+ SortedFloatingObjects = new ObservableConcurrentSortedList(FloatingObjects.Values, entityComparer);
+ SortedVoxelMaps = new ObservableConcurrentSortedList(VoxelMaps.Values, entityComparer);
+ SortedPlayers = new ObservableConcurrentSortedList(Players.Values, Comparer
.Create((x, y) =>
string.Compare(x?.Name, y?.Name, StringComparison.InvariantCultureIgnoreCase))
);
- SortedFactions = new SortedView(Factions.Values, Comparer
+ SortedFactions = new ObservableConcurrentSortedList(Factions.Values, Comparer
.Create((x, y) =>
string.Compare(x?.Name, y?.Name, StringComparison.InvariantCultureIgnoreCase))
);
@@ -93,6 +97,16 @@ public EntityTreeViewModel(UserControl control, ITorchServer server)
}
}
+ private void UpdateSortComparer()
+ {
+ var comparer = new EntityViewModel.Comparer(_currentSort);
+ SortedGrids.SetComparer(comparer);
+ FilteredSortedGrids.SetComparer(comparer);
+ SortedCharacters.SetComparer(comparer);
+ SortedFloatingObjects.SetComparer(comparer);
+ SortedVoxelMaps.SetComparer(comparer);
+ }
+
private void RegisterLiveNonEntities(ITorchSession session, TorchSessionState newState)
{
switch (newState)
@@ -105,12 +119,12 @@ private void RegisterLiveNonEntities(ITorchSession session, TorchSessionState ne
if (player is null) continue;
if (Players.ContainsKey(player.IdentityId)) continue;
- Players.Add(new KeyValuePair(player.IdentityId, new PlayerViewModel(player, identity)));
+ Players.Add(player.IdentityId, new PlayerViewModel(player, identity));
}
foreach (MyFaction faction in MySession.Static.Factions.GetAllFactions())
{
- Factions.Add(new KeyValuePair(faction.FactionId, new FactionViewModel(faction)));
+ Factions.Add(faction.FactionId, new FactionViewModel(faction));
}
Sync.Players.RealPlayerIdentityCreated += NewPlayerCreated;
@@ -121,7 +135,9 @@ private void RegisterLiveNonEntities(ITorchSession session, TorchSessionState ne
case TorchSessionState.Unloading:
Sync.Players.RealPlayerIdentityCreated -= NewPlayerCreated;
MySession.Static.Factions.FactionCreated -= NewFactionCreated;
+ MySession.Static.Factions.FactionStateChanged -= FactionChanged;
Players.Clear();
+ Factions.Clear();
break;
}
}
@@ -155,7 +171,7 @@ private void NewFactionCreated(long id)
{
var faction = MySession.Static.Factions.GetPlayerFaction(id);
if (faction is null) return;
- Factions.Add(new KeyValuePair(faction.FactionId, new FactionViewModel(faction)));
+ Factions.Add(faction.FactionId, new FactionViewModel(faction));
});
}
@@ -163,7 +179,7 @@ private void NewPlayerCreated(long identityId)
{
var player = MySession.Static.Players.TryGetPlayer(identityId);
if (player is null) return;
- Players.Add(new KeyValuePair(player.Identity.IdentityId, new PlayerViewModel(player.Identity, new MyPlayer.PlayerId())));
+ Players.Add(player.Identity.IdentityId, new PlayerViewModel(player.Identity, player.Id));
}
public void Init()
diff --git a/Torch.Server/ViewModels/PluginManagerViewModel.cs b/Torch.Server/ViewModels/PluginManagerViewModel.cs
index 48ecd76a..96761d7f 100644
--- a/Torch.Server/ViewModels/PluginManagerViewModel.cs
+++ b/Torch.Server/ViewModels/PluginManagerViewModel.cs
@@ -1,18 +1,14 @@
-using System;
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using Torch.API;
using Torch.API.Managers;
using Torch.API.Plugins;
-using Torch.Collections;
+using Torch.Collections.Concurrent;
namespace Torch.Server.ViewModels
{
public class PluginManagerViewModel : ViewModel
{
- public MtObservableList Plugins { get; } = new MtObservableList();
+ public ObservableConcurrentList Plugins { get; } = new ObservableConcurrentList();
private PluginViewModel _selectedPlugin;
public PluginViewModel SelectedPlugin
diff --git a/Torch.Server/Views/ChatControl.xaml b/Torch.Server/Views/ChatControl.xaml
index 115f0063..aa617d4e 100644
--- a/Torch.Server/Views/ChatControl.xaml
+++ b/Torch.Server/Views/ChatControl.xaml
@@ -10,7 +10,12 @@
-
+
diff --git a/Torch.Server/Views/Converters/ModToIdConverter.cs b/Torch.Server/Views/Converters/ModToIdConverter.cs
index e8781404..2e0aff0b 100644
--- a/Torch.Server/Views/Converters/ModToIdConverter.cs
+++ b/Torch.Server/Views/Converters/ModToIdConverter.cs
@@ -9,6 +9,7 @@
using Torch.Server.ViewModels;
using NLog;
using Torch.Collections;
+using Torch.Collections.Concurrent;
namespace Torch.Server.Views.Converters
{
@@ -32,7 +33,7 @@ public object Convert(object[] values, Type targetType, object parameter, Cultur
{
//if (targetType != typeof(int))
// throw new NotSupportedException("ModToIdConverter can only convert mods into int values or vise versa!");
- if (values[0] is ModItemInfo mod && values[1] is MtObservableList modList)
+ if (values[0] is ModItemInfo mod && values[1] is ObservableConcurrentList modList)
{
return modList.IndexOf(mod);
}
diff --git a/Torch.Server/Views/LogEventViewer.xaml b/Torch.Server/Views/LogEventViewer.xaml
index 51304923..8385b638 100644
--- a/Torch.Server/Views/LogEventViewer.xaml
+++ b/Torch.Server/Views/LogEventViewer.xaml
@@ -28,7 +28,18 @@
-
+
diff --git a/Torch.Server/Views/ModListControl.xaml.cs b/Torch.Server/Views/ModListControl.xaml.cs
index 16b93640..7adb9157 100644
--- a/Torch.Server/Views/ModListControl.xaml.cs
+++ b/Torch.Server/Views/ModListControl.xaml.cs
@@ -1,32 +1,23 @@
using System;
using System.Collections.Generic;
-using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
-using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
-using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using System.Windows.Navigation;
-using System.Windows.Shapes;
using System.Runtime.CompilerServices;
-using System.Windows.Threading;
-using VRage.Game;
using NLog;
-using Sandbox.Engine.Networking;
using Torch.API;
using Torch.Server.Managers;
using Torch.API.Managers;
using Torch.Server.ViewModels;
using Torch.Server.Annotations;
-using Torch.Collections;
+using Torch.Collections.Concurrent;
using Torch.Utils;
using Torch.Views;
@@ -82,9 +73,9 @@ private void _instanceManager_InstanceLoaded(ConfigDedicatedViewModel obj)
{
Log.Info("Instance loaded.");
Dispatcher.Invoke(() => {
- DataContext = obj?.Mods ?? new MtObservableList();
+ DataContext = obj?.Mods ?? new ObservableConcurrentList();
UpdateLayout();
- ((MtObservableList)DataContext).CollectionChanged += OnModlistUpdate;
+ ((ObservableConcurrentList)DataContext).CollectionChanged += OnModlistUpdate;
});
}
@@ -127,7 +118,7 @@ private void AddBtn_OnClick(object sender, RoutedEventArgs e)
}
private void RemoveBtn_OnClick(object sender, RoutedEventArgs e)
{
- var modList = ((MtObservableList)DataContext);
+ var modList = ((ObservableConcurrentList)DataContext);
if (ModList.SelectedItem is ModItemInfo mod && modList.Contains(mod))
modList.Remove(mod);
}
@@ -213,7 +204,7 @@ private void UserControl_MouseMove(object sender, MouseEventArgs e)
if( targetMod != null && !ReferenceEquals(_draggedMod, targetMod))
{
_hasOrderChanged = true;
- var modList = (MtObservableList)DataContext;
+ var modList = (ObservableConcurrentList)DataContext;
modList.Move(modList.IndexOf(targetMod), _draggedMod);
//modList.RemoveAt(modList.IndexOf(_draggedMod));
//modList.Insert(modList.IndexOf(targetMod), _draggedMod);
@@ -260,7 +251,7 @@ private void BulkButton_OnClick(object sender, RoutedEventArgs e)
var editor = new CollectionEditor();
//let's see just how poorly we can do this
- var modList = ((MtObservableList)DataContext).ToList();
+ var modList = ((ObservableConcurrentList)DataContext).ToList();
var idList = modList.Select(m => m.ToString()).ToList();
var tasks = new List();
//blocking
diff --git a/Torch.Server/Views/ModsControl.xaml b/Torch.Server/Views/ModsControl.xaml
index bcaba5f3..9bb00b1f 100644
--- a/Torch.Server/Views/ModsControl.xaml
+++ b/Torch.Server/Views/ModsControl.xaml
@@ -6,7 +6,15 @@
mc:Ignorable="d">
-
+
@@ -14,8 +22,8 @@
-
-
+
+
diff --git a/Torch.Server/Views/PluginBrowser.xaml.cs b/Torch.Server/Views/PluginBrowser.xaml.cs
index 761e3331..b7d10eff 100644
--- a/Torch.Server/Views/PluginBrowser.xaml.cs
+++ b/Torch.Server/Views/PluginBrowser.xaml.cs
@@ -1,27 +1,18 @@
using System;
-using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
-using System.Net;
-using System.Net.Http;
using System.Runtime.CompilerServices;
-using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
-using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using System.Windows.Shapes;
-using Newtonsoft.Json;
using NLog;
using Torch.API.WebAPI;
-using Torch.Collections;
+using Torch.Collections.Concurrent;
using Torch.Server.Annotations;
-using Torch.Managers;
using Torch.API.Managers;
namespace Torch.Server.Views
@@ -33,8 +24,8 @@ public partial class PluginBrowser : Window, INotifyPropertyChanged
{
private static Logger Log = LogManager.GetCurrentClassLogger();
- public MtObservableList PluginsSource { get; set; } = new MtObservableList();
- public MtObservableList Plugins { get; set; } = new MtObservableList();
+ public ObservableConcurrentList PluginsSource { get; set; } = new ObservableConcurrentList();
+ public ObservableConcurrentList Plugins { get; set; } = new ObservableConcurrentList();
public PluginItem CurrentItem { get; set; }
public const string PLUGINS_SEARCH_TEXT = "Plugins search...";
private string PreviousSearchQuery = "";
diff --git a/Torch.Server/Views/WorldGeneratorDialog.xaml.cs b/Torch.Server/Views/WorldGeneratorDialog.xaml.cs
index 9854a761..e0a2650d 100644
--- a/Torch.Server/Views/WorldGeneratorDialog.xaml.cs
+++ b/Torch.Server/Views/WorldGeneratorDialog.xaml.cs
@@ -46,7 +46,7 @@ public WorldGeneratorDialog(InstanceManager instanceManager)
InitializeComponent();
_loadLocalization();
- string worldsDir = Path.Combine(MyFileSystem.ContentPath, "CustomWorlds");
+ string worldsDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "game", "Content", "CustomWorlds");
var result = new List>();
GetWorldInfo(worldsDir, result);
@@ -93,6 +93,9 @@ private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
// Trash code to work around inconsistent path formats.
var fileRelPath = file.Replace($"{_currentItem.Path.TrimEnd('\\')}\\", "");
var destPath = Path.Combine(worldPath, fileRelPath);
+ var destDir = Path.GetDirectoryName(destPath);
+ if (destDir != null && !Directory.Exists(destDir))
+ Directory.CreateDirectory(destDir);
File.Copy(file, destPath);
}
@@ -103,6 +106,7 @@ private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
_instanceManager.SelectWorld(worldPath, false);
_instanceManager.ImportSelectedWorldConfig();
+ _instanceManager.DedicatedConfig.SelectedWorld?.SaveSandbox();
Close();
}
diff --git a/Torch.Server/app.config b/Torch.Server/app.config
index 3e0e37cf..09bfa471 100644
--- a/Torch.Server/app.config
+++ b/Torch.Server/app.config
@@ -1,3 +1,12 @@
-
+
+
+
+
+
+
+
+
+
+
diff --git a/Torch.Server/packages.config b/Torch.Server/packages.config
index e04f6688..bf9148b7 100644
--- a/Torch.Server/packages.config
+++ b/Torch.Server/packages.config
@@ -5,7 +5,7 @@
-
+
diff --git a/Torch.sln b/Torch.sln
index a2d19a96..2573942c 100644
--- a/Torch.sln
+++ b/Torch.sln
@@ -45,6 +45,7 @@ Global
{7E01635C-3B67-472E-BCD6-C5539564F214}.Release|x64.ActiveCfg = Release|x64
{7E01635C-3B67-472E-BCD6-C5539564F214}.Release|x64.Build.0 = Release|x64
{7E01635C-3B67-472E-BCD6-C5539564F214}.Debug|Any CPU.Build.0 = Debug|x64
+ {7E01635C-3B67-472E-BCD6-C5539564F214}.Release|Any CPU.Build.0 = Release|x64
{FBA5D932-6254-4A1E-BAF4-E229FA94E3C2}.Debug|Any CPU.ActiveCfg = Debug|x64
{FBA5D932-6254-4A1E-BAF4-E229FA94E3C2}.Debug|x64.ActiveCfg = Debug|x64
{FBA5D932-6254-4A1E-BAF4-E229FA94E3C2}.Debug|x64.Build.0 = Debug|x64
@@ -52,6 +53,7 @@ Global
{FBA5D932-6254-4A1E-BAF4-E229FA94E3C2}.Release|x64.ActiveCfg = Release|x64
{FBA5D932-6254-4A1E-BAF4-E229FA94E3C2}.Release|x64.Build.0 = Release|x64
{FBA5D932-6254-4A1E-BAF4-E229FA94E3C2}.Debug|Any CPU.Build.0 = Debug|x64
+ {FBA5D932-6254-4A1E-BAF4-E229FA94E3C2}.Release|Any CPU.Build.0 = Release|x64
{CA50886B-7B22-4CD8-93A0-C06F38D4F77D}.Debug|Any CPU.ActiveCfg = Debug|x64
{CA50886B-7B22-4CD8-93A0-C06F38D4F77D}.Debug|x64.ActiveCfg = Debug|x64
{CA50886B-7B22-4CD8-93A0-C06F38D4F77D}.Debug|x64.Build.0 = Debug|x64
diff --git a/Torch/Collections/Concurrent/DispatcherObservableEvent.cs b/Torch/Collections/Concurrent/DispatcherObservableEvent.cs
new file mode 100644
index 00000000..dd6ea8ec
--- /dev/null
+++ b/Torch/Collections/Concurrent/DispatcherObservableEvent.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Reflection;
+using System.Threading;
+using System.Windows.Threading;
+using System.Xml.Serialization;
+using Newtonsoft.Json;
+
+namespace Torch.Collections.Concurrent
+{
+ ///
+ /// Dispatches event handlers on the dispatcher thread they were subscribed from.
+ ///
+ ///
+ /// Serialization methods only capture observer counts and do not persist handlers.
+ ///
+ /// Event args type.
+ /// Event handler delegate type.
+ internal sealed class DispatcherObservableEvent where TEvtArgs : EventArgs
+ {
+ private delegate void InvokeHandler(TEvtHandle handler, object sender, TEvtArgs args);
+
+ private static readonly InvokeHandler _invokeDirectly;
+
+ static DispatcherObservableEvent()
+ {
+ MethodInfo invoke = typeof(TEvtHandle).GetMethod("Invoke", BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly);
+ Debug.Assert(invoke != null, "No invoke method on handler type");
+ if (invoke != null) _invokeDirectly = (InvokeHandler)Delegate.CreateDelegate(typeof(InvokeHandler), invoke);
+ }
+
+ private event EventHandler Event;
+
+ private int _observerCount;
+
+ public bool IsObserved => _observerCount > 0;
+
+ public void Raise(object sender, TEvtArgs args)
+ {
+ Event?.Invoke(sender, args);
+ }
+
+ public void Add(TEvtHandle handler)
+ {
+ if (handler == null)
+ return;
+ Interlocked.Increment(ref _observerCount);
+ Event += new DispatcherDelegate(handler).Invoke;
+ }
+
+ public void Remove(TEvtHandle handler)
+ {
+ if (Event == null || handler == null)
+ return;
+
+ Delegate[] invocationList = Event.GetInvocationList();
+ for (int i = invocationList.Length - 1; i >= 0; i--)
+ {
+ var wrapper = (DispatcherDelegate)invocationList[i].Target;
+ if (wrapper.Handler.Equals(handler))
+ {
+ Event -= wrapper.Invoke;
+ Interlocked.Decrement(ref _observerCount);
+ return;
+ }
+ }
+ }
+
+ internal int ObserverCount => _observerCount;
+
+ ///
+ /// Serializes observer count to XML for diagnostics.
+ ///
+ internal string SerializeToXml()
+ {
+ XmlSerializer serializer = new XmlSerializer(typeof(DispatcherObservableEventSnapshot));
+ using StringWriter sw = new StringWriter();
+ serializer.Serialize(sw, new DispatcherObservableEventSnapshot { ObserverCount = _observerCount });
+ return sw.ToString();
+ }
+
+ ///
+ /// Deserializes observer count from XML.
+ ///
+ internal static DispatcherObservableEventSnapshot? DeserializeFromXml(string xml)
+ {
+ if (string.IsNullOrWhiteSpace(xml))
+ return null;
+ try
+ {
+ XmlSerializer serializer = new XmlSerializer(typeof(DispatcherObservableEventSnapshot));
+ using StringReader sr = new StringReader(xml);
+ return serializer.Deserialize(sr) as DispatcherObservableEventSnapshot;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"XML deserialization failed for DispatcherObservableEvent: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Serializes observer count to JSON for diagnostics.
+ ///
+ internal string SerializeToJson()
+ {
+ return JsonConvert.SerializeObject(new DispatcherObservableEventSnapshot { ObserverCount = _observerCount });
+ }
+
+ ///
+ /// Deserializes observer count from JSON.
+ ///
+ internal static DispatcherObservableEventSnapshot? DeserializeFromJson(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return null;
+ try
+ {
+ return JsonConvert.DeserializeObject(json);
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"JSON deserialization failed for DispatcherObservableEvent: {ex.Message}");
+ return null;
+ }
+ }
+
+ private struct DispatcherDelegate
+ {
+ private readonly Dispatcher _dispatcher;
+ internal readonly TEvtHandle Handler;
+
+ internal DispatcherDelegate(TEvtHandle handler)
+ {
+ _dispatcher = Dispatcher.FromThread(Thread.CurrentThread);
+ Handler = handler;
+ }
+
+ public void Invoke(object sender, TEvtArgs args)
+ {
+ if (_dispatcher == null || _dispatcher == Dispatcher.FromThread(Thread.CurrentThread))
+ _invokeDirectly(Handler, sender, args);
+ else
+ _dispatcher.BeginInvoke((Delegate)(object)Handler, DispatcherPriority.DataBind, sender, args);
+ }
+ }
+
+ [XmlRoot("DispatcherObservableEvent")]
+ public sealed class DispatcherObservableEventSnapshot
+ {
+ public int ObserverCount { get; set; }
+ }
+ }
+}
diff --git a/Torch/Collections/Concurrent/ObservableConcurrentDictionary.cs b/Torch/Collections/Concurrent/ObservableConcurrentDictionary.cs
new file mode 100644
index 00000000..f3a60406
--- /dev/null
+++ b/Torch/Collections/Concurrent/ObservableConcurrentDictionary.cs
@@ -0,0 +1,366 @@
+using System;
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Xml.Serialization;
+using Newtonsoft.Json;
+
+#nullable enable
+
+namespace Torch.Collections.Concurrent
+{
+ ///
+ /// Thread-safe observable dictionary for UI binding and background updates.
+ /// Replaces MtObservableSortedDictionary for unsorted dictionary needs.
+ ///
+ /// The type of the keys in the dictionary. Must be non-nullable.
+ /// The type of the values in the dictionary.
+ [XmlRoot("ObservableConcurrentDictionary")]
+ public class ObservableConcurrentDictionary :
+ IDictionary,
+ INotifyCollectionChanged,
+ INotifyPropertyChanged
+ where TKey : notnull
+ {
+ private readonly ConcurrentDictionary _dict = new();
+ private readonly ObservableConcurrentDictionaryValues _valuesView;
+ private readonly DispatcherObservableEvent _collectionChangedEvent = new();
+ private readonly DispatcherObservableEvent _propertyChangedEvent = new();
+
+ public ObservableConcurrentDictionary()
+ {
+ _valuesView = new(this);
+ }
+
+ // Used for serializing since XmlSerializer can't handle ConcurrentDictionary
+ [XmlIgnore]
+ public IReadOnlyDictionary Items => _dict;
+
+ [XmlArray("Entries")]
+ [XmlArrayItem("Entry")]
+ public List>? XmlEntries
+ {
+ get
+ {
+ // ConcurrentDictionary is already thread-safe for enumeration, no lock needed
+ List> list = new();
+ list.AddRange(_dict.Select(kvp => new SerializableKeyValuePair(kvp.Key, kvp.Value)));
+ return list;
+ }
+ set
+ {
+ _dict.Clear();
+ if (value == null)
+ return;
+ foreach (SerializableKeyValuePair kvp in value)
+ _dict[kvp.Key] = kvp.Value;
+ }
+ }
+
+ public event NotifyCollectionChangedEventHandler? CollectionChanged
+ {
+ add => _collectionChangedEvent.Add(value);
+ remove => _collectionChangedEvent.Remove(value);
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged
+ {
+ add => _propertyChangedEvent.Add(value);
+ remove => _propertyChangedEvent.Remove(value);
+ }
+
+ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+ {
+ _propertyChangedEvent.Raise(this, new(propertyName));
+ }
+
+ protected virtual void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
+ {
+ _collectionChangedEvent.Raise(this, e);
+ }
+
+ ///
+ /// Attempts to add the specified key and value to the dictionary.
+ ///
+ public bool TryAdd(TKey key, TValue value)
+ {
+ if (!_dict.TryAdd(key, value)) return false;
+
+ OnCollectionChanged(new(NotifyCollectionChangedAction.Add, new KeyValuePair(key, value)));
+ OnPropertyChanged(nameof(Count));
+ return true;
+ }
+
+ ///
+ /// Adds a key/value pair to the dictionary by generating a value if the key does not exist,
+ /// or updates an existing key with a new value.
+ ///
+ public TValue AddOrUpdate(TKey key, Func addValueFactory, Func updateValueFactory)
+ {
+ bool wasAdded = false;
+ TValue? capturedOldValue = default;
+ bool hadOldValue = false;
+
+ TValue newValue = _dict.AddOrUpdate(
+ key,
+ k =>
+ {
+ wasAdded = true;
+ return addValueFactory(k);
+ },
+ (k, oldVal) =>
+ {
+ capturedOldValue = oldVal;
+ hadOldValue = true;
+ return updateValueFactory(k, oldVal);
+ });
+
+ if (wasAdded)
+ {
+ OnCollectionChanged(new(NotifyCollectionChangedAction.Add, new List> { new(key, newValue) }));
+ OnPropertyChanged(nameof(Count));
+ }
+ else if (hadOldValue)
+ {
+ OnCollectionChanged(new(NotifyCollectionChangedAction.Replace,
+ new List> { new(key, newValue) },
+ new List> { new(key, capturedOldValue!) }));
+ }
+
+ return newValue;
+ }
+
+ ///
+ /// Adds a key/value pair to the dictionary if the key does not already exist,
+ /// or updates a key/value pair in the dictionary if the key already exists.
+ ///
+ public TValue AddOrUpdate(TKey key, TValue addValue, Func updateValueFactory)
+ {
+ bool wasAdded = false;
+ TValue? capturedOldValue = default;
+ bool hadOldValue = false;
+
+ TValue newValue = _dict.AddOrUpdate(
+ key,
+ k =>
+ {
+ wasAdded = true;
+ return addValue;
+ },
+ (k, oldVal) =>
+ {
+ capturedOldValue = oldVal;
+ hadOldValue = true;
+ return updateValueFactory(k, oldVal);
+ });
+
+ if (wasAdded)
+ {
+ OnCollectionChanged(new(NotifyCollectionChangedAction.Add, new List> { new(key, newValue) }));
+ OnPropertyChanged(nameof(Count));
+ }
+ else if (hadOldValue)
+ {
+ OnCollectionChanged(new(NotifyCollectionChangedAction.Replace,
+ new List> { new(key, newValue) },
+ new List> { new(key, capturedOldValue!) }));
+ }
+
+ return newValue;
+ }
+
+ ///
+ /// Attempts to remove the value with the specified key from the dictionary.
+ ///
+ public bool TryRemove(TKey key, out TValue value)
+ {
+ if (!_dict.TryRemove(key, out value)) return false;
+
+ // Raise proper Remove event
+ if (value != null)
+ OnCollectionChanged(new(NotifyCollectionChangedAction.Remove, value));
+
+ OnPropertyChanged(nameof(Count));
+ return true;
+ }
+
+ public bool TryGetValue(TKey key, out TValue value) => _dict.TryGetValue(key, out value);
+
+ ///
+ /// Clears all the elements from the dictionary.
+ ///
+ public void Clear()
+ {
+ _dict.Clear();
+ OnCollectionChanged(new(NotifyCollectionChangedAction.Reset));
+ OnPropertyChanged(nameof(Count));
+ }
+
+ ///
+ /// Attempts to update the value associated with the specified key in the dictionary.
+ ///
+ public bool TryUpdate(TKey key, TValue newValue)
+ {
+ if (!_dict.TryGetValue(key, out TValue oldValue) || !_dict.TryUpdate(key, newValue, oldValue)) return false;
+ OnCollectionChanged(new(NotifyCollectionChangedAction.Replace,
+ new KeyValuePair(key, newValue),
+ new KeyValuePair(key, oldValue)));
+ return true;
+ }
+
+ ///
+ /// Gets or sets the value associated with the specified key in the dictionary.
+ ///
+ public TValue this[TKey key]
+ {
+ get => _dict[key];
+ set
+ {
+ _dict[key] = value;
+ OnCollectionChanged(new(NotifyCollectionChangedAction.Replace,
+ new KeyValuePair(key, value)));
+ }
+ }
+
+ ///
+ /// Determines whether the dictionary contains the specified key.
+ ///
+ public bool ContainsKey(TKey key) => _dict.ContainsKey(key);
+
+ ///
+ /// Gets the number of key/value pairs contained in the dictionary.
+ ///
+ public int Count => _dict.Count;
+
+ public IEnumerator> GetEnumerator() => _dict.GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ // --- IDictionary implementations ---
+ public void Add(TKey key, TValue value)
+ {
+ if (!TryAdd(key, value))
+ throw new ArgumentException($"Key {key} already exists", nameof(key));
+ }
+
+ public bool Remove(TKey key)
+ {
+ return TryRemove(key, out _);
+ }
+
+ public ICollection Keys => _dict.Keys;
+ public ICollection Values => _valuesView;
+
+ // --- ICollection> explicit implementations ---
+ bool ICollection>.IsReadOnly => false;
+
+ void ICollection>.Add(KeyValuePair item)
+ {
+ ((IDictionary)this).Add(item.Key, item.Value);
+ }
+
+ bool ICollection>.Contains(KeyValuePair item)
+ {
+ return TryGetValue(item.Key, out var value) && EqualityComparer.Default.Equals(value, item.Value);
+ }
+
+ void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex)
+ {
+ int i = arrayIndex;
+ foreach (var kvp in _dict)
+ {
+ array[i++] = kvp;
+ }
+ }
+
+ bool ICollection>.Remove(KeyValuePair item)
+ {
+ if (((ICollection>)this).Contains(item))
+ return ((IDictionary)this).Remove(item.Key);
+ return false;
+ }
+
+ // --- XML Serialization (optional) ---
+ ///
+ /// Serializes the current instance of the dictionary to an XML string representation.
+ ///
+ public string SerializeToXml()
+ {
+ XmlSerializer serializer = new(typeof(ObservableConcurrentDictionary));
+ using StringWriter sw = new();
+ serializer.Serialize(sw, this);
+ return sw.ToString();
+ }
+
+ ///
+ /// Deserializes an XML string into an instance of .
+ ///
+ public static ObservableConcurrentDictionary? DeserializeFromXml(string xml)
+ {
+ if (string.IsNullOrWhiteSpace(xml))
+ return null;
+ try
+ {
+ XmlSerializer serializer = new(typeof(ObservableConcurrentDictionary));
+ using StringReader sr = new(xml);
+ return (ObservableConcurrentDictionary?)serializer.Deserialize(sr);
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"XML deserialization failed for {typeof(TKey)}→{typeof(TValue)}: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Serializes the current instance of the dictionary to a JSON string representation.
+ ///
+ public string SerializeToJson()
+ {
+ return JsonConvert.SerializeObject(_dict);
+ }
+
+ ///
+ /// Deserializes a JSON string into an ObservableConcurrentDictionary.
+ ///
+ public static ObservableConcurrentDictionary? DeserializeFromJson(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return null;
+ try
+ {
+ Dictionary items = JsonConvert.DeserializeObject>(json);
+ if (items == null)
+ return null;
+ var result = new ObservableConcurrentDictionary();
+ foreach (var kvp in items)
+ result._dict[kvp.Key] = kvp.Value;
+ return result;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"JSON deserialization failed for {typeof(TKey)}→{typeof(TValue)}: {ex.Message}");
+ return null;
+ }
+ }
+
+ // ---- Nested type for XML ----
+ public class SerializableKeyValuePair
+ {
+ public K Key { get; }
+ public V Value { get; }
+
+ public SerializableKeyValuePair() : this(default!, default!) { }
+
+ public SerializableKeyValuePair(K key, V value)
+ {
+ Key = key;
+ Value = value;
+ }
+ }
+ }
+}
diff --git a/Torch/Collections/Concurrent/ObservableConcurrentDictionaryValues.cs b/Torch/Collections/Concurrent/ObservableConcurrentDictionaryValues.cs
new file mode 100644
index 00000000..f799c94d
--- /dev/null
+++ b/Torch/Collections/Concurrent/ObservableConcurrentDictionaryValues.cs
@@ -0,0 +1,232 @@
+#nullable enable
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.IO;
+using System.Xml.Serialization;
+using Newtonsoft.Json;
+
+namespace Torch.Collections.Concurrent
+{
+ ///
+ /// Observable values view for .
+ ///
+ ///
+ /// Use the dictionary's Values property for UI binding when value updates must raise change events.
+ /// Serialization methods operate on a snapshot of the current values.
+ ///
+ public sealed class ObservableConcurrentDictionaryValues :
+ ICollection,
+ INotifyCollectionChanged,
+ INotifyPropertyChanged
+ where TKey : notnull
+ {
+ private readonly ObservableConcurrentDictionary _owner;
+ private readonly DispatcherObservableEvent _collectionChangedEvent = new();
+ private readonly DispatcherObservableEvent _propertyChangedEvent = new();
+
+ internal ObservableConcurrentDictionaryValues(ObservableConcurrentDictionary owner)
+ {
+ _owner = owner ?? throw new ArgumentNullException(nameof(owner));
+ _owner.CollectionChanged += OwnerCollectionChanged;
+ _owner.PropertyChanged += OwnerPropertyChanged;
+ }
+
+ public event NotifyCollectionChangedEventHandler? CollectionChanged
+ {
+ add => _collectionChangedEvent.Add(value);
+ remove => _collectionChangedEvent.Remove(value);
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged
+ {
+ add => _propertyChangedEvent.Add(value);
+ remove => _propertyChangedEvent.Remove(value);
+ }
+
+ public int Count => _owner.Count;
+
+ public bool IsReadOnly => true;
+
+ public bool Contains(TValue item)
+ {
+ EqualityComparer comparer = EqualityComparer.Default;
+ foreach (var value in _owner.Items.Values)
+ {
+ if (comparer.Equals(value, item))
+ return true;
+ }
+ return false;
+ }
+
+ public void CopyTo(TValue[] array, int arrayIndex)
+ {
+ if (array == null)
+ throw new ArgumentNullException(nameof(array));
+ foreach (var value in _owner.Items.Values)
+ {
+ array[arrayIndex++] = value;
+ }
+ }
+
+ public IEnumerator GetEnumerator() => _owner.Items.Values.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ void ICollection.Add(TValue item) => throw new NotSupportedException();
+
+ bool ICollection.Remove(TValue item) => throw new NotSupportedException();
+
+ void ICollection.Clear() => throw new NotSupportedException();
+
+ private void OwnerPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ try
+ {
+ _propertyChangedEvent.Raise(this, e);
+ }
+ catch
+ {
+ _propertyChangedEvent.Raise(this, new(nameof(Count)));
+ }
+ }
+
+ private void OwnerCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ try
+ {
+ if (TryTranslateEvent(e, out NotifyCollectionChangedEventArgs? translated))
+ {
+ if (translated != null) _collectionChangedEvent.Raise(this, translated);
+ return;
+ }
+ }
+ catch
+ {
+ // Fall through to reset.
+ }
+
+ _collectionChangedEvent.Raise(this, new(NotifyCollectionChangedAction.Reset));
+ }
+
+ private static bool TryTranslateEvent(NotifyCollectionChangedEventArgs e, out NotifyCollectionChangedEventArgs? translated)
+ {
+ translated = null;
+ switch (e.Action)
+ {
+ case NotifyCollectionChangedAction.Add:
+ if (TryGetSingleValue(e.NewItems, out var added))
+ {
+ translated = new(NotifyCollectionChangedAction.Add, added);
+ return true;
+ }
+ break;
+ case NotifyCollectionChangedAction.Remove:
+ if (TryGetSingleValue(e.OldItems, out var removed))
+ {
+ translated = new(NotifyCollectionChangedAction.Remove, removed);
+ return true;
+ }
+ break;
+ case NotifyCollectionChangedAction.Replace:
+ if (TryGetSingleValue(e.NewItems, out var newValue) && TryGetSingleValue(e.OldItems, out var oldValue))
+ {
+ translated = new(NotifyCollectionChangedAction.Replace, newValue, oldValue);
+ return true;
+ }
+ break;
+ case NotifyCollectionChangedAction.Reset:
+ translated = new(NotifyCollectionChangedAction.Reset);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TryGetSingleValue(IList? items, out TValue? value)
+ {
+ value = default;
+ if (items is not { Count: 1 })
+ return false;
+
+ object item = items[0];
+ if (item is TValue direct)
+ {
+ value = direct;
+ return true;
+ }
+
+ if (item is KeyValuePair kvp)
+ {
+ value = kvp.Value;
+ return true;
+ }
+
+ return false;
+ }
+
+ private List SnapshotItems()
+ {
+ return new(_owner.Items.Values);
+ }
+
+ ///
+ /// Serializes a snapshot of current values to XML.
+ ///
+ public string SerializeToXml()
+ {
+ XmlSerializer serializer = new(typeof(List));
+ using StringWriter sw = new();
+ serializer.Serialize(sw, SnapshotItems());
+ return sw.ToString();
+ }
+
+ ///
+ /// Deserializes an XML list snapshot.
+ ///
+ public static List? DeserializeItemsFromXml(string xml)
+ {
+ if (string.IsNullOrWhiteSpace(xml))
+ return null;
+ try
+ {
+ XmlSerializer serializer = new(typeof(List));
+ using StringReader sr = new(xml);
+ return serializer.Deserialize(sr) as List;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"XML deserialization failed for {typeof(TValue)}: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Serializes a snapshot of current values to JSON.
+ ///
+ public string SerializeToJson()
+ {
+ return JsonConvert.SerializeObject(SnapshotItems());
+ }
+
+ ///
+ /// Deserializes a JSON array into a list snapshot.
+ ///
+ public static List? DeserializeItemsFromJson(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return null;
+ try
+ {
+ return JsonConvert.DeserializeObject>(json);
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"JSON deserialization failed for {typeof(TValue)}: {ex.Message}");
+ return null;
+ }
+ }
+ }
+}
diff --git a/Torch/Collections/Concurrent/ObservableConcurrentHashSet.cs b/Torch/Collections/Concurrent/ObservableConcurrentHashSet.cs
new file mode 100644
index 00000000..b7bd66b9
--- /dev/null
+++ b/Torch/Collections/Concurrent/ObservableConcurrentHashSet.cs
@@ -0,0 +1,189 @@
+#nullable enable
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+
+namespace Torch.Collections.Concurrent
+{
+ ///
+ /// Thread-safe observable hash set using SynchronizationContext for UI safety.
+ ///
+ /// The type of elements in the set. Must be non-nullable.
+ public class ObservableConcurrentHashSet : ICollection, INotifyCollectionChanged, INotifyPropertyChanged where T : notnull
+ {
+ private readonly HashSet _items;
+ private readonly object _lock = new();
+ private readonly SynchronizationContext? _context;
+
+ public ObservableConcurrentHashSet()
+ {
+ _items = new();
+ _context = SynchronizationContext.Current;
+ }
+
+ public ObservableConcurrentHashSet(IEqualityComparer comparer)
+ {
+ _items = new(comparer);
+ _context = SynchronizationContext.Current;
+ }
+
+ ///
+ /// Gets the number of elements contained in the set.
+ ///
+ public int Count
+ {
+ get { lock (_lock) return _items.Count; }
+ }
+
+ ///
+ /// Returns false; the set is not read-only.
+ ///
+ public bool IsReadOnly => false;
+
+ ///
+ /// Occurs when the collection changes.
+ ///
+ public event NotifyCollectionChangedEventHandler? CollectionChanged;
+
+ ///
+ /// Occurs when a property value changes.
+ ///
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ ///
+ /// Attempts to add the specified item to the set.
+ ///
+ /// The item to add.
+ /// true if the item was added; false if it already exists.
+ public bool Add(T item)
+ {
+ bool added;
+ lock (_lock)
+ {
+ added = _items.Add(item);
+ }
+
+ if (added)
+ {
+ OnPropertyChanged(nameof(Count));
+ OnCollectionChanged(new(NotifyCollectionChangedAction.Add, item));
+ }
+
+ return added;
+ }
+
+ ///
+ /// Adds an item to the set (explicit implementation of ICollection.Add).
+ ///
+ void ICollection.Add(T item) => Add(item);
+
+ ///
+ /// Attempts to remove the specified item from the set.
+ ///
+ /// The item to remove.
+ /// true if the item was removed; false if it does not exist.
+ public bool Remove(T item)
+ {
+ bool removed;
+ lock (_lock)
+ {
+ removed = _items.Remove(item);
+ }
+
+ if (removed)
+ {
+ OnPropertyChanged(nameof(Count));
+ OnCollectionChanged(new(NotifyCollectionChangedAction.Remove, item));
+ }
+
+ return removed;
+ }
+
+ ///
+ /// Removes all items from the set.
+ ///
+ public void Clear()
+ {
+ bool hadItems;
+ lock (_lock)
+ {
+ hadItems = _items.Count > 0;
+ if (hadItems)
+ _items.Clear();
+ }
+
+ if (hadItems)
+ {
+ OnPropertyChanged(nameof(Count));
+ OnCollectionChanged(new(NotifyCollectionChangedAction.Reset));
+ }
+ }
+
+ ///
+ /// Determines whether the set contains the specified item.
+ ///
+ public bool Contains(T item)
+ {
+ lock (_lock) return _items.Contains(item);
+ }
+
+ ///
+ /// Copies the elements of the set to an array, starting at a particular index.
+ ///
+ public void CopyTo(T[] array, int arrayIndex)
+ {
+ lock (_lock)
+ {
+ _items.CopyTo(array, arrayIndex);
+ }
+ }
+
+ ///
+ /// Returns an enumerator that iterates through the set (snapshot).
+ ///
+ public IEnumerator GetEnumerator()
+ {
+ List snapshot;
+ lock (_lock) snapshot = _items.ToList();
+ return snapshot.GetEnumerator();
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ ///
+ /// Creates a list containing all elements in the set (snapshot).
+ ///
+ public List ToList()
+ {
+ lock (_lock) return _items.ToList();
+ }
+
+ protected virtual void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
+ {
+ if (_context != null && _context != SynchronizationContext.Current)
+ {
+ _context.Post(_ => CollectionChanged?.Invoke(this, e), null);
+ }
+ else
+ {
+ CollectionChanged?.Invoke(this, e);
+ }
+ }
+
+ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+ {
+ if (_context != null && _context != SynchronizationContext.Current)
+ {
+ _context.Post(_ => PropertyChanged?.Invoke(this, new(propertyName)), null);
+ }
+ else
+ {
+ PropertyChanged?.Invoke(this, new(propertyName));
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Torch/Collections/Concurrent/ObservableConcurrentList.cs b/Torch/Collections/Concurrent/ObservableConcurrentList.cs
new file mode 100644
index 00000000..d752f9ad
--- /dev/null
+++ b/Torch/Collections/Concurrent/ObservableConcurrentList.cs
@@ -0,0 +1,284 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Linq;
+
+#nullable enable
+
+namespace Torch.Collections.Concurrent
+{
+ ///
+ /// Thread-safe observable list using SynchronizationContext for UI safety.
+ /// Replaces MtObservableList for thread-safe observable list needs.
+ ///
+ /// The type of elements in the list.
+ public sealed class ObservableConcurrentList : IList, IList, INotifyCollectionChanged, INotifyPropertyChanged
+ {
+ private readonly List _items = new();
+ private readonly object _itemsLock = new();
+
+ private readonly DispatcherObservableEvent _collectionChangedEvent = new();
+ private readonly DispatcherObservableEvent _propertyChangedEvent = new();
+
+ public event NotifyCollectionChangedEventHandler? CollectionChanged
+ {
+ add
+ {
+ _collectionChangedEvent.Add(value);
+ RaisePropertyChanged(nameof(IsObserved));
+ }
+ remove
+ {
+ _collectionChangedEvent.Remove(value);
+ RaisePropertyChanged(nameof(IsObserved));
+ }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged
+ {
+ add
+ {
+ _propertyChangedEvent.Add(value);
+ RaisePropertyChanged(nameof(IsObserved));
+ }
+ remove
+ {
+ _propertyChangedEvent.Remove(value);
+ RaisePropertyChanged(nameof(IsObserved));
+ }
+ }
+
+ ///
+ /// True when there are active collection or property observers.
+ ///
+ public bool IsObserved => _collectionChangedEvent.IsObserved || _propertyChangedEvent.IsObserved;
+
+ public ObservableConcurrentList()
+ {
+ }
+
+ ///
+ /// Only use for serialization converters!!!
+ ///
+ public ObservableConcurrentList(IEnumerable collection) : this()
+ {
+ lock (_itemsLock)
+ {
+ foreach (var item in collection)
+ _items.Add(item); // direct add for initial population
+ }
+ }
+
+ public void Add(T item)
+ {
+ lock (_itemsLock)
+ {
+ _items.Add(item);
+ }
+ InvokeCollectionChanged(new(NotifyCollectionChangedAction.Add, item));
+ }
+
+ public void Clear()
+ {
+ lock (_itemsLock)
+ {
+ _items.Clear();
+ }
+ InvokeCollectionChanged(new(NotifyCollectionChangedAction.Reset));
+ }
+
+ public bool Contains(T item)
+ {
+ lock (_itemsLock)
+ {
+ return _items.Contains(item);
+ }
+ }
+
+ public bool Remove(T item)
+ {
+ bool removed;
+ lock (_itemsLock)
+ {
+ removed = _items.Remove(item);
+ }
+ if (removed)
+ {
+ InvokeCollectionChanged(new(NotifyCollectionChangedAction.Remove, item));
+ RaisePropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
+ }
+ return removed;
+ }
+
+ public void Insert(int index, T item)
+ {
+ lock (_itemsLock)
+ {
+ _items.Insert(index, item);
+ }
+ InvokeCollectionChanged(new(NotifyCollectionChangedAction.Add, item, index));
+ }
+
+ ///
+ /// Moves the specified item to a new index in the list.
+ ///
+ /// The index to move the item to.
+ /// The item to move.
+ public void Move(int newIndex, T item)
+ {
+ int oldIndex, adjustedNewIndex;
+ lock (_itemsLock)
+ {
+ oldIndex = _items.IndexOf(item);
+ if (oldIndex == -1)
+ throw new ArgumentException("Item not found in the list.", nameof(item));
+ if (oldIndex == newIndex)
+ return;
+ adjustedNewIndex = newIndex;
+ // Adjust newIndex if removing before the target position
+ if (oldIndex < adjustedNewIndex)
+ adjustedNewIndex--;
+ _items.RemoveAt(oldIndex);
+ _items.Insert(adjustedNewIndex, item);
+ }
+ // Raise a single Move event for UI efficiency
+ InvokeCollectionChanged(new(NotifyCollectionChangedAction.Move, item, adjustedNewIndex, oldIndex));
+ }
+
+ public void RemoveAt(int index)
+ {
+ T? removedItem;
+ lock (_itemsLock)
+ {
+ removedItem = _items[index];
+ _items.RemoveAt(index);
+ }
+ InvokeCollectionChanged(new(NotifyCollectionChangedAction.Remove, removedItem, index));
+ }
+
+ public int IndexOf(T item)
+ {
+ lock (_itemsLock)
+ {
+ return _items.IndexOf(item);
+ }
+ }
+
+ public void CopyTo(T[] array, int arrayIndex)
+ {
+ lock (_itemsLock)
+ {
+ _items.CopyTo(array, arrayIndex);
+ }
+ }
+
+ public T? this[int index]
+ {
+ get
+ {
+ lock (_itemsLock)
+ {
+ return _items[index];
+ }
+ }
+ set
+ {
+ lock (_itemsLock)
+ {
+ T? old = _items[index];
+ _items[index] = value;
+ InvokeCollectionChanged(new(NotifyCollectionChangedAction.Replace, value, old, index));
+ }
+ }
+ }
+
+ public int Count
+ {
+ get
+ {
+ lock (_itemsLock)
+ {
+ return _items.Count;
+ }
+ }
+ }
+
+ public bool IsReadOnly => false;
+
+ private void InvokeCollectionChanged(NotifyCollectionChangedEventArgs args)
+ {
+ _collectionChangedEvent.Raise(this, args);
+ RaisePropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
+ }
+
+ private void RaisePropertyChanged(PropertyChangedEventArgs args) => _propertyChangedEvent.Raise(this, args);
+
+ private void RaisePropertyChanged(string propertyName)
+ {
+ RaisePropertyChanged(new PropertyChangedEventArgs(propertyName));
+ }
+
+ public IEnumerator GetEnumerator()
+ {
+ lock (_itemsLock)
+ {
+ return _items.ToList().GetEnumerator(); // snapshot iteration
+ }
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ // --- IList explicit implementations ---
+ int IList.Add(object? value)
+ {
+ if (value is T t)
+ {
+ Add(t);
+ return Count - 1;
+ }
+ return -1;
+ }
+
+ bool IList.Contains(object? value) => value is T t && Contains(t);
+
+ void IList.Clear() => Clear();
+
+ int IList.IndexOf(object value) => value is T t ? IndexOf(t) : -1;
+
+ void IList.Insert(int index, object value)
+ {
+ if (value is T t)
+ Insert(index, t);
+ }
+
+ void IList.Remove(object value)
+ {
+ if (value is T t)
+ Remove(t);
+ }
+
+ void IList.RemoveAt(int index) => RemoveAt(index);
+
+ bool IList.IsReadOnly => IsReadOnly;
+ bool IList.IsFixedSize => false;
+
+ object? IList.this[int index]
+ {
+ get => this[index];
+ set => this[index] = (T?)value;
+ }
+
+ public void CopyTo(Array array, int index)
+ {
+ lock (_itemsLock)
+ {
+ ((ICollection)_items).CopyTo(array, index);
+ }
+ }
+
+ bool ICollection.IsSynchronized => true;
+ object ICollection.SyncRoot => _itemsLock;
+ }
+}
diff --git a/Torch/Collections/Concurrent/ObservableConcurrentSortedList.cs b/Torch/Collections/Concurrent/ObservableConcurrentSortedList.cs
new file mode 100644
index 00000000..7c7b1220
--- /dev/null
+++ b/Torch/Collections/Concurrent/ObservableConcurrentSortedList.cs
@@ -0,0 +1,277 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.ComponentModel;
+
+#nullable enable
+
+namespace Torch.Collections.Concurrent
+{
+ ///
+ /// Thread-safe sorted view over an observable collection, marshaled to a synchronization context.
+ ///
+ /// The item type.
+ public sealed class ObservableConcurrentSortedList : IReadOnlyCollection, INotifyCollectionChanged, INotifyPropertyChanged
+ {
+ private readonly ICollection _backing;
+ private readonly List _store;
+ private readonly object _storeLock = new();
+ private IComparer? _comparer;
+ private Predicate? _filter;
+
+ private readonly DispatcherObservableEvent _collectionChangedEvent = new();
+ private readonly DispatcherObservableEvent _propertyChangedEvent = new();
+
+ public ObservableConcurrentSortedList(ICollection