Files
Nitrox/NitroxServer-Subnautica/Program.cs
2025-07-06 00:23:46 +02:00

603 lines
24 KiB
C#

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<string> gameInstallDir;
private static readonly CircularBuffer<string> 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);
}
/// <summary>
/// Initialize server here so that the JIT can compile the EntryPoint method without having to resolve dependencies
/// that require the <see cref="AppDomain.AssemblyResolve" /> handler.
/// </summary>
/// <remarks>
/// https://stackoverflow.com/a/6089153/1277156
/// </remarks>
[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<string>(() => gameDir);
}
else
{
gameInstallDir = new Lazy<string>(() =>
{
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<T> (et al). This way, cancellation can be faster.
NitroxServiceLocator.InitializeDependencyContainer(new SubnauticaServerAutoFacRegistrar());
NitroxServiceLocator.BeginNewLifetimeScope();
server = NitroxServiceLocator.LocateService<Server>();
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<string> ConsoleCommandHandler()
{
ConsoleCommandProcessor commandProcessor = null;
return submit =>
{
try
{
commandProcessor ??= NitroxServiceLocator.LocateService<ConsoleCommandProcessor>();
}
catch (Exception)
{
// ignored
}
commandProcessor?.ProcessCommand(submit, Optional.Empty, Perms.CONSOLE);
};
}
}
private static void CloseWindowHandler(PosixSignalContext context)
{
context.Cancel = false;
serverCts?.Cancel();
}
/// <summary>
/// Handles per-key input of the console and passes input submit to <see cref="ConsoleCommandProcessor" />.
/// </summary>
private static async Task HandleConsoleInputAsync(Action<string> submitHandler, CancellationToken ct = default)
{
ConcurrentQueue<string> 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<string, Assembly> resolvedAssemblyCache = [];
public static Assembly Handler(object sender, ResolveEventArgs args)
{
static Assembly ResolveFromLib(ReadOnlySpan<char> 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;
}
}
}