diff --git a/Robust.Client/ClientIoC.cs b/Robust.Client/ClientIoC.cs index b444bd09ed2..e197f433fb7 100644 --- a/Robust.Client/ClientIoC.cs +++ b/Robust.Client/ClientIoC.cs @@ -8,6 +8,7 @@ using Robust.Client.GameStates; using Robust.Client.Graphics; using Robust.Client.Graphics.Clyde; +using Robust.Client.HWId; using Robust.Client.Input; using Robust.Client.Map; using Robust.Client.Placement; @@ -158,6 +159,7 @@ public static void RegisterIoC(GameController.DisplayMode mode, IDependencyColle deps.Register(); deps.Register(); + deps.Register(); } } } diff --git a/Robust.Client/Console/Commands/LauncherAuthCommand.cs b/Robust.Client/Console/Commands/LauncherAuthCommand.cs index 27d1469aad8..5fa5c519255 100644 --- a/Robust.Client/Console/Commands/LauncherAuthCommand.cs +++ b/Robust.Client/Console/Commands/LauncherAuthCommand.cs @@ -20,8 +20,9 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args) { var wantName = args.Length > 0 ? args[0] : null; - var basePath = Path.GetDirectoryName(UserDataDir.GetUserDataDir(_gameController))!; - var dbPath = Path.Combine(basePath, "launcher", "settings.db"); + var basePath = UserDataDir.GetRootUserDataDir(_gameController); + var launcherDirName = Environment.GetEnvironmentVariable("SS14_LAUNCHER_APPDATA_NAME") ?? "launcher"; + var dbPath = Path.Combine(basePath, launcherDirName, "settings.db"); #if USE_SYSTEM_SQLITE SQLitePCL.raw.SetProvider(new SQLitePCL.SQLite3Provider_sqlite3()); diff --git a/Robust.Client/HWId/BasicHWId.cs b/Robust.Client/HWId/BasicHWId.cs new file mode 100644 index 00000000000..9dc4d7960a9 --- /dev/null +++ b/Robust.Client/HWId/BasicHWId.cs @@ -0,0 +1,86 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using Microsoft.Win32; +using Robust.Client.Utility; +using Robust.Shared.IoC; +using Robust.Shared.Network; + +namespace Robust.Client.HWId; + +internal sealed class BasicHWId : IHWId +{ + [Dependency] private readonly IGameControllerInternal _gameController = default!; + + public const int LengthHwid = 32; + + public byte[] GetLegacy() + { + if (OperatingSystem.IsWindows()) + return GetWindowsHWid("Hwid"); + + return []; + } + + public byte[] GetModern() + { + byte[] raw; + + if (OperatingSystem.IsWindows()) + raw = GetWindowsHWid("Hwid2"); + else + raw = GetFileHWid(); + + return [0, ..raw]; + } + + private static byte[] GetWindowsHWid(string keyName) + { + const string keyPath = @"HKEY_CURRENT_USER\SOFTWARE\Space Wizards\Robust"; + + var regKey = Registry.GetValue(keyPath, keyName, null); + if (regKey is byte[] { Length: LengthHwid } bytes) + return bytes; + + var newId = new byte[LengthHwid]; + RandomNumberGenerator.Fill(newId); + Registry.SetValue( + keyPath, + keyName, + newId, + RegistryValueKind.Binary); + + return newId; + } + + private byte[] GetFileHWid() + { + var path = UserDataDir.GetRootUserDataDir(_gameController); + var hwidPath = Path.Combine(path, ".hwid"); + + var value = ReadHWidFile(hwidPath); + if (value != null) + return value; + + value = RandomNumberGenerator.GetBytes(LengthHwid); + File.WriteAllBytes(hwidPath, value); + + return value; + } + + private static byte[]? ReadHWidFile(string path) + { + try + { + var value = File.ReadAllBytes(path); + if (value.Length == LengthHwid) + return value; + } + catch (FileNotFoundException) + { + // First time the file won't exist. + } + + return null; + } +} diff --git a/Robust.Client/Utility/UserDataDir.cs b/Robust.Client/Utility/UserDataDir.cs index 19a507bc0e7..9ebcaad46c7 100644 --- a/Robust.Client/Utility/UserDataDir.cs +++ b/Robust.Client/Utility/UserDataDir.cs @@ -1,7 +1,6 @@ using System; using System.IO; using JetBrains.Annotations; -using Robust.Shared.IoC; namespace Robust.Client.Utility { @@ -9,6 +8,12 @@ internal static class UserDataDir { [Pure] public static string GetUserDataDir(IGameControllerInternal gameController) + { + return Path.Combine(GetRootUserDataDir(gameController), "data"); + } + + [Pure] + public static string GetRootUserDataDir(IGameControllerInternal gameController) { string appDataDir; @@ -30,8 +35,7 @@ public static string GetUserDataDir(IGameControllerInternal gameController) appDataDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); #endif - return Path.Combine(appDataDir, gameController.Options.UserDataDirectoryName, "data"); + return Path.Combine(appDataDir, gameController.Options.UserDataDirectoryName); } - } } diff --git a/Robust.Server/ServerIoC.cs b/Robust.Server/ServerIoC.cs index d69eb0f6eb5..fafe7d3bd5b 100644 --- a/Robust.Server/ServerIoC.cs +++ b/Robust.Server/ServerIoC.cs @@ -97,6 +97,7 @@ internal static void RegisterIoC(IDependencyCollection deps) deps.Register(); deps.Register(); deps.Register(); + deps.Register(); } } } diff --git a/Robust.Shared/CVars.cs b/Robust.Shared/CVars.cs index ebd4e29b2e2..8228eb19228 100644 --- a/Robust.Shared/CVars.cs +++ b/Robust.Shared/CVars.cs @@ -394,6 +394,18 @@ protected CVars() public static readonly CVarDef NetEncryptionThreadChannelSize = CVarDef.Create("net.encryption_thread_channel_size", 16); + /// + /// Whether the server should request HWID system for client identification. + /// + /// + /// + /// Note that modern HWIDs are only available if the connection is authenticated. + /// + /// + public static readonly CVarDef NetHWId = + CVarDef.Create("net.hwid", true, CVar.SERVERONLY); + + /** * SUS */ diff --git a/Robust.Shared/Network/AuthManager.cs b/Robust.Shared/Network/AuthManager.cs index f3ed56e8f40..ea2250e891f 100644 --- a/Robust.Shared/Network/AuthManager.cs +++ b/Robust.Shared/Network/AuthManager.cs @@ -15,6 +15,11 @@ internal interface IAuthManager string? Token { get; set; } string? PubKey { get; set; } + /// + /// If true, the user allows HWID information to be provided to servers. + /// + bool AllowHwid { get; set; } + void LoadFromEnv(); } @@ -26,6 +31,7 @@ internal sealed class AuthManager : IAuthManager public string? Server { get; set; } = DefaultAuthServer; public string? Token { get; set; } public string? PubKey { get; set; } + public bool AllowHwid { get; set; } = true; public void LoadFromEnv() { @@ -49,6 +55,11 @@ public void LoadFromEnv() Token = token; } + if (TryGetVar("ROBUST_AUTH_ALLOW_HWID", out var allowHwid)) + { + AllowHwid = allowHwid.Trim() == "1"; + } + static bool TryGetVar(string var, [NotNullWhen(true)] out string? val) { val = Environment.GetEnvironmentVariable(var); diff --git a/Robust.Shared/Network/HWId.cs b/Robust.Shared/Network/HWId.cs deleted file mode 100644 index ad936c0310b..00000000000 --- a/Robust.Shared/Network/HWId.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Security.Cryptography; -using Microsoft.Win32; -using Robust.Shared.Console; - -namespace Robust.Shared.Network -{ - internal static class HWId - { - public const int LengthHwid = 32; - - public static byte[] Calc() - { - if (OperatingSystem.IsWindows()) - { - var regKey = Registry.GetValue(@"HKEY_CURRENT_USER\SOFTWARE\Space Wizards\Robust", "Hwid", null); - if (regKey is byte[] { Length: LengthHwid } bytes) - return bytes; - - var newId = new byte[LengthHwid]; - RandomNumberGenerator.Fill(newId); - Registry.SetValue( - @"HKEY_CURRENT_USER\SOFTWARE\Space Wizards\Robust", - "Hwid", - newId, - RegistryValueKind.Binary); - - return newId; - } - - return Array.Empty(); - } - } - -#if DEBUG - internal sealed class HwidCommand : LocalizedCommands - { - public override string Command => "hwid"; - - public override void Execute(IConsoleShell shell, string argStr, string[] args) - { - shell.WriteLine(Convert.ToBase64String(HWId.Calc(), Base64FormattingOptions.None)); - } - } -#endif -} diff --git a/Robust.Shared/Network/IHWId.cs b/Robust.Shared/Network/IHWId.cs new file mode 100644 index 00000000000..2d5f272032a --- /dev/null +++ b/Robust.Shared/Network/IHWId.cs @@ -0,0 +1,67 @@ +using System; +using Robust.Shared.Console; +using Robust.Shared.IoC; +using Robust.Shared.Utility; + +namespace Robust.Shared.Network; + +/// +/// Fetches HWID (hardware ID) unique identifiers for the local system. +/// +internal interface IHWId +{ + /// + /// Gets the "legacy" HWID. + /// + /// + /// These are directly sent to servers and therefore susceptible to malicious spoofing. + /// They should not be relied on for the future. + /// + /// + /// An opaque value that gets sent to the server to identify this computer, + /// or an empty array if legacy HWID is not supported on this platform. + /// + byte[] GetLegacy(); + + /// + /// Gets the "modern" HWID. + /// + /// + /// An opaque value that gets sent to the auth server to identify this computer, + /// or null if modern HWID is not supported on this platform. + /// + byte[]? GetModern(); +} + +/// +/// Implementation of that does nothing, always returning an empty result. +/// +internal sealed class DummyHWId : IHWId +{ + public byte[] GetLegacy() + { + return []; + } + + public byte[] GetModern() + { + return []; + } +} + +#if DEBUG +internal sealed class HwidCommand : LocalizedCommands +{ + [Dependency] private readonly IHWId _hwId = default!; + + public override string Command => "hwid"; + + public override void Execute(IConsoleShell shell, string argStr, string[] args) + { + shell.WriteLine($""" + legacy: {Convert.ToBase64String(_hwId.GetLegacy(), Base64FormattingOptions.None)} + modern: {Base64Helpers.ToBase64Nullable(_hwId.GetModern())} + """); + } +} +#endif diff --git a/Robust.Shared/Network/Messages/Handshake/MsgEncryptionRequest.cs b/Robust.Shared/Network/Messages/Handshake/MsgEncryptionRequest.cs index 1c281453b98..8d263136606 100644 --- a/Robust.Shared/Network/Messages/Handshake/MsgEncryptionRequest.cs +++ b/Robust.Shared/Network/Messages/Handshake/MsgEncryptionRequest.cs @@ -13,6 +13,7 @@ internal sealed class MsgEncryptionRequest : NetMessage public byte[] VerifyToken; public byte[] PublicKey; + public bool WantHwid; public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer) { @@ -20,6 +21,7 @@ public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer VerifyToken = buffer.ReadBytes(tokenLength); var keyLength = buffer.ReadVariableInt32(); PublicKey = buffer.ReadBytes(keyLength); + WantHwid = buffer.ReadBoolean(); } public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer) @@ -28,6 +30,7 @@ public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer buffer.Write(VerifyToken); buffer.WriteVariableInt32(PublicKey.Length); buffer.Write(PublicKey); + buffer.Write(WantHwid); } } } diff --git a/Robust.Shared/Network/Messages/Handshake/MsgEncryptionResponse.cs b/Robust.Shared/Network/Messages/Handshake/MsgEncryptionResponse.cs index 0442a16f0df..658a3990b16 100644 --- a/Robust.Shared/Network/Messages/Handshake/MsgEncryptionResponse.cs +++ b/Robust.Shared/Network/Messages/Handshake/MsgEncryptionResponse.cs @@ -14,12 +14,15 @@ internal sealed class MsgEncryptionResponse : NetMessage public Guid UserId; public byte[] SealedData; + public byte[] LegacyHwid; public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer) { UserId = buffer.ReadGuid(); var keyLength = buffer.ReadVariableInt32(); SealedData = buffer.ReadBytes(keyLength); + var legacyHwidLength = buffer.ReadVariableInt32(); + LegacyHwid = buffer.ReadBytes(legacyHwidLength); } public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer) @@ -27,6 +30,8 @@ public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer buffer.Write(UserId); buffer.WriteVariableInt32(SealedData.Length); buffer.Write(SealedData); + buffer.WriteVariableInt32(LegacyHwid.Length); + buffer.Write(LegacyHwid); } } } diff --git a/Robust.Shared/Network/Messages/Handshake/MsgLoginStart.cs b/Robust.Shared/Network/Messages/Handshake/MsgLoginStart.cs index 21e2f790e3d..ca8d39755fc 100644 --- a/Robust.Shared/Network/Messages/Handshake/MsgLoginStart.cs +++ b/Robust.Shared/Network/Messages/Handshake/MsgLoginStart.cs @@ -16,7 +16,6 @@ internal sealed class MsgLoginStart : NetMessage public override MsgGroups MsgGroup => MsgGroups.Core; public string UserName; - public ImmutableArray HWId; public bool CanAuth; public bool NeedPubKey; public bool Encrypt; @@ -24,8 +23,6 @@ internal sealed class MsgLoginStart : NetMessage public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer) { UserName = buffer.ReadString(); - var length = buffer.ReadByte(); - HWId = ImmutableArray.Create(buffer.ReadBytes(length)); CanAuth = buffer.ReadBoolean(); NeedPubKey = buffer.ReadBoolean(); Encrypt = buffer.ReadBoolean(); @@ -34,8 +31,6 @@ public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer) { buffer.Write(UserName); - buffer.Write((byte) HWId.Length); - buffer.Write(HWId.AsSpan()); buffer.Write(CanAuth); buffer.Write(NeedPubKey); buffer.Write(Encrypt); diff --git a/Robust.Shared/Network/NetManager.ClientConnect.cs b/Robust.Shared/Network/NetManager.ClientConnect.cs index 1a0e0cfdafc..7e8a4d3c9cc 100644 --- a/Robust.Shared/Network/NetManager.ClientConnect.cs +++ b/Robust.Shared/Network/NetManager.ClientConnect.cs @@ -132,13 +132,13 @@ private async Task CCDoHandshake(NetPeerData peer, NetConnection connection, str var hasPubKey = !string.IsNullOrEmpty(pubKey); var authenticate = !string.IsNullOrEmpty(authToken); - var hwId = ImmutableArray.Create(HWId.Calc()); + byte[] legacyHwid = []; + var msgLogin = new MsgLoginStart { UserName = userNameRequest, CanAuth = authenticate, NeedPubKey = !hasPubKey, - HWId = hwId, Encrypt = encrypt }; @@ -191,7 +191,14 @@ private async Task CCDoHandshake(NetPeerData peer, NetConnection connection, str var authHashBytes = MakeAuthHash(sharedSecret, keyBytes); var authHash = Convert.ToBase64String(authHashBytes); - var joinReq = new JoinRequest(authHash); + byte[]? modernHwid = null; + if (_authManager.AllowHwid && encRequest.WantHwid) + { + legacyHwid = _hwId.GetLegacy(); + modernHwid = _hwId.GetModern(); + } + + var joinReq = new JoinRequest(authHash, Base64Helpers.ToBase64Nullable(modernHwid)); var request = new HttpRequestMessage(HttpMethod.Post, authServer + "api/session/join"); request.Content = JsonContent.Create(joinReq); request.Headers.Authorization = new AuthenticationHeaderValue("SS14Auth", authToken); @@ -202,7 +209,8 @@ private async Task CCDoHandshake(NetPeerData peer, NetConnection connection, str var encryptionResponse = new MsgEncryptionResponse { SealedData = sealedData, - UserId = userId!.Value.UserId + UserId = userId!.Value.UserId, + LegacyHwid = legacyHwid }; var outEncRespMsg = peer.Peer.CreateMessage(); @@ -217,7 +225,7 @@ private async Task CCDoHandshake(NetPeerData peer, NetConnection connection, str var msgSuc = new MsgLoginSuccess(); msgSuc.ReadFromBuffer(response, _serializer); - var channel = new NetChannel(this, connection, msgSuc.UserData with { HWId = hwId }, msgSuc.Type); + var channel = new NetChannel(this, connection, msgSuc.UserData with { HWId = [..legacyHwid] }, msgSuc.Type); _channels.Add(connection, channel); peer.AddChannel(channel); @@ -520,6 +528,6 @@ private Task AwaitData(NetConnection connection, } } - private sealed record JoinRequest(string Hash); + private sealed record JoinRequest(string Hash, string? Hwid); } } diff --git a/Robust.Shared/Network/NetManager.ServerAuth.cs b/Robust.Shared/Network/NetManager.ServerAuth.cs index 8296c446dda..a8eda20dc86 100644 --- a/Robust.Shared/Network/NetManager.ServerAuth.cs +++ b/Robust.Shared/Network/NetManager.ServerAuth.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Immutable; using System.Linq; using System.Net; using System.Net.Http; @@ -79,10 +80,12 @@ private async void HandleHandshake(NetPeerData peer, NetConnection connection) var verifyToken = new byte[4]; RandomNumberGenerator.Fill(verifyToken); + var wantHwid = _config.GetCVar(CVars.NetHWId); var msgEncReq = new MsgEncryptionRequest { PublicKey = needPk ? CryptoPublicKey : Array.Empty(), - VerifyToken = verifyToken + VerifyToken = verifyToken, + WantHwid = wantHwid }; var outMsgEncReq = peer.Peer.CreateMessage(); @@ -153,10 +156,24 @@ private async void HandleHandshake(NetPeerData peer, NetConnection connection) $"Patron: {joinedRespJson.UserData.PatronTier}"); var userId = new NetUserId(joinedRespJson.UserData!.UserId); + ImmutableArray> modernHWIds = [ + ..joinedRespJson.ConnectionData!.Hwids + .Select(h => ImmutableArray.Create(Convert.FromBase64String(h))) + ]; + ImmutableArray legacyHwid = [..msgEncResponse.LegacyHwid]; + if (!wantHwid) + { + // If the client somehow sends a HWID even if we didn't ask for one, ignore it. + modernHWIds = []; + legacyHwid = []; + } + userData = new NetUserData(userId, joinedRespJson.UserData.UserName) { PatronTier = joinedRespJson.UserData.PatronTier, - HWId = msgLogin.HWId + HWId = legacyHwid, + ModernHWIds = modernHWIds, + Trust = joinedRespJson.ConnectionData!.Trust }; padSuccessMessage = false; type = LoginType.LoggedIn; @@ -199,7 +216,8 @@ private async void HandleHandshake(NetPeerData peer, NetConnection connection) userData = new NetUserData(userId, name) { - HWId = msgLogin.HWId + HWId = [], + ModernHWIds = [] }; } @@ -359,8 +377,9 @@ private async void HandleApproval(NetIncomingMessage message) } // ReSharper disable ClassNeverInstantiated.Local - private sealed record HasJoinedResponse(bool IsValid, HasJoinedUserData? UserData); + private sealed record HasJoinedResponse(bool IsValid, HasJoinedUserData? UserData, HasJoinedConnectionData? ConnectionData); private sealed record HasJoinedUserData(string UserName, Guid UserId, string? PatronTier); + private sealed record HasJoinedConnectionData(string[] Hwids, float Trust); // ReSharper restore ClassNeverInstantiated.Local } } diff --git a/Robust.Shared/Network/NetManager.cs b/Robust.Shared/Network/NetManager.cs index 76ec09748e3..f418e0b1926 100644 --- a/Robust.Shared/Network/NetManager.cs +++ b/Robust.Shared/Network/NetManager.cs @@ -111,6 +111,7 @@ public sealed partial class NetManager : IClientNetManager, IServerNetManager, I [Dependency] private readonly ILogManager _logMan = default!; [Dependency] private readonly ProfManager _prof = default!; [Dependency] private readonly HttpClientHolder _http = default!; + [Dependency] private readonly IHWId _hwId = default!; /// /// Holds lookup table for NetMessage.Id -> NetMessage.Type diff --git a/Robust.Shared/Network/NetUserData.cs b/Robust.Shared/Network/NetUserData.cs index b5ee8c33c90..428ea3d96ae 100644 --- a/Robust.Shared/Network/NetUserData.cs +++ b/Robust.Shared/Network/NetUserData.cs @@ -20,6 +20,23 @@ public sealed record NetUserData public ImmutableArray HWId { get; init; } + /// + /// Unique identifiers for a client's computer, account and connection. + /// + /// + /// If any of these values match between two connections, + /// it means the auth server believes them to be the same user. + /// + public ImmutableArray> ModernHWIds { get; init; } + + /// + /// A trust value that reports the auth server's estimate of how likely this user is to be a malicious/suspicious account. + /// + /// + /// A value of 0.5 can be considered "neutral", 1 being "fully trusted". + /// + public float Trust { get; init; } + public NetUserData(NetUserId userId, string userName) { UserId = userId; diff --git a/Robust.Shared/Utility/Base64Helpers.cs b/Robust.Shared/Utility/Base64Helpers.cs index 21c17be6b42..6daeffaec56 100644 --- a/Robust.Shared/Utility/Base64Helpers.cs +++ b/Robust.Shared/Utility/Base64Helpers.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Text; namespace Robust.Shared.Utility @@ -50,5 +51,16 @@ public static byte[] ConvertFromBase64Url(string s) return Convert.FromBase64String(s); } + /// + /// Convert a byte array to base64. Returns null if the input byte array is null. + /// + [return: NotNullIfNotNull(nameof(data))] + public static string? ToBase64Nullable(byte[]? data) + { + if (data == null) + return null; + + return Convert.ToBase64String(data, Base64FormattingOptions.None); + } } } diff --git a/Robust.UnitTesting/RobustIntegrationTest.NetManager.cs b/Robust.UnitTesting/RobustIntegrationTest.NetManager.cs index 7553aa27a08..598a498c90f 100644 --- a/Robust.UnitTesting/RobustIntegrationTest.NetManager.cs +++ b/Robust.UnitTesting/RobustIntegrationTest.NetManager.cs @@ -132,7 +132,8 @@ async Task DoConnect() var sessionId = new NetUserId(userId); var userData = new NetUserData(sessionId, userName) { - HWId = ImmutableArray.Empty + HWId = ImmutableArray.Empty, + ModernHWIds = [] }; var args = await OnConnecting( diff --git a/Robust.UnitTesting/Server/RobustServerSimulation.cs b/Robust.UnitTesting/Server/RobustServerSimulation.cs index 55232b2e510..806ac4f3535 100644 --- a/Robust.UnitTesting/Server/RobustServerSimulation.cs +++ b/Robust.UnitTesting/Server/RobustServerSimulation.cs @@ -3,6 +3,7 @@ using System.Reflection; using JetBrains.Annotations; using Moq; +using Robust.Client.HWId; using Robust.Server; using Robust.Server.Configuration; using Robust.Server.Console; @@ -198,6 +199,7 @@ public ISimulation InitializeInstance() container.RegisterInstance(new Mock().Object); container.Register(); container.Register(); + container.Register(); var realReflection = new ServerReflectionManager(); realReflection.LoadAssemblies(new List(2) diff --git a/Robust.UnitTesting/Shared/GameObjects/EntityState_Tests.cs b/Robust.UnitTesting/Shared/GameObjects/EntityState_Tests.cs index 73a02377708..e042a1aa59a 100644 --- a/Robust.UnitTesting/Shared/GameObjects/EntityState_Tests.cs +++ b/Robust.UnitTesting/Shared/GameObjects/EntityState_Tests.cs @@ -36,6 +36,7 @@ public void ComponentChangedSerialized() container.Register(); container.Register(); container.Register(); + container.Register(); container.Register(); container.Register(); container.Register();