Skip to content
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
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
17 changes: 6 additions & 11 deletions NitroxPatcher/Patches/Persistent/Enum_GetValues_Patch.cs
Original file line number Diff line number Diff line change
@@ -1,30 +1,25 @@
using System;
using System.Linq;
using System.Reflection;
using NitroxClient.MonoBehaviours.Gui.Input;

namespace NitroxPatcher.Patches.Persistent;

/// <summary>
/// Specific patch for GameInput.Button enum to return also our own values.
/// Patch for GameInput.Button enum. Nitrox buttons are not added here to avoid duplicate keybinds in the input settings:
/// the game builds the binding list from both Enum.GetValues(GameInput.Button) and GameInput.AllActions when they differ.
/// We extend AllActions in GameInputSystem_Initialize_Patch instead, so the binding UI gets Nitrox keys from that single source.
/// </summary>
public partial class Enum_GetValues_Patch : NitroxPatch, IPersistentPatch
{
private static readonly MethodInfo TARGET_METHOD = Reflect.Method(() => Enum.GetValues(default));

public static void Postfix(Type enumType, ref Array __result)
{
// Intentionally do not extend __result with Nitrox buttons. They are added via GameInput.AllActions
// in GameInputSystem_Initialize_Patch so the settings UI shows each keybind once.
if (enumType != typeof(GameInput.Button))
{
return;
}

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

__result = result;
// Leave __result unchanged (original enum values only).
}
}
103 changes: 79 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,18 +11,70 @@
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)
{
// Extend AllActions so the game creates InputAction entries for Nitrox buttons when building the action map.
// Without this, gameInputSystem.actions[NitroxButton] would not exist and RegisterKeybindsActions would throw.
int buttonId = KeyBindingManager.NITROX_BASE_ID;
oldAllActions = GameInput.AllActions;
GameInputAccessor.AllActions =
[
.. GameInput.AllActions,
.. Enumerable.Range(buttonId, KeyBindingManager.KeyBindings.Count).Cast<GameInput.Button>()
];

CachedEnumString<GameInput.Button> actionNames = GameInput.ActionNames;
foreach (KeyBinding keyBinding in KeyBindingManager.KeyBindings)
{
GameInput.Button button = (GameInput.Button)buttonId++;
actionNames.valueToString[button] = keyBinding.ButtonLabel;
Expand All @@ -38,37 +92,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);
}
}
}
}