Skip to content
48 changes: 39 additions & 9 deletions Nitrox.Launcher/Models/Design/ServerEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,21 @@ public async Task StopAsync()
}
catch (OperationCanceledException)
{
// ignored
// Graceful shutdown timed out, force kill the process
Log.Warn($"Server process {LastProcessId} did not stop gracefully within timeout, force killing...");
try
{
using ProcessEx? proc = ProcessEx.GetFirstProcess(GetServerExeName(), ex => ex.Id == LastProcessId);
if (proc != null)
{
proc.Terminate();
Log.Info($"Server process {LastProcessId} forcefully terminated");
}
}
catch (Exception ex)
{
Log.Error(ex, $"Failed to force kill server process {LastProcessId}");
}
}
}

Expand Down Expand Up @@ -359,18 +373,34 @@ internal async Task ResetCtsAsync(bool cancelPreexisting = false)
await Dispatcher.UIThread.InvokeAsync(() => IsServerClosing = true);
await Dispatcher.UIThread.InvokeAsync(async () =>
{
while (ProcessEx.ProcessExists(GetServerExeName(), ex => ex.Id == LastProcessId))
using CancellationTokenSource shutdownCts = new(TimeSpan.FromSeconds(10));
try
{
try
{
await CommandQueue.Writer.WriteAsync("quit");
CommandQueue.Writer.TryComplete();
}
catch (ChannelClosedException)
while (ProcessEx.ProcessExists(GetServerExeName(), ex => ex.Id == LastProcessId))
{
await Task.Delay(500);
try
{
await CommandQueue.Writer.WriteAsync("quit", shutdownCts.Token);
CommandQueue.Writer.TryComplete();
}
catch (ChannelClosedException)
{
await Task.Delay(500, shutdownCts.Token);
}
catch (OperationCanceledException)
{
// Timeout reached, force terminate
Log.Warn($"Server {Name} did not respond to quit command, attempting force terminate");
using ProcessEx? proc = ProcessEx.GetFirstProcess(GetServerExeName(), ex => ex.Id == LastProcessId);
proc?.Terminate();
break;
Comment on lines +393 to +396
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Terminating the server on timeout can break server saving. I'd prefer we investigate why the server doesn't exit and fix instead of terminating it.

If you know how to trigger the bug, please create a new issue with steps. Then we fix this in a separate PR.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I agree, I think it could also help with speed of merging the prs. Here it feels like three prs in one

}
}
}
catch (Exception ex)
{
Log.Error(ex, $"Error during server shutdown for {Name}");
}
CommandQueue = Channel.CreateUnbounded<string>();
IsOnline = false;
Output.Clear();
Expand Down
35 changes: 35 additions & 0 deletions Nitrox.Launcher/Models/Services/ServerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,41 @@ public void Dispose()
serverRefreshCts.Dispose();
WeakReferenceMessenger.Default.UnregisterAll(this);
watcher?.Dispose();

// Stop all embedded servers when launcher closes
// Non-embedded servers are left running as they have their own console window
lock (serversLock)
{
List<Task> stopTasks = [];
foreach (ServerEntry server in servers)
{
if (server.IsOnline && server.IsEmbedded && server.Process != null)
{
try
{
Log.Info($"Stopping embedded server {server.Name} during launcher shutdown");
stopTasks.Add(server.StopAsync());
}
catch (Exception ex)
{
Log.Error(ex, $"Error stopping server {server.Name} during shutdown");
}
}
}

// Wait for all servers to stop with a timeout
if (stopTasks.Count > 0)
{
try
{
Task.WaitAll([.. stopTasks], TimeSpan.FromSeconds(30));
}
catch (Exception ex)
{
Log.Error(ex, "Error waiting for servers to stop during shutdown");
}
}
}
}
Comment on lines +250 to 285
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this code ever called? No debugger breakpoint hits on launcher exit. I think the code can be removed.

MainWindowViewModel also already handles server exit.


public ServerEntry[] Servers
Expand Down
2 changes: 1 addition & 1 deletion Nitrox.Model/Helper/Reflect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ public static PropertyInfo Property<T>(Expression<Func<T, object>> expression)
return (PropertyInfo)GetMemberInfo(expression);
}

private static MemberInfo GetMemberInfo(LambdaExpression expression, Type implementingType = null)
private static MemberInfo GetMemberInfo(LambdaExpression expression, Type? implementingType = null)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for improving this code. Added in #2674 in case your PR stays unmerged for a while.

{
Expression currentExpression = expression.Body;
while (true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,8 @@ private async Task CreateOrLoadPrefabCacheAsync(string nitroxCachePath)
{
logger.ZLogWarning($"An error occurred while deserializing the prefab cache. Re-creating it: {ex.Message:@Error}");
}
if (cache.HasValue)
if (cache.HasValue && cache.Value.Version == CACHE_VERSION)
Copy link
Collaborator

@Measurity Measurity Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for noticing this bug. Fixed in #2674 in case your PR stays unmerged for a while.

{
if (cache.Value.Version != CACHE_VERSION)
{
logger.ZLogInformation($"Found outdated cache ({cache.Value.Version}, expected {CACHE_VERSION})");
}
prefabPlaceholdersGroupPaths = cache.Value.PrefabPlaceholdersGroupPaths;
randomPossibilitiesByClassId = cache.Value.RandomPossibilitiesByClassId;
groupsByClassId = cache.Value.GroupsByClassId;
Expand All @@ -162,7 +158,14 @@ private async Task CreateOrLoadPrefabCacheAsync(string nitroxCachePath)
// Fallback solution
else
{
logger.ZLogInformation($"Building cache, this may take a while...");
if (cache.HasValue)
{
logger.ZLogInformation($"Found outdated cache ({cache.Value.Version}, expected {CACHE_VERSION}). Rebuilding cache, this may take a while...");
}
else
{
logger.ZLogInformation($"Building cache, this may take a while...");
}
// Get all prefab-classIds linked to the (partial) bundle path
string prefabDatabasePath = Path.Combine(options.Value.GetSubnauticaResourcesPath(), "StreamingAssets", "SNUnmanagedData", "prefabs.db");
Dictionary<string, string> prefabDatabase = LoadPrefabDatabase(prefabDatabasePath);
Expand Down
10 changes: 1 addition & 9 deletions Nitrox.Shared.targets
Original file line number Diff line number Diff line change
Expand Up @@ -87,21 +87,13 @@
Condition="!Exists('$(OutputDirectory)')"
ContinueOnError="WarnAndContinue" />

<!-- Copy in debug because some systems break when they don't use our assembly resolve to look inside lib folder -->
<!-- Copy (don't Move) so dependencies stay in output root. Some code paths load assemblies before/when AssemblyResolve runs or from contexts that don't use it (e.g. Release vs Debug), causing empty UI or missing types when DLLs are only in lib/. -->
<Copy SourceFiles="@(FilesToMove)"
DestinationFolder="$(OutputDirectory)"
OverwriteReadOnlyFiles="True"
SkipUnchangedFiles="True"
Retries="3"
RetryDelayMilliseconds="100"
Condition="'$(Configuration)' == 'Debug'"
ContinueOnError="ErrorAndContinue" />

<!-- Move every matching files to OutputDirectory -->
<Move SourceFiles="@(FilesToMove)"
DestinationFolder="$(OutputDirectory)"
OverwriteReadOnlyFiles="True"
Condition="'$(Configuration)' == 'Release'"
ContinueOnError="ErrorAndContinue" />
Comment on lines +90 to 97
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intended behavior. Avalonia doesn't use our AppDomain.CurrentDomain.AssemblyResolve. In Debug we're fine with duplicating the files so Avalonia Previewer works.

</Target>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace NitroxClient.MonoBehaviours.Gui.Input.KeyBindings.Actions;

public class ChatKeyBindingAction : KeyBinding
{
public ChatKeyBindingAction() : base("Nitrox_Settings_Keybind_OpenChat", "y") { }
public ChatKeyBindingAction() : base("Nitrox_Settings_Keybind_OpenChat", "y", "dpad/up") { }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these universal across all unity controllers? Will it only work for say playstation but not Xbox?


public override void Execute(InputAction.CallbackContext _)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace NitroxClient.MonoBehaviours.Gui.Input.KeyBindings.Actions;

public class DiscordFocusBindingAction : KeyBinding
{
public DiscordFocusBindingAction() : base("Nitrox_Settings_Keybind_FocusDiscord", "i") { }
public DiscordFocusBindingAction() : base("Nitrox_Settings_Keybind_FocusDiscord", "i", "dpad/down") { }

public override void Execute(InputAction.CallbackContext _)
{
Expand Down
2 changes: 1 addition & 1 deletion NitroxPatcher/NitroxPatcher.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revert file changes.


<PropertyGroup>
<TargetFrameworks>net472;net10.0</TargetFrameworks>
Expand Down
19 changes: 13 additions & 6 deletions NitroxPatcher/Patches/Persistent/Enum_GetValues_Patch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
namespace NitroxPatcher.Patches.Persistent;

/// <summary>
/// Specific patch for GameInput.Button enum to return also our own values.
/// Extends Enum.GetValues() to include Nitrox buttons, which is required for the rebinding UI to recognize them as valid.
/// Duplicate checking ensures compatibility when Nautilus or other mods also extend the enum.
/// GameInputSystem_Initialize_Patch also extends GameInput.AllActions (with its own duplicate check) for action creation.
/// </summary>
public partial class Enum_GetValues_Patch : NitroxPatch, IPersistentPatch
{
Expand All @@ -18,13 +20,18 @@ public static void Postfix(Type enumType, ref Array __result)
{
return;
}

GameInput.Button[] result =

// Check if Nitrox buttons are already in the result (e.g. added by Nautilus or a previous call)
int firstNitroxButton = KeyBindingManager.NITROX_BASE_ID;
if (__result.Cast<GameInput.Button>().Any(b => (int)b >= firstNitroxButton && (int)b < firstNitroxButton + KeyBindingManager.KeyBindings.Count))
{
return;
}

__result = (GameInput.Button[])
[
.. __result.Cast<GameInput.Button>(),
.. Enumerable.Range(KeyBindingManager.NITROX_BASE_ID, KeyBindingManager.KeyBindings.Count).Cast<GameInput.Button>()
.. Enumerable.Range(firstNitroxButton, KeyBindingManager.KeyBindings.Count).Cast<GameInput.Button>()
];

__result = result;
}
}
122 changes: 98 additions & 24 deletions NitroxPatcher/Patches/Persistent/GameInputSystem_Initialize_Patch.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using HarmonyLib;
Expand All @@ -9,22 +11,93 @@
namespace NitroxPatcher.Patches.Persistent;

/// <summary>
/// Inserts Nitrox's keybinds in the new Subnautica input system
/// Inserts Nitrox's keybinds in the new Subnautica input system.
/// Extends GameInput.AllActions so the game creates InputAction entries for Nitrox buttons,
/// which is required for compatibility with Nautilus when both mods run together.
/// </summary>
public partial class GameInputSystem_Initialize_Patch : NitroxPatch, IPersistentPatch
public class GameInputSystem_Initialize_Patch : NitroxPatch, IPersistentPatch
{
private static readonly MethodInfo TARGET_METHOD = Reflect.Method((GameInputSystem t) => t.Initialize());
private static readonly MethodInfo DEINITIALIZE_METHOD = Reflect.Method((GameInputSystem t) => t.Deinitialize());
private static GameInput.Button[] oldAllActions;

public static void Prefix(GameInputSystem __instance)
public override void Patch(Harmony harmony)
{
CachedEnumString<GameInput.Button> actionNames = GameInput.ActionNames;
PatchPrefix(harmony, TARGET_METHOD, ((Action<GameInputSystem>)Prefix).Method);
PatchTranspiler(harmony, TARGET_METHOD, ((Func<IEnumerable<CodeInstruction>, IEnumerable<CodeInstruction>>)Transpiler).Method);
PatchPrefix(harmony, DEINITIALIZE_METHOD, ((Action)DeinitializePrefix).Method);
}

/// <summary>
/// Set the actions callbacks for our own keybindings once they're actually created.
/// If the game didn't create entries (e.g. when AllActions extension doesn't apply to this game version),
/// we create and add the InputActions ourselves, matching Nautilus's approach.
/// </summary>
private static void RegisterKeybindsActions(GameInputSystem gameInputSystem)
{
int buttonId = KeyBindingManager.NITROX_BASE_ID;
foreach (KeyBinding keyBinding in KeyBindingManager.KeyBindings)
{
GameInput.Button button = (GameInput.Button)buttonId++;
if (!gameInputSystem.actions.TryGetValue(button, out InputAction action))
{
// Game didn't create this action (AllActions may not be used for action creation in this build).
// Create and add it ourselves, matching Nautilus's InitializePostfix pattern.
string buttonName = GameInput.ActionNames.valueToString.TryGetValue(button, out string name) ? name : button.ToString();
action = new InputAction(buttonName, InputActionType.Button);
if (!string.IsNullOrEmpty(keyBinding.DefaultKeyboardKey))
{
action.AddBinding($"<Keyboard>/{keyBinding.DefaultKeyboardKey}");
}
if (!string.IsNullOrEmpty(keyBinding.DefaultControllerKey))
{
action.AddBinding($"<Gamepad>/{keyBinding.DefaultControllerKey}");
}
gameInputSystem.actions[button] = action;
action.started += gameInputSystem.OnActionStarted;
action.Enable();
}
action.started += keyBinding.Execute;
}
}

private static void Prefix(GameInputSystem __instance)
{
int buttonId = KeyBindingManager.NITROX_BASE_ID;
oldAllActions = GameInput.AllActions;

// Only extend AllActions if Nitrox buttons aren't already present.
// AllActions is typically initialized from Enum.GetValues (patched by Enum_GetValues_Patch),
// so it may already contain Nitrox buttons. Adding them again would cause duplicate keybinds
// in the settings UI since the game builds the binding list from both sources.
if (!GameInput.AllActions.Any(b => (int)b >= buttonId && (int)b < buttonId + KeyBindingManager.KeyBindings.Count))
{
GameInputAccessor.AllActions =
[
.. GameInput.AllActions,
.. Enumerable.Range(buttonId, KeyBindingManager.KeyBindings.Count).Cast<GameInput.Button>()
];
}

CachedEnumString<GameInput.Button> actionNames = GameInput.ActionNames;

// Access Language.main's internal strings dictionary so we can register "Option{buttonId}"
// entries for the binding conflict dialog (the game formats conflict messages using
// "Option" + button.ToString(), which yields "Option1000" for Nitrox buttons).
Dictionary<string, string> langStrings = null;
if (Language.main != null)
{
langStrings = (Dictionary<string, string>)AccessTools.Field(typeof(Language), "strings").GetValue(Language.main);
}

foreach (KeyBinding keyBinding in KeyBindingManager.KeyBindings)
{
GameInput.Button button = (GameInput.Button)buttonId++;
actionNames.valueToString[button] = keyBinding.ButtonLabel;

// Register the "Option1000" style key so the conflict dialog shows the translated label
langStrings?.TryAdd($"Option{(int)button}", Language.main.Get(keyBinding.ButtonLabel));

if (!string.IsNullOrEmpty(keyBinding.DefaultKeyboardKey))
{
// See GameInputSystem.bindingsKeyboard definition
Expand All @@ -38,37 +111,38 @@ public static void Prefix(GameInputSystem __instance)
}
}

private static void DeinitializePrefix()
{
GameInputAccessor.AllActions = oldAllActions;
}

/*
* Modifying the actions must happen before actionMapGameplay.Enable because that line is responsible
* for activating the actions callback we'll be setting
*
*
* GameInputSystem_Initialize_Patch.RegisterKeybindsActions(this); <--- [INSERTED LINE]
* this.actionMapGameplay.Enable();
*/
public static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions)
private static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions)
{
return new CodeMatcher(instructions).MatchStartForward([
new(OpCodes.Ldarg_0),
new(OpCodes.Ldfld),
new(OpCodes.Callvirt, Reflect.Method((InputActionMap t) => t.Enable()))
])
.Insert([
new CodeInstruction(OpCodes.Ldarg_0),
new CodeInstruction(OpCodes.Call, Reflect.Method(() => RegisterKeybindsActions(default)))
])
return new CodeMatcher(instructions).MatchStartForward(new(OpCodes.Ldarg_0), new(OpCodes.Ldfld), new(OpCodes.Callvirt, Reflect.Method((InputActionMap t) => t.Enable())))
.Insert(new CodeInstruction(OpCodes.Ldarg_0), new CodeInstruction(OpCodes.Call, Reflect.Method(() => RegisterKeybindsActions(default))))
.InstructionEnumeration();
}

/// <summary>
/// Set the actions callbacks for our own keybindings once they're actually created only
/// </summary>
public static void RegisterKeybindsActions(GameInputSystem gameInputSystem)

private static class GameInputAccessor
{
int buttonId = KeyBindingManager.NITROX_BASE_ID;
foreach (KeyBinding keyBinding in KeyBindingManager.KeyBindings)
/// <summary>
/// Required because "GameInput.AllActions" is a read only field.
/// </summary>
private static readonly FieldInfo allActionsField = Reflect.Field(() => GameInput.AllActions);

public static GameInput.Button[] AllActions
{
GameInput.Button button = (GameInput.Button)buttonId++;
gameInputSystem.actions[button].started += keyBinding.Execute;
set
{
allActionsField.SetValue(null, value);
}
}
}
}
Loading