Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
Decompiled source of LethalGuardPlus Beta v0.1.1
LethalGuardPlus.Beta.dll
Decompiled a day ago
The result has been truncated due to the large size, download it to view full contents!
using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Numerics; using System.Reflection; using System.Reflection.Emit; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Text; using System.Text.RegularExpressions; using BepInEx; using BepInEx.Bootstrap; using BepInEx.Configuration; using BepInEx.Logging; using Dissonance; using Dissonance.Audio.Codecs; using Dissonance.Integrations.Unity_NFGO; using Dissonance.Networking; using GameNetcodeStuff; using HarmonyLib; using LethalGuardPlus.AntiGrief; using LethalGuardPlus.Rules; using Microsoft.CodeAnalysis; using Steamworks; using Steamworks.Data; using TMPro; using Unity.Collections; using Unity.Netcode; using UnityEngine; using UnityEngine.EventSystems; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName = ".NET Standard 2.1")] [assembly: AssemblyCompany("LethalGuardPlus.Beta")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyFileVersion("1.0.0.0")] [assembly: AssemblyInformationalVersion("1.0.0+98d08b30694a902213176300fa9e833d71e5c978")] [assembly: AssemblyProduct("LethalGuardPlus.Beta")] [assembly: AssemblyTitle("LethalGuardPlus.Beta")] [assembly: AssemblyVersion("1.0.0.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace LethalGuardPlus { internal static class ClientGuard { [HarmonyPatch] private static class PlayerJoinedPatch { private static MethodBase TargetMethod() { return AccessTools.Method("Dissonance.Networking.Client.EventQueue:EnqueuePlayerJoined", (Type[])null, (Type[])null); } [HarmonyPrefix] private static void Prefix(string playerName, ref CodecSettings codecSettings) { //IL_001d: Unknown result type (might be due to invalid IL or missing references) //IL_0033: Expected I4, but got Unknown //IL_0062: Unknown result type (might be due to invalid IL or missing references) //IL_0067: Unknown result type (might be due to invalid IL or missing references) string text = Validate.Name(playerName); if (text != null) { MarkSuspicious(playerName); Offenders.ReportVoicePlayer(playerName, "CLIENT-LONG-NAME", text); } string text2 = Validate.Codec((byte)(int)((CodecSettings)(ref codecSettings)).Codec, ((CodecSettings)(ref codecSettings)).FrameSize, ((CodecSettings)(ref codecSettings)).SampleRate); if (text2 != null) { MarkSuspicious(playerName); Offenders.ReportVoicePlayer(playerName, "CLIENT-CODEC", text2); if (Plugin.VoiceGuard.Value) { codecSettings = new CodecSettings((Codec)1, 960u, 48000); } } } } [HarmonyPatch] private static class ReceiveFramePatch { private static MethodBase TargetMethod() { return AccessTools.Method("Dissonance.Audio.Playback.SpeechSessionStream:ReceiveFrame", (Type[])null, (Type[])null); } [HarmonyPrefix] private static bool Prefix(VoicePacket packet) { //IL_0000: Unknown result type (might be due to invalid IL or missing references) string senderPlayerId = packet.SenderPlayerId; if (IsSuspicious(senderPlayerId)) { Offenders.ReportVoicePlayer(senderPlayerId, "CLIENT-SUSPICIOUS-VOICE", "voice frame from suspicious player"); return !Plugin.VoiceGuard.Value; } string key = senderPlayerId ?? ""; if (Frames.IsFlood(key, Environment.TickCount, Plugin.MaxVoiceFramesPerSecond.Value)) { int num = 5; Offenders.ReportVoicePlayer(senderPlayerId, "CLIENT-VOICE-FLOOD", $"{Frames.CountOf(key)} frames in {num}s (limit {Plugin.MaxVoiceFramesPerSecond.Value}/s average)"); return !Plugin.VoiceGuard.Value; } return true; } } private static readonly object Lock = new object(); private static readonly HashSet<string> Suspicious = new HashSet<string>(); private static readonly FloodCounter Frames = new FloodCounter(); internal static bool IsSuspicious(string name) { lock (Lock) { return name != null && Suspicious.Contains(name); } } internal static void MarkSuspicious(string name) { lock (Lock) { Suspicious.Add(name ?? ""); } } } internal static class DuplicateGuard { internal const string MainGuid = "lethalguardplus"; internal static readonly Version BetaTarget = new Version(1, 1, 0); private static string _pending; private static float _firstSeen = -1f; internal static bool Resolve(Harmony own) { //IL_008d: Unknown result type (might be due to invalid IL or missing references) //IL_009a: Expected O, but got Unknown //IL_0040: Unknown result type (might be due to invalid IL or missing references) try { Version main = null; if (Chainloader.PluginInfos.TryGetValue("lethalguardplus", out var value)) { main = value.Metadata.Version; } DuplicateAction duplicateAction = DuplicateInstall.Decide(main, BetaTarget); switch (duplicateAction) { case DuplicateAction.None: return true; case DuplicateAction.TakeOver: new Harmony("lethalguardplus").UnpatchSelf(); break; } _pending = DuplicateInstall.Notice(duplicateAction, main); Plugin.Log.LogWarning((object)_pending); own.Patch((MethodBase)AccessTools.Method(typeof(StartOfRound), "Update", (Type[])null, (Type[])null), (HarmonyMethod)null, new HarmonyMethod(typeof(DuplicateGuard), "NoticePostfix", (Type[])null), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); return duplicateAction == DuplicateAction.TakeOver; } catch (Exception ex) { Plugin.Log.LogWarning((object)("Duplicate install check failed: " + ex.Message)); return true; } } private static void NoticePostfix() { try { if (_pending != null && !((Object)(object)HUDManager.Instance == (Object)null)) { float realtimeSinceStartup = Time.realtimeSinceStartup; if (_firstSeen < 0f) { _firstSeen = realtimeSinceStartup; } if (!(realtimeSinceStartup - _firstSeen < 3f)) { HostNotice.Local(_pending); _pending = null; } } } catch (Exception ex) { _pending = null; Plugin.Log.LogWarning((object)("Duplicate install notice failed: " + ex.Message)); } } } internal static class Limits { internal const int MaxNameLength = 64; internal const int MaxRoomNameLength = 128; internal const int MaxRooms = 32; internal const int MaxRelayDestinations = 64; internal const int MaxFrameSize = 5760; internal const float MaxTiltDegrees = 2f; internal const float MaxPlaceDistance = 6f; internal const float MinPlaceInterval = 0.5f; internal const float LeverWatchdogSeconds = 5f; internal const int MaxCustomMessageBytes = 65536; } internal static class LobbyTag { internal static void Apply(Harmony harmony) { //IL_0044: Unknown result type (might be due to invalid IL or missing references) //IL_0051: Expected O, but got Unknown try { MethodInfo methodInfo = AccessTools.Method(typeof(GameNetworkManager), "SteamMatchmaking_OnLobbyCreated", (Type[])null, (Type[])null); if (methodInfo == null) { Plugin.Log.LogWarning((object)"HideModdedTag: lobby creation method not found, lobby stays in the modded list."); } else { harmony.Patch((MethodBase)methodInfo, (HarmonyMethod)null, new HarmonyMethod(typeof(LobbyTag), "Postfix", (Type[])null), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); } } catch (Exception arg) { Plugin.Log.LogWarning((object)$"HideModdedTag: patch failed, lobby stays in the modded list. {arg}"); } } private static void Postfix(Result result, Lobby lobby) { //IL_0000: Unknown result type (might be due to invalid IL or missing references) //IL_0002: Invalid comparison between Unknown and I4 if ((int)result == 1 && Plugin.HideModdedTag.Value) { ((Lobby)(ref lobby)).SetData("dmods", "f"); Plugin.Log.LogInfo((object)"HideModdedTag: lobby listed as vanilla."); } } } internal static class Offenders { private class Counter { public int Pending; public long Total; public DateTime LastLog; } private const double SummaryIntervalSec = 5.0; private static readonly object Lock = new object(); private static readonly Dictionary<string, Counter> Counters = new Dictionary<string, Counter>(); private static readonly Dictionary<string, int> SampleCounts = new Dictionary<string, int>(); private static string _logFile; private static readonly SessionBudget Budget = new SessionBudget(2000000L, 3000000L); public static void Init(string bepinexRoot) { _logFile = Path.Combine(bepinexRoot, "LethalGuardPlus.log"); try { LogRotation.TrimFile(_logFile, File.ReadAllLines); } catch { } WriteFile(string.Format("{0} {1:yyyy-MM-dd HH:mm:ss} =====", "===== Session started", DateTime.Now)); foreach (string item in SessionHeader()) { WriteFile(item); } Plugin.Log.LogInfo((object)("[LG+] For bug reports send: " + Path.GetFullPath(_logFile))); } private static IEnumerable<string> SessionHeader() { List<string> list = new List<string> { "LethalGuard+ Beta 0.1.1", $"KickLevel={Plugin.KickLevel.Value} NoticeLevel={Plugin.NoticeLevel.Value} AutoBanDuration={Plugin.AutoBanDuration.Value}" }; List<string> list2 = new List<string>(); List<string> list3 = new List<string>(); try { foreach (KeyValuePair<ConfigDefinition, ConfigEntryBase> item in Plugin.Cfg) { ConfigDefinition key = item.Key; ConfigEntryBase value = item.Value; if (key.Section == "2. Modules") { object boxedValue = value.BoxedValue; if (boxedValue is bool && !(bool)boxedValue) { list2.Add(key.Key); continue; } } if (key.Section == "3. Advanced" && !object.Equals(value.BoxedValue, value.DefaultValue)) { list3.Add(LogPolicy.AdvancedLine(key.Key, value.GetSerializedValue())); } } } catch (Exception ex) { list.Add("Config could not be listed: " + ex.Message); } list2.Sort(StringComparer.Ordinal); list3.Sort(StringComparer.Ordinal); list.Add("Disabled modules: " + ((list2.Count == 0) ? "none" : string.Join(", ", list2))); if (list3.Count > 0) { list.Add("Advanced: " + string.Join(" ", list3)); } return list; } public static void WriteHeaderTail(IEnumerable<string> lines) { foreach (string line in lines) { WriteFile(Sanitize(line)); } } public static void Report(ulong clientId, string rule, string detail) { bool trusted; bool flag = BanGuard.OnReport(clientId, rule, out trusted); string text = Describe(clientId); Grade grade = RuleGrades.Of(rule); Emit($"{clientId}|{rule}", "[HOST] " + rule + " (" + RuleGrades.Label(grade) + ") | " + text + " | " + detail + " | kicked=" + (flag ? "yes" : "no"), flag || grade == Grade.Certain); HostNotice.Notify(clientId, rule, trusted); } public static void Observe(ulong clientId, string rule, string detail) { Emit($"{clientId}|{rule}", "[HOST] " + rule + " (" + RuleGrades.Label(RuleGrades.Of(rule)) + ") | " + Describe(clientId) + " | " + detail + " | observed only", important: false); } public static void Sample(string tag, string detail) { lock (Lock) { SampleCounts.TryGetValue(tag, out var value); if (!LogPolicy.ShouldWriteSample(tag, Plugin.LogSamples.Value, value)) { return; } SampleCounts[tag] = value + 1; } WriteFile($"[{DateTime.Now:HH:mm:ss.fff}] [SAMPLE] {tag} | {Sanitize(detail)}", tag == null || !tag.EndsWith("-SAMPLE")); } public static string Clean(string s) { return Validate.Short(Sanitize(s)); } private static string Sanitize(string s) { if (s == null) { return null; } StringBuilder stringBuilder = null; for (int i = 0; i < s.Length; i++) { if (s[i] < ' ' || s[i] == '\u007f') { if (stringBuilder == null) { stringBuilder = new StringBuilder(s); } stringBuilder[i] = '?'; } } return stringBuilder?.ToString() ?? s; } public static void ReportVoicePlayer(string dissonanceName, string rule, string detail) { Emit("v:" + dissonanceName + "|" + rule, "[CLIENT] " + rule + " | dissonance=" + Validate.Short(dissonanceName) + " | " + detail, important: false); } private static void Emit(string key, string line, bool important) { int num = 0; bool flag; lock (Lock) { if (!Counters.TryGetValue(key, out var value)) { Dictionary<string, Counter> counters = Counters; Counter obj = new Counter { LastLog = DateTime.MinValue }; value = obj; counters[key] = obj; } value.Total++; if (value.Total == 1) { important = true; } value.Pending++; DateTime utcNow = DateTime.UtcNow; flag = (utcNow - value.LastLog).TotalSeconds >= 5.0; if (flag) { num = value.Pending - 1; value.Pending = 0; value.LastLog = utcNow; } } if (flag) { string text = Sanitize((num > 0) ? $"{line} (+{num} repeats in last {5.0:0}s)" : line); Plugin.Log.LogWarning((object)text); WriteFile($"[{DateTime.Now:HH:mm:ss.fff}] {text}", important); } } public static string Describe(ulong clientId) { try { StartOfRound instance = StartOfRound.Instance; if ((Object)(object)instance != (Object)null && instance.allPlayerScripts != null) { PlayerControllerB[] allPlayerScripts = instance.allPlayerScripts; foreach (PlayerControllerB val in allPlayerScripts) { if ((Object)(object)val != (Object)null && val.actualClientId == clientId && (val.isPlayerControlled || val.isPlayerDead)) { return $"clientId={clientId} steamId={val.playerSteamId} name=\"{Clean(val.playerUsername)}\""; } } } } catch { } return $"clientId={clientId} steamId=?"; } private static void WriteFile(string line, bool important = true) { if (_logFile == null) { return; } try { lock (Lock) { string text = Budget.Take(line + Environment.NewLine, important, DateTime.Now.ToString("HH:mm:ss.fff")); if (text != null) { File.AppendAllText(_logFile, text); } } } catch { } } } internal enum MsgType : byte { ClientState = 1, VoiceData, TextData, HandshakeRequest, HandshakeResponse, ErrorWrongSession, ServerRelayReliable, ServerRelayUnreliable, DeltaChannelState, RemoveClient, HandshakeP2P } internal class MalformedPacketException : Exception { public MalformedPacketException(string what) : base(what) { } } internal struct Packet { public const ushort Magic = 35783; private readonly byte[] _buf; private readonly int _end; private int _pos; public int Remaining => _end - _pos; public Packet(ArraySegment<byte> seg) { _buf = seg.Array; _pos = seg.Offset; _end = seg.Offset + seg.Count; } private void Need(int n, string what) { if (n < 0 || _end - _pos < n) { throw new MalformedPacketException(what); } } public byte U8() { Need(1, "byte"); return _buf[_pos++]; } public ushort U16() { Need(2, "ushort"); ushort result = (ushort)((_buf[_pos] << 8) | _buf[_pos + 1]); _pos += 2; return result; } public uint U32() { Need(4, "uint"); int result = (_buf[_pos] << 24) | (_buf[_pos + 1] << 16) | (_buf[_pos + 2] << 8) | _buf[_pos + 3]; _pos += 4; return (uint)result; } public string Str() { ushort num = U16(); if (num == 0) { return null; } int num2 = num - 1; Need(num2, "string"); string result = Encoding.UTF8.GetString(_buf, _pos, num2); _pos += num2; return result; } public ArraySegment<byte> Segment() { ushort num = U16(); Need(num, "byte[]"); ArraySegment<byte> result = new ArraySegment<byte>(_buf, _pos, num); _pos += num; return result; } public bool Header(out MsgType type) { type = (MsgType)0; if (Remaining < 3 || U16() != 35783) { return false; } type = (MsgType)U8(); return true; } public void Codec(out byte codec, out uint frameSize, out int sampleRate) { codec = U8(); frameSize = U32(); sampleRate = (int)U32(); } } internal static class Validate { private static readonly int[] SampleRates = new int[5] { 8000, 12000, 16000, 24000, 48000 }; public static string Codec(byte codec, uint frameSize, int sampleRate) { if (codec > 1) { return $"unknown codec={codec}"; } if (frameSize == 0 || frameSize > 5760) { return $"FrameSize={frameSize} (limit {5760})"; } if (Array.IndexOf(SampleRates, sampleRate) < 0) { return $"SampleRate={sampleRate}"; } return null; } public static string Name(string name) { if (name == null) { return "name is null"; } if (name.Length > 64) { return $"name is {name.Length} characters (limit {64}): {Short(name)}"; } return null; } public static string Short(string s) { if (s == null) { return "null"; } if (s.Length > 40) { return s.Substring(0, 40) + $"…(+{s.Length - 40})"; } return s; } } [BepInPlugin("lethalguardplus.beta", "LethalGuard+ Beta", "0.1.1")] [BepInDependency(/*Could not decode attribute arguments.*/)] public class Plugin : BaseUnityPlugin { public const string Guid = "lethalguardplus.beta"; public const string Name = "LethalGuard+ Beta"; public const string Version = "0.1.1"; internal const string GeneralSection = "1. General"; internal const string ModulesSection = "2. Modules"; internal const string AdvancedSection = "3. Advanced"; private const string AdvancedWarning = " Do not change unless you know what it does."; internal static ManualLogSource Log; internal static ConfigFile Cfg; internal static ConfigEntry<KickLevel> KickLevel; internal static ConfigEntry<NoticeLevel> NoticeLevel; internal static ConfigEntry<AutoBanDuration> AutoBanDuration; internal static ConfigEntry<bool> KnownAttackerList; internal static ConfigEntry<bool> HideModdedTag; internal static ConfigEntry<bool> VoiceGuard; internal static ConfigEntry<bool> MessageGuard; internal static ConfigEntry<bool> SpawnGuard; internal static ConfigEntry<bool> VoteGuard; internal static ConfigEntry<bool> ChatGuard; internal static ConfigEntry<bool> CreditGuard; internal static ConfigEntry<bool> JoinSlotGuard; internal static ConfigEntry<bool> TerminalGuard; internal static ConfigEntry<bool> ShipControlGuard; internal static ConfigEntry<bool> FurnitureGuard; internal static ConfigEntry<bool> ItemDeleteGuard; internal static ConfigEntry<bool> ItemTeleportGuard; internal static ConfigEntry<string> TrustedPlayers; internal static ConfigEntry<string> NeverKickRules; internal static ConfigEntry<string> AlwaysKickRules; internal static ConfigEntry<int> MaxVoicePacketsPerSecond; internal static ConfigEntry<int> MaxVoiceFramesPerSecond; internal static ConfigEntry<int> MaxNetMessagesPerSecond; internal static ConfigEntry<int> MaxChatMessagesPer5Seconds; internal static ConfigEntry<bool> LogSamples; private void Awake() { //IL_001b: Unknown result type (might be due to invalid IL or missing references) //IL_0021: Expected O, but got Unknown Log = ((BaseUnityPlugin)this).Logger; Cfg = ((BaseUnityPlugin)this).Config; Harmony val = new Harmony("lethalguardplus.beta"); if (DuplicateGuard.Resolve(val)) { BindGeneral(); BindModules(); BindAdvanced(); MigrateOldConfig(); Settings.Reload(); TrustedPlayers.SettingChanged += delegate { Settings.Reload(); }; NeverKickRules.SettingChanged += delegate { Settings.Reload(); }; AlwaysKickRules.SettingChanged += delegate { Settings.Reload(); }; Offenders.Init(Paths.BepInExRootPath); val.PatchAll(typeof(Plugin).Assembly); LobbyTag.Apply(val); AntiGriefModule.Apply(val); SessionLog.Apply(val); VersionBeacon.Apply(val); Log.LogInfo((object)string.Format("{0} {1} loaded. KickLevel={2} NoticeLevel={3} AutoBanDuration={4} VoiceGuard={5}", "LethalGuard+ Beta", "0.1.1", KickLevel.Value, NoticeLevel.Value, AutoBanDuration.Value, VoiceGuard.Value)); } } private void BindGeneral() { KickLevel = ((BaseUnityPlugin)this).Config.Bind<KickLevel>("1. General", "KickLevel", LethalGuardPlus.Rules.KickLevel.Recommended, "Host only. Every level blocks the same cheats; this only decides who is ALSO kicked automatically (kicked players cannot rejoin this lobby). Recommended: only requests no normal game can send. Risky: also floods and spam. VeryRisky: also state conflicts that lag, desync or other mods can cause. Unsafe: everything. Higher levels can kick innocent players; that is your responsibility. Off: nobody is kicked. Voice rules never kick (except the crash attack)."); NoticeLevel = ((BaseUnityPlugin)this).Config.Bind<NoticeLevel>("1. General", "NoticeLevel", LethalGuardPlus.Rules.NoticeLevel.Likely, "Host only. Which blocked requests are shown in your local chat (only you see it). Certain / Likely / Possible / All. Likely shows a bit more than Recommended kicks, so you can see false alarms. Off: no chat lines at all (kick lines included)."); AutoBanDuration = ((BaseUnityPlugin)this).Config.Bind<AutoBanDuration>("1. General", "AutoBanDuration", LethalGuardPlus.Rules.AutoBanDuration.Session, "Host only. A player kicked automatically for a certain cheat can never rejoin this lobby. If the same Steam account is caught again in another lobby within 30 days, it is banned for this long: Session (only that lobby again), OneDay, OneWeek or Permanent. A first offence is never more than a lobby ban. Bans are stored in BepInEx/config/LethalGuardPlus.bans.txt. Commands: /kick <name>, /ban <name or SteamID> [| reason], /unban <name, SteamID or last>, /bans, /recent."); KnownAttackerList = ((BaseUnityPlugin)this).Config.Bind<bool>("1. General", "KnownAttackerList", true, "Host only. true: Steam accounts known for attacking public lobbies cannot join (built-in list, updated with the mod). /unban <SteamID> exempts one account."); HideModdedTag = ((BaseUnityPlugin)this).Config.Bind<bool>("1. General", "HideModdedTag", true, "Host only. true: your lobby is listed as a normal (vanilla) lobby, so players see it without ticking the modded filter. Set false if you also run mods that change gameplay and every player must have them."); } private void BindModules() { VoiceGuard = ((BaseUnityPlugin)this).Config.Bind<bool>("2. Modules", "VoiceGuard", true, "true: broken or fake voice packets are dropped. false: they are only logged (the crash fix for unreadable packets always stays on)."); MessageGuard = ((BaseUnityPlugin)this).Config.Bind<bool>("2. Modules", "MessageGuard", true, "Host only. true: network messages from each player are limited (message floods, oversized RPCs, RPCs to objects that do not exist) so one player cannot freeze or crash the lobby. false: off."); SpawnGuard = ((BaseUnityPlugin)this).Config.Bind<bool>("2. Modules", "SpawnGuard", true, "Host only. true: a player cannot make the host spawn or delete things in bulk (opening one gift box or mask many times, deleting living enemies, faking deaths for other players, making monsters kill other players). false: off."); VoteGuard = ((BaseUnityPlugin)this).Config.Bind<bool>("2. Modules", "VoteGuard", true, "Host only. true: only dead players can vote for the ship to leave early, once per round (a cheater could make the ship leave instantly while everyone is alive). false: off."); ChatGuard = ((BaseUnityPlugin)this).Config.Bind<bool>("2. Modules", "ChatGuard", true, "Host only. true: players cannot chat as someone else, text formatting tags (huge text, colors) are removed, chat lines that are not from the game are marked with the sender's name so nobody can pose as the server, and chat spam is limited. false: off."); CreditGuard = ((BaseUnityPlugin)this).Config.Bind<bool>("2. Modules", "CreditGuard", true, "Host only. true: a player cannot buy items, furniture, vehicles or routes for free, or wipe the group's credits with a fake purchase. Prices changed by other mods are only logged, not blocked. false: off."); JoinSlotGuard = ((BaseUnityPlugin)this).Config.Bind<bool>("2. Modules", "JoinSlotGuard", true, "Host only. true: when the lobby is already full, a player joining at the same moment is turned away instead of being given the host's character (which breaks the lobby). false: off."); TerminalGuard = ((BaseUnityPlugin)this).Config.Bind<bool>("2. Modules", "TerminalGuard", true, "Host only. true: only a living player standing at the terminal can start using it, so nobody can lock the terminal from across the map; terminal sound spam and broken terminal sounds are dropped. Leaving the terminal is never blocked. false: off."); ShipControlGuard = ((BaseUnityPlugin)this).Config.Bind<bool>("2. Modules", "ShipControlGuard", true, "Host only. true: spamming the ship doors, lights, magnet, teleporter button or alarm horn is limited, and dead players cannot use them. Normal use is never affected. false: off."); FurnitureGuard = ((BaseUnityPlugin)this).Config.Bind<bool>("2. Modules", "FurnitureGuard", true, "Host only. true: furniture placement, ship lever and end-of-round requests are checked. false: off."); ItemDeleteGuard = ((BaseUnityPlugin)this).Config.Bind<bool>("2. Modules", "ItemDeleteGuard", true, "Host only. true: a player cannot delete a game item from their hands or inventory for everyone. Keys (opening doors) and shotgun shells (reloading) still work, and items added by other mods are never touched. false: off."); ItemTeleportGuard = ((BaseUnityPlugin)this).Config.Bind<bool>("2. Modules", "ItemTeleportGuard", false, "Host only. true: a player cannot pick up an item from across the map, drop or place an item they are not holding, or put a far away item into a belt bag. WARNING: can break item/sell/belt bag mods that move items in their own way, so it is off by default. false: off."); } private void BindAdvanced() { TrustedPlayers = ((BaseUnityPlugin)this).Config.Bind<string>("3. Advanced", "TrustedPlayers", "", "Comma separated SteamIDs that are never kicked automatically (requests are still blocked and logged). Do not change unless you know what it does."); NeverKickRules = ((BaseUnityPlugin)this).Config.Bind<string>("3. Advanced", "NeverKickRules", "", "Comma separated rule names (see LethalGuardPlus.log) that are never kicked, whatever KickLevel is. Do not change unless you know what it does."); AlwaysKickRules = ((BaseUnityPlugin)this).Config.Bind<string>("3. Advanced", "AlwaysKickRules", "", "Comma separated rule names (see LethalGuardPlus.log) that are always kicked, whatever KickLevel is. Do not change unless you know what it does."); MaxVoicePacketsPerSecond = ((BaseUnityPlugin)this).Config.Bind<int>("3. Advanced", "MaxVoicePacketsPerSecond", 200, "Host: max voice packets per second from one player, averaged over 5 seconds (normal talking is ~50). Do not change unless you know what it does."); MaxVoiceFramesPerSecond = ((BaseUnityPlugin)this).Config.Bind<int>("3. Advanced", "MaxVoiceFramesPerSecond", 150, "Client: max voice frames per second from one player, averaged over 5 seconds (normal talking is ~50). Do not change unless you know what it does."); MaxNetMessagesPerSecond = ((BaseUnityPlugin)this).Config.Bind<int>("3. Advanced", "MaxNetMessagesPerSecond", 500, "Host: max network messages per second from one player (all kinds, voice included), averaged over 5 seconds. Do not change unless you know what it does."); MaxChatMessagesPer5Seconds = ((BaseUnityPlugin)this).Config.Bind<int>("3. Advanced", "MaxChatMessagesPer5Seconds", 5, "Host: max chat messages from one player in 5 seconds. Do not change unless you know what it does."); LogSamples = ((BaseUnityPlugin)this).Config.Bind<bool>("3. Advanced", "LogSamples", false, "true: also write measurement lines (*-SAMPLE) to LethalGuardPlus.log. Do not change unless you know what it does."); } private void MigrateOldConfig() { try { if (!(AccessTools.Property(typeof(ConfigFile), "OrphanedEntries")?.GetValue(((BaseUnityPlugin)this).Config) is IDictionary { Count: not 0 } dictionary)) { return; } List<(string, string, string)> list = new List<(string, string, string)>(); foreach (DictionaryEntry item in dictionary) { object key = item.Key; ConfigDefinition val = (ConfigDefinition)((key is ConfigDefinition) ? key : null); if (val != null) { list.Add((val.Section, val.Key, item.Value as string)); } } if (ConfigMigration.LegacyHideModdedTagOff(list)) { HideModdedTag.Value = false; Log.LogInfo((object)"Config: kept HideModdedTag=false from the old config."); } dictionary.Clear(); ((BaseUnityPlugin)this).Config.Save(); } catch (Exception ex) { Log.LogWarning((object)("Config: old settings could not be migrated. " + ex.Message)); } } } internal static class Settings { public static HashSet<ulong> Trusted = new HashSet<ulong>(); public static HashSet<string> Never = new HashSet<string>(); public static HashSet<string> Always = new HashSet<string>(); internal static void Reload() { try { List<string> list = new List<string>(); List<string> list2 = new List<string>(); Trusted = KickPolicy.ParseSteamIds(Plugin.TrustedPlayers.Value, list); Never = KickPolicy.ParseRules(Plugin.NeverKickRules.Value, list2); Always = KickPolicy.ParseRules(Plugin.AlwaysKickRules.Value, list2); foreach (string item in list.Distinct()) { Plugin.Log.LogWarning((object)("Config: invalid SteamID ignored: " + Offenders.Clean(item))); } foreach (string item2 in list2.Distinct()) { Plugin.Log.LogWarning((object)("Config: unknown rule name ignored: " + Offenders.Clean(item2))); } } catch (Exception ex) { Plugin.Log.LogWarning((object)("Config: lists could not be read. " + ex.Message)); } } } [HarmonyPatch(typeof(NfgoServer), "NamedMessageHandler")] internal static class ServerGuard { private enum Owner { Found, NotRegistered, Unavailable } private static readonly FieldRef<NfgoServer, byte[]> ReceiveBuffer = AccessTools.FieldRefAccess<NfgoServer, byte[]>("_receiveBuffer"); private static readonly FieldInfo ClientsField = AccessTools.Field(typeof(BaseServer<NfgoServer, NfgoClient, NfgoConn>), "_clients"); private static MethodInfo _findByConnection; private static bool _lookupWarned; private static readonly FloodCounter Packets = new FloodCounter(); internal static void Forget(ulong clientId) { Packets.Remove(clientId.ToString()); } [HarmonyPrefix] private static bool Prefix(NfgoServer __instance, ulong sender, FastBufferReader stream) { //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_000c: Unknown result type (might be due to invalid IL or missing references) //IL_013f: Unknown result type (might be due to invalid IL or missing references) ArraySegment<byte> arraySegment; try { uint num = default(uint); ((FastBufferReader)(ref stream)).ReadValueSafe<uint>(ref num, default(ForPrimitives)); int num2 = ((FastBufferReader)(ref stream)).Length - ((FastBufferReader)(ref stream)).Position; if (num > (uint)num2) { string detail = $"declared={num} bytes, actual={num2} bytes"; if (Plugin.VoiceGuard.Value) { Offenders.Report(sender, "FAKE-LENGTH", detail); return false; } Offenders.Observe(sender, "FAKE-LENGTH", detail); num = (uint)num2; } ref byte[] reference = ref ReceiveBuffer.Invoke(__instance); if (reference == null || reference.Length < num) { reference = new byte[Math.Max(1024u, num)]; } ((FastBufferReader)(ref stream)).ReadBytesSafe(ref reference, (int)num, 0); arraySegment = new ArraySegment<byte>(reference, 0, (int)num); } catch (Exception ex) { if (Plugin.VoiceGuard.Value) { Offenders.Report(sender, "UNREADABLE-PACKET", ex.Message); } else { Offenders.Observe(sender, "UNREADABLE-PACKET", ex.Message); } return false; } string rule; string text = Check(__instance, sender, arraySegment, out rule); if (text != null && rule == "BEFORE-HANDSHAKE") { if (Plugin.VoiceGuard.Value) { return false; } } else if (text != null) { if (Plugin.VoiceGuard.Value) { Offenders.Report(sender, rule, text); return false; } Offenders.Observe(sender, rule, text); } ((BaseServer<NfgoServer, NfgoClient, NfgoConn>)(object)__instance).NetworkReceivedPacket(new NfgoConn(sender), arraySegment); return false; } private static string Check(NfgoServer server, ulong sender, ArraySegment<byte> data, out string rule) { rule = "RATE-LIMIT"; string key = sender.ToString(); int value = Plugin.MaxVoicePacketsPerSecond.Value; if (Packets.IsFlood(key, Environment.TickCount, value)) { return $"{Packets.CountOf(key)} packets in {5}s (limit {value}/s average)"; } Packet p = new Packet(data); try { if (!p.Header(out var type)) { return null; } switch (type) { case MsgType.HandshakeRequest: return CheckHandshake(ref p, out rule); case MsgType.ClientState: return CheckClientState(server, sender, ref p, out rule); case MsgType.DeltaChannelState: return CheckDelta(server, sender, ref p, out rule); case MsgType.ServerRelayReliable: case MsgType.ServerRelayUnreliable: return CheckRelay(server, sender, ref p, out rule); default: return null; } } catch (MalformedPacketException ex) { rule = "MALFORMED-PACKET"; return "unreadable: " + ex.Message; } } private static string CheckHandshake(ref Packet p, out string rule) { p.Codec(out var codec, out var frameSize, out var sampleRate); string text = p.Str(); rule = "HANDSHAKE-CODEC"; string text2 = Validate.Codec(codec, frameSize, sampleRate); if (text2 != null) { return text2 + " name=" + Validate.Short(text); } rule = "HANDSHAKE-NAME"; return Validate.Name(text); } private static string CheckClientState(NfgoServer server, ulong sender, ref Packet p, out string rule) { p.U32(); string text = p.Str(); ushort num = p.U16(); p.Codec(out var codec, out var frameSize, out var sampleRate); rule = "CLIENTSTATE-NAME"; string text2 = Validate.Name(text); if (text2 != null) { return text2; } rule = "CLIENTSTATE-CODEC"; text2 = Validate.Codec(codec, frameSize, sampleRate); if (text2 != null) { return text2 + " name=" + Validate.Short(text); } rule = "SPOOFED-PLAYER"; ushort id; string name; switch (LookupOwner(server, sender, out id, out name)) { case Owner.NotRegistered: rule = "BEFORE-HANDSHAKE"; return "ClientState"; case Owner.Found: if (num != id || text != name) { return $"claims another identity: id={num} name={Validate.Short(text)} (own: id={id} name={Validate.Short(name)})"; } break; } ushort num2 = p.U16(); rule = "CLIENTSTATE-ROOM"; if (num2 > 32) { return $"{num2} rooms (limit {32})"; } for (int i = 0; i < num2; i++) { string text3 = p.Str(); if (text3 != null && text3.Length > 128) { return $"room name is {text3.Length} characters: {Validate.Short(text3)}"; } } return null; } private static string CheckDelta(NfgoServer server, ulong sender, ref Packet p, out string rule) { p.U32(); p.U8(); ushort num = p.U16(); string text = p.Str(); rule = "DELTA-SPOOFED-PLAYER"; ushort id; string name; switch (LookupOwner(server, sender, out id, out name)) { case Owner.NotRegistered: rule = "BEFORE-HANDSHAKE"; return "DeltaChannelState"; case Owner.Found: if (num != id) { return $"changing another player's rooms: peer={num} (own id={id})"; } break; } rule = "DELTA-ROOM"; if (text != null && text.Length > 128) { return $"room name is {text.Length} characters: {Validate.Short(text)}"; } return null; } private static string CheckRelay(NfgoServer server, ulong sender, ref Packet p, out string rule) { p.U32(); int num = p.U8(); rule = "RELAY-TARGETS"; if (num > 64) { return $"{num} destinations (limit {64})"; } for (int i = 0; i < num; i++) { p.U16(); } Packet packet = new Packet(p.Segment()); rule = "RELAY-CONTENT"; if (!packet.Header(out var type)) { return null; } switch (type) { case MsgType.HandshakeP2P: return null; default: return $"packet type not allowed inside relay: {type}"; case MsgType.VoiceData: case MsgType.TextData: { packet.U32(); if (type == MsgType.TextData) { packet.U8(); } ushort num2 = packet.U16(); rule = ((type == MsgType.VoiceData) ? "SPOOFED-VOICE" : "SPOOFED-TEXT"); ushort id; string name; switch (LookupOwner(server, sender, out id, out name)) { case Owner.NotRegistered: rule = "BEFORE-HANDSHAKE"; return type.ToString(); case Owner.Found: if (num2 != id) { return $"{type} on behalf of another player: sender id={num2} (own id={id})"; } break; } return null; } } } private static Owner LookupOwner(NfgoServer server, ulong sender, out ushort id, out string name) { //IL_0077: Unknown result type (might be due to invalid IL or missing references) id = 0; name = null; object obj = ClientsField?.GetValue(server); if (obj != null && _findByConnection == null) { _findByConnection = AccessTools.Method(obj.GetType(), "TryFindClientByConnection", (Type[])null, (Type[])null); } if (obj == null || _findByConnection == null) { if (!_lookupWarned) { _lookupWarned = true; Plugin.Log.LogWarning((object)"Dissonance client list not readable; voice identity checks disabled."); } return Owner.Unavailable; } object[] array = new object[2] { (object)new NfgoConn(sender), null }; if (!(bool)_findByConnection.Invoke(obj, array) || !(array[1] is ClientInfo<NfgoConn> val)) { return Owner.NotRegistered; } id = val.PlayerId; name = val.PlayerName; return Owner.Found; } } internal static class SessionLog { private static bool _written; internal static void Apply(Harmony harmony) { //IL_0028: Unknown result type (might be due to invalid IL or missing references) //IL_0035: Expected O, but got Unknown try { harmony.Patch((MethodBase)AccessTools.Method(typeof(GameNetworkManager), "Start", (Type[])null, (Type[])null), (HarmonyMethod)null, new HarmonyMethod(typeof(SessionLog), "StartPostfix", (Type[])null), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); } catch (Exception ex) { Plugin.Log.LogWarning((object)("SessionLog: patch failed. " + ex.Message)); } } private static void StartPostfix() { if (_written) { return; } _written = true; try { List<string> list = new List<string>(); GameNetworkManager instance = GameNetworkManager.Instance; list.Add("Game version: " + (((Object)(object)instance != (Object)null) ? instance.gameVersionNum.ToString() : "?")); list.Add($"Plugins ({Chainloader.PluginInfos.Count}):"); foreach (PluginInfo value in Chainloader.PluginInfos.Values) { list.Add($" {value.Metadata.GUID} {value.Metadata.Version}"); } Offenders.WriteHeaderTail(list); } catch (Exception ex) { Plugin.Log.LogWarning((object)("SessionLog: header failed. " + ex.Message)); } } } internal static class VersionBeacon { internal const string MessageName = "LethalGuardPlus.Version"; internal static void Apply(Harmony harmony) { //IL_0044: Unknown result type (might be due to invalid IL or missing references) //IL_0051: Expected O, but got Unknown try { MethodInfo methodInfo = AccessTools.Method(typeof(StartOfRound), "OnClientConnect", (Type[])null, (Type[])null); if (methodInfo == null) { Plugin.Log.LogWarning((object)"VersionBeacon: join method not found, version is not sent."); } else { harmony.Patch((MethodBase)methodInfo, (HarmonyMethod)null, new HarmonyMethod(typeof(VersionBeacon), "Postfix", (Type[])null), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); } } catch (Exception ex) { Plugin.Log.LogWarning((object)("VersionBeacon: patch failed, version is not sent. " + ex.Message)); } } private unsafe static void Postfix(StartOfRound __instance, ulong clientId) { //IL_0068: Unknown result type (might be due to invalid IL or missing references) try { NetworkManager singleton = NetworkManager.Singleton; if ((Object)(object)singleton == (Object)null || !singleton.IsServer || clientId == 0L || !__instance.ClientPlayerList.ContainsKey(clientId) || !singleton.ConnectedClients.ContainsKey(clientId)) { return; } string text = "LethalGuard+ Beta 0.1.1"; FastBufferWriter val = default(FastBufferWriter); ((FastBufferWriter)(ref val))..ctor(FastBufferWriter.GetWriteSize(text, false), (Allocator)2, -1); try { ((FastBufferWriter)(ref val)).WriteValueSafe(text, false); singleton.CustomMessagingManager.SendNamedMessage("LethalGuardPlus.Version", clientId, val, (NetworkDelivery)2); } finally { ((IDisposable)(*(FastBufferWriter*)(&val))/*cast due to .constrained prefix*/).Dispose(); } } catch (Exception ex) { Plugin.Log.LogWarning((object)$"VersionBeacon: send failed for client {clientId}: {ex.Message}"); } } } } namespace LethalGuardPlus.Rules { internal static class RuleNames { public const string FurnitureIdentity = "FURNITURE-IDENTITY"; public const string FurnitureDead = "FURNITURE-DEAD"; public const string FurnitureNotIdle = "FURNITURE-NOT-IDLE"; public const string FurnitureAngle = "FURNITURE-ANGLE"; public const string FurnitureDistance = "FURNITURE-DISTANCE"; public const string FurnitureTerminalRoom = "FURNITURE-TERMINAL-ROOM"; public const string FurnitureRate = "FURNITURE-RATE"; public const string LeverState = "LEVER-STATE"; public const string LeverWatchdog = "LEVER-WATCHDOG"; public const string EndGameIdentity = "ENDGAME-IDENTITY"; public const string RpcUnknownSender = "RPC-UNKNOWN-SENDER"; public const string RateLimit = "RATE-LIMIT"; public const string FakeLength = "FAKE-LENGTH"; public const string UnreadablePacket = "UNREADABLE-PACKET"; public const string MalformedPacket = "MALFORMED-PACKET"; public const string HandshakeCodec = "HANDSHAKE-CODEC"; public const string HandshakeName = "HANDSHAKE-NAME"; public const string ClientStateName = "CLIENTSTATE-NAME"; public const string ClientStateCodec = "CLIENTSTATE-CODEC"; public const string ClientStateRoom = "CLIENTSTATE-ROOM"; public const string SpoofedPlayer = "SPOOFED-PLAYER"; public const string DeltaSpoofedPlayer = "DELTA-SPOOFED-PLAYER"; public const string DeltaRoom = "DELTA-ROOM"; public const string RelayTargets = "RELAY-TARGETS"; public const string RelayContent = "RELAY-CONTENT"; public const string SpoofedVoice = "SPOOFED-VOICE"; public const string SpoofedText = "SPOOFED-TEXT"; public const string BeforeHandshake = "BEFORE-HANDSHAKE"; public const string MessageFlood = "MESSAGE-FLOOD"; public const string RpcOversize = "RPC-OVERSIZE"; public const string RpcUnknownObject = "RPC-UNKNOWN-OBJECT"; public const string RpcMalformed = "RPC-MALFORMED"; public const string RpcBeforeSpawn = "RPC-BEFORE-SPAWN"; public const string CustomMessageOversize = "CUSTOM-MESSAGE-OVERSIZE"; public const string MessageRateSample = "MESSAGE-RATE-SAMPLE"; public const string GiftRepeat = "GIFT-REPEAT"; public const string GiftNotHolder = "GIFT-NOT-HOLDER"; public const string MaskRepeat = "MASK-REPEAT"; public const string MaskNotHolder = "MASK-NOT-HOLDER"; public const string EnemyDespawn = "ENEMY-DESPAWN"; public const string KillIdentity = "KILL-IDENTITY"; public const string KillRepeat = "KILL-REPEAT"; public const string EnemyAttackIdentity = "ENEMY-ATTACK-IDENTITY"; public const string SpawnRateSample = "SPAWN-RATE-SAMPLE"; public const string VoteAlive = "VOTE-ALIVE"; public const string VoteRepeat = "VOTE-REPEAT"; public const string ChatIdentity = "CHAT-IDENTITY"; public const string ChatRate = "CHAT-RATE"; public const string ChatRewriteSample = "CHAT-REWRITE-SAMPLE"; public const string FurnitureStoreIdentity = "FURNITURE-STORE-IDENTITY"; public const string ShopIndex = "SHOP-INDEX"; public const string CreditFree = "CREDIT-FREE"; public const string CreditOverspend = "CREDIT-OVERSPEND"; public const string CreditPriceSample = "CREDIT-PRICE-SAMPLE"; public const string JoinIdentity = "JOIN-IDENTITY"; public const string JoinSlotFull = "JOIN-SLOT-FULL"; public const string TerminalFar = "TERMINAL-FAR"; public const string TerminalDead = "TERMINAL-DEAD"; public const string TerminalRate = "TERMINAL-RATE"; public const string TerminalAudioIndex = "TERMINAL-AUDIO-INDEX"; public const string ShipControlRate = "SHIP-CONTROL-RATE"; public const string ShipControlDead = "SHIP-CONTROL-DEAD"; public const string DebugRpc = "DEBUG-RPC"; public const string StorageIndex = "STORAGE-INDEX"; public const string ItemDeleteHeld = "ITEM-DELETE-HELD"; public const string ItemDeleteSlot = "ITEM-DELETE-SLOT"; public const string ItemRemoteGrab = "ITEM-REMOTE-GRAB"; public const string ItemNotHeld = "ITEM-NOT-HELD"; public const string BagRemoteAdd = "BAG-REMOTE-ADD"; public const string ItemThrowFar = "ITEM-THROW-FAR"; public const string ItemSlotIndex = "ITEM-SLOT-INDEX"; public const string FurnitureAngleSample = "FURNITURE-ANGLE-SAMPLE"; public const string TerminalPosSample = "TERMINAL-POS-SAMPLE"; public const string TerminalUseSample = "TERMINAL-USE-SAMPLE"; public const string LeverTimingSample = "LEVER-TIMING-SAMPLE"; } internal struct PlacementInput { public int SenderIndex; public int PlayerWhoMoved; public bool SenderDead; public bool ShipIdle; public float TiltDegrees; public float Distance; public bool IsTerminal; public bool TerminalInsideRoom; public bool TooSoon; } internal struct PlacementLimits { public float MaxTiltDegrees; public float MaxDistance; } internal struct ShipState { public bool GameHasStarted; public bool InShipPhase; public bool TravellingToNewLevel; public bool BeganLoadingNewLevel; public bool ShipHasLanded; public bool ShipIsLeaving; } internal static class AntiGriefRules { public static string CheckPlacement(in PlacementInput p, in PlacementLimits l) { if (p.PlayerWhoMoved != p.SenderIndex) { return "FURNITURE-IDENTITY"; } if (p.SenderDead) { return "FURNITURE-DEAD"; } if (!p.ShipIdle) { return "FURNITURE-NOT-IDLE"; } if (!(p.TiltDegrees <= l.MaxTiltDegrees)) { return "FURNITURE-ANGLE"; } if (!(p.Distance <= l.MaxDistance)) { return "FURNITURE-DISTANCE"; } if (p.IsTerminal && !p.TerminalInsideRoom) { return "FURNITURE-TERMINAL-ROOM"; } if (p.TooSoon) { return "FURNITURE-RATE"; } return null; } public static float TiltDegrees(Quaternion newRotation, Quaternion meshRotation) { Quaternion rotation = newRotation * Quaternion.Inverse(meshRotation); Vector3 vector = Vector3.Transform(Vector3.UnitY, rotation); return (float)(Math.Atan2(Math.Sqrt((double)vector.X * (double)vector.X + (double)vector.Z * (double)vector.Z), vector.Y) * 180.0 / Math.PI); } public static bool IsLeverEffectValid(bool leverPulled, in ShipState s) { if (!s.GameHasStarted) { return false; } if (leverPulled) { if (s.InShipPhase && !s.TravellingToNewLevel) { return !s.BeganLoadingNewLevel; } return false; } if (s.ShipHasLanded) { return !s.ShipIsLeaving; } return false; } public static bool ShouldResetLever(bool inShipPhase, bool beganLoadingNewLevel, bool leverHasBeenPulled) { return inShipPhase && !beganLoadingNewLevel && leverHasBeenPulled; } public static string CheckEndGame(int senderIndex, int playerClientId) { if (senderIndex == playerClientId) { return null; } return "ENDGAME-IDENTITY"; } } public enum AutoBanDuration { Session, OneDay, OneWeek, Permanent } public enum BanLength { Session, Timed, Permanent } internal sealed class AutoBanDecision { public BanLength Length; public DateTime? UntilUtc; public bool RecordStrike; public bool SecondOffence; } internal static class AutoBanPolicy { public static AutoBanDecision Decide(string rule, bool steamKnown, AutoBanDuration setting, Strike existing, string lobby, DateTime nowUtc) { AutoBanDecision autoBanDecision = new AutoBanDecision { Length = BanLength.Session }; if (!steamKnown || RuleGrades.Of(rule) != Grade.Certain) { return autoBanDecision; } autoBanDecision.RecordStrike = true; if (existing == null || StrikeRules.IsExpired(existing, nowUtc) || string.Equals(existing.Lobby, lobby, StringComparison.Ordinal)) { return autoBanDecision; } autoBanDecision.SecondOffence = true; switch (setting) { case AutoBanDuration.Permanent: autoBanDecision.Length = BanLength.Permanent; break; case AutoBanDuration.OneDay: case AutoBanDuration.OneWeek: autoBanDecision.Length = BanLength.Timed; autoBanDecision.UntilUtc = nowUtc + DurationOf(setting).Value; break; } return autoBanDecision; } public static TimeSpan? DurationOf(AutoBanDuration d) { return d switch { AutoBanDuration.OneDay => TimeSpan.FromDays(1.0), AutoBanDuration.OneWeek => TimeSpan.FromDays(7.0), _ => null, }; } public static string Describe(AutoBanDuration d) { return d switch { AutoBanDuration.OneDay => "1 day", AutoBanDuration.OneWeek => "1 week", AutoBanDuration.Permanent => "permanently", _ => "this lobby", }; } } internal sealed class BanEntry { public ulong Id; public bool Exempt; public DateTime? UntilUtc; public string Note; } internal sealed class ChatCommand { public string Name; public string Target; public string Reason; } internal static class BanRules { public static readonly KeyValuePair<ulong, string>[] KnownAttackers = new KeyValuePair<ulong, string>[1] { new KeyValuePair<ulong, string>(76561199295933088uL, "\"o\" bot: removed ship furniture, crashed hosts (2026-10)") }; public const string TimeFormat = "yyyy-MM-ddTHH:mm'Z'"; public const string Usage = "Commands: /kick <name>, /ban <name or SteamID> [| reason], /unban <name, SteamID or last>, /bans, /recent"; public const int TargetNotFound = -1; public const int TargetAmbiguous = -2; public static bool TryParseClaimedSteamId(string payload, out ulong id) { id = 0uL; if (string.IsNullOrEmpty(payload)) { return false; } string[] array = payload.Split(','); if (array.Length >= 2) { return TryParseSteamId(array[1], out id); } return false; } public static bool TryParseSteamId(string s, out ulong id) { id = 0uL; if (s == null) { return false; } s = s.Trim(); if (s.Length == 17 && s.StartsWith("7656119", StringComparison.Ordinal)) { return ulong.TryParse(s, out id); } return false; } public static string FormatTime(DateTime utc) { return utc.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm'Z'", CultureInfo.InvariantCulture); } public static bool TryParseTime(string s, out DateTime utc) { s = (s ?? "").Trim(); if (s.Length > 0 && !s.EndsWith("Z", StringComparison.OrdinalIgnoreCase)) { s += "Z"; } bool num = DateTime.TryParseExact(s, "yyyy-MM-ddTHH:mm'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out utc); if (num) { utc = DateTime.SpecifyKind(utc, DateTimeKind.Utc); } return num; } internal static string OneLine(string s) { return (s ?? "").Replace('\r', ' ').Replace('\n', ' ').Trim(); } public static bool TryParseLine(string line, out BanEntry entry) { entry = null; if (string.IsNullOrWhiteSpace(line)) { return false; } line = line.Trim(); if (line.StartsWith("#", StringComparison.Ordinal)) { return false; } BanEntry banEntry = new BanEntry { Note = "" }; if (line.StartsWith("!", StringComparison.Ordinal)) { banEntry.Exempt = true; line = line.Substring(1); } int num = line.IndexOf('#'); string obj = ((num >= 0) ? line.Substring(0, num) : line); if (num >= 0) { banEntry.Note = line.Substring(num + 1).Trim(); } string[] array = obj.Split(new char[2] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); if (array.Length == 0 || !TryParseSteamId(array[0], out banEntry.Id)) { return false; } for (int i = 1; i < array.Length; i++) { if (!array[i].StartsWith("until=", StringComparison.OrdinalIgnoreCase)) { return false; } if (!TryParseTime(array[i].Substring("until=".Length), out var utc)) { return false; } banEntry.UntilUtc = utc; } entry = banEntry; return true; } public static string FormatLine(BanEntry e) { string text = OneLine(e.Note); string text2 = ((!e.Exempt && e.UntilUtc.HasValue) ? (" until=" + FormatTime(e.UntilUtc.Value)) : ""); return (e.Exempt ? "!" : "") + e.Id + text2 + ((text.Length > 0) ? (" # " + text) : ""); } public static bool IsActive(BanEntry e, DateTime nowUtc) { if (e == null || e.Exempt) { return false; } if (e.UntilUtc.HasValue) { return e.UntilUtc.Value > nowUtc; } return true; } public static DateTime? MergeUntil(DateTime? existingUntil, bool existingPermanent, DateTime? incomingUntil) { if (existingPermanent || !incomingUntil.HasValue) { return null; } if (!existingUntil.HasValue) { return incomingUntil; } if (!(existingUntil.Value > incomingUntil.Value)) { return incomingUntil; } return existingUntil; } public static ChatCommand ParseCommand(string text) { if (string.IsNullOrEmpty(text)) { return null; } text = text.Trim(); if (!text.StartsWith("/", StringComparison.Ordinal)) { return null; } int num = text.IndexOf(' '); string text2 = ((num < 0) ? text.Substring(1) : text.Substring(1, num - 1)).ToLowerInvariant(); string text3 = ((num < 0) ? "" : text.Substring(num + 1).Trim()); switch (text2) { case "bans": case "recent": return new ChatCommand { Name = text2, Target = "", Reason = "" }; case "kick": case "unban": return new ChatCommand { Name = text2, Target = text3, Reason = "" }; case "ban": { int num2 = text3.IndexOf('|'); return new ChatCommand { Name = text2, Target = ((num2 < 0) ? text3 : text3.Substring(0, num2)).Trim(), Reason = ((num2 < 0) ? "" : text3.Substring(num2 + 1).Trim()) }; } default: return null; } } public static int ResolveTarget(string target, IList<string> names) { if (string.IsNullOrWhiteSpace(target) || names == null) { return -1; } target = target.Trim(); if (target.StartsWith("#", StringComparison.Ordinal) && int.TryParse(target.Substring(1), out var result)) { if (result < 0 || result >= names.Count || names[result] == null) { return -1; } return result; } int num = Match(target, names, (string n, string t) => string.Equals(n, t, StringComparison.OrdinalIgnoreCase)); if (num != -1) { return num; } num = Match(target, names, (string n, string t) => n.StartsWith(t, StringComparison.OrdinalIgnoreCase)); if (num != -1) { return num; } return Match(target, names, (string n, string t) => n.IndexOf(t, StringComparison.OrdinalIgnoreCase) >= 0); } private static int Match(string target, IList<string> names, Func<string, string, bool> match) { int num = -1; for (int i = 0; i < names.Count; i++) { if (names[i] != null && match(names[i], target)) { if (num != -1) { return -2; } num = i; } } return num; } } internal sealed class UndoEntry { public ulong SteamId { get; set; } public string Name { get; set; } public BanLength Length { get; set; } public bool Auto { get; set; } } internal sealed class BanUndo { public const int Capacity = 50; private readonly List<UndoEntry> _stack = new List<UndoEntry>(); public int Count => _stack.Count; public void Push(UndoEntry e) { _stack.Add(e); if (_stack.Count > 50) { _stack.RemoveAt(0); } } public UndoEntry PopUndoable(Func<ulong, bool> isBanned, Func<ulong, bool> hasStrike) { return Pop((UndoEntry e) => isBanned(e.SteamId) || (e.Auto && hasStrike(e.SteamId))); } public UndoEntry PopActive(Func<ulong, bool> isStillBanned) { return Pop((UndoEntry e) => isStillBanned(e.SteamId)); } private UndoEntry Pop(Func<UndoEntry, bool> keep) { while (_stack.Count > 0) { UndoEntry undoEntry = _stack[_stack.Count - 1]; _stack.RemoveAt(_stack.Count - 1); if (keep(undoEntry)) { return undoEntry; } } return null; } } internal static class ChatRules { public enum SystemKind { Foreign, Joined, LeftBehind, StartedShip, Hidden } public const int MaxSystemLength = 120; public const string Joined = " joined the ship."; public const string LeftBehind = " was left behind."; private static readonly Regex StartedShip = new Regex("^\\[playerNum(\\d{1,3})\\] started the ship\\.\\z", RegexOptions.CultureInvariant); public const int MaxHiddenLength = 512; private const string HiddenOpen = "<size=0>"; private const string HiddenClose = "</size>"; private static readonly Regex MinimapVersionResp = new Regex("^<size=0>Tyzeron\\.Minimap/\\d{1,3}/VersionResp/([0-9A-Za-z.+\\-]{1,16})</size>\\z", RegexOptions.CultureInvariant); public static bool Relays(SystemKind kind) { return kind != SystemKind.Hidden; } public static string CheckPlayerChat(int senderIndex, int playerId) { if (playerId == senderIndex) { return null; } return "CHAT-IDENTITY"; } public static string StripTags(string s) { if (string.IsNullOrEmpty(s) || s.IndexOf('<') < 0) { return s; } StringBuilder stringBuilder = new StringBuilder(s.Length); Stack<int> stack = new Stack<int>(); foreach (char c in s) { switch (c) { case '<': stack.Push(stringBuilder.Length); stringBuilder.Append(c); continue; case '>': if (stack.Count > 0) { stringBuilder.Length = stack.Pop(); continue; } break; } stringBuilder.Append(c); } return stringBuilder.ToString(); } private static bool IsBreaking(char c) { if (char.IsControl(c)) { return true; } UnicodeCategory unicodeCategory = char.GetUnicodeCategory(c); if ((uint)(unicodeCategory - 12) <= 1u || unicodeCategory == UnicodeCategory.Format) { return true; } return false; } private static bool IsEscapeLetter(char c) { if (c != 'n' && c != 'r' && c != 't' && c != 'v' && c != 'u') { return c == 'U'; } return true; } public static string FlattenControl(string s) { if (string.IsNullOrEmpty(s) || !NeedsFlatten(s)) { return s; } StringBuilder stringBuilder = new StringBuilder(s.Length + 8); for (int i = 0; i < s.Length; i++) { char c = s[i]; if (IsBreaking(c)) { stringBuilder.Append(' '); continue; } stringBuilder.Append(c); if (c == '\\' && i + 1 < s.Length && IsEscapeLetter(s[i + 1])) { stringBuilder.Append(' '); } } return stringBuilder.ToString(); } private static bool NeedsFlatten(string s) { for (int i = 0; i < s.Length; i++) { if (IsBreaking(s[i])) { return true; } if (s[i] == '\\' && i + 1 < s.Length && IsEscapeLetter(s[i + 1])) { return true; } } return false; } public static string NeutralizePlaceholders(string s) { if (string.IsNullOrEmpty(s)) { return s; } string text; do { text = s; s = s.Replace("[playerNum", "[player Num"); } while (s != text); return s; } public static string CleanPlayerChat(string message) { return NeutralizePlaceholders(FlattenControl(StripTags(message))); } public static SystemKind ClassifySystem(string message, int senderIndex, bool joinedAlready, bool hostSeesDead, int shipStarter = -1) { if (IsHidden(message)) { return SystemKind.Hidden; } if (string.IsNullOrEmpty(message) || message.Length > 120 || NeedsFlatten(StripTags(message))) { return SystemKind.Foreign; } Match match = StartedShip.Match(message); if (match.Success) { if (!int.TryParse(match.Groups[1].Value, out var result) || (result != senderIndex && (shipStarter < 0 || result != shipStarter))) { return SystemKind.Foreign; } return SystemKind.StartedShip; } if (!joinedAlready && message.EndsWith(" joined the ship.", StringComparison.Ordinal) && message.Length > " joined the ship.".Length) { return SystemKind.Joined; } if (hostSeesDead && message.EndsWith(" was left behind.", StringComparison.Ordinal) && message.Length > " was left behind.".Length) { return SystemKind.LeftBehind; } return SystemKind.Foreign; } private static bool IsHidden(string message) { if (message == null || message.Length > 512 || message.Length <= "<size=0>".Length + "</size>".Length || !message.StartsWith("<size=0>", StringComparison.Ordinal) || !message.EndsWith("</size>", StringComparison.Ordinal)) { return false; } string text = message.Substring("<size=0>".Length, message.Length - "<size=0>".Length - "</size>".Length); if (text.IndexOf('<') < 0) { return !NeedsFlatten(text); } return false; } public static bool TryParseMinimapVersion(string message, out string version) { version = null; if (message == null) { return false; } Match match = MinimapVersionResp.Match(message); if (!match.Success) { return false; } version = match.Groups[1].Value; return true; } public static string RewriteSystem(string message, SystemKind kind, string senderName) { if (kind == SystemKind.StartedShip) { return message; } string s = StripTags(message ?? ""); if (kind != SystemKind.Foreign) { return NeutralizePlaceholders(FlattenControl(s)); } s = NeutralizePlaceholders(FlattenControl(s)); if (s.Length > 120) { s = s.Substring(0, 120); } string text = NeutralizePlaceholders(FlattenControl(StripTags(senderName))); if (string.IsNullOrEmpty(text)) { text = "?"; } return "[" + text + "] " + s; } } internal static class ConfigMigration { public static bool LegacyHideModdedTagOff(IEnumerable<(string Section, string Key, string Value)> orphans) { if (orphans == null) { return false; } foreach (var (text, text2, text3) in orphans) { if (text == "General" && text2 == "HideModdedTag" && text3 != null && string.Equals(text3.Trim(), "false", StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } } internal static class CreditRules { public static string CheckSpend(int spent, int maxPrice, bool mayBeFree) { if (spent < 0) { return null; } if (spent == 0 && !mayBeFree) { return "CREDIT-FREE"; } if (spent > maxPrice) { return "CREDIT-OVERSPEND"; } return null; } public static bool InRange(int index, int count) { if (index >= 0) { return index < count; } return false; } } internal enum DuplicateAction { None, TakeOver, StandDown } internal static class DuplicateInstall { public static DuplicateAction Decide(Version main, Version target) { if (main == null) { return DuplicateAction.None; } if (!(main < target)) { return DuplicateAction.StandDown; } return DuplicateAction.TakeOver; } public static string Notice(DuplicateAction action, Version main) { return action switch { DuplicateAction.TakeOver => $"LethalGuard+ Beta is running in place of LethalGuard+ {main}. Keep both installed: the next LethalGuard+ update takes over by itself.", DuplicateAction.StandDown => $"LethalGuard+ {main} is installed, so the Beta is turned off. Please remove LethalGuard+ Beta.", _ => null, }; } } internal class FloodCounter { public const int WindowMs = 5000; private readonly object _lock = new object(); private readonly Dictionary<string, (int start, int count)> _windows = new Dictionary<string, (int, int)>(); public bool IsFlood(string key, int nowMs, int perSecondLimit) { return IsOverWindow(key, nowMs, perSecondLimit * 5); } public bool IsOverWindow(string key, int nowMs, int maxPerWindow) { lock (_lock) { if (!_windows.TryGetValue(key, out (int, int) value) || nowMs - value.Item1 >= 5000) { value = (nowMs, 0); } value.Item2++; _windows[key] = value; return value.Item2 > maxPerWindow; } } public void Remove(string key) { lock (_lock) { _windows.Remove(key); } } public void Clear() { lock (_lock) { _windows.Clear(); } } public int CountOf(string key) { lock (_lock) { (int, int) value; return _windows.TryGetValue(key, out value) ? value.Item2 : 0; } } } internal static class ItemRules { public const float MaxReach = 10f; public const float MaxReleaseDistance = 50f; public const int ItemOnlySlotIndex = 50; public static string CheckDespawnHeld(bool hasItem, bool isVanilla, bool isKey) { if (hasItem && isVanilla && !isKey) { return "ITEM-DELETE-HELD"; } return null; } public static string CheckDestroySlot(bool hasItem, bool isVanilla, bool isAmmo) { if (hasItem && isVanilla && !isAmmo) { return "ITEM-DELETE-SLOT"; } return null; } public static string CheckGrab(float distance) { if (distance <= 10f) { return null; } return "ITEM-REMOTE-GRAB"; } public static string CheckThrow(bool possesses) { if (!possesses) { return "ITEM-NOT-HELD"; } return null; } public static string CheckBagAdd(float distance) { if (distance <= 10f) { return null; } return "BAG-REMOTE-ADD"; } public static string CheckReleaseTarget(float distance) { if (distance <= 50f) { return null; } return "ITEM-THROW-FAR"; } public static string CheckSlotIndex(int slot, int slotCount) { if (slot != 50 && (slot < 0 || slot >= slotCount)) { return "ITEM-SLOT-INDEX"; } return null; } } public enum KickLevel { Off, Recommended, Risky, VeryRisky, Unsafe } public enum NoticeLevel { Off, Certain, Likely, Possible, All } internal sealed class KickInput { public string Rule; public KickLevel Level; public bool Trusted; public ISet<string> Never; public ISet<string> Always; } internal static class KickPolicy { public static bool ShouldKick(KickInput i) { if (i.Trusted) { return false; } if (i.Never != null && i.Never.Contains(i.Rule)) { return false; } if (i.Always != null && i.Always.Contains(i.Rule)) { return true; } if (!RuleGrades.IsKickable(i.Rule)) { return false; } return (int)RuleGrades.Of(i.Rule) <= MaxGrade(i.Level); } private static int MaxGrade(KickLevel level) { return level switch { KickLevel.Recommended => 1, KickLevel.Risky => 2, KickLevel.VeryRisky => 3, KickLevel.Unsafe => 4, _ => 0, }; } public static bool ShouldNotify(Grade g, NoticeLevel level) { return (int)g <= (int)level; } public static HashSet<string> ParseRules(string csv, List<string> invalid) { HashSet<string> hashSet = new HashSet<string>(StringComparer.Ordinal); foreach (string item in Split(csv)) { string text = item.ToUpperInvariant(); if (RuleGrades.IsKnown(text)) { hashSet.Add(text); } else { invalid?.Add(item); } } return hashSet; } public static HashSet<ulong> ParseSteamIds(string csv, List<string> invalid) { HashSet<ulong> hashSet = new HashSet<ulong>(); foreach (string item in Split(csv)) { if (BanRules.TryParseSteamId(item, out var id)) { hashSet.Add(id); } else { invalid?.Add(item); } } return hashSet; } private static IEnumerable<string> Split(string csv) { if (string.IsNullOrEmpty(csv)) { yield break; } string[] array = csv.Split(','); for (int i = 0; i < array.Length; i++) { string text = array[i].Trim(); if (text.Length > 0) { yield return text; } } } } internal class LeverWatchdog { private double? _deadline; private ulong _sender; public bool Armed => _deadline.HasValue; public void Arm(ulong sender, double now, double seconds) { if (!_deadline.HasValue) { _deadline = now + seconds; _sender = sender; } } public void Disarm() { _deadline = null; } public bool Expired(double now, out ulong sender) { sender = _sender; if (!_deadline.HasValue || now < _deadline.Value) { return false; } _deadline = null; return true; } } internal static class LogPolicy { public const int MaxSampleLinesPerTag = 200; public static bool ShouldWriteSample(string tag, bool logSamples, int writtenForTag) { if (tag == null || !tag.EndsWith("-SAMPLE")) { return true; } if (logSamples) { return writtenForTag < 200; } return false; } public static string AdvancedLine(string key, string value) { if (key == "TrustedPlayers") { return $"TrustedPlayers={KickPolicy.ParseSteamIds(value, null).Count} ids"; } return key + "=" + value; } } internal static class LogRotation { public const string SessionMarker = "===== Session started"; public const int MaxSessions = 10; public const long MaxBytesAtStart = 6000000L; public const long MaxBytesPerSession = 2000000L; public const long HardBytesPerSession = 3000000L; public static void TrimFile(string path, Func<string, string[]> read) { if (File.Exists(path)) { string[] lines; try { lines = read(path); } catch { return; } File.WriteAllLines(path, TrimSessions(lines, 10, 6000000L)); } } public static List<string> TrimSessions(IReadOnlyList<string> lines, int maxSessions, long maxBytes) { List<List<string>> list = new List<List<string>>(); foreach (string line in lines) { if (list.Count == 0 || line.StartsWith("===== Session started")) { list.Add(new List<string>()); } list[list.Count - 1].Add(line); } int num = maxSessions - 1; if (num < 0) { num = 0; } if (list.Count > num) { list.RemoveRange(0, list.Count - num); } List<long> list2 = new List<long>(); long num2 = 0L; foreach (List<string> item in list) { long num3 = 0L; foreach (string item2 in item) { num3 += Encoding.UTF8.GetByteCount(item2) + 2; } list2.Add(num3); num2 += num3; } int num4 = 0; while (num4 < list.Count - 1 && num2 > maxBytes) { num2 -= list2[num4++]; } List<string> list3 = new List<string>(); for (int i = num4; i < list.Count; i++) { list3.AddRange(list[i]); } return list3; } } internal enum MessageKind { Other, ServerRpc, Custom } internal static class MessageRules { public const int MaxRpcPayloadBytes = 65536; public static string Check(MessageKind kind, int payloadBytes, ulong objectId, bool objectSpawned, int maxCustomBytes) { switch (kind) { case MessageKind.ServerRpc: if (payloadBytes > 65536) { return "RPC-OVERSIZE"; } if (!objectSpawned) { if (objectId != 0L) { return "RPC-UNKNOWN-OBJECT"; } return "RPC-BEFORE-SPAWN"; } return null; case MessageKind.Custom: if (payloadBytes <= maxCustomBytes) { return null; } return "CUSTOM-MESSAGE-OVERSIZE"; default: return null; } } } internal static class MiscRules { public static string CheckStorageIndex(int unlockableId, int unlockableCount) { if (unlockableId < 0 || unlockableId >= unlockableCount) { return "STORAGE-INDEX"; } return null; } } internal sealed class NoticeAggregator { private sealed class Entry { public string Name; public string Rule; public bool Trusted; public double WindowEnd; public int Count; public long Seq; } public const double WindowSec = 5.0; public const int MaxLinesPerWindow = 6; private readonly object _lock = new object(); private readonly Dictionary<string, Entry> _entries = new Dictionary<string, Entry>(); private long _seq; private double _globalEnd = double.NegativeInfinity; private int _linesInWindow; private int _hidden; public List<string> Record(string playerKey, string name, string rule, bool trusted, double nowSec) { lock (_lock) { List<string> list = FlushLocked(nowSec); string key = playerKey + "|" + rule; if (_entries.TryGetValue(key, out var value)) { value.Count++; value.Name = name; value.Trusted = trusted; return list; } value = new Entry { Name = name, Rule = rule, Trusted = trusted, WindowEnd = nowSec + 5.0, Seq = _seq++ }; _entries[key] = value; Emit(list, Line(value, null), nowSec); return list; } } public List<string> Flush(double nowSec) { lock (_lock) { return FlushLocked(nowSec); } } public void Clear() { lock (_lock) { _entries.Clear(); _globalEnd = double.NegativeInfinity; _linesInWindow = 0; _hidden = 0; } } private List<string> FlushLocked(double nowSec) { List<string> list = new List<string>(); RotateGlobal(list, nowSec); foreach (KeyValuePair<string, Entry> item in (from kv in _entries where nowSec >= kv.Value.WindowEnd orderby kv.Value.Seq select kv).ToList()) { Entry value = item.Value; if (value.Count == 0) { _entries.Remove(item.Key); continue; } Emit(list, Line(value, value.Count), nowSec); value.Count = 0; value.WindowEnd = nowSec + 5.0; } return list; } private void RotateGlobal(List<string> output, double nowSec) { if (!(nowSec < _globalEnd)) { _globalEnd = nowSec + 5.0; _linesInWindow = 0; if (_hidden > 0) { output.Insert(0, NoticeFormat.Info($"{_hidden} more notices hidden · see LethalGuardPlus.log")); _hidden = 0; } } } private void Emit(List<string> output, string line, double nowSec) { RotateGlobal(output, nowSec); if (_linesInWindow < 6) { output.Add(line); _linesInWindow++; } else { _hidden++; } } private static string Line(Entry e, int? count) { return NoticeFormat.Blocked(e.Name, e.Rule, count, e.Trusted); } } internal static class NoticeFormat { private const string Orange = "#ff9a1f"; private const string Red = "#ff4a4a"; private const string Grey = "#b5b5b5"; private const string Text = "#f2f2f2"; private const string Code = "#8a8a8a"; private static readonly Dictionary<string, string> Descriptions = new Dictionary<string, string>(StringComparer.Ordinal) { { "CHAT-IDENTITY", "fake chat" }, { "CHAT-RATE", "chat spam" }, { "FURNITURE-IDENTITY", "fake furniture move" }, { "FURNITURE-RATE", "furniture spam" }, { "FURNITURE-DISTANCE", "furniture moved too far" }, { "FURNITURE-ANGLE", "tilted furniture" }, { "FURNITURE-DEAD", "furniture move while dead" }, { "FURNITURE-NOT-IDLE", "furniture move mid-action" }, { "FURNITURE-TERMINAL-ROOM", "furniture blocking terminal" }, { "FURNITURE-STORE-IDENTITY", "fake store furniture" }, { "ENDGAME-IDENTITY", "fake ship leave" }, { "LEVER-STATE", "invalid lever pull" }, { "LEVER-WATCHDOG", "invalid lever pull" }, { "SHIP-CONTROL-RATE", "ship control spam" }, { "SHIP-CONTROL-DEAD", "ship control while dead" }, { "TERMINAL-RATE", "terminal spam" }, { "TERMINAL-FAR", "terminal use from afar" }, { "TERMINAL-DEAD", "terminal use while dead" }, { "TERMINAL-AUDIO-INDEX", "invalid terminal sound" }, { "ITEM-REMOTE-GRAB", "remote item grab" }, { "BAG-REMOTE-ADD", "remote bag add" }, { "ITEM-THROW-FAR", "item thrown too far" }, { "ITEM-DELETE-HELD", "deleting held item" }, { "ITEM-DELETE-SLOT", "invalid item delete" }, { "ITEM-NOT-HELD", "using unheld item" }, { "ITEM-SLOT-INDEX", "invalid item slot" }, { "STORAGE-INDEX", "invalid storage item" }, { "GIFT-REPEAT", "gift opened twice" }, { "GIFT-NOT-HOLDER", "gift not held" }, { "MASK-REPEAT", "mask used twice" }, { "MASK-NOT-HOLDER", "mask not held" }, { "CREDIT-FREE", "free purchase" }, { "CREDIT-OVERSPEND", "overspend" }, { "SHOP-INDEX", "invalid shop item" }, { "KILL-IDENTITY", "fake player kill" }, { "KILL-REPEAT", "repeated player kill" }, { "ENEMY-ATTACK-IDENTITY", "fake enemy attack" }, { "ENEMY-DESPAWN", "enemy delete" }, { "JOIN-IDENTITY", "fake identity on join" }, { "VOTE-REPEAT", "double vote" }, { "VOTE-ALIVE", "vote while alive" }, { "FAKE-LENGTH", "voice crash packet" }, { "SPOOFED-PLAYER", "fake voice identity" }, { "DELTA-SPOOFED-PLAYER", "fake voice identity" }, { "SPOOFED-VOICE", "fake voice identity" }, { "SPOOFED-TEXT", "fake voice identity" }, { "UNREADABLE-PACKET", "bad voice packet" }, { "MALFORMED-PACKET", "bad voice packet" }, { "HANDSHAKE-CODEC", "bad voice packet" }, { "HANDSHAKE-NAME", "bad voice packet" }, { "CLIENTSTATE-CODEC", "bad voice packet" }, { "CLIENTSTATE-NAME", "bad voice packet" }, { "CLIENTSTATE-ROOM", "bad voice packet" }, { "DELTA-ROOM", "bad voice packet" }, { "RELAY-TARGETS", "bad voice packet" }, { "RELAY-CONTENT", "bad voice packet" }, { "RATE-LIMIT", "voice packet spam" }, { "MESSAGE-FLOOD", "network spam" }, { "RPC-OVERSIZE", "oversized request" }, { "CUSTOM-MESSAGE-OVERSIZE", "oversized request" }, { "RPC-MALFORMED", "broken request" }, { "RPC-UNKNOWN-OBJECT", "broken request" }, { "RPC-UNKNOWN-SENDER", "broken request" }, { "DEBUG-RPC", "debug menu use" } }; public static string Blocked(string name, string rule, int? count, bool trusted) { string text = (count.HasValue ? $" ({count.Value}×)" : ""); string text2 = (trusted ? " (trusted)" : ""); return Line("#ff9a1f", name + " — " + Describe(rule) + " blocked" + text + text2, rule); } public static string Removed(string name, string rule, bool second, string banLength, bool undo) { string text = ((!second) ? (name + " removed — " + Describe(rule)) : ((banLength != null) ? (name + " banned " + banLength + " — " + Describe(rule) + " (2nd)") : (name + " removed again — " + Describe(rule)))); if (undo) { text += " · /unban last"; } return Line("#ff4a4a", text, rule); } public static string Info(string text) { return Line("#b5b5b5", text, null); } public static string Describe(string rule) { if (rule == null || !Descriptions.TryGetValue(rule, out var value)) { return "suspicious request"; } return value; } public static bool HasDescription(string rule) { if (rule != null) { return Descriptions.ContainsKey(rule); } return false; } private static string Line(string tagColor, string body, string rule) { string text = "<color=" + tagColor + "><b>[LG+]</b></color> <color=#f2f2f2>" + body + "</color>"; if (rule != null) { return text + " <color=#8a8a8a>" + rule + "</color>"; } return text; } } internal class RateTracker { private readonly Dictionary<ulong, double> _lastAccepted = new Dictionary<ulong, double>(); public bool TooSoon(ulong sender, double now, double minInterval) { if (_lastAccepted.TryGetValue(sender, out var value)) { return now - value < minInterval; } return false; } public void Accept(ulong sender, double now) { _lastAccepted[sender] = now; } public void Clear() { _lastAccepted.Clear(); } } internal enum Grade { Certain = 1, Likely, Possible, Suspect } internal static class RuleGrades { private static readonly Dictionary<string, Grade> Table = new Dictionary<string, Grade>(StringComparer.Ordinal) { { "FURNITURE-IDENTITY", Grade.Certain }, { "ENDGAME-IDENTITY", Grade.Certain }, { "FURNITURE-STORE-IDENTITY", Grade.Certain }, { "KILL-IDENTITY", Grade.Certain }, { "ENEMY-ATTACK-IDENTITY", Grade.Certain }, { "CHAT-IDENTITY", Grade.Certain }, { "SHOP-INDEX", Grade.Certain }, { "RPC-OVERSIZE", Grade.Certain }, { "JOIN-IDENTITY", Grade.Certain }, { "DEBUG-RPC", Grade.Certain }, { "STORAGE-INDEX", Grade.Certain }, { "MESSAGE-FLOOD", Grade.Likely }, { "CHAT-RATE", Grade.Likely }, { "TERMINAL-RATE", Grade.Likely }, { "SHIP-CONTROL-RATE", Grade.Likely }, { "FURNITURE-RATE", Grade.Likely }, { "CUSTOM-MESSAGE-OVERSIZE", Grade.Likely }, { "TERMINAL-AUDIO-INDEX", Grade.Likely }, { "ITEM-SLOT-INDEX", Grade.Likely }, { "FAKE-LENGTH", Grade.Likely }, { "CREDIT-FREE", Grade.Possible }, { "CREDIT-OVERSPEND", Grade.Possible }, { "TERMINAL-FAR", Grade.Possible }, { "TERMINAL-DEAD", Grade.Possible }, { "SHIP-CONTROL-DEAD", Grade.Possible }, { "ITEM-DELETE-HELD", Grade.Possible }, { "ITEM-DELETE-SLOT", Grade.Possible }, { "ITEM-REMOTE-GRAB", Grade.Possible }, { "ITEM-NOT-HELD", Grade.Possible }, { "BAG-REMOTE-ADD", Grade.Possible }, { "ITEM-THROW-FAR", Grade.Possible }, { "GIFT-REPEAT", Grade.Possible }, { "GIFT-NOT-HOLDER", Grade.Possible }, { "MASK-REPEAT", Grade.Possible }, { "MASK-NOT-HOLDER", Grade.Possible }, { "KILL-REPEAT", Grade.Possible }, { "ENEMY-DESPAWN", Grade.Possible }, { "VOTE-ALIVE", Grade.Possible }, { "VOTE-REPEAT", Grade.Possible }, { "FURNITURE-DEAD", Grade.Possible }, { "FURNITURE-NOT-IDLE", Grade.Possible }, { "FURNITURE-ANGLE", Grade.Possible }, { "FURNITURE-DISTANCE", Grade.Possible }, { "FURNITURE-TERMINAL-ROOM", Grade.Possible }, { "RPC-UNKNOWN-OBJECT", Grade.Possible }, { "RPC-MALFORMED", Grade.Possible }, { "SPOOFED-VOICE", Grade.Possible }, { "SPOOFED-TEXT", Grade.Possible }, { "SPOOFED-PLAYER", Grade.Possible }, { "DELTA-SPOOFED-PLAYER", Grade.Possible }, { "RELAY-CONTENT", Grade.Possible }, { "RELAY-TARGETS", Grade.Possible }, { "HANDSHAKE-CODEC", Grade.Possible }, { "HANDSHAKE-NAME", Grade.Possible }, { "CLIENTSTATE-CODEC", Grade.Possible }, { "CLIENTSTATE-NAME", Grade.Possible }, { "CLIENTSTATE-ROOM", Grade.Possible }, { "DELTA-ROOM", Grade.Possible }, { "MALFORMED-PACKET", Grade.Possible }, { "UNREADABLE-PACKET", Grade.Possible }, { "RATE-LIMIT", Grade.Suspect }, { "LEVER-STATE", Grade.Suspect }, { "LEVER-WATCHDOG", Grade.Suspect }, { "RPC-UNKNOWN-SENDER", Grade.Suspect } }; private static readonly HashSet<string> Voice = new HashSet<string>(StringComparer.Ordinal) { "FAKE-LENGTH", "UNREADABLE-PACKET", "MALFORMED-PACKET", "HANDSHAKE-CODEC", "HANDSHAKE-NAME", "CLIENTSTATE-NAME", "CLIENTSTATE-CODEC", "CLIENTSTATE-ROOM", "SPOOFED-PLAYER", "DELTA-SPOOFED-PLAYER", "DELTA-ROOM", "RELAY-TARGETS", "RELAY-CONTENT", "SPOOFED-VOICE", "SPOOFED-TEXT", "RATE-LIMIT" }; public static IEnumerable<string> AllRules => Table.Keys; public static Grade Of(string rule) { if (rule == null || !Table.TryGetValue(rule, out var value)) { return Grade.Suspect; } return value; } public static bool IsKnown(string rule) { if (rule != null) { return Table.ContainsKey(rule); } return false; } public static bool IsVoice(string rule) { if (rule != null) { return Voice.Contains(rule); } return false; } public static bool IsKickable(string rule) { if (IsVoice(rule)) { return rule == "FAKE-LENGTH"; } return true; } public static string Label(Grade g) { return g switch { Grade.Certain => "certain", Grade.Likely => "likely", Grade.Possible => "possible", _ => "suspect", }; } } internal sealed class SeenPlayer { public ulong SteamId; public string Name; public DateTime JoinedUtc; public DateTime? LeftUtc; } internal sealed class SeenPlayers { public const int Capacity = 200; private readonly Dictionary<ulong, SeenPlayer> _bySteam = new Dictionary<ulong, SeenPlayer>(); private readonly Dictionary<ulong, ulong> _clientToSteam = new Dictionary<ulong, ulong>(); public void Joined(ulong clientId, ulong steamId, DateTime nowUtc) { _clientToSteam[clientId] = steamId; if (_bySteam.TryGetValue(steamId, out var value)) { value.JoinedUtc = nowUtc; value.LeftUtc = null; return; } if (_bySteam.Count >= 200) { DropOldestLeft(); } _bySteam[steamId] = new SeenPlayer { SteamId = steamId, Name = "?", JoinedUtc = nowUtc }; } public void Named(ulong clientId, string name) { if (!string.IsNullOrWhiteSpace(name) && TryGet(clientId, out var p)) { p.Name = name.Trim(); } } public void Left(ulong clientId, DateTime nowUtc) { if (TryGet(clientId, out var p)) { p.LeftUtc = nowUtc; _clientToSteam.Remove(clientId); } } public SeenPlayer Get(ulong steamId) { if (!_bySteam.TryGetValue(steamId, out var value)) { return null; } return value; } public IList<SeenPlayer> Recent(int count) { List<SeenPlayer> list = new List<SeenPlayer>(); foreach (SeenPlayer value in _bySteam.Values) { if (value.LeftUtc.HasValue) { list.Add(value); } } list.Sort((SeenPlayer a, SeenPlayer b) => b.LeftUtc.Value.CompareTo(a.LeftUtc.Value)); if (list.Count > count) { list.RemoveRange(count, list.Count - count); } return list; } public int Resolve(string target, bool includeConnected, out SeenPlayer found) { found = null; if (string.IsNullOrWhiteSpace(target) || target.Trim().StartsWith("#", StringComparison.Ordinal)) { return -1; } List<SeenPlayer> list = new List<SeenPlayer>(); List<string> list2 = new List<string>(); foreach (SeenPlayer value in _bySteam.Values) { if (value.LeftUtc.HasValue || includeConnected) { list.Add(value); list2.Add(value.Name); } } int num = BanRules.ResolveTarget(target, list2); if (num < 0) { return num; } found = list[num]; return 0; } public void Clear() { _bySteam.Clear(); _clientToSteam.Clear(); } public static string Ago(TimeSpan t) { if (t < TimeSpan.FromMinutes(1.0)) { return "<1m ago"; } if (t < TimeSpan.FromHours(1.0)) { return (int)t.TotalMinutes + "m ago"; } if (t < TimeSpan.FromHours(48.0)) { return (int)t.TotalHours + "h ago"; } return (int)t.TotalDays + "d ago"; } private bool TryGet(ulong clientId, out SeenPlayer p) { p = null; if (_clientToSteam.TryGetValue(clientId, out var value)) { return _bySteam.TryGetValue(value, out p); } return false; } private void DropOldestLeft() { SeenPlayer seenPlayer = null; foreach (SeenPlayer value in _bySteam.Values) { if (value.LeftUtc.HasValue && (seenPlayer == null || value.LeftUtc.Value < seenPlayer.LeftUtc.Value)) { seenPlayer = value; } } if (seenPlayer != null) { _bySteam.Remove(seenPlayer.SteamId); } } } internal sealed class SessionBudget { private readonly long _soft; private readonly long _hard; private long _used; private bool _softNoted; private bool _stopped; public long Dropped { get; private set; } public SessionBudget(long soft, long hard) { _soft = soft; _hard = hard; } public string Take(string text, bool important, string stamp) { if (_stopped) { Dropped++; return null; } long num = Encoding.UTF8.GetByteCount(text); if (_used + num <= _soft) { _used += num; return text; } string text2 = null; if (!_softNoted) { _softNoted = true; text2 = "[" + stamp + "] log limit reached: from now on only first, certain and ban lines are written\r\n"; _used += Encoding.UTF8.GetByteCount(text2); } if (!important) { Dropped++; return text2; } if (_used + num > _hard) { _stopped = true; Dropped++; return $"{text2}[{stamp}] log hard limit reached ({Dropped} lines dropped)\r\n"; } _used += num; return text2 + text; } } public enum ShipControl { Doors, Lights, Magnet, Teleporter, Horn } internal static class ShipControlRules { public static (int max, double windowSec) Limit(ShipControl c) { return c switch { ShipControl.Doors => (max: 12, windowSec: 5.0), ShipControl.Lights => (max: 15, windowSec: 5.0), ShipControl.Magnet => (max: 6, windowSec: 5.0), ShipControl.Teleporter => (max: 4, windowSec: 5.0), _ => (max: 4, windowSec: 3.0), }; } public static string Check(bool dead, bool overRate) { if (dead) { return "SHIP-CONTROL-DEAD"; } if (overRate) { return "SHIP-CONTROL-RATE"; } return null; } } internal static class SlotRules { public static int FindFreeSlot(int slotCount, ICollection<int> taken) { for (int i = 1; i < slotCount; i++) { if (!taken.Contains(i)) { return i; } } return -1; } } internal static class SpawnRules { public const int Gift = 0; public const int Mask = 1; public const double KillCooldownSeconds = 2.0; public static string CheckOneShot(int kind, bool alreadyUsed, bool heldByOther) { if (alreadyUsed) { if (kind != 0) { return "MASK-REPEAT"; } return "GIFT-REPEAT"; } if (heldByOther) { if (kind != 0) { return "MASK-NOT-HOLDER"; } return "GIFT-NOT-HOLDER"; } return null; } public static string CheckDespawnEnemy(bool isEnemy, bool enemyDead) { if (!isEnemy || enemyDead) { return null; } return "ENEMY-DESPAWN"; } public static string CheckKill(int ownIndex, int playerId, bool hostSeesDead, double secondsSinceLastKill) { if (playerId != ownIndex) { return "KILL-IDENTITY"; } if (hostSeesDead || secondsSinceLastKill < 2.0) { return "KILL-REPEAT"; } return null; } public static string CheckEnemyAttack(int senderIndex, int playerId) { if (playerId == senderIndex) { return null; } return "ENEMY-ATTACK-IDENTITY"; } } internal class SpawnTally { private class Window { public int Start; public int Spawns; public int Despawns; public readonly Dictionary<string, int> Names = new Dictionary<string, int>(); } private readonly int _windowMs; private readonly int _threshold; private readonly Dictionary<ulong, Window> _windows = new Dictionary<ulong, Window>(); public SpawnTally(int windowMs, int threshold) { _windowMs = windowMs; _threshold = threshold; } public string Record(ulong sender, int now, bool spawn, string name) { string result = null; if (!_windows.TryGetValue(sender, out var value)) { Dictionary<ulong, Window> windows = _windows; Window obj = new Window { Start = now }; value = obj; windows[sender] = obj; } else if (now - value.Start >= _windowMs) { if (value.Spawns > _threshold || value.Despawns > _threshold) { result = Summarize(value); } value = (_windows[sender] = new Window { Start = now }); } if (spawn) { value.Spawns++; } else { value.Despawns++; } string key = name ?? "?"; value.Names.TryGetValue(key, out var value2); if (value2 > 0 || value.Names.Count < 16) { value.Names[key] = value2 + 1; } return result; } public void Clear() { _windows.Clear(); } private string Summarize(Window w) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append($"spawns={w.Spawns} despawns={w.Despawns} in {_windowMs / 1000}s:"); foreach (KeyValuePair<string, int> item in w.Names.OrderByDescending((KeyValuePair<string, int> kv) => kv.Value).Take(5)) { stringBuilder.Append($" {item.Key} x{item.Value}"); } return stringBuilder.ToString(); } } internal sealed class Strike { public ulong Id; public DateTime FirstUtc; public DateTime LastUtc; public string Lobby; public string Rule; public string Name; } internal static class StrikeRules { public static readonly TimeSpan Lifetime = TimeSpan.FromDays(30.0); public static bool TryParseLine(string line, out Strike s) { s = null; if (string.IsNullOrWhiteSpace(line)) { return false; } line = line.Trim(); if (line.StartsWith("#", StringComparison.Ordinal)) { return false; } Strike strike = new Strike { Name = "", Rule = "", Lobby = null }; int num = line.IndexOf('#'); string obj = ((num >= 0) ? line.Substring(0, num) : line); if (num >= 0) { strike.Name = line.Substring(num + 1).Trim(); } string[] array = obj.Split(new char[2] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); if (array.Length == 0 || !BanRules.TryParseSteamId(array[0], out strike.Id)) { return false; } bool flag = false; bool flag2 = false; for (int i = 1; i < array.Length; i++) { int num2 = array[i].IndexOf('='); if (num2 <= 0) { continue; } string text = array[i].Substring(0, num2).ToLowerInvariant(); string text2 = array[i].Substring(num2 + 1); switch (text) { case "last": if (!BanRules.TryParseTime(text2, out strike.LastUtc)) { return false; } flag = true; break; case "first": if (!BanRules.TryParseTime(text2, out strike.FirstUtc)) { return false; } flag2 = true; break; case "lobby": strike.Lobby = text2; break; case "rule": strike.Rule = text2; break; } } if (!flag || string.IsNullOrEmpty(strike.Lobby)) { return false; } if (!flag2) { strike.FirstUtc = strike.LastUtc; } s = strike; return true; } public static string FormatLine(Strike s) { string text = BanRules.OneLine(s.Name); return s.Id + " last=" + BanRules.FormatTime(s.LastUtc) + " first=" + BanRules.FormatTime(s.FirstUtc) + " lobby=" + Token(s.Lobby) + " rule=" + Token(s.Rule) + ((text.Length > 0) ? (" # " + text) : ""); } private static string Token(string v) { return BanRules.OneLine(v).Replace(' ', '_').Replace('\t', '_') .Replace('#', '_'); } public static bool IsExpired(Strike s, DateTime nowUtc) { return nowUtc - s.LastUtc >= Lifetime; } public static Strike Next(Strike existing, ulong id, string name, string rule, string lobby, DateTime nowUtc) { bool flag = existing == null || IsExpired(existing, nowUtc); return new Strike { Id = id, FirstUtc = (flag ? nowUtc : existing.FirstUtc), LastUtc = nowUtc, Lobby = lobby, Rule = rule, Name = name }; } } internal static class TerminalRules { public const float MaxUseDistance = 8f; public const int RateMax = 8; public const int AudioRateMax = 20; public const double RateWindowSec = 5.0; public static string CheckInUse(bool inUse, bool dead, float distance, bool overRate) { if (!inUse) { return null; } if (dead) { return "TERMINAL-DEAD"; } if (!(distance <= 8f)) { return "TERMINAL-FAR"; } if (overRate) { return "TERMINAL-RATE"; } return null; } public static string CheckAudio(int clipIndex, int clipCount, bool overRate) { if (clipIndex < 0 || clipIndex >= clipCount) { return "TERMINAL-AUDIO-INDEX"; } if (overRate) { return "TERMINAL-RATE"; } return null; } } internal static class VoteRules { public static string CheckVote(bool hostSeesDead, bool alreadyVoted) { if (!hostSeesDead) { return "VOTE-ALIVE"; } if (alreadyVoted) { return "VOTE-REPEAT"; } return null; } } internal class WindowCounter { private readonly object _lock = new object(); private readonly Dictionary<string, Queue<double>> _hits = new Dictionary<string, Queue<double>>(); public bool Over(string key, double nowSec, int max, double windowSec) { lock (_lock) { if (!_hits.TryGetValue(key, out var value)) { value = (_hits[key] = new Queue<double>()); } while (value.Count > 0 && nowSec - value.Peek() > windowSec) { value.Dequeue(); } value.Enqueue(nowSec); while (value.Count > max + 64) { value.Dequeue(); } return value.Count > max; } } public void Clear() { lock (_lock) { _hits.Clear(); } } } } namespace LethalGuardPlus.AntiGrief { internal static class AntiGriefModule { internal static void Apply(Harmony harmony) { Try("FurnitureGuard", delegate { FurnitureGuard.Apply(harmony); }); Try("EndGameGuard", delegate { EndGameGuard.Apply(harmony); }); Try("LeverGuard", delegate { LeverGuard.Apply(harmony); }); Try("MessageGuard", delegate { MessageGuard.Apply(harmony); }); Try("SpawnGuard", delegate { SpawnGuard.Apply(harmony); }); Try("VoteGuard", delegate { VoteGuard.Apply(harmony); }); Try("ChatGuard", delegate { ChatGuard.Apply(harmony); }); Try("CreditGuard", delegate { CreditGuard.Apply(harmony); }); Try("BanGuard", delegate { BanGuard.Apply(harmony); }); Try("JoinSlotGuard", delegate { JoinSlotGuard.Apply(harmony); }); Try("TerminalGuard", delegate { TerminalGuard.Apply(harmony); }); Try("ShipControlGuard", delegate { ShipControlGuard.Apply(harmony); }); Try("MiscGuard", delegate { MiscGuard.Apply(harmony); }); Try("ItemGuard", delegate { ItemGuard.Apply(harmony); }); Try("HostNotice", delegate { HostNotice.Apply(harmony); }); Try("Disconnect", delegate { //IL_002d: Unknown result type (might be due to invalid IL or missing references) //IL_003a: Expected O, but got Unknown harmony.Patch((MethodBase)AccessTools.Method(typeof(GameNetworkManager), "Disconnect", (Type[])null, (Type[])null), (HarmonyMethod)null, new HarmonyMethod(typeof(AntiGriefModule), "DisconnectPostfix", (Type[])null), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); }); Try("ClientDisconnect", delegate { //IL_002d: Unknown result type (might be due to invalid IL or missing references) //IL_003a: Expected O, but got Unknown harmony.Patch((MethodBase)AccessTools.Method(typeof(StartOfRound), "OnClientDisconnect", (Type[])null, (Type[])null), (HarmonyMethod)null, new HarmonyMethod(typeof(AntiGriefModule), "ClientDisconnectPostfix", (Type[])null), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); }); } private static void ClientDisconnectPostfix(ulong clientId) { try { ServerGuard.Forget(clientId); } catch (Exception ex) { Plugin.Log.LogWarning((object)("ClientDisconnect cleanup failed: " + ex.Message)); } } private static void Try(string name, Action apply) { try {