using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default | DebuggableAttribute.DebuggingModes.DisableOptimizations | DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints | DebuggableAttribute.DebuggingModes.EnableEditAndContinue)] [assembly: AssemblyTitle("IndividualInventory")] [assembly: AssemblyDescription("")] [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] [assembly: AssemblyProduct("IndividualInventory")] [assembly: AssemblyCopyright("Copyright © 2026")] [assembly: AssemblyTrademark("")] [assembly: ComVisible(false)] [assembly: Guid("2d915084-84f2-40c1-8624-cf0659d0e0d4")] [assembly: AssemblyFileVersion("1.0.0.0")] [assembly: TargetFramework(".NETFramework,Version=v4.7.2", FrameworkDisplayName = ".NET Framework 4.7.2")] [assembly: AssemblyVersion("1.0.0.0")] namespace FandrayMods.WorldBoundInventory; [BepInPlugin("fandray.valheim.worldboundinventory", "WorldBoundInventory", "1.1.0")] public sealed class WorldBoundInventoryPlugin : BaseUnityPlugin { public const string PluginGuid = "fandray.valheim.worldboundinventory"; public const string PluginName = "WorldBoundInventory"; public const string PluginVersion = "1.1.0"; internal static ManualLogSource Log; internal static ConfigEntry ModEnabled; internal static ConfigEntry ImportInventoryIntoFirstWorld; internal static ConfigEntry EmptyInventoryInNewWorlds; internal static ConfigEntry AutosaveSeconds; internal static ConfigEntry KeepBackup; internal static ConfigEntry EnforceModOnServer; internal static ConfigEntry RequireExactAssembly; internal static ConfigEntry HandshakeTimeoutSeconds; private Harmony _harmony; private void Awake() { //IL_009a: Unknown result type (might be due to invalid IL or missing references) //IL_00a4: Expected O, but got Unknown //IL_0137: Unknown result type (might be due to invalid IL or missing references) //IL_0141: Expected O, but got Unknown //IL_0166: Unknown result type (might be due to invalid IL or missing references) //IL_0170: Expected O, but got Unknown Log = ((BaseUnityPlugin)this).Logger; ModEnabled = ((BaseUnityPlugin)this).Config.Bind("General", "Enabled", true, "Enable separate character inventory for every world/server."); ImportInventoryIntoFirstWorld = ((BaseUnityPlugin)this).Config.Bind("Migration", "ImportInventoryIntoFirstWorld", true, "On the first launch with this mod, assign the character's current inventory to the first world entered."); EmptyInventoryInNewWorlds = ((BaseUnityPlugin)this).Config.Bind("General", "EmptyInventoryInNewWorlds", true, "When the character enters an unknown world, create an empty inventory instead of copying the last vanilla inventory."); AutosaveSeconds = ((BaseUnityPlugin)this).Config.Bind("Saving", "AutosaveSeconds", 15f, new ConfigDescription("Delay between inventory autosaves. Vanilla profile saves and game exit also save immediately.", (AcceptableValueBase)(object)new AcceptableValueRange(5f, 300f), Array.Empty())); KeepBackup = ((BaseUnityPlugin)this).Config.Bind("Saving", "KeepBackup", true, "Keep the previous valid slot as a .bak file."); EnforceModOnServer = ((BaseUnityPlugin)this).Config.Bind("Server enforcement", "EnforceModOnServer", true, "When this instance is the host/server, reject clients that do not have this mod."); RequireExactAssembly = ((BaseUnityPlugin)this).Config.Bind("Server enforcement", "RequireExactAssembly", true, "Require clients to use the exact same WorldBoundInventory.dll as the server (SHA-256 check)."); HandshakeTimeoutSeconds = ((BaseUnityPlugin)this).Config.Bind("Server enforcement", "HandshakeTimeoutSeconds", 8f, new ConfigDescription("Maximum time allowed for the client mod handshake.", (AcceptableValueBase)(object)new AcceptableValueRange(3f, 30f), Array.Empty())); WorldInventoryStore.Initialize(Path.Combine(Paths.ConfigPath, "WorldBoundInventory", "Inventories")); _harmony = new Harmony("fandray.valheim.worldboundinventory"); if (!WorldInventoryPatches.Apply(_harmony)) { ((BaseUnityPlugin)this).Logger.LogError((object)"Required PlayerProfile methods were not found. The mod has been disabled to protect the inventory."); ModEnabled.Value = false; } else if (!ServerHandshakeGate.Apply(_harmony)) { ((BaseUnityPlugin)this).Logger.LogError((object)"Required ZNet handshake methods were not found. The mod has been disabled to avoid unprotected connections."); ModEnabled.Value = false; _harmony.UnpatchSelf(); } else { ((BaseUnityPlugin)this).Logger.LogInfo((object)"WorldBoundInventory 1.1.0 loaded."); } } private void Update() { if (ModEnabled != null && ModEnabled.Value) { WorldInventoryStore.Tick(Time.unscaledTime); ServerHandshakeGate.Tick(Time.unscaledTime); } } private void OnApplicationQuit() { WorldInventoryStore.SaveActive("application quit", force: true); } private void OnDestroy() { WorldInventoryStore.SaveActive("plugin shutdown", force: true); ServerHandshakeGate.Reset(); if (_harmony != null) { _harmony.UnpatchSelf(); } } } internal static class WorldInventoryPatches { private static MethodInfo FindPlayerMethod(string name) { return AccessTools.GetDeclaredMethods(typeof(PlayerProfile)).FirstOrDefault((MethodInfo method) => method.Name == name && method.GetParameters().Any((ParameterInfo parameter) => typeof(Player).IsAssignableFrom(parameter.ParameterType))); } internal static bool Apply(Harmony harmony) { //IL_004a: Unknown result type (might be due to invalid IL or missing references) //IL_0057: Expected O, but got Unknown //IL_006a: Unknown result type (might be due to invalid IL or missing references) //IL_0078: Expected O, but got Unknown //IL_00b5: Unknown result type (might be due to invalid IL or missing references) //IL_00c2: Expected O, but got Unknown MethodInfo methodInfo = FindPlayerMethod("LoadPlayerData"); MethodInfo methodInfo2 = FindPlayerMethod("SavePlayerData"); if (methodInfo == null || methodInfo2 == null) { return false; } harmony.Patch((MethodBase)methodInfo, (HarmonyMethod)null, new HarmonyMethod(typeof(WorldInventoryPatches), "AfterLoadPlayerData", (Type[])null), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); harmony.Patch((MethodBase)methodInfo2, new HarmonyMethod(typeof(WorldInventoryPatches), "BeforeSavePlayerData", (Type[])null), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); MethodInfo methodInfo3 = AccessTools.Method(typeof(Inventory), "Changed", Type.EmptyTypes, (Type[])null); if (methodInfo3 != null) { harmony.Patch((MethodBase)methodInfo3, (HarmonyMethod)null, new HarmonyMethod(typeof(WorldInventoryPatches), "AfterInventoryChanged", (Type[])null), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); WorldInventoryStore.InventoryChangedPatchInstalled = true; } else { WorldBoundInventoryPlugin.Log.LogWarning((object)"Inventory.Changed was not found. Timed saving will still be used."); } return true; } public static void AfterLoadPlayerData(PlayerProfile __instance, object[] __args) { if (WorldBoundInventoryPlugin.ModEnabled.Value) { Player val = FindPlayer(__args); if ((Object)(object)val != (Object)null) { WorldInventoryStore.OnVanillaInventoryLoaded(__instance, val); } } } public static void BeforeSavePlayerData(PlayerProfile __instance, object[] __args) { if (WorldBoundInventoryPlugin.ModEnabled.Value) { Player val = FindPlayer(__args); if ((Object)(object)val != (Object)null) { WorldInventoryStore.SaveForProfile(__instance, val, "vanilla profile save"); } } } public static void AfterInventoryChanged(Inventory __instance) { WorldInventoryStore.MarkDirty(__instance); } private static Player FindPlayer(object[] args) { if (args == null) { return null; } foreach (object obj in args) { Player val = (Player)((obj is Player) ? obj : null); if ((Object)(object)val != (Object)null) { return val; } } return null; } } internal static class WorldInventoryStore { private enum SlotLoadResult { Missing, Loaded, Corrupt } private const string FileMagic = "WBI_SLOT"; private const int FileVersion = 1; private const int MaximumPayloadBytes = 67108864; private static readonly object Sync = new object(); private static string _rootDirectory; private static PlayerProfile _pendingProfile; private static Player _pendingPlayer; private static PlayerProfile _activeProfile; private static Player _activePlayer; private static Inventory _activeInventory; private static string _activeCharacterId; private static string _activeWorldId; private static string _activeWorldName; private static string _activeSlotPath; private static bool _dirty; private static bool _blocked; private static bool _switching; private static bool _preserveBackupOnNextSave; private static float _nextAutosaveTime; internal static bool InventoryChangedPatchInstalled { get; set; } internal static void Initialize(string rootDirectory) { _rootDirectory = rootDirectory; Directory.CreateDirectory(_rootDirectory); } internal static void OnVanillaInventoryLoaded(PlayerProfile profile, Player player) { lock (Sync) { _pendingProfile = profile; _pendingPlayer = player; } TryActivatePending(); } internal static void Tick(float now) { TryActivatePending(); lock (Sync) { if (!((Object)(object)_activePlayer == (Object)null) && _activeInventory != null && !_blocked && !_switching && !(now < _nextAutosaveTime)) { _nextAutosaveTime = now + GetAutosaveInterval(); if (_dirty || !InventoryChangedPatchInstalled) { SaveActiveLocked("timed autosave", force: false); } } } } internal static void MarkDirty(Inventory inventory) { if (!WorldBoundInventoryPlugin.ModEnabled.Value) { return; } lock (Sync) { if (!_switching && !_blocked && inventory != null && inventory == _activeInventory) { _dirty = true; } } } internal static void SaveForProfile(PlayerProfile profile, Player player, string reason) { lock (Sync) { if (!_blocked && !_switching && !((Object)(object)_activePlayer == (Object)null) && profile == _activeProfile && player == _activePlayer) { SaveActiveLocked(reason, force: true); } } } internal static void SaveActive(string reason, bool force) { if (WorldBoundInventoryPlugin.ModEnabled == null || !WorldBoundInventoryPlugin.ModEnabled.Value) { return; } lock (Sync) { SaveActiveLocked(reason, force); } } private static void TryActivatePending() { PlayerProfile pendingProfile; Player pendingPlayer; lock (Sync) { pendingProfile = _pendingProfile; pendingPlayer = _pendingPlayer; } if (pendingProfile == null || (Object)(object)pendingPlayer == (Object)null || (Object)(object)ZNet.instance == (Object)null || !WorldIdentity.TryGet(out var worldId, out var worldName)) { return; } string stableId = CharacterIdentity.GetStableId(pendingProfile, pendingPlayer); if (string.IsNullOrEmpty(stableId)) { WorldBoundInventoryPlugin.Log.LogError((object)"Could not determine a stable character ID. Inventory switching was cancelled for safety."); lock (Sync) { _pendingProfile = null; _pendingPlayer = null; return; } } lock (Sync) { if (pendingPlayer == _activePlayer && string.Equals(stableId, _activeCharacterId, StringComparison.Ordinal) && string.Equals(worldId, _activeWorldId, StringComparison.Ordinal)) { _pendingProfile = null; _pendingPlayer = null; return; } if ((Object)(object)_activePlayer != (Object)null && !_blocked) { SaveActiveLocked("before world switch", force: true); } ActivateLocked(pendingProfile, pendingPlayer, stableId, worldId, worldName); _pendingProfile = null; _pendingPlayer = null; } } private static void ActivateLocked(PlayerProfile profile, Player player, string characterId, string worldId, string worldName) { Inventory inventory = ((Humanoid)player).GetInventory(); if (inventory == null) { WorldBoundInventoryPlugin.Log.LogError((object)"Player inventory is unavailable; switching was cancelled."); return; } byte[] payload = SerializeInventory(inventory); string characterDirectory = GetCharacterDirectory(characterId); Directory.CreateDirectory(characterDirectory); string text = Path.Combine(characterDirectory, "world_" + HashForPath(worldId) + ".wbi"); _switching = true; _blocked = false; try { byte[] payload2; string source; switch (TryLoadSlot(text, characterId, worldId, out payload2, out source)) { case SlotLoadResult.Loaded: ReplaceInventory(player, inventory, payload2); ActivateSession(profile, player, inventory, characterId, worldId, worldName, text); _preserveBackupOnNextSave = string.Equals(source, "the backup slot", StringComparison.Ordinal); WorldBoundInventoryPlugin.Log.LogInfo((object)("Loaded inventory for world '" + worldName + "' from " + source + ".")); return; case SlotLoadResult.Corrupt: _blocked = true; ClearActiveSession(); WorldBoundInventoryPlugin.Log.LogError((object)("The inventory slot and its backup could not be read: " + text + ". The vanilla inventory was left untouched and this slot will NOT be overwritten.")); return; } bool flag = (!Directory.EnumerateFiles(characterDirectory, "world_*.wbi*", SearchOption.TopDirectoryOnly).Any() && WorldBoundInventoryPlugin.ImportInventoryIntoFirstWorld.Value) || !WorldBoundInventoryPlugin.EmptyInventoryInNewWorlds.Value; if (!flag) { ReplaceInventory(player, inventory, new byte[0]); } ActivateSession(profile, player, inventory, characterId, worldId, worldName, text); _switching = false; SaveActiveLocked(flag ? "create first/copied world slot" : "create empty world slot", force: true); WorldBoundInventoryPlugin.Log.LogInfo((object)("Created " + (flag ? "an imported" : "an empty") + " inventory for world '" + worldName + "'.")); } catch (Exception ex) { try { ReplaceInventory(player, inventory, payload); } catch (Exception ex2) { WorldBoundInventoryPlugin.Log.LogError((object)("Restoring the pre-switch inventory also failed: " + ex2)); } _blocked = true; ClearActiveSession(); WorldBoundInventoryPlugin.Log.LogError((object)("Inventory switching failed. The vanilla inventory was left as safely as possible. " + ex)); } finally { _switching = false; } } private static void ActivateSession(PlayerProfile profile, Player player, Inventory inventory, string characterId, string worldId, string worldName, string slotPath) { _activeProfile = profile; _activePlayer = player; _activeInventory = inventory; _activeCharacterId = characterId; _activeWorldId = worldId; _activeWorldName = worldName; _activeSlotPath = slotPath; _dirty = true; _blocked = false; _preserveBackupOnNextSave = false; _nextAutosaveTime = Time.unscaledTime + GetAutosaveInterval(); } private static void ClearActiveSession() { _activeProfile = null; _activePlayer = null; _activeInventory = null; _activeCharacterId = null; _activeWorldId = null; _activeWorldName = null; _activeSlotPath = null; _dirty = false; _preserveBackupOnNextSave = false; } private static float GetAutosaveInterval() { if (WorldBoundInventoryPlugin.AutosaveSeconds == null) { return 15f; } return Mathf.Clamp(WorldBoundInventoryPlugin.AutosaveSeconds.Value, 5f, 300f); } private static void SaveActiveLocked(string reason, bool force) { if ((Object)(object)_activePlayer == (Object)null || _activeInventory == null || _blocked || _switching || (!force && !_dirty && InventoryChangedPatchInstalled)) { return; } try { byte[] payload = SerializeInventory(_activeInventory); byte[] data = BuildSlotFile(_activeCharacterId, _activeWorldId, _activeWorldName, payload); if (_preserveBackupOnNextSave && File.Exists(_activeSlotPath + ".bak")) { File.Copy(_activeSlotPath + ".bak", _activeSlotPath, overwrite: true); } WriteAtomically(_activeSlotPath, data); _preserveBackupOnNextSave = false; _dirty = false; WorldBoundInventoryPlugin.Log.LogDebug((object)("Inventory saved (" + reason + ") for world '" + _activeWorldName + "'.")); } catch (Exception ex) { WorldBoundInventoryPlugin.Log.LogError((object)("Could not save world inventory (" + reason + "): " + ex)); } } private static byte[] SerializeInventory(Inventory inventory) { //IL_0001: Unknown result type (might be due to invalid IL or missing references) //IL_0007: Expected O, but got Unknown ZPackage val = new ZPackage(); inventory.Save(val); return val.GetArray(); } private static void ReplaceInventory(Player player, Inventory inventory, byte[] payload) { //IL_0021: Unknown result type (might be due to invalid IL or missing references) //IL_002b: Expected O, but got Unknown ((Humanoid)player).UnequipAllItems(); inventory.RemoveAll(); if (payload != null && payload.Length != 0) { inventory.Load(new ZPackage(payload)); } MethodInfo methodInfo = AccessTools.Method(((object)player).GetType(), "SetupEquipment", Type.EmptyTypes, (Type[])null) ?? AccessTools.Method(typeof(Humanoid), "SetupEquipment", Type.EmptyTypes, (Type[])null); if (methodInfo != null) { methodInfo.Invoke(player, null); } MethodInfo methodInfo2 = AccessTools.Method(typeof(Inventory), "Changed", Type.EmptyTypes, (Type[])null); if (methodInfo2 != null) { methodInfo2.Invoke(inventory, null); } } private static SlotLoadResult TryLoadSlot(string slotPath, string expectedCharacterId, string expectedWorldId, out byte[] payload, out string source) { payload = null; source = null; bool flag = File.Exists(slotPath); bool flag2 = File.Exists(slotPath + ".bak"); if (!flag && !flag2) { return SlotLoadResult.Missing; } string error = null; if (flag && TryReadSlotFile(slotPath, expectedCharacterId, expectedWorldId, out payload, out error)) { source = "the main slot"; return SlotLoadResult.Loaded; } if (flag) { WorldBoundInventoryPlugin.Log.LogWarning((object)("Main inventory slot is invalid: " + error)); } if (flag2 && TryReadSlotFile(slotPath + ".bak", expectedCharacterId, expectedWorldId, out payload, out error)) { source = "the backup slot"; WorldBoundInventoryPlugin.Log.LogWarning((object)"The backup inventory was loaded because the main slot was invalid."); return SlotLoadResult.Loaded; } if (flag2) { WorldBoundInventoryPlugin.Log.LogWarning((object)("Backup inventory slot is invalid: " + error)); } return SlotLoadResult.Corrupt; } private static byte[] BuildSlotFile(string characterId, string worldId, string worldName, byte[] payload) { byte[] array = ComputeHash(payload); using MemoryStream memoryStream = new MemoryStream(); using BinaryWriter binaryWriter = new BinaryWriter(memoryStream, Encoding.UTF8); binaryWriter.Write("WBI_SLOT"); binaryWriter.Write(1); binaryWriter.Write(characterId ?? string.Empty); binaryWriter.Write(worldId ?? string.Empty); binaryWriter.Write(worldName ?? string.Empty); binaryWriter.Write(DateTime.UtcNow.Ticks); binaryWriter.Write(payload.Length); binaryWriter.Write(payload); binaryWriter.Write(array.Length); binaryWriter.Write(array); binaryWriter.Flush(); return memoryStream.ToArray(); } private static bool TryReadSlotFile(string path, string expectedCharacterId, string expectedWorldId, out byte[] payload, out string error) { payload = null; error = null; try { using FileStream input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); using BinaryReader binaryReader = new BinaryReader(input, Encoding.UTF8); string a = binaryReader.ReadString(); int num = binaryReader.ReadInt32(); string a2 = binaryReader.ReadString(); string a3 = binaryReader.ReadString(); binaryReader.ReadString(); binaryReader.ReadInt64(); if (!string.Equals(a, "WBI_SLOT", StringComparison.Ordinal) || num != 1) { error = "unknown file format/version"; return false; } if (!string.Equals(a2, expectedCharacterId, StringComparison.Ordinal) || !string.Equals(a3, expectedWorldId, StringComparison.Ordinal)) { error = "slot identity does not match this character/world"; return false; } int num2 = binaryReader.ReadInt32(); if (num2 < 0 || num2 > 67108864) { error = "invalid inventory payload size"; return false; } byte[] array = binaryReader.ReadBytes(num2); if (array.Length != num2) { error = "inventory payload is truncated"; return false; } int num3 = binaryReader.ReadInt32(); if (num3 != 32) { error = "invalid checksum size"; return false; } byte[] array2 = binaryReader.ReadBytes(num3); if (array2.Length != num3 || !FixedTimeEquals(array2, ComputeHash(array))) { error = "checksum mismatch"; return false; } payload = array; return true; } catch (Exception ex) { error = ex.GetType().Name + ": " + ex.Message; return false; } } private static bool FixedTimeEquals(byte[] left, byte[] right) { if (left == null || right == null || left.Length != right.Length) { return false; } int num = 0; for (int i = 0; i < left.Length; i++) { num |= left[i] ^ right[i]; } return num == 0; } private static byte[] ComputeHash(byte[] data) { using SHA256 sHA = SHA256.Create(); return sHA.ComputeHash(data ?? new byte[0]); } private static void WriteAtomically(string path, byte[] data) { string directoryName = Path.GetDirectoryName(path); if (string.IsNullOrEmpty(directoryName)) { throw new InvalidOperationException("Inventory slot has no parent directory."); } Directory.CreateDirectory(directoryName); string text = path + ".tmp"; string text2 = path + ".bak"; try { using (FileStream fileStream = new FileStream(text, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) { fileStream.Write(data, 0, data.Length); fileStream.Flush(flushToDisk: true); } if (!File.Exists(path)) { File.Move(text, path); return; } try { File.Replace(text, path, text2, ignoreMetadataErrors: true); } catch (PlatformNotSupportedException) { FallbackReplace(text, path, text2); } catch (IOException) { FallbackReplace(text, path, text2); } if (!WorldBoundInventoryPlugin.KeepBackup.Value && File.Exists(text2)) { File.Delete(text2); } } finally { if (File.Exists(text)) { File.Delete(text); } } } private static void FallbackReplace(string temporaryPath, string path, string backupPath) { File.Copy(path, backupPath, overwrite: true); File.Copy(temporaryPath, path, overwrite: true); File.Delete(temporaryPath); } private static string GetCharacterDirectory(string characterId) { return Path.Combine(_rootDirectory, "character_" + HashForPath(characterId)); } private static string HashForPath(string value) { byte[] bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); byte[] array = ComputeHash(bytes); StringBuilder stringBuilder = new StringBuilder(24); for (int i = 0; i < 12; i++) { stringBuilder.Append(array[i].ToString("x2", CultureInfo.InvariantCulture)); } return stringBuilder.ToString(); } } internal static class CharacterIdentity { internal static string GetStableId(PlayerProfile profile, Player player) { object value = ReflectionValue.TryInvoke(profile, "GetPlayerID"); string text = NormalizeNumber(value); if (!string.IsNullOrEmpty(text) && text != "0") { return "player-id:" + text; } object obj = ReflectionValue.TryInvoke(profile, "GetFilename"); string text2 = obj as string; if (!string.IsNullOrWhiteSpace(text2)) { return "profile-file:" + text2.Trim(); } object obj2 = ReflectionValue.TryGetField(profile, "m_filename", "m_fileName"); text2 = obj2 as string; if (!string.IsNullOrWhiteSpace(text2)) { return "profile-file:" + text2.Trim(); } object obj3 = ReflectionValue.TryInvoke(profile, "GetPlayerName"); string text3 = obj3 as string; if (string.IsNullOrWhiteSpace(text3) && (Object)(object)player != (Object)null) { text3 = player.GetPlayerName(); } return string.IsNullOrWhiteSpace(text3) ? null : ("player-name-fallback:" + text3.Trim()); } private static string NormalizeNumber(object value) { if (value == null) { return null; } return (value is IFormattable formattable) ? formattable.ToString(null, CultureInfo.InvariantCulture) : value.ToString(); } } internal static class WorldIdentity { internal static bool TryGet(out string worldId, out string worldName) { worldId = null; worldName = "unknown"; ZNet instance = ZNet.instance; if ((Object)(object)instance == (Object)null) { return false; } object obj = ReflectionValue.TryInvoke(instance, "GetWorldName"); string text = obj as string; if (!string.IsNullOrWhiteSpace(text)) { worldName = text.Trim(); } object value = ReflectionValue.TryInvoke(instance, "GetWorldUID"); string text2 = NormalizeUid(value); if (string.IsNullOrEmpty(text2) || text2 == "0") { object instance2 = ReflectionValue.TryGetField(instance, "m_world"); object value2 = ReflectionValue.TryGetField(instance2, "m_uid", "m_worldUID", "m_worldUid"); text2 = NormalizeUid(value2); } if (string.IsNullOrEmpty(text2) || text2 == "0") { return false; } worldId = "world-uid:" + text2; return true; } private static string NormalizeUid(object value) { if (value == null) { return null; } return (value is IFormattable formattable) ? formattable.ToString(null, CultureInfo.InvariantCulture) : value.ToString(); } } internal static class ReflectionValue { internal static object TryInvoke(object instance, string methodName) { if (instance == null) { return null; } try { MethodInfo methodInfo = AccessTools.Method(instance.GetType(), methodName, Type.EmptyTypes, (Type[])null); return (methodInfo == null) ? null : methodInfo.Invoke(instance, null); } catch { return null; } } internal static object TryGetField(object instance, params string[] fieldNames) { if (instance == null || fieldNames == null) { return null; } for (int i = 0; i < fieldNames.Length; i++) { try { FieldInfo fieldInfo = AccessTools.Field(instance.GetType(), fieldNames[i]); if (fieldInfo != null) { return fieldInfo.GetValue(instance); } } catch { } } return null; } } internal static class ServerHandshakeGate { private sealed class PeerHandshake { internal float Deadline; internal float HeartbeatDeadline; internal bool Approved; internal bool Rejected; internal bool DisconnectSent; internal float SoftDisconnectAt; internal float HardDisconnectAt; internal string Reason; } private const int ProtocolVersion = 1; private const string ClientHelloRpc = "WBI_ClientHello_V1"; private const string ClientHeartbeatRpc = "WBI_ClientHeartbeat_V1"; private const string ServerAcceptedRpc = "WBI_ServerAccepted_V1"; private const string ServerDeniedRpc = "WBI_ServerDenied_V1"; private static readonly object Sync = new object(); private static readonly Dictionary Peers = new Dictionary(); private static MethodInfo _getPeerByRpc; private static MethodInfo _disconnectPeer; private static string _assemblySha256; private static bool _serverConfirmed; private static ZRpc _serverRpc; private static float _nextHeartbeatTime; internal static bool Apply(Harmony harmony) { //IL_00a8: Unknown result type (might be due to invalid IL or missing references) //IL_00b5: Expected O, but got Unknown //IL_00c8: Unknown result type (might be due to invalid IL or missing references) //IL_00d6: Expected O, but got Unknown MethodInfo methodInfo = AccessTools.GetDeclaredMethods(typeof(ZNet)).FirstOrDefault((MethodInfo method) => method.Name == "OnNewConnection" && method.GetParameters().Length == 1 && typeof(ZNetPeer).IsAssignableFrom(method.GetParameters()[0].ParameterType)); MethodInfo methodInfo2 = AccessTools.GetDeclaredMethods(typeof(ZNet)).FirstOrDefault(delegate(MethodInfo method) { if (method.Name != "RPC_PeerInfo") { return false; } ParameterInfo[] parameters = method.GetParameters(); return parameters.Length >= 2 && typeof(ZRpc).IsAssignableFrom(parameters[0].ParameterType) && typeof(ZPackage).IsAssignableFrom(parameters[1].ParameterType); }); if (methodInfo == null || methodInfo2 == null) { return false; } CacheNetworkMethods(); _assemblySha256 = CalculateOwnAssemblyHash(); harmony.Patch((MethodBase)methodInfo, (HarmonyMethod)null, new HarmonyMethod(typeof(ServerHandshakeGate), "AfterNewConnection", (Type[])null), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); harmony.Patch((MethodBase)methodInfo2, new HarmonyMethod(typeof(ServerHandshakeGate), "BeforePeerInfo", (Type[])null), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); WorldBoundInventoryPlugin.Log.LogInfo((object)("Server enforcement ready. Assembly SHA-256: " + ShortHash(_assemblySha256))); return true; } public static void AfterNewConnection(ZNet __instance, ZNetPeer __0) { if (!WorldBoundInventoryPlugin.ModEnabled.Value || __0 == null || __0.m_rpc == null) { return; } ZRpc rpc = __0.m_rpc; try { rpc.Register("WBI_ClientHello_V1", (Action)OnClientHello); rpc.Register("WBI_ClientHeartbeat_V1", (Action)OnClientHeartbeat); rpc.Register("WBI_ServerAccepted_V1", (Action)OnServerAccepted); rpc.Register("WBI_ServerDenied_V1", (Action)OnServerDenied); if (__instance.IsServer() && IsEnforcementEnabled()) { lock (Sync) { if (!Peers.TryGetValue(rpc, out var value)) { value = new PeerHandshake(); Peers[rpc] = value; } value.Deadline = Time.unscaledTime + GetTimeout(); return; } } _serverConfirmed = false; _serverRpc = rpc; SendClientHello(rpc); } catch (Exception ex) { WorldBoundInventoryPlugin.Log.LogError((object)("Could not initialize the mandatory-mod handshake: " + ex)); if (__instance.IsServer() && IsEnforcementEnabled()) { Reject(rpc, "handshake initialization failed"); } } } public static bool BeforePeerInfo(ZNet __instance, ZRpc __0) { if (!WorldBoundInventoryPlugin.ModEnabled.Value || !__instance.IsServer() || !IsEnforcementEnabled()) { return true; } lock (Sync) { if (__0 != null && Peers.TryGetValue(__0, out var value) && value.Approved) { return true; } } Reject(__0, "WorldBoundInventory is missing or incompatible"); return false; } internal static void Tick(float now) { if ((Object)(object)ZNet.instance == (Object)null) { return; } if (!ZNet.instance.IsServer()) { if (_serverConfirmed && _serverRpc != null && now >= _nextHeartbeatTime) { _nextHeartbeatTime = now + 3f; SendClientHeartbeat(_serverRpc); } return; } if (!IsEnforcementEnabled()) { lock (Sync) { Peers.Clear(); return; } } List list = new List(); List list2 = new List(); List list3 = new List(); List list4 = new List(); lock (Sync) { foreach (KeyValuePair peer in Peers) { ZRpc key = peer.Key; PeerHandshake value = peer.Value; if (!IsRpcConnected(key)) { list4.Add(key); continue; } if (!value.Approved && !value.Rejected && now >= value.Deadline) { list.Add(key); } if (value.Approved && !value.Rejected && now >= value.HeartbeatDeadline) { list.Add(key); } if (value.Rejected && !value.DisconnectSent && now >= value.SoftDisconnectAt) { value.DisconnectSent = true; list2.Add(key); } if (value.Rejected && now >= value.HardDisconnectAt) { list3.Add(key); } } for (int i = 0; i < list4.Count; i++) { Peers.Remove(list4[i]); } } for (int j = 0; j < list.Count; j++) { Reject(list[j], "mandatory mod handshake timed out"); } for (int k = 0; k < list2.Count; k++) { TryInvokeRpc(list2[k], "Disconnect"); } for (int l = 0; l < list3.Count; l++) { HardDisconnect(list3[l]); lock (Sync) { Peers.Remove(list3[l]); } } } internal static void Reset() { lock (Sync) { Peers.Clear(); _serverConfirmed = false; _serverRpc = null; _nextHeartbeatTime = 0f; } } private static void SendClientHello(ZRpc rpc) { //IL_0001: Unknown result type (might be due to invalid IL or missing references) //IL_0007: Expected O, but got Unknown ZPackage val = new ZPackage(); WriteClientIdentity(val); rpc.Invoke("WBI_ClientHello_V1", new object[1] { val }); WorldBoundInventoryPlugin.Log.LogDebug((object)"Mandatory-mod hello sent to server."); } private static void SendClientHeartbeat(ZRpc rpc) { //IL_0018: Unknown result type (might be due to invalid IL or missing references) //IL_001e: Expected O, but got Unknown try { if (!IsRpcConnected(rpc)) { _serverConfirmed = false; return; } ZPackage val = new ZPackage(); WriteClientIdentity(val); rpc.Invoke("WBI_ClientHeartbeat_V1", new object[1] { val }); } catch (Exception ex) { WorldBoundInventoryPlugin.Log.LogDebug((object)("Could not send mandatory-mod heartbeat: " + ex.Message)); } } private static void WriteClientIdentity(ZPackage package) { package.Write(1); package.Write("fandray.valheim.worldboundinventory"); package.Write("1.1.0"); package.Write(_assemblySha256 ?? string.Empty); } private static void OnClientHello(ZRpc rpc, ZPackage package) { //IL_00f5: Unknown result type (might be due to invalid IL or missing references) //IL_00fc: Expected O, but got Unknown if ((Object)(object)ZNet.instance == (Object)null || !ZNet.instance.IsServer() || !IsEnforcementEnabled()) { return; } try { int protocol = package.ReadInt(); string guid = package.ReadString(); string version = package.ReadString(); string text = package.ReadString(); string text2 = ValidateClient(protocol, guid, version, text); if (text2 != null) { Reject(rpc, text2); return; } lock (Sync) { if (!Peers.TryGetValue(rpc, out var value)) { value = new PeerHandshake(); Peers[rpc] = value; } value.Approved = true; value.Rejected = false; value.HeartbeatDeadline = Time.unscaledTime + GetHeartbeatGrace(); } ZPackage val = new ZPackage(); val.Write(1); val.Write("1.1.0"); val.Write(_assemblySha256 ?? string.Empty); rpc.Invoke("WBI_ServerAccepted_V1", new object[1] { val }); WorldBoundInventoryPlugin.Log.LogInfo((object)("Client passed mandatory WorldBoundInventory validation (DLL " + ShortHash(text) + ").")); } catch (Exception ex) { Reject(rpc, "malformed WorldBoundInventory handshake: " + ex.GetType().Name); } } private static void OnClientHeartbeat(ZRpc rpc, ZPackage package) { if ((Object)(object)ZNet.instance == (Object)null || !ZNet.instance.IsServer() || !IsEnforcementEnabled()) { return; } try { int protocol = package.ReadInt(); string guid = package.ReadString(); string version = package.ReadString(); string assemblyHash = package.ReadString(); string text = ValidateClient(protocol, guid, version, assemblyHash); if (text != null) { Reject(rpc, "heartbeat validation failed: " + text); return; } lock (Sync) { if (!Peers.TryGetValue(rpc, out var value) || !value.Approved || value.Rejected) { Reject(rpc, "heartbeat arrived before an approved handshake"); } else { value.HeartbeatDeadline = Time.unscaledTime + GetHeartbeatGrace(); } } } catch (Exception ex) { Reject(rpc, "malformed heartbeat: " + ex.GetType().Name); } } private static void OnServerAccepted(ZRpc rpc, ZPackage package) { if ((Object)(object)ZNet.instance == (Object)null || ZNet.instance.IsServer()) { return; } try { int num = package.ReadInt(); string text = package.ReadString(); string hash = package.ReadString(); if (num != 1 || !string.Equals(text, "1.1.0", StringComparison.Ordinal)) { WorldBoundInventoryPlugin.Log.LogError((object)"Server sent an incompatible WorldBoundInventory confirmation."); return; } _serverConfirmed = true; _serverRpc = rpc; _nextHeartbeatTime = Time.unscaledTime + 1f; WorldBoundInventoryPlugin.Log.LogInfo((object)("Server requires and accepted WorldBoundInventory " + text + " (server DLL " + ShortHash(hash) + ").")); } catch (Exception ex) { WorldBoundInventoryPlugin.Log.LogError((object)("Invalid WorldBoundInventory server confirmation: " + ex.Message)); } } private static void OnServerDenied(ZRpc rpc, ZPackage package) { if (!((Object)(object)ZNet.instance == (Object)null) && !ZNet.instance.IsServer()) { string text = "incompatible client mod"; try { text = package.ReadString(); } catch { } WorldBoundInventoryPlugin.Log.LogError((object)("Connection rejected by WorldBoundInventory: " + text)); } } private static string ValidateClient(int protocol, string guid, string version, string assemblyHash) { if (protocol != 1) { return "network protocol mismatch"; } if (!string.Equals(guid, "fandray.valheim.worldboundinventory", StringComparison.Ordinal)) { return "plugin identity mismatch"; } if (!string.Equals(version, "1.1.0", StringComparison.Ordinal)) { return "mod version mismatch: client " + version + ", server 1.1.0"; } if (WorldBoundInventoryPlugin.RequireExactAssembly.Value && !HashesEqual(assemblyHash, _assemblySha256)) { return "WorldBoundInventory.dll SHA-256 mismatch"; } return null; } private static void Reject(ZRpc rpc, string reason) { //IL_00da: Unknown result type (might be due to invalid IL or missing references) //IL_00e1: Expected O, but got Unknown if (rpc == null) { return; } bool flag = false; lock (Sync) { if (!Peers.TryGetValue(rpc, out var value)) { value = new PeerHandshake(); Peers[rpc] = value; } if (!value.Rejected) { flag = true; value.Rejected = true; value.Approved = false; value.Reason = reason; value.SoftDisconnectAt = Time.unscaledTime + 0.35f; value.HardDisconnectAt = Time.unscaledTime + 1.5f; } } if (!flag) { return; } WorldBoundInventoryPlugin.Log.LogWarning((object)("Rejected a client before world entry: " + reason + ".")); try { ZPackage val = new ZPackage(); val.Write(reason ?? "incompatible client mod"); rpc.Invoke("WBI_ServerDenied_V1", new object[1] { val }); rpc.Invoke("Error", new object[1] { 3 }); } catch (Exception ex) { WorldBoundInventoryPlugin.Log.LogDebug((object)("Could not send rejection details before disconnect: " + ex.Message)); } } private static void TryInvokeRpc(ZRpc rpc, string method) { try { if (rpc != null && IsRpcConnected(rpc)) { rpc.Invoke(method, new object[0]); } } catch (Exception ex) { WorldBoundInventoryPlugin.Log.LogDebug((object)("Soft disconnect failed: " + ex.Message)); } } private static void HardDisconnect(ZRpc rpc) { try { ZNet instance = ZNet.instance; if (!((Object)(object)instance == (Object)null) && !(_getPeerByRpc == null) && !(_disconnectPeer == null)) { object? obj = _getPeerByRpc.Invoke(instance, new object[1] { rpc }); ZNetPeer val = (ZNetPeer)((obj is ZNetPeer) ? obj : null); if (val != null) { _disconnectPeer.Invoke(instance, new object[1] { val }); } } } catch (Exception ex) { WorldBoundInventoryPlugin.Log.LogDebug((object)("Hard disconnect cleanup failed: " + ex.Message)); } } private static bool IsRpcConnected(ZRpc rpc) { try { return rpc != null && rpc.IsConnected(); } catch { return false; } } private static void CacheNetworkMethods() { _getPeerByRpc = AccessTools.GetDeclaredMethods(typeof(ZNet)).FirstOrDefault(delegate(MethodInfo method) { ParameterInfo[] parameters = method.GetParameters(); return method.Name == "GetPeer" && parameters.Length == 1 && typeof(ZRpc).IsAssignableFrom(parameters[0].ParameterType); }); _disconnectPeer = AccessTools.GetDeclaredMethods(typeof(ZNet)).FirstOrDefault(delegate(MethodInfo method) { ParameterInfo[] parameters = method.GetParameters(); return method.Name == "Disconnect" && parameters.Length == 1 && typeof(ZNetPeer).IsAssignableFrom(parameters[0].ParameterType); }); } private static float GetTimeout() { return Mathf.Clamp(WorldBoundInventoryPlugin.HandshakeTimeoutSeconds.Value, 3f, 30f); } private static float GetHeartbeatGrace() { return Mathf.Max(15f, GetTimeout() * 2f); } private static bool IsEnforcementEnabled() { return WorldBoundInventoryPlugin.EnforceModOnServer != null && WorldBoundInventoryPlugin.EnforceModOnServer.Value; } private static string CalculateOwnAssemblyHash() { try { string location = Assembly.GetExecutingAssembly().Location; using FileStream inputStream = new FileStream(location, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using SHA256 sHA = SHA256.Create(); byte[] array = sHA.ComputeHash(inputStream); StringBuilder stringBuilder = new StringBuilder(array.Length * 2); for (int i = 0; i < array.Length; i++) { stringBuilder.Append(array[i].ToString("x2")); } return stringBuilder.ToString(); } catch (Exception ex) { WorldBoundInventoryPlugin.Log.LogError((object)("Could not calculate WorldBoundInventory.dll SHA-256: " + ex)); return string.Empty; } } private static bool HashesEqual(string left, string right) { if (string.IsNullOrEmpty(left) || string.IsNullOrEmpty(right) || left.Length != right.Length) { return false; } int num = 0; for (int i = 0; i < left.Length; i++) { num |= char.ToLowerInvariant(left[i]) ^ char.ToLowerInvariant(right[i]); } return num == 0; } private static string ShortHash(string hash) { return string.IsNullOrEmpty(hash) ? "unavailable" : hash.Substring(0, Math.Min(12, hash.Length)); } }