global using NitroxModel.Logger; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Net; using System.Net.NetworkInformation; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; using NitroxModel; using NitroxModel.Core; using NitroxModel.DataStructures; using NitroxModel.DataStructures.GameLogic; using NitroxModel.DataStructures.Util; using NitroxModel.Helper; using NitroxServer; using NitroxServer_Subnautica.Communication; using NitroxServer.ConsoleCommands.Processor; namespace NitroxServer_Subnautica; [SuppressMessage("Usage", "DIMA001:Dependency Injection container is used directly")] public class Program { private static Lazy gameInstallDir; private static readonly CircularBuffer inputHistory = new(1000); private static int currentHistoryIndex; private static readonly CancellationTokenSource serverCts = new(); private static async Task Main(string[] args) { AppDomain.CurrentDomain.AssemblyResolve += AssemblyResolver.Handler; AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += AssemblyResolver.Handler; await StartServer(args); } /// /// Initialize server here so that the JIT can compile the EntryPoint method without having to resolve dependencies /// that require the handler. /// /// /// https://stackoverflow.com/a/6089153/1277156 /// [MethodImpl(MethodImplOptions.NoInlining)] private static async Task StartServer(string[] args) { // The thread that writers to console is paused while selecting text in console. So console writer needs to be async. Log.Setup(true, isConsoleApp: !args.Contains("--embedded", StringComparer.OrdinalIgnoreCase)); AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException; PosixSignalRegistration.Create(PosixSignal.SIGTERM, CloseWindowHandler); PosixSignalRegistration.Create(PosixSignal.SIGQUIT, CloseWindowHandler); PosixSignalRegistration.Create(PosixSignal.SIGINT, CloseWindowHandler); PosixSignalRegistration.Create(PosixSignal.SIGHUP, CloseWindowHandler); CultureManager.ConfigureCultureInfo(); if (!Console.IsInputRedirected) { Console.TreatControlCAsInput = true; } Log.Info($"Starting NitroxServer {NitroxEnvironment.ReleasePhase} v{NitroxEnvironment.Version} for {GameInfo.Subnautica.FullName}"); Log.Debug($@"Process start args: ""{string.Join(@""", """, Environment.GetCommandLineArgs())}"""); Task handleConsoleInputTask; Server server; try { handleConsoleInputTask = HandleConsoleInputAsync(ConsoleCommandHandler(), serverCts.Token); AppMutex.Hold(() => Log.Info("Waiting on other Nitrox servers to initialize before starting.."), serverCts.Token); Stopwatch watch = Stopwatch.StartNew(); // Allow game path to be given as command argument string gameDir; if (args.Length > 0 && Directory.Exists(args[0]) && File.Exists(Path.Combine(args[0], GameInfo.Subnautica.ExeName))) { gameDir = Path.GetFullPath(args[0]); gameInstallDir = new Lazy(() => gameDir); } else { gameInstallDir = new Lazy(() => { return gameDir = NitroxUser.GamePath; }); } Log.Info($"Using game files from: \'{gameInstallDir.Value}\'"); // TODO: Fix DI to not be slow (should not use IO in type constructors). Instead, use Lazy (et al). This way, cancellation can be faster. NitroxServiceLocator.InitializeDependencyContainer(new SubnauticaServerAutoFacRegistrar()); NitroxServiceLocator.BeginNewLifetimeScope(); server = NitroxServiceLocator.LocateService(); string serverSaveName = Server.GetSaveName(args, "My World"); Log.SaveName = serverSaveName; using (CancellationTokenSource portWaitCts = CancellationTokenSource.CreateLinkedTokenSource(serverCts.Token)) { TimeSpan portWaitTimeout = TimeSpan.FromSeconds(30); portWaitCts.CancelAfter(portWaitTimeout); await WaitForAvailablePortAsync(server.Port, portWaitTimeout, portWaitCts.Token); } if (!serverCts.IsCancellationRequested) { if (!server.Start(serverSaveName, serverCts)) { throw new Exception("Unable to start server."); } else { Log.Info($"Server started ({Math.Round(watch.Elapsed.TotalSeconds, 1)}s)"); Log.Info("To get help for commands, run help in console or /help in chatbox"); } } } finally { // Allow other servers to start initializing. AppMutex.Release(); } await handleConsoleInputTask; server.Stop(true); try { if (Environment.UserInteractive && Console.In != StreamReader.Null && Debugger.IsAttached) { Task.Delay(100).Wait(); // Wait for async logs to flush to console Console.WriteLine($"{Environment.NewLine}Press any key to continue . . ."); Console.ReadKey(true); } } catch { // ignored } Action ConsoleCommandHandler() { ConsoleCommandProcessor commandProcessor = null; return submit => { try { commandProcessor ??= NitroxServiceLocator.LocateService(); } catch (Exception) { // ignored } commandProcessor?.ProcessCommand(submit, Optional.Empty, Perms.CONSOLE); }; } } private static void CloseWindowHandler(PosixSignalContext context) { context.Cancel = false; serverCts?.Cancel(); } /// /// Handles per-key input of the console and passes input submit to . /// private static async Task HandleConsoleInputAsync(Action submitHandler, CancellationToken ct = default) { ConcurrentQueue commandQueue = new(); if (Console.IsInputRedirected) { Log.Info("Server input stream is redirected"); _ = Task.Run(() => { while (!ct.IsCancellationRequested) { string commandRead = Console.ReadLine(); commandQueue.Enqueue(commandRead); } }, ct).ContinueWith(t => { if (t.IsFaulted) { Log.Error(t.Exception); } }, ct); } else { Log.Info("Server input stream is available"); StringBuilder inputLineBuilder = new(); void ClearInputLine() { currentHistoryIndex = 0; inputLineBuilder.Clear(); Console.Write($"\r{new string(' ', Console.WindowWidth - 1)}\r"); } void RedrawInput(int start = 0, int end = 0) { int lastPosition = Console.CursorLeft; // Expand range to end if end value is -1 if (start > -1 && end == -1) { end = Math.Max(inputLineBuilder.Length - start, 0); } if (start == 0 && end == 0) { // Redraw entire line Console.Write($"\r{new string(' ', Console.WindowWidth - 1)}\r{inputLineBuilder}"); } else { // Redraw part of line string changedInputSegment = inputLineBuilder.ToString(start, end); Console.CursorVisible = false; Console.Write($"{changedInputSegment}{new string(' ', inputLineBuilder.Length - changedInputSegment.Length - Console.CursorLeft + 1)}"); Console.CursorVisible = true; } Console.CursorLeft = lastPosition; } _ = Task.Run(async () => { while (!ct.IsCancellationRequested) { if (!Console.KeyAvailable) { try { await Task.Delay(10, ct); } catch (TaskCanceledException) { // ignored } continue; } ConsoleKeyInfo keyInfo = Console.ReadKey(true); // Handle (ctrl) hotkeys if ((keyInfo.Modifiers & ConsoleModifiers.Control) != 0) { switch (keyInfo.Key) { case ConsoleKey.C: if (inputLineBuilder.Length > 0) { ClearInputLine(); continue; } await serverCts.CancelAsync(); return; case ConsoleKey.D: await serverCts.CancelAsync(); return; default: // Unhandled modifier key continue; } } if (keyInfo.Modifiers == 0) { switch (keyInfo.Key) { case ConsoleKey.LeftArrow when Console.CursorLeft > 0: Console.CursorLeft--; continue; case ConsoleKey.RightArrow when Console.CursorLeft < inputLineBuilder.Length: Console.CursorLeft++; continue; case ConsoleKey.Backspace: if (inputLineBuilder.Length > Console.CursorLeft - 1 && Console.CursorLeft > 0) { inputLineBuilder.Remove(Console.CursorLeft - 1, 1); Console.CursorLeft--; Console.Write(' '); Console.CursorLeft--; RedrawInput(); } continue; case ConsoleKey.Delete: if (inputLineBuilder.Length > 0 && Console.CursorLeft < inputLineBuilder.Length) { inputLineBuilder.Remove(Console.CursorLeft, 1); RedrawInput(Console.CursorLeft, inputLineBuilder.Length - Console.CursorLeft); } continue; case ConsoleKey.Home: Console.CursorLeft = 0; continue; case ConsoleKey.End: Console.CursorLeft = inputLineBuilder.Length; continue; case ConsoleKey.Escape: ClearInputLine(); continue; case ConsoleKey.Tab: if (Console.CursorLeft + 4 < Console.WindowWidth) { inputLineBuilder.Insert(Console.CursorLeft, " "); RedrawInput(Console.CursorLeft, -1); Console.CursorLeft += 4; } continue; case ConsoleKey.UpArrow when inputHistory.Count > 0 && currentHistoryIndex > -inputHistory.Count: inputLineBuilder.Clear(); inputLineBuilder.Append(inputHistory[--currentHistoryIndex]); RedrawInput(); Console.CursorLeft = Math.Min(inputLineBuilder.Length, Console.WindowWidth); continue; case ConsoleKey.DownArrow when inputHistory.Count > 0 && currentHistoryIndex < 0: if (currentHistoryIndex == -1) { ClearInputLine(); continue; } inputLineBuilder.Clear(); inputLineBuilder.Append(inputHistory[++currentHistoryIndex]); RedrawInput(); Console.CursorLeft = Math.Min(inputLineBuilder.Length, Console.WindowWidth); continue; } } // Handle input submit to submit handler if (keyInfo.Key == ConsoleKey.Enter) { string submit = inputLineBuilder.ToString(); if (inputHistory.Count == 0 || inputHistory[inputHistory.LastChangedIndex] != submit) { inputHistory.Add(submit); } currentHistoryIndex = 0; commandQueue.Enqueue(submit); inputLineBuilder.Clear(); Console.WriteLine(); continue; } // If unhandled key, append as input. if (keyInfo.KeyChar != 0) { Console.Write(keyInfo.KeyChar); if (Console.CursorLeft - 1 < inputLineBuilder.Length) { inputLineBuilder.Insert(Console.CursorLeft - 1, keyInfo.KeyChar); RedrawInput(Console.CursorLeft, -1); } else { inputLineBuilder.Append(keyInfo.KeyChar); } } } }, ct).ContinueWith(t => { if (t.IsFaulted) { Log.Error(t.Exception); } }, ct); } using IpcHost ipcHost = IpcHost.StartReadingCommands(command => commandQueue.Enqueue(command), ct); if (!Console.IsInputRedirected) { // Important to not hang process: keep command handler on the main thread when input not redirected (i.e. don't Task.Run) while (!ct.IsCancellationRequested) { while (commandQueue.TryDequeue(out string command)) { submitHandler(command); } try { await Task.Delay(10, ct); } catch (OperationCanceledException) { // ignored } } } else { // Important to not hang process (when running launcher from release exe): free main thread if input redirected await Task.Run(async () => { while (!ct.IsCancellationRequested) { while (commandQueue.TryDequeue(out string command)) { submitHandler(command); } try { await Task.Delay(10, ct); } catch (OperationCanceledException) { // ignored } } }, ct).ContinueWithHandleError(); } } private static async Task WaitForAvailablePortAsync(int port, TimeSpan timeout = default, CancellationToken ct = default) { if (timeout == default) { timeout = TimeSpan.FromSeconds(30); } else { Validate.IsTrue(timeout.TotalSeconds >= 5, "Timeout must be at least 5 seconds."); } int messageLength = 0; void PrintPortWarn(TimeSpan timeRemaining) { string message = $"Port {port} UDP is already in use. Please change the server port or close out any program that may be using it. Retrying for {Math.Floor(timeRemaining.TotalSeconds)} seconds until it is available..."; messageLength = message.Length; Log.Warn(message); } DateTimeOffset time = DateTimeOffset.UtcNow; bool first = true; try { while (true) { ct.ThrowIfCancellationRequested(); IPEndPoint endPoint = IPGlobalProperties.GetIPGlobalProperties().GetActiveUdpListeners().FirstOrDefault(ip => ip.Port == port); if (endPoint == null) { break; } if (first) { first = false; PrintPortWarn(timeout); } else if (Environment.UserInteractive && !Console.IsInputRedirected && Console.In != StreamReader.Null) { // If not first time, move cursor up the number of lines it takes up to overwrite previous message int numberOfLines = (int)Math.Ceiling( ((double)messageLength + 15) / Console.BufferWidth ); for (int i = 0; i < numberOfLines; i++) { if (Console.CursorTop > 0) // Check to ensure we don't go out of bounds { Console.CursorTop--; } } Console.CursorLeft = 0; PrintPortWarn(timeout - (DateTimeOffset.UtcNow - time)); } await Task.Delay(500, ct); } } catch (OperationCanceledException) { // ignored } } private static void CurrentDomainOnUnhandledException(object sender, UnhandledExceptionEventArgs e) { if (e.ExceptionObject is Exception ex) { Log.Error(ex); } if (!Environment.UserInteractive || Console.IsInputRedirected || Console.In == StreamReader.Null) { return; } // TODO: Implement log file opening by server name /*string mostRecentLogFile = Log.GetMostRecentLogFile(); // Log.SaveName if (mostRecentLogFile == null) { return; } Log.Info("Press L to open log file before closing. Press any other key to close . . .");*/ Log.Info("Press L to open log folder before closing. Press any other key to close . . ."); ConsoleKeyInfo key = Console.ReadKey(true); if (key.Key == ConsoleKey.L) { // Log.Info($"Opening log file at: {mostRecentLogFile}.."); // using Process process = FileSystem.Instance.OpenOrExecuteFile(mostRecentLogFile); Process.Start(new ProcessStartInfo { FileName = Log.LogDirectory, Verb = "open", UseShellExecute = true })?.Dispose(); } Environment.Exit(1); } private static class AssemblyResolver { private static string currentExecutableDirectory; private static readonly Dictionary resolvedAssemblyCache = []; public static Assembly Handler(object sender, ResolveEventArgs args) { static Assembly ResolveFromLib(ReadOnlySpan dllName) { dllName = dllName.Slice(0, Math.Max(dllName.IndexOf(','), 0)); if (dllName.IsEmpty) { return null; } if (!dllName.EndsWith(".dll")) { dllName = string.Concat(dllName, ".dll"); } if (dllName.EndsWith(".resources.dll")) { return null; } string dllNameStr = dllName.ToString(); // If available, return cached assembly if (resolvedAssemblyCache.TryGetValue(dllNameStr, out Assembly val)) { return val; } // Load DLLs where this program (exe) is located string dllPath = Path.Combine(GetExecutableDirectory(), "lib", dllNameStr); // Prefer to use Newtonsoft dll from game instead of our own due to protobuf issues. TODO: Remove when we do our own deserialization of game data instead of using the game's protobuf. if (dllPath.IndexOf("Newtonsoft.Json.dll", StringComparison.OrdinalIgnoreCase) >= 0 || !File.Exists(dllPath)) { // Try find game managed libraries if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { dllPath = Path.Combine(gameInstallDir.Value, "Resources", "Data", "Managed", dllNameStr); } else { dllPath = Path.Combine(gameInstallDir.Value, "Subnautica_Data", "Managed", dllNameStr); } } try { // Read assemblies as bytes as to not lock the file so that Nitrox can patch assemblies while server is running. Assembly assembly = Assembly.Load(File.ReadAllBytes(dllPath)); return resolvedAssemblyCache[dllNameStr] = assembly; } catch { return null; } } Assembly assembly = ResolveFromLib(args.Name); if (assembly == null && !args.Name.Contains(".resources")) { assembly = Assembly.Load(args.Name); } return assembly; } private static string GetExecutableDirectory() { if (currentExecutableDirectory != null) { return currentExecutableDirectory; } string pathAttempt = Assembly.GetEntryAssembly()?.Location; if (string.IsNullOrWhiteSpace(pathAttempt)) { using Process proc = Process.GetCurrentProcess(); pathAttempt = proc.MainModule?.FileName; } return currentExecutableDirectory = new Uri(Path.GetDirectoryName(pathAttempt ?? ".") ?? Directory.GetCurrentDirectory()).LocalPath; } } }