Decompiled source of Overtime v1.0.3
BepInEx/plugins/Overtime/Overtime.dll
Decompiled 12 hours 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.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security; using System.Security.Cryptography; using System.Security.Permissions; using System.Text; using System.Threading; using System.Threading.Tasks; using BepInEx; using BepInEx.Bootstrap; using BepInEx.Configuration; using BepInEx.Logging; using BepInEx.Unity.IL2CPP; using BepInEx.Unity.IL2CPP.Utils.Collections; using Fusion; using HLG.Runtime.Serialisation; using HarmonyLib; using Il2CppInterop.Runtime; using Il2CppInterop.Runtime.Injection; using Il2CppInterop.Runtime.InteropTypes; using Il2CppInterop.Runtime.InteropTypes.Arrays; using Il2CppSystem; using Il2CppSystem.Collections; using Il2CppSystem.Collections.Generic; using Il2CppSystem.Reflection; using Microsoft.CodeAnalysis; using Obi; using Overtime.Config; using Overtime.Core; using Overtime.Features.Cap; using Overtime.Features.Console; using Overtime.Features.Content; using Overtime.Features.Content.Customers; using Overtime.Features.CutContent; using Overtime.Features.Events; using Overtime.Features.Fixes; using Overtime.Features.Integrity; using Overtime.Features.LateJoin; using Overtime.Features.LateJoin.Resync; using Overtime.Features.Night; using Overtime.Features.Polish; using Overtime.Features.Scares; using Overtime.Features.Story; using Overtime.Features.Surprises; using Overtime.Features.Tutorials; using Overtime.Features.Veteran; using Overtime.Features.Voice; using Overtime.Integration; using Overtime.Logic; using Overtime.Net; using POpusCodec; using POpusCodec.Enums; using Photon.Voice; using Photon.Voice.Unity; using Steamworks; using TMPro; using UnityEngine; using UnityEngine.Audio; using UnityEngine.Events; using UnityEngine.InputSystem; using UnityEngine.Rendering; using UnityEngine.SceneManagement; using UnityEngine.UI; using UnityEngine.Video; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETCoreApp,Version=v6.0", FrameworkDisplayName = ".NET 6.0")] [assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)] [assembly: AssemblyVersion("0.0.0.0")] [module: UnverifiableCode] [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 Overtime { [BepInPlugin("Vippy.Overtime", "Overtime", "1.0.3")] [BepInDependency(/*Could not decode attribute arguments.*/)] [BepInDependency(/*Could not decode attribute arguments.*/)] public class Plugin : BasePlugin { public const string Guid = "Vippy.Overtime"; public const string Version = "1.0.3"; private OvertimeConfig _config; private ModuleRegistry _registry; private static string PluginDirectory => Path.GetDirectoryName(typeof(Plugin).Assembly.Location); public override void Load() { _config = new OvertimeConfig(); _config.Bind(((BasePlugin)this).Config); Log.Attach(((BasePlugin)this).Log, () => _config.LogLevel); ModuleLog moduleLog = Log.For("Plugin"); if (_config.DiagnosticsFile) { Diagnostics.Start(Paths.BepInExRootPath, new string[3] { "Overtime 1.0.3, launched " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), "game " + Application.version, "log level is " + _config.LogLevel.ToString() + "; this file keeps every line regardless" }); moduleLog.Info("diagnostics go to " + Diagnostics.Path); } if (_config.Migration.Outcome == MigrationOutcome.NewerThanBuild) { moduleLog.Warning("the settings file was written by Overtime " + _config.Migration.FromVersion + ", which is newer than this build. It has been left alone."); } else if (_config.Migration.StepsRun > 0) { moduleLog.Info("settings migrated from " + _config.Migration.FromVersion + " to " + _config.Migration.ToVersion); } if (_config.AllPatchesDisabled) { moduleLog.Warning("every patch is switched off in the settings, so the game runs as it shipped"); return; } ModSettingsMenuBridge.Register("Vippy.Overtime", "1.0.3"); ModSettingsMenuBridge.MarkHostRows(_config.IsHostOnly); SplashVideo.PluginDirectory = PluginDirectory; ContentModule contentModule = new ContentModule(_config, PluginDirectory); ChannelModule channelModule = new ChannelModule(_config, "1.0.3"); ConsoleModule consoleModule = new ConsoleModule(_config); _registry = new ModuleRegistry(Log.For); _registry.Register(channelModule); _registry.Register(new SettingsSyncModule(_config, channelModule)); _registry.Register(new LateJoinModule(_config)); _registry.Register(new JoinRequestModule()); _registry.Register(new LateJoinResyncModule(_config)); _registry.Register(contentModule); _registry.Register(new ReviewRangeModule(_config, contentModule.Rows)); _registry.Register(new NamedReviewsModule(_config)); StoryVariety.HostFinishedStory = SteamStory.HostFinishedStory; _registry.Register(new NightPlanModule(_config)); _registry.Register(new ContentFallbackModule()); _registry.Register(new EventRollerModule(_config)); _registry.Register(new ScareModule(_config)); _registry.Register(new SurpriseModule(_config)); _registry.Register(new SpectateModule(_config)); _registry.Register(consoleModule); _registry.Register(new ChatModule(_config, channelModule, consoleModule.Runner)); _registry.Register(new PingModule(_config, channelModule)); _registry.Register(new PerformanceModule(_config)); _registry.Register(new VoiceModule(_config)); _registry.Register(new FixesModule(_config)); _registry.Register(new CapModule(_config)); _registry.Register(new IntegrityModule(_config)); _registry.Register(new StoryVarietyModule(_config)); _registry.Register(new VeteranModule(_config)); _registry.Register(new ExtraCustomerModule(_config)); _registry.Register(new TutorialModule(_config, channelModule)); _registry.Register(new CutHintsModule(_config)); _registry.Register(new CutCustomersModule(_config)); _registry.Register(new CutCastModule(_config)); _registry.Register(new NorbertModule(_config)); _registry.Register(new PhoneCallModule(_config)); _registry.LoadAll(); foreach (IOvertimeModule module in _registry.Modules) { Diagnostics.Expect(module.Name); Diagnostics.Marker("module " + module.Name + ": " + _registry.Describe(module.Name)); } Diagnostics.Marker("settings: late join " + _config.LateJoinMode.ToString() + ", scares " + _config.GetChoice("00 Overtime", "Scares") + ", humour " + _config.GetChoice("00 Overtime", "Humour") + ", story variety " + _config.GetChoice("00 Overtime", "Story Variety") + ", new content " + _config.GetBool("00 Overtime", "New Content") + ", veteran moments " + _config.GetChoice("00 Overtime", "Veteran Moments")); moduleLog.Info("loaded, " + _registry.Modules.Count + " modules, late join is " + _config.LateJoinMode); } public override bool Unload() { _registry?.UnloadAll(); Diagnostics.Stop(); _config?.Release(); Log.Detach(); return true; } } } namespace Overtime.Net { internal sealed class ChannelModule : ModuleBase { [HarmonyPatch(typeof(FusionNetworkManager), "OnSceneLoadDone")] internal static class SessionPatch { private static void Postfix(NetworkRunner runner) { _live?.OnSessionReady(runner); } } [HarmonyPatch(typeof(FusionNetworkManager), "OnPlayerLeft")] internal static class LeavePatch { private static void Postfix(PlayerRef player) { //IL_000a: Unknown result type (might be due to invalid IL or missing references) _live?.OnPlayerLeft(player); } } [HarmonyPatch(typeof(FusionNetworkManager), "OnShutdown")] internal static class ShutdownPatch { private static void Postfix() { _live?.OnSessionClosed(); } } private static ChannelModule _live; private readonly OvertimeConfig _config; private readonly ChannelCore _core; private ReliableChannel _transport; private Action _tick; protected override IEnumerable<Type> PatchTypes => new Type[4] { typeof(ReliableChannel.DeliveryPatch), typeof(SessionPatch), typeof(LeavePatch), typeof(ShutdownPatch) }; public static ChannelModule Live => _live; public ProbeResult Probe => _core.Probe; public ChannelKind Kind { get { if (_transport == null || !_transport.IsAttached) { return ChannelKind.None; } return ChannelKind.Reliable; } } public HashSet<int> MismatchedPeers => _core.MismatchedPeers; public event Action<PlayerRef> GuestGreeted; public event Action HostGreeted; public event Action SessionClosed; public ChannelModule(OvertimeConfig config, string modVersion) : base("Channel") { _config = config; _core = new ChannelCore(modVersion, () => _config.Hash(), () => ContentStamp.From(ContentHealth.Current), Say, _config.SelfCheckOnLoad); _core.GuestGreeted += OnGuestGreeted; _core.HostGreeted += delegate { this.HostGreeted?.Invoke(); }; } public void Handle(WireMessageId id, Action<int, byte[]> handler) { _core.Handle(id, handler); } public void Forget(WireMessageId id) { _core.Forget(id); } public bool SendToPlayer(PlayerRef player, WireMessageId id, byte[] payload) { if (((PlayerRef)(ref player)).IsRealPlayer) { return _core.SendToPlayer(((PlayerRef)(ref player)).PlayerId, id, payload); } return false; } public static PlayerRef Peer(NetworkRunner runner, int playerId) { //IL_0002: Unknown result type (might be due to invalid IL or missing references) return PlayerRefFor(runner, playerId); } public bool SendToServer(WireMessageId id, byte[] payload) { return _core.SendToServer(id, payload); } public int Broadcast(WireMessageId id, byte[] payload) { return _core.Broadcast(id, payload); } public string WhyPackPeopleAreHeld() { List<int> list = Guests(); if (list != null) { return _core.WhyPackPeopleAreHeld(list, Gamertags.Of); } return null; } public string PackHoldNote() { List<int> list = Guests(); PackHold packHold = ((list == null) ? null : _core.PackHoldOn(list)); if (packHold != null) { return PackPeopleGate.HostNote(packHold, Gamertags.Of(packHold.PlayerId)); } return null; } private static List<int> Guests() { //IL_002e: Unknown result type (might be due to invalid IL or missing references) //IL_0033: Unknown result type (might be due to invalid IL or missing references) //IL_003d: Unknown result type (might be due to invalid IL or missing references) //IL_003f: Unknown result type (might be due to invalid IL or missing references) NetworkRunner runner = GameRefs.Runner; if ((Object)(object)runner == (Object)null || !runner.IsServer) { return null; } List<int> list = new List<int>(); foreach (PlayerRef item in GameRefs.ActivePlayers(runner)) { PlayerRef current = item; if (((PlayerRef)(ref current)).IsRealPlayer && current != runner.LocalPlayer) { list.Add(((PlayerRef)(ref current)).PlayerId); } } return list; } protected override void Apply() { _transport = new ReliableChannel(_core.Deliver, base.Log); _tick = delegate { _core.Tick(Time.realtimeSinceStartup); }; OvertimeDriver.AddSlow(_tick); _live = this; } protected override void Revert() { if (_tick != null) { OvertimeDriver.RemoveSlow(_tick); _tick = null; } _transport?.Detach(); _transport = null; _core.OnSessionClosed(); this.GuestGreeted = null; this.HostGreeted = null; this.SessionClosed = null; _live = null; } private void OnSessionReady(NetworkRunner runner) { //IL_0044: Unknown result type (might be due to invalid IL or missing references) //IL_0049: Unknown result type (might be due to invalid IL or missing references) if (!((Object)(object)runner == (Object)null) && _transport != null) { if (!_transport.Attach(runner)) { _core.Fail("the reliable path could not be wired to the runner"); return; } ChannelCore core = _core; ReliableChannel transport = _transport; bool isServer = runner.IsServer; PlayerRef localPlayer = runner.LocalPlayer; core.OnSessionReady(transport, isServer, ((PlayerRef)(ref localPlayer)).PlayerId, Time.realtimeSinceStartup); } } private void OnPlayerLeft(PlayerRef player) { if (((PlayerRef)(ref player)).IsRealPlayer) { _core.OnPlayerLeft(((PlayerRef)(ref player)).PlayerId); } } private void OnSessionClosed() { _transport?.Release(); _core.OnSessionClosed(); Gamertags.Clear(); this.SessionClosed?.Invoke(); } private void OnGuestGreeted(int playerId) { //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_000b: Unknown result type (might be due to invalid IL or missing references) //IL_0020: Unknown result type (might be due to invalid IL or missing references) PlayerRef obj = PlayerRefFor(GameRefs.Runner, playerId); if (((PlayerRef)(ref obj)).IsRealPlayer) { this.GuestGreeted?.Invoke(obj); } } private void Say(OvertimeLogLevel level, string message) { switch (level) { case OvertimeLogLevel.Error: base.Log.Error(message); break; case OvertimeLogLevel.Warning: base.Log.Warning(message); break; case OvertimeLogLevel.Info: base.Log.Info(message); break; default: base.Log.Verbose(message); break; } } private static PlayerRef PlayerRefFor(NetworkRunner runner, int playerId) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) //IL_0014: Unknown result type (might be due to invalid IL or missing references) //IL_001f: Unknown result type (might be due to invalid IL or missing references) //IL_0020: Unknown result type (might be due to invalid IL or missing references) //IL_0037: Unknown result type (might be due to invalid IL or missing references) //IL_003d: Unknown result type (might be due to invalid IL or missing references) foreach (PlayerRef item in GameRefs.ActivePlayers(runner)) { PlayerRef current = item; if (((PlayerRef)(ref current)).PlayerId == playerId) { return current; } } return PlayerRef.None; } } internal sealed class ReliableChannel : IChannelTransport { [HarmonyPatch(typeof(NetworkEvents), "Fusion_INetworkRunnerCallbacks_OnReliableDataReceived")] internal static class DeliveryPatch { private static bool Prefix(NetworkEvents __instance, PlayerRef player, ArraySegment<byte> data) { //IL_0032: Unknown result type (might be due to invalid IL or missing references) ReliableChannel live = _live; if (live == null || (Object)(object)live._events == (Object)null || ((Il2CppObjectBase)__instance).Pointer != ((Il2CppObjectBase)live._events).Pointer) { return true; } live.Receive(player, data); return false; } } private const string SendToPlayerField = "NativeMethodInfoPtr_SendReliableDataToPlayer_Public_Void_PlayerRef_ReliableKey_Il2CppStructArray_1_Byte_0"; private const string SendToServerField = "NativeMethodInfoPtr_SendReliableDataToServer_Public_Void_ReliableKey_Il2CppStructArray_1_Byte_0"; private static ReliableChannel _live; private static IntPtr _sendToPlayer; private static IntPtr _sendToServer; private readonly Action<int, byte[], int, int> _deliver; private readonly ModuleLog _log; private NetworkRunner _runner; private NetworkEvents _events; private GameObject _holder; public bool IsAttached { get { if ((Object)(object)_runner != (Object)null) { return (Object)(object)_events != (Object)null; } return false; } } public ReliableChannel(Action<int, byte[], int, int> deliver, ModuleLog log) { _deliver = deliver; _log = log; } public bool Attach(NetworkRunner runner) { //IL_0043: Unknown result type (might be due to invalid IL or missing references) //IL_004d: Expected O, but got Unknown //IL_00c9: Unknown result type (might be due to invalid IL or missing references) //IL_00ce: Unknown result type (might be due to invalid IL or missing references) if ((Object)(object)runner == (Object)null) { return false; } if (IsAttached && ((Il2CppObjectBase)_runner).Pointer == ((Il2CppObjectBase)runner).Pointer) { return true; } if (!ResolveSends()) { return false; } Detach(); _holder = new GameObject("OvertimeChannel"); Object.DontDestroyOnLoad((Object)(object)_holder); _events = _holder.AddComponent<NetworkEvents>(); FillEmptyEvents(_events); INetworkRunnerCallbacks val = ((Il2CppObjectBase)_events).TryCast<INetworkRunnerCallbacks>(); if (val == null) { Detach(); return false; } runner.AddCallbacks(new Il2CppReferenceArray<INetworkRunnerCallbacks>((INetworkRunnerCallbacks[])(object)new INetworkRunnerCallbacks[1] { val })); _runner = runner; _live = this; if (runner.IsServer) { AllowClientToServer(runner); } ModuleLog log = _log; PlayerRef localPlayer = runner.LocalPlayer; log.Verbose("reliable channel attached to the runner as player " + ((PlayerRef)(ref localPlayer)).PlayerId); return true; } private bool ResolveSends() { if (_sendToPlayer != IntPtr.Zero && _sendToServer != IntPtr.Zero) { return true; } _sendToPlayer = MethodInfoOf("NativeMethodInfoPtr_SendReliableDataToPlayer_Public_Void_PlayerRef_ReliableKey_Il2CppStructArray_1_Byte_0", "SendReliableDataToPlayer", "Fusion.PlayerRef", "Fusion.Sockets.ReliableKey", "System.Byte[]"); _sendToServer = MethodInfoOf("NativeMethodInfoPtr_SendReliableDataToServer_Public_Void_ReliableKey_Il2CppStructArray_1_Byte_0", "SendReliableDataToServer", "Fusion.Sockets.ReliableKey", "System.Byte[]"); if (_sendToPlayer != IntPtr.Zero && _sendToServer != IntPtr.Zero) { return true; } _log.Warning("the runner's reliable send methods could not be found in this build of the game"); return false; } private static IntPtr MethodInfoOf(string fieldName, string methodName, params string[] argumentTypes) { FieldInfo field = typeof(NetworkRunner).GetField(fieldName, BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); if (field != null && field.GetValue(null) is IntPtr intPtr && intPtr != IntPtr.Zero) { return intPtr; } return IL2CPP.GetIl2CppMethod(Il2CppClassPointerStore<NetworkRunner>.NativeClassPtr, false, methodName, "System.Void", argumentTypes); } private unsafe void AllowClientToServer(NetworkRunner runner) { //IL_0029: Unknown result type (might be due to invalid IL or missing references) //IL_002e: Unknown result type (might be due to invalid IL or missing references) //IL_002f: Unknown result type (might be due to invalid IL or missing references) //IL_0031: Unknown result type (might be due to invalid IL or missing references) //IL_0058: Unknown result type (might be due to invalid IL or missing references) //IL_005a: Unknown result type (might be due to invalid IL or missing references) NetworkProjectConfig config = runner.Config; NetworkConfiguration val = ((config == null) ? null : config.Network); if (val == null) { _log.Warning("the runner has no network configuration to read, so the client to server reliable mode is whatever the game set"); return; } ReliableDataTransfers reliableDataTransferModes = val.ReliableDataTransferModes; if ((reliableDataTransferModes & 1) != 0) { _log.Verbose("reliable data transfer modes are " + ((object)(*(ReliableDataTransfers*)(&reliableDataTransferModes))/*cast due to .constrained prefix*/).ToString()); return; } val.ReliableDataTransferModes = (ReliableDataTransfers)(reliableDataTransferModes | 1); _log.Warning("the game's network config had client to server reliable data off (" + ((object)(*(ReliableDataTransfers*)(&reliableDataTransferModes))/*cast due to .constrained prefix*/).ToString() + "); switched on for this session, or a guest's first hello would get it disconnected"); } private void FillEmptyEvents(NetworkEvents events) { int num = 0; PropertyInfo[] properties = typeof(NetworkEvents).GetProperties(BindingFlags.Instance | BindingFlags.Public); foreach (PropertyInfo propertyInfo in properties) { if (propertyInfo.CanRead && propertyInfo.CanWrite && typeof(UnityEventBase).IsAssignableFrom(propertyInfo.PropertyType) && propertyInfo.GetValue(events) == null && !(propertyInfo.PropertyType.GetConstructor(Type.EmptyTypes) == null)) { propertyInfo.SetValue(events, Activator.CreateInstance(propertyInfo.PropertyType)); num++; } } _log.Verbose("gave the callback forwarder " + num + " empty events"); } public void Detach() { if ((Object)(object)_events != (Object)null && (Object)(object)_runner != (Object)null) { _runner.RemoveCallbacks(new Il2CppReferenceArray<INetworkRunnerCallbacks>((INetworkRunnerCallbacks[])(object)new INetworkRunnerCallbacks[1] { ((Il2CppObjectBase)_events).TryCast<INetworkRunnerCallbacks>() })); } Release(); } public void Release() { if ((Object)(object)_holder != (Object)null) { Object.Destroy((Object)(object)_holder); } _holder = null; _events = null; _runner = null; if (_live == this) { _live = null; } } public bool SendToPlayer(int playerId, byte[] frame) { //IL_000c: Unknown result type (might be due to invalid IL or missing references) //IL_0011: Unknown result type (might be due to invalid IL or missing references) //IL_001c: Unknown result type (might be due to invalid IL or missing references) if (!IsAttached) { return false; } PlayerRef player = PlayerFor(playerId); if (((PlayerRef)(ref player)).IsRealPlayer) { return Send(player, frame); } return false; } public unsafe bool SendToServer(byte[] frame) { if (!IsAttached) { return false; } Il2CppStructArray<byte> val = new Il2CppStructArray<byte>(frame); IntPtr* ptr = stackalloc IntPtr[2]; fixed (byte* overtime = ReliableKeyBytes.Overtime) { *ptr = (IntPtr)overtime; ptr[1] = IL2CPP.Il2CppObjectBaseToPtr((Il2CppObjectBase)(object)val); Invoke(_sendToServer, ptr); } return true; } public int Broadcast(byte[] frame) { //IL_0020: Unknown result type (might be due to invalid IL or missing references) //IL_0025: Unknown result type (might be due to invalid IL or missing references) //IL_002f: Unknown result type (might be due to invalid IL or missing references) //IL_0036: Unknown result type (might be due to invalid IL or missing references) //IL_0043: Unknown result type (might be due to invalid IL or missing references) if (!IsAttached) { return 0; } int num = 0; foreach (PlayerRef item in GameRefs.ActivePlayers(_runner)) { PlayerRef current = item; if (((PlayerRef)(ref current)).IsRealPlayer && !(current == _runner.LocalPlayer) && Send(current, frame)) { num++; } } return num; } private unsafe bool Send(PlayerRef player, byte[] frame) { Il2CppStructArray<byte> val = new Il2CppStructArray<byte>(frame); IntPtr* ptr = stackalloc IntPtr[3]; fixed (byte* overtime = ReliableKeyBytes.Overtime) { *ptr = (IntPtr)(&player); ptr[1] = (IntPtr)overtime; ptr[2] = IL2CPP.Il2CppObjectBaseToPtr((Il2CppObjectBase)(object)val); Invoke(_sendToPlayer, ptr); } return true; } private unsafe void Invoke(IntPtr method, IntPtr* args) { IntPtr zero = IntPtr.Zero; IL2CPP.il2cpp_runtime_invoke(method, IL2CPP.Il2CppObjectBaseToPtrNotNull((Il2CppObjectBase)(object)_runner), (void**)args, ref zero); Il2CppException.RaiseExceptionIfNecessary(zero); } private PlayerRef PlayerFor(int playerId) { //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_000b: Unknown result type (might be due to invalid IL or missing references) //IL_0016: Unknown result type (might be due to invalid IL or missing references) //IL_002c: Unknown result type (might be due to invalid IL or missing references) //IL_0031: Unknown result type (might be due to invalid IL or missing references) //IL_003c: Unknown result type (might be due to invalid IL or missing references) //IL_003d: Unknown result type (might be due to invalid IL or missing references) //IL_0054: Unknown result type (might be due to invalid IL or missing references) //IL_005a: Unknown result type (might be due to invalid IL or missing references) PlayerRef localPlayer = _runner.LocalPlayer; if (((PlayerRef)(ref localPlayer)).PlayerId == playerId) { return localPlayer; } foreach (PlayerRef item in GameRefs.ActivePlayers(_runner)) { PlayerRef current = item; if (((PlayerRef)(ref current)).PlayerId == playerId) { return current; } } return PlayerRef.None; } private void Receive(PlayerRef sender, ArraySegment<byte> data) { byte[] array = Copy(data); _deliver(((PlayerRef)(ref sender)).PlayerId, array, 0, array.Length); } private static byte[] Copy(ArraySegment<byte> segment) { Il2CppArrayBase<byte> array = segment.Array; if (array == null) { return Array.Empty<byte>(); } byte[] array2 = new byte[segment.Count]; for (int i = 0; i < array2.Length; i++) { array2[i] = array[segment.Offset + i]; } return array2; } } internal sealed class SettingsSyncModule : ModuleBase { private readonly OvertimeConfig _config; private readonly ChannelModule _channel; private readonly List<string> _watched = new List<string>(); private bool _noticed; protected override IEnumerable<Type> PatchTypes => Array.Empty<Type>(); public SettingsSyncModule(OvertimeConfig config, ChannelModule channel) : base("Settings") { _config = config; _channel = channel; } protected override void Apply() { _channel.Handle(WireMessageId.Settings, OnSettings); _channel.GuestGreeted += OnGuestGreeted; _channel.SessionClosed += OnSessionClosed; foreach (ConfigEntrySpec item in from spec in ConfigSchema.EntriesThrough(OvertimePhase.Polish) where spec.HostOnly select spec) { _watched.Add(item.ToString()); _config.Watch(item.Section, item.Key, OnHostSettingChanged); } } protected override void Revert() { _channel.GuestGreeted -= OnGuestGreeted; _channel.SessionClosed -= OnSessionClosed; foreach (string item in _watched) { string[] array = item.Split(new string[1] { " / " }, 2, StringSplitOptions.None); if (array.Length == 2) { _config.Unwatch(array[0], array[1], OnHostSettingChanged); } } _watched.Clear(); _config.HostOverrides.Clear(); } private void OnGuestGreeted(PlayerRef player) { //IL_000e: Unknown result type (might be due to invalid IL or missing references) if (GameRefs.IsHost && _channel.SendToPlayer(player, WireMessageId.Settings, Block())) { base.Log.Verbose("sent the host settings to player " + ((PlayerRef)(ref player)).PlayerId); } } private void OnHostSettingChanged() { if (GameRefs.IsHost) { int num = _channel.Broadcast(WireMessageId.Settings, Block()); if (num > 0) { base.Log.Info("a host setting changed, " + num + " sent the new block"); } } } private void OnSettings(int senderPlayerId, byte[] payload) { if (GameRefs.IsHost) { return; } if (!SettingsBlock.TryDecode(payload, out var values)) { base.Log.Warning("dropped a malformed settings block from the host"); } else if (_config.HostOverrides.Apply(values)) { base.Log.Info("the host decides " + _config.HostOverrides.Count + " settings in this session: " + string.Join(", ", _config.HostOverrides.Entries.Select((KeyValuePair<string, string> pair) => pair.Key + " = " + pair.Value))); if (!_noticed) { _noticed = true; base.Log.Warning(HostSettingsNotice.Summary(HostSettingsNotice.Differences(_config.HostAuthoritativeValues(), values))); } } } private void OnSessionClosed() { _noticed = false; if (_config.HostOverrides.Clear()) { base.Log.Info("the session ended, your own settings are back"); } } private byte[] Block() { return SettingsBlock.Encode(_config.HostAuthoritativeValues()); } } } namespace Overtime.Logic { public enum AdmissionOutcome { Vanilla, Admit, AdmitAsSpectator, Refuse } public enum AdmissionReason { ModeOff, SceneIsNotGame, LobbyFull, DayComplete, Cutscene, HuntInProgress, Clear } public readonly struct AdmissionDecision { public AdmissionOutcome Outcome { get; } public AdmissionReason Reason { get; } public NetworkErrorKey Error { get; } public AdmissionDecision(AdmissionOutcome outcome, AdmissionReason reason, NetworkErrorKey error) { Outcome = outcome; Reason = reason; Error = error; } } public enum NetworkErrorKey { None, GameInSession, LobbyFull } public readonly struct SessionSnapshot { public string ActiveSceneName { get; } public int PlayersAfterJoin { get; } public int MaxPlayers { get; } public bool DayCompleteAwaitingReport { get; } public bool InCutscene { get; } public bool HuntInProgress { get; } public SessionSnapshot(string activeSceneName, int playersAfterJoin, int maxPlayers, bool dayCompleteAwaitingReport, bool inCutscene, bool huntInProgress) { ActiveSceneName = activeSceneName; PlayersAfterJoin = playersAfterJoin; MaxPlayers = maxPlayers; DayCompleteAwaitingReport = dayCompleteAwaitingReport; InCutscene = inCutscene; HuntInProgress = huntInProgress; } } public readonly struct AdmissionSettings { public LateJoinMode Mode { get; } public bool AllowMidHunt { get; } public bool AllowDuringCutscene { get; } public bool AllowAfterDayComplete { get; } public AdmissionSettings(LateJoinMode mode, bool allowMidHunt, bool allowDuringCutscene, bool allowAfterDayComplete) { Mode = mode; AllowMidHunt = allowMidHunt; AllowDuringCutscene = allowDuringCutscene; AllowAfterDayComplete = allowAfterDayComplete; } } public static class AdmissionPolicy { public const string GameSceneName = "Game"; public const int HardCeiling = 7; public static int CountAfterJoin(int fusionCount, int lobbyMembers) { if (lobbyMembers <= 0 || lobbyMembers >= fusionCount) { return fusionCount; } return lobbyMembers; } public static AdmissionDecision Decide(SessionSnapshot session, AdmissionSettings settings) { if (settings.Mode == LateJoinMode.Off) { return new AdmissionDecision(AdmissionOutcome.Vanilla, AdmissionReason.ModeOff, NetworkErrorKey.None); } if (!string.Equals(session.ActiveSceneName, "Game", StringComparison.Ordinal)) { return new AdmissionDecision(AdmissionOutcome.Vanilla, AdmissionReason.SceneIsNotGame, NetworkErrorKey.None); } int num = ((session.MaxPlayers > 0) ? Math.Min(session.MaxPlayers, 7) : 7); if (session.PlayersAfterJoin > num) { return new AdmissionDecision(AdmissionOutcome.Refuse, AdmissionReason.LobbyFull, NetworkErrorKey.LobbyFull); } if (session.DayCompleteAwaitingReport && !settings.AllowAfterDayComplete) { return new AdmissionDecision(AdmissionOutcome.Refuse, AdmissionReason.DayComplete, NetworkErrorKey.GameInSession); } if (session.InCutscene && !settings.AllowDuringCutscene) { return new AdmissionDecision(AdmissionOutcome.Refuse, AdmissionReason.Cutscene, NetworkErrorKey.GameInSession); } if (session.HuntInProgress) { if (!settings.AllowMidHunt) { return new AdmissionDecision(AdmissionOutcome.Refuse, AdmissionReason.HuntInProgress, NetworkErrorKey.GameInSession); } return new AdmissionDecision(AdmissionOutcome.AdmitAsSpectator, AdmissionReason.HuntInProgress, NetworkErrorKey.None); } return new AdmissionDecision(AdmissionOutcome.Admit, AdmissionReason.Clear, NetworkErrorKey.None); } } public static class ArcGate { public static IReadOnlyList<CustomerSpec> Open(IEnumerable<CustomerSpec> candidates, int night, bool endless, Func<string, bool> happened) { List<CustomerSpec> list = new List<CustomerSpec>(); if (candidates == null) { return list; } foreach (CustomerSpec candidate in candidates) { if (candidate != null && IsOpen(candidate, night, endless, happened)) { list.Add(candidate); } } return list; } public static bool IsOpen(CustomerSpec spec, int night, bool endless, Func<string, bool> happened) { if (spec == null || night < spec.FromNight) { return false; } if (spec.IsArc && endless) { return false; } if (spec.Nights.Count > 0 && !Contains(spec.Nights, night)) { return false; } foreach (string afterId in spec.AfterIds) { if (!Happened(afterId, happened)) { return false; } } return true; } public static IReadOnlyList<CustomerSpec> Pinned(IEnumerable<CustomerSpec> open, int night) { List<CustomerSpec> list = new List<CustomerSpec>(); HashSet<string> hashSet = new HashSet<string>(StringComparer.Ordinal); if (open == null) { return list; } foreach (CustomerSpec item in open) { if (item != null && Contains(item.Nights, night) && hashSet.Add(item.Person)) { list.Add(item); } } return list; } public static bool Happened(string needed, Func<string, bool> happened) { if (string.IsNullOrEmpty(needed) || happened == null) { return false; } string[] array = needed.Split('|'); foreach (string text in array) { if (text.Length > 0 && happened(text)) { return true; } } return false; } public static IReadOnlyList<CustomerSpec> WithPinned(IReadOnlyList<CustomerSpec> chosen, IReadOnlyList<CustomerSpec> pinned, int wanted) { List<CustomerSpec> list = new List<CustomerSpec>(); if (pinned != null) { foreach (CustomerSpec item in pinned) { if (item != null && list.Count < wanted) { list.Add(item); } } } HashSet<string> hashSet = new HashSet<string>(StringComparer.Ordinal); HashSet<string> hashSet2 = new HashSet<string>(StringComparer.Ordinal); foreach (CustomerSpec item2 in list) { hashSet.Add(item2.Person); hashSet2.Add(item2.DonorDialogueId); } if (chosen != null) { foreach (CustomerSpec item3 in chosen) { if (item3 != null && list.Count < wanted && !hashSet.Contains(item3.Person) && !hashSet2.Contains(item3.DonorDialogueId)) { list.Add(item3); hashSet.Add(item3.Person); hashSet2.Add(item3.DonorDialogueId); } } } return list; } private static bool Contains(IReadOnlyList<int> nights, int night) { foreach (int night2 in nights) { if (night2 == night) { return true; } } return false; } } public static class BorrowedBodies { public static IReadOnlyList<int> Strays(IReadOnlyList<string> spawnedBefore, int countBefore, IEnumerable<string> donorIds) { List<int> list = new List<int>(); if (spawnedBefore == null || donorIds == null || countBefore < 0 || countBefore >= spawnedBefore.Count) { return list; } foreach (string donorId in donorIds) { if (string.IsNullOrEmpty(donorId)) { continue; } for (int num = spawnedBefore.Count - 1; num >= countBefore; num--) { if (!list.Contains(num) && string.Equals(spawnedBefore[num], donorId, StringComparison.Ordinal)) { list.Add(num); break; } } } list.Sort((int a, int b) => b.CompareTo(a)); return list; } } public interface IChannelTransport { bool SendToPlayer(int playerId, byte[] frame); bool SendToServer(byte[] frame); int Broadcast(byte[] frame); } public sealed class ChannelCore { public const double HostSelfTestSeconds = 3.0; public const double GuestReplySeconds = 10.0; private readonly string _modVersion; private readonly Func<uint> _configHash; private readonly Func<ContentStamp> _content; private readonly Action<OvertimeLogLevel, string> _log; private readonly bool _loudSelfCheck; private readonly Dictionary<WireMessageId, Action<int, byte[]>> _handlers = new Dictionary<WireMessageId, Action<int, byte[]>>(); private readonly HandshakeWatch _selfTest = new HandshakeWatch(3.0); private readonly HandshakeWatch _reply = new HandshakeWatch(10.0); private readonly HashSet<string> _saidThisSession = new HashSet<string>(StringComparer.Ordinal); private MessageRouter _router; private IChannelTransport _transport; private bool _sawFirstFrame; public ProbeResult Probe { get; private set; } public bool IsHost { get; private set; } public int LocalPlayerId { get; private set; } = -1; public bool HostAnswered { get; private set; } public HashSet<int> GreetedPeers { get; } = new HashSet<int>(); public HashSet<int> MismatchedPeers { get; } = new HashSet<int>(); public HashSet<int> ContentMismatchedPeers { get; } = new HashSet<int>(); public event Action<int> GuestGreeted; public event Action HostGreeted; public ChannelCore(string modVersion, Func<uint> configHash, Func<ContentStamp> content, Action<OvertimeLogLevel, string> log, bool loudSelfCheck) { _modVersion = modVersion ?? string.Empty; _configHash = configHash ?? ((Func<uint>)(() => 0u)); _content = content ?? ((Func<ContentStamp>)(() => default(ContentStamp))); _log = log ?? ((Action<OvertimeLogLevel, string>)delegate { }); _loudSelfCheck = loudSelfCheck; } public void Handle(WireMessageId id, Action<int, byte[]> handler) { _handlers[id] = handler ?? throw new ArgumentNullException("handler"); if (_router != null) { RebuildRouter(); } } public void Forget(WireMessageId id) { if (_handlers.Remove(id) && _router != null) { RebuildRouter(); } } public void OnSessionReady(IChannelTransport transport, bool isHost, int localPlayerId, double now) { _transport = transport ?? throw new ArgumentNullException("transport"); IsHost = isHost; LocalPlayerId = localPlayerId; Probe = ProbeResult.Unknown; HostAnswered = false; _sawFirstFrame = false; RebuildRouter(); byte[] frame = Wire.Encode(WireMessageId.Hello, MyHello().Encode()); if (isHost) { _selfTest.Sent(now); if (!_transport.SendToPlayer(localPlayerId, frame)) { _selfTest.Answered(); Fail("the hello to this host's own player could not be sent"); } } else { _reply.Sent(now); if (!_transport.SendToServer(frame)) { _reply.Answered(); Fail("the hello could not be sent"); } } } public void OnPlayerLeft(int playerId) { if (GreetedPeers.Remove(playerId) | MismatchedPeers.Remove(playerId) | ContentMismatchedPeers.Remove(playerId)) { _log(OvertimeLogLevel.Verbose, "player " + playerId + " left, so their hello no longer counts for that slot"); } } public void OnSessionClosed() { GreetedPeers.Clear(); MismatchedPeers.Clear(); ContentMismatchedPeers.Clear(); _saidThisSession.Clear(); _selfTest.Answered(); _reply.Answered(); Probe = ProbeResult.Unknown; HostAnswered = false; _sawFirstFrame = false; _transport = null; _router = null; } public void Fail(string why) { if (Probe != ProbeResult.Failed) { Probe = ProbeResult.Failed; _log(OvertimeLogLevel.Warning, "self check: " + why); } } public void Deliver(int senderPlayerId, byte[] frame, int offset, int count) { if (!_sawFirstFrame) { _sawFirstFrame = true; _log(OvertimeLogLevel.Info, "first frame this session: from player " + senderPlayerId + ", " + count + " bytes, magic " + (Wire.IsOurs(frame, offset, count) ? "ok" : "missing")); } if (_router == null) { _log(OvertimeLogLevel.Warning, "a frame from player " + senderPlayerId + " arrived before the session was ready, dropped"); } else { _router.Dispatch(senderPlayerId, frame, offset, count); } } public void Tick(double now) { if (IsHost && _selfTest.Overdue(now)) { _log(OvertimeLogLevel.Warning, "self test: the hello this host sent to its own player did not come back within " + 3.0 + " s, so Fusion is not delivering on the reliable path; nothing of Overtime's will cross to a guest this session"); } if (!IsHost && _reply.Overdue(now)) { _log(OvertimeLogLevel.Warning, "no answer to our hello within " + 10.0 + " s: the host never answered on Overtime's channel; host settings (late join, story variety, skip tutorials, scares, new content and the rest marked [Host]) are the host's, whatever yours say. Either the host has no Overtime, or its channel is down (its log says which)"); } } public bool SendToPlayer(int playerId, WireMessageId id, byte[] payload) { if (_transport != null) { return _transport.SendToPlayer(playerId, Wire.Encode(id, payload)); } return false; } public bool SendToServer(WireMessageId id, byte[] payload) { if (_transport != null) { return _transport.SendToServer(Wire.Encode(id, payload)); } return false; } public int Broadcast(WireMessageId id, byte[] payload) { return _transport?.Broadcast(Wire.Encode(id, payload)) ?? 0; } public string WhyPackPeopleAreHeld(IEnumerable<int> activeGuestIds, Func<int, string> nameOf = null) { return PackPeopleGate.WhyHeld(activeGuestIds, GreetedPeers, MismatchedPeers, ContentMismatchedPeers, nameOf); } public PackHold PackHoldOn(IEnumerable<int> activeGuestIds) { return PackPeopleGate.Holding(activeGuestIds, GreetedPeers, MismatchedPeers, ContentMismatchedPeers); } private Hello MyHello() { return new Hello(_modVersion, _configHash(), IsHost, _content()); } private void RebuildRouter() { _router = new MessageRouter(IsHost, delegate(string message) { _log(OvertimeLogLevel.Warning, message); }); _router.Handle(WireMessageId.Hello, OnHello); foreach (KeyValuePair<WireMessageId, Action<int, byte[]>> handler in _handlers) { _router.Handle(handler.Key, handler.Value); } } private void OnHello(int senderPlayerId, byte[] payload) { if (!Hello.TryDecode(payload, out var hello)) { _log(OvertimeLogLevel.Warning, "dropped a malformed hello from player " + senderPlayerId); return; } if (hello.FromHost && IsHost) { _selfTest.Answered(); Pass(); return; } Pass(); Hello mine = MyHello(); HelloVerdict helloVerdict = HelloCheck.Compare(mine, hello); string text = (IsHost ? ("player " + senderPlayerId) : "the host"); switch (helloVerdict) { case HelloVerdict.VersionMismatch: ContentMismatchedPeers.Remove(senderPlayerId); if (MismatchedPeers.Add(senderPlayerId)) { _log(OvertimeLogLevel.Warning, text + " is on Overtime " + hello.ModVersion + " and this machine is on " + _modVersion + ", so the game runs as it shipped between us"); } break; case HelloVerdict.ContentMismatch: MismatchedPeers.Remove(senderPlayerId); if (ContentMismatchedPeers.Add(senderPlayerId)) { _log(OvertimeLogLevel.Warning, text + " carries content " + hello.Content.ToString() + " and this machine carries " + mine.Content.ToString() + (IsHost ? ", so the pack's people stay off the list while they are here" : ", so the host is not adding the pack's people tonight")); } break; case HelloVerdict.SettingsDiffer: MismatchedPeers.Remove(senderPlayerId); ContentMismatchedPeers.Remove(senderPlayerId); _log(OvertimeLogLevel.Verbose, text + " has different host settings, the host's values win"); break; default: MismatchedPeers.Remove(senderPlayerId); ContentMismatchedPeers.Remove(senderPlayerId); _log(OvertimeLogLevel.Verbose, text + " matches on version, content and settings"); break; } if (hello.FromHost) { HostAnswered = true; _reply.Answered(); if (helloVerdict != HelloVerdict.VersionMismatch) { this.HostGreeted?.Invoke(); } } else if (!IsHost) { _log(OvertimeLogLevel.Warning, "a guest hello from player " + senderPlayerId + " reached this guest, which should not be possible in host mode; dropped"); } else if (!_transport.SendToPlayer(senderPlayerId, Wire.Encode(WireMessageId.Hello, mine.Encode()))) { _log(OvertimeLogLevel.Warning, "could not answer player " + senderPlayerId + "'s hello: the transport refused it, so they are not in this session's player list"); } else if (helloVerdict != HelloVerdict.VersionMismatch) { GreetedPeers.Add(senderPlayerId); this.GuestGreeted?.Invoke(senderPlayerId); } } private void Pass() { if (Probe != ProbeResult.Passed) { Probe = ProbeResult.Passed; if (_saidThisSession.Add("live")) { _log(OvertimeLogLevel.Warning, _loudSelfCheck ? "self check: the channel is live, a message went end to end" : "the channel is live"); } } } } public enum ChannelKind { None, Reliable, RpcPiggyback } public enum ProbeResult { Unknown, Passed, Failed } public static class ChannelSelector { public static ChannelKind Choose(ProbeResult reliable, bool piggybackAvailable) { switch (reliable) { case ProbeResult.Passed: return ChannelKind.Reliable; case ProbeResult.Failed: if (!piggybackAvailable) { return ChannelKind.None; } return ChannelKind.RpcPiggyback; default: return ChannelKind.Reliable; } } } public static class ChatLine { public const int MaxLength = 120; public static string Clean(string typed) { if (string.IsNullOrEmpty(typed)) { return null; } StringBuilder stringBuilder = new StringBuilder(Math.Min(typed.Length, 120)); bool flag = true; foreach (char c in typed) { char c2 = ((char.IsControl(c) || c == ' ') ? ' ' : c); if (c2 == ' ') { if (flag) { continue; } flag = true; } else { flag = false; } stringBuilder.Append(c2); if (stringBuilder.Length == 120) { break; } } while (stringBuilder.Length > 0 && stringBuilder[stringBuilder.Length - 1] == ' ') { stringBuilder.Length--; } if (stringBuilder.Length != 0) { return stringBuilder.ToString(); } return null; } } public sealed class SendRate { private sealed class Bucket { public double Tokens; public double LastSeen; } private readonly Dictionary<int, Bucket> _buckets = new Dictionary<int, Bucket>(); private readonly double _minimumGapSeconds; private readonly int _burst; public SendRate(double minimumGapSeconds, int burst) { _minimumGapSeconds = Math.Max(0.001, minimumGapSeconds); _burst = Math.Max(1, burst); } public bool Allow(int playerId, double nowSeconds) { if (!_buckets.TryGetValue(playerId, out var value)) { value = new Bucket { Tokens = _burst, LastSeen = nowSeconds }; _buckets[playerId] = value; } value.Tokens = Math.Min(_burst, value.Tokens + (nowSeconds - value.LastSeen) / _minimumGapSeconds); value.LastSeen = nowSeconds; if (value.Tokens < 1.0) { return false; } value.Tokens -= 1.0; return true; } public void Forget(int playerId) { _buckets.Remove(playerId); } public void Clear() { _buckets.Clear(); } } public readonly struct SaidLine { public string Text { get; } public SaidLine(string text) { Text = text; } public byte[] Encode() { return new WireWriter().WriteString(Text ?? string.Empty).ToArray(); } public static bool TryDecode(byte[] payload, out SaidLine line) { line = default(SaidLine); if (!new WireReader(payload).TryReadString(out var value)) { return false; } string text = ChatLine.Clean(value); if (text == null) { return false; } line = new SaidLine(text); return true; } } public readonly struct ChatMessage { public string Speaker { get; } public string Text { get; } public ChatMessage(string speaker, string text) { Speaker = speaker; Text = text; } public byte[] Encode() { return new WireWriter().WriteString(Speaker ?? string.Empty).WriteString(Text ?? string.Empty).ToArray(); } public static bool TryDecode(byte[] payload, out ChatMessage message) { message = default(ChatMessage); WireReader wireReader = new WireReader(payload); if (!wireReader.TryReadString(out var value) || !wireReader.TryReadString(out var value2)) { return false; } string text = ChatLine.Clean(value2); if (text == null) { return false; } message = new ChatMessage(ChatLine.Clean(value) ?? "?", text); return true; } } public readonly struct PingPoint { public float X { get; } public float Y { get; } public float Z { get; } public byte ColourIndex { get; } public PingPoint(float x, float y, float z, byte colourIndex) { X = x; Y = y; Z = z; ColourIndex = colourIndex; } public byte[] Encode() { return new WireWriter().WriteSingle(X).WriteSingle(Y).WriteSingle(Z) .WriteByte(ColourIndex) .ToArray(); } public static bool TryDecode(byte[] payload, out PingPoint point) { point = default(PingPoint); WireReader wireReader = new WireReader(payload); if (!wireReader.TryReadSingle(out var value) || !wireReader.TryReadSingle(out var value2) || !wireReader.TryReadSingle(out var value3) || !wireReader.TryReadByte(out var value4)) { return false; } if (float.IsNaN(value) || float.IsNaN(value2) || float.IsNaN(value3) || float.IsInfinity(value) || float.IsInfinity(value2) || float.IsInfinity(value3)) { return false; } point = new PingPoint(value, value2, value3, value4); return true; } } public enum ChatVerdict { Active, TurnedOff, AnotherChatIsLoaded } public static class ChatStandDown { public const string ChatterGuid = "clementinise.shiftatmidnight.chatter"; public static ChatVerdict Evaluate(bool enabledInConfig, IEnumerable<string> loadedPluginGuids) { if (!enabledInConfig) { return ChatVerdict.TurnedOff; } if (loadedPluginGuids != null) { foreach (string loadedPluginGuid in loadedPluginGuids) { if (string.Equals(loadedPluginGuid, "clementinise.shiftatmidnight.chatter", StringComparison.OrdinalIgnoreCase)) { return ChatVerdict.AnotherChatIsLoaded; } } } return ChatVerdict.Active; } } public enum OvertimePhase { Skeleton, Channel, LateJoin, LateJoinGaps, Fixes, CapAndIntegrity, Content, Polish, NotYetBuilt } public enum ConfigValueKind { Bool, Int, Float, Choice, Text } public sealed class ConfigEntrySpec { public string Section { get; } public string Key { get; } public string Description { get; } public ConfigValueKind Kind { get; } public object DefaultValue { get; } public OvertimePhase Phase { get; } public bool HostOnly { get; } public double? Min { get; } public double? Max { get; } public IReadOnlyList<string> Choices { get; } public string FullDescription { get { if (!HostOnly) { return Description; } return Description + " [Host]"; } } public bool IsNumeric { get { if (Kind != ConfigValueKind.Int) { return Kind == ConfigValueKind.Float; } return true; } } public ConfigEntrySpec(string section, string key, string description, ConfigValueKind kind, object defaultValue, OvertimePhase phase, bool hostOnly = false, double? min = null, double? max = null, IReadOnlyList<string> choices = null) { Section = section; Key = key; Description = description; Kind = kind; DefaultValue = defaultValue; Phase = phase; HostOnly = hostOnly; Min = min; Max = max; Choices = choices ?? Array.Empty<string>(); } public string DefaultAsChoice() { return DefaultValue as string; } public override string ToString() { return Section + " / " + Key; } public IEnumerable<string> Validate() { if (string.IsNullOrWhiteSpace(Section)) { yield return this?.ToString() + ": section is empty"; } else if (Section.Length < 4 || !char.IsDigit(Section[0]) || !char.IsDigit(Section[1]) || Section[2] != ' ') { yield return this?.ToString() + ": section needs a two digit ordering prefix, because the menu sorts flat"; } if (string.IsNullOrWhiteSpace(Key)) { yield return this?.ToString() + ": key is empty"; } if (string.IsNullOrWhiteSpace(Description)) { yield return this?.ToString() + ": description is empty"; } else if (!Description.EndsWith(".", StringComparison.Ordinal)) { yield return this?.ToString() + ": description should be a sentence and end with a full stop"; } if (HostOnly && !FullDescription.Contains("[Host]")) { yield return this?.ToString() + ": host-only entry is missing its marker"; } if (IsNumeric) { if (!Min.HasValue || !Max.HasValue) { yield return this?.ToString() + ": numeric entry needs a range so the menu draws a slider"; } else if (Min >= Max) { yield return this?.ToString() + ": range is inverted"; } else { double num = Convert.ToDouble(DefaultValue); if (num < Min || num > Max) { yield return this?.ToString() + ": default sits outside its own range"; } } if (Kind == ConfigValueKind.Int && !(DefaultValue is int)) { yield return this?.ToString() + ": integer entry has a non-integer default"; } if (Kind == ConfigValueKind.Float && !(DefaultValue is float)) { yield return this?.ToString() + ": float entry has a non-float default"; } } else if (Min.HasValue || Max.HasValue) { yield return this?.ToString() + ": only numeric entries take a range"; } if (Kind == ConfigValueKind.Choice) { if (Choices.Count < 2) { yield return this?.ToString() + ": a choice entry needs at least two options"; } if (DefaultAsChoice() == null || !Choices.Contains(DefaultAsChoice())) { yield return this?.ToString() + ": default is not one of the options"; } } else if (Choices.Count != 0) { yield return this?.ToString() + ": only choice entries take options"; } if (Kind == ConfigValueKind.Bool && !(DefaultValue is bool)) { yield return this?.ToString() + ": boolean entry has a non-boolean default"; } } } public static class ConfigSchema { public const string SectionTop = "00 Overtime"; public const string SectionLateJoin = "10 Late Join"; public const string SectionBugFixes = "20 Bug Fixes"; public const string SectionNetworkIntegrity = "21 Network Integrity"; public const string SectionPerformance = "30 Performance"; public const string SectionScares = "40 Scares"; public const string SectionStoryVariety = "45 Story Variety"; public const string SectionVeteran = "47 Veteran Moments"; public const string SectionHumour = "50 Humour"; public const string SectionVoice = "55 Voice"; public const string SectionContent = "60 Content"; public const string SectionCutContent = "65 Cut Content"; public const string SectionPolish = "70 Polish"; public const string SectionAdvanced = "90 Advanced"; public const string KeyLateJoinMode = "Late Join Mode"; public const string KeyConfigVersion = "Config Version"; public const string KeyLogLevel = "Log Level"; public const string KeyDisableAllPatches = "Disable All Patches"; public const string KeySelfCheckOnLoad = "Self Check On Load"; public const string KeyDiagnosticsFile = "Diagnostics File"; public const string KeyAllowMidHunt = "Allow Mid Hunt"; public const string KeyAllowDuringCutscene = "Allow During Cutscene"; public const string KeyAllowAfterDayComplete = "Allow After Day Complete"; public const string KeyUpdateNightInBrowser = "Update Night In Browser"; public const string KeyAnnounceJoins = "Announce Joins To Host Log"; public const string KeyMaxPlayers = "Max Players"; public const string KeyResyncRevenue = "Resync Revenue"; public const string KeyResyncSceneToggles = "Resync Scene Toggles"; public const string KeyResyncHuntState = "Resync Hunt State"; public const string KeyScares = "Scares"; public const string KeyHumour = "Humour"; public const string KeyNightScareBudget = "Night Scare Budget"; public const string KeyMinimumGapSeconds = "Minimum Gap Seconds"; public const string KeyReuseVanillaToggles = "Reuse Vanilla Toggles"; public const string KeyScaleDifficulty = "Scale Difficulty With Players"; public const string KeyOddMoments = "Odd Moments"; public const string KeyCowBehaviour = "Cow Behaviour"; public const string KeyWanderingCustomers = "Wandering Customers"; public const string KeySpinWheelSurprises = "Spin Wheel Surprises"; public const string KeySpectateCycling = "Spectate Cycling"; public const string KeyPingMarker = "Ping Marker"; public const string KeyPingKey = "Ping Key"; public const string KeyTextChat = "Text Chat"; public const string KeyChatKey = "Chat Key"; public const string KeyConsole = "Console"; public const string KeyConsoleKey = "Console Key"; public const string KeyUnstuckKey = "Unstuck Key"; public const string KeyShelfFeedback = "Shelf Feedback"; public const string KeySkipTutorials = "Skip Tutorials"; public const string KeyPerformanceFixes = "Performance Fixes"; public const string KeyBugFixes = "Bug Fixes"; public const string KeyObiRopeCulling = "Obi Rope Culling"; public const string KeyObiRopeCullDistance = "Obi Rope Cull Distance"; public const string KeyLodBias = "LOD Bias"; public const string KeyShadowDistance = "Shadow Distance"; public const string KeyKeybindLookupCache = "Keybind Lookup Cache"; public const string KeyCountdownTextRate = "Countdown Text Rate"; public const string KeyCountingScriptClamp = "Counting Script Clamp"; public const string KeySplashTiming = "Splash Timing"; public const string KeySplashVideo = "Splash Video"; public const string KeyRunInBackground = "Run In Background"; public const string KeySavedVolumeAtStart = "Saved Volume At Start"; public const string KeyInteractRetryQueue = "Interact Retry Queue"; public const string KeyShelfRelease = "Shelf Release"; public const string KeyQuitWatchdog = "Quit Watchdog"; public const string KeyRestockRace = "Restock Race"; public const string KeyMissingTextFallback = "Missing Text Fallback"; public const string KeyNightStallWatch = "Night Stall Watch"; public const string KeyReportPlayerCount = "Report Player Count"; public const string KeyPlayerListOnJoin = "Player List On Join"; public const string KeyTutorialGate = "Tutorial Gate"; public const string KeyCarComputerRelease = "Car Computer Release"; public const string KeyQuestionPause = "Question Pause"; public const string KeyVideoBackButton = "Video Back Button"; public const string KeyGuestHostRpcs = "Guest Host Rpcs"; public const string KeyShotSpeakerGuard = "Shot Speaker Guard"; public const string KeyReportCloseGuard = "Report Close Guard"; public const string KeyLeavingCustomerGuard = "Leaving Customer Guard"; public const string KeyOtherCustomerExit = "Other Customer Exit"; public const string KeyHostLeftMessage = "Host Left Message"; public const string KeyMicPickerQuiet = "Mic Picker Quiet"; public const string KeyValidateRpcSenders = "Validate Rpc Senders"; public const string KeyBlockCheatMenuRpcs = "Block Cheat Menu Rpcs"; public const string KeyRateLimitResyncRequests = "Rate Limit Resync Requests"; public const string KeyLogRejectedRpcs = "Log Rejected Rpcs"; public const string KeyExtraCustomers = "Extra Customers"; public const string KeyExtraCustomersPerNight = "Extra Customers Per Night"; public const string KeyExtraReviews = "Extra Reviews"; public const string KeyExtraEodLines = "Extra EOD Lines"; public const string KeyExtraIdRecords = "Extra ID Records"; public const string KeyRandomEventRoller = "Random Event Roller"; public const string KeyHaroldBrant = "Harold Brant 55 56"; public const string KeyReservedEodIds = "Reserved Eod Ids"; public const string KeyNewContent = "New Content"; public const string KeyStoryVariety = "Story Variety"; public const string KeyReplaySameNight = "Replay Same Night"; public const string KeyAlternateTells = "Alternate Tells"; public const string KeyShuffleQueue = "Shuffle Queue"; public const string KeyDoppelOrHuman = "Doppel Or Human"; public const string KeyStoryBeats = "Story Beats"; public const string KeyVeteranMoments = "Veteran Moments"; public const string KeyReturnVisits = "Return Visits"; public const string KeyUncopiedCopies = "Copies Of The Uncopied"; public const string KeySecondTells = "Second Tells"; public const string KeyFamilyOfTheDead = "Family Of The Dead"; public const string KeyKarenReturns = "Karen Whitfield Returns"; public const string KeyReviewEchoes = "Review Echoes"; public const string KeyCutHints = "Cut Hints"; public const string KeyCutCustomers = "Cut Customers"; public const string KeyNewThieves = "New Thieves"; public const string KeyNewDrivers = "New Drivers"; public const string KeyNorbertRemembers = "Norbert Remembers"; public const string KeyExtraPhoneLines = "Extra Phone Lines"; public const string KeyStudioMic = "Studio Mic"; public const string KeyMicBitrate = "Mic Bitrate"; public const string KeyMicSampleRate = "Mic Sample Rate"; public const string KeyMicFrameMs = "Mic Frame Ms"; public const string KeyMicAlwaysSend = "Mic Always Send"; public const string KeyMicNoiseSuppression = "Mic Noise Suppression"; public const string KeyMicHighPass = "Mic High Pass"; public const string KeyMicAutoGain = "Mic Auto Gain"; public const string KeyMicEchoCancel = "Mic Echo Cancel"; public static readonly IReadOnlyList<string> LateJoinModeChoices = new string[3] { "Off", "Invites", "Open" }; public static readonly IReadOnlyList<string> ScareChoices = new string[4] { "Off", "Vanilla", "More", "Much More" }; public static readonly IReadOnlyList<string> HumourChoices = new string[3] { "Off", "Vanilla", "More" }; public static readonly IReadOnlyList<string> StoryVarietyChoices = StoryVariety.Choices; public static readonly IReadOnlyList<string> VeteranChoices = VeteranRarities.Choices; public static readonly IReadOnlyList<string> MicSampleRateChoices = MicPlan.SampleRateChoices; public static readonly IReadOnlyList<string> MicFrameMsChoices = MicPlan.FrameMsChoices; public static readonly IReadOnlyList<string> LogLevelChoices = new string[5] { "Off", "Error", "Warning", "Info", "Verbose" }; private static readonly ConfigEntrySpec[] All; public static IReadOnlyList<ConfigEntrySpec> Entries => All; public static IEnumerable<ConfigEntrySpec> EntriesThrough(OvertimePhase phase) { return All.Where((ConfigEntrySpec entry) => entry.Phase <= phase); } public static ConfigEntrySpec Find(string section, string key) { return All.FirstOrDefault((ConfigEntrySpec entry) => entry.Section == section && entry.Key == key); } public static IReadOnlyList<string> Validate() { List<string> list = new List<string>(); HashSet<string> hashSet = new HashSet<string>(StringComparer.Ordinal); ConfigEntrySpec[] all = All; foreach (ConfigEntrySpec configEntrySpec in all) { list.AddRange(configEntrySpec.Validate()); if (!hashSet.Add(configEntrySpec.Section + "\0" + configEntrySpec.Key)) { list.Add("duplicate entry: " + configEntrySpec); } } return list; } static ConfigSchema() { ConfigEntrySpec[] array = new ConfigEntrySpec[108]; IReadOnlyList<string> lateJoinModeChoices = LateJoinModeChoices; array[0] = new ConfigEntrySpec("00 Overtime", "Late Join Mode", "Who can join a shift that has already started. Invites lets friends and Steam overlay joins in without showing the lobby in the browser. Open also lists it in the browser.", ConfigValueKind.Choice, "Invites", OvertimePhase.LateJoin, hostOnly: true, null, null, lateJoinModeChoices); lateJoinModeChoices = ScareChoices; array[1] = new ConfigEntrySpec("00 Overtime", "Scares", "How much extra horror gets added on top of the shipped scares. Off leaves the night exactly as the game wrote it.", ConfigValueKind.Choice, "More", OvertimePhase.Polish, hostOnly: false, null, null, lateJoinModeChoices); lateJoinModeChoices = HumourChoices; array[2] = new ConfigEntrySpec("00 Overtime", "Humour", "How much extra writing gets added to customers, reviews and reports. Off leaves the shipped text alone.", ConfigValueKind.Choice, "More", OvertimePhase.Polish, hostOnly: false, null, null, lateJoinModeChoices); string defaultValue = StoryVariety.ChoiceFor(StoryVarietyLevel.Auto); lateJoinModeChoices = StoryVarietyChoices; array[3] = new ConfigEntrySpec("00 Overtime", "Story Variety", "How much a story run changes from one save to the next. Light mixes up the everyday customers, lets in versions of people the game wrote and never sends, and mixes night 1 around the tutorial: Clyde still comes first, the customer after him is a human, and Nathan is still the first doppelganger. Full also deals out which everyday customers are doppelgangers and moves the story's own beats where nothing needs them: a few story visitors come on a different night, story customers drift a slot or two, the Delgado twins can swap, and three events move within their night. Every beat the story needs to go on stays, and nothing from a later night comes early. Auto is Full for a host who has finished the story and Light otherwise. Off plays the nights exactly as the game builds them. Endless is left alone either way.", ConfigValueKind.Choice, defaultValue, OvertimePhase.Content, hostOnly: true, null, null, lateJoinModeChoices); string defaultValue2 = VeteranRarities.ChoiceFor(VeteranRarity.Rare); lateJoinModeChoices = VeteranChoices; array[4] = new ConfigEntrySpec("00 Overtime", "Veteran Moments", "Rare moments for somebody who has run the thirteen nights too many times. Everything it adds leans on what this save has already done: a person who came in on an earlier night, a name in the killed list, a prop the story has already shown. Rare means a run holds one, sometimes two. Off plays the nights exactly as the game builds them. Endless and nights 1 to 3 are left alone either way.", ConfigValueKind.Choice, defaultValue2, OvertimePhase.Content, hostOnly: true, null, null, lateJoinModeChoices); array[5] = new ConfigEntrySpec("00 Overtime", "New Content", "Adds new customers, records, reviews and end of day lines written in the game's own voice. The host's setting decides who walks in for everybody.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[6] = new ConfigEntrySpec("00 Overtime", "Performance Fixes", "Turns on the frame time work. Nothing here changes how the game plays.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[7] = new ConfigEntrySpec("00 Overtime", "Bug Fixes", "Turns on the fixes for the game's own defects. Nothing here changes how the game plays.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[8] = new ConfigEntrySpec("10 Late Join", "Allow Mid Hunt", "Let somebody join while a hunt is running. They watch until the hunt ends, then they spawn in.", ConfigValueKind.Bool, true, OvertimePhase.LateJoin, hostOnly: true); array[9] = new ConfigEntrySpec("10 Late Join", "Allow During Cutscene", "Let somebody join while a cutscene is playing. The cutscene does not replay for them.", ConfigValueKind.Bool, false, OvertimePhase.LateJoin, hostOnly: true); array[10] = new ConfigEntrySpec("10 Late Join", "Allow After Day Complete", "Let somebody join between the end of the shift and the end of day report. Off sends them back with the game's own message.", ConfigValueKind.Bool, false, OvertimePhase.LateJoin, hostOnly: true); array[11] = new ConfigEntrySpec("10 Late Join", "Update Night In Browser", "Keep the night number in the Steam lobby up to date so the browser row tells the truth about where the shift is.", ConfigValueKind.Bool, true, OvertimePhase.LateJoin, hostOnly: true); array[12] = new ConfigEntrySpec("10 Late Join", "Announce Joins To Host Log", "Write one line to the log for every join decision the host makes. Useful when something looks wrong.", ConfigValueKind.Bool, true, OvertimePhase.LateJoin, hostOnly: true); array[13] = new ConfigEntrySpec("10 Late Join", "Resync Revenue", "Send the running revenue to somebody who joins late, because the game only ever sends the change and never the total.", ConfigValueKind.Bool, true, OvertimePhase.LateJoinGaps, hostOnly: true); array[14] = new ConfigEntrySpec("10 Late Join", "Resync Scene Toggles", "Show a late joiner the one off events that already fired tonight, so the store looks the same to everyone.", ConfigValueKind.Bool, true, OvertimePhase.LateJoinGaps, hostOnly: true); array[15] = new ConfigEntrySpec("10 Late Join", "Resync Hunt State", "Put somebody who joins during a hunt behind another player's camera until it ends, then give them their body. The hunt itself is never replayed for them.", ConfigValueKind.Bool, true, OvertimePhase.LateJoinGaps, hostOnly: true); array[16] = new ConfigEntrySpec("10 Late Join", "Max Players", "How many people can be in the shift at once. The game's dropdown stops at three and then jumps to six, and its identity table runs out at seven.", ConfigValueKind.Int, 3, OvertimePhase.CapAndIntegrity, hostOnly: true, 3.0, 7.0); array[17] = new ConfigEntrySpec("20 Bug Fixes", "Keybind Lookup Cache", "Stops the game looking your keybinds up by name every frame. Needs Performance Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[18] = new ConfigEntrySpec("20 Bug Fixes", "Countdown Text Rate", "Updates the rat and roach countdowns once a second instead of fifty times a second. Needs Performance Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[19] = new ConfigEntrySpec("20 Bug Fixes", "Counting Script Clamp", "Stops a finished counter rebuilding its text every frame. Needs Performance Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[20] = new ConfigEntrySpec("20 Bug Fixes", "Splash Timing", "Lets the Kwalee splash play to its end before the menu loads. The game times the menu from before the video starts, so the load lands on the video's last second and it freezes. Needs Performance Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[21] = new ConfigEntrySpec("20 Bug Fixes", "Splash Video", "Under Proton, plays a 1080p copy of the Kwalee splash that the game decodes itself. The stock 4K clip goes through Wine's video decoder, which plays it at about two thirds speed with the sound breaking up, and now and then never starts it, leaving the launch stuck on the splash. Also carries on to the menu if a splash video has not started within ten seconds. Does nothing to the video on Windows. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[22] = new ConfigEntrySpec("20 Bug Fixes", "Run In Background", "Keeps the game running when its window is not focused. The game pauses itself the moment you alt-tab, which is how a launch gets stuck on the Kwalee splash or in the lobby if another window has focus, and how a host who tabs out freezes the whole lobby. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[23] = new ConfigEntrySpec("20 Bug Fixes", "Saved Volume At Start", "Plays the Kwalee splash and the first launch screens at your saved Music and SFX volume. The game only applies your sliders once the menu loads, so everything before it plays at full, and the splash video ignores them altogether. Never turns anything up. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[24] = new ConfigEntrySpec("20 Bug Fixes", "Interact Retry Queue", "Replays an interaction that arrived before the player it belongs to, instead of dropping it. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[25] = new ConfigEntrySpec("20 Bug Fixes", "Quit Watchdog", "Closes the process if it is still there twelve seconds after you quit. The engine shuts down cleanly and the window then hangs on the way out, so Quit looked frozen. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[26] = new ConfigEntrySpec("20 Bug Fixes", "Shelf Release", "Lets you back out of a shelf another player touched while you were stocking it. The game lets go only if the shelf still names you as its user, so without this you stay staring at it. Only the shelf you walked into is touched. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[27] = new ConfigEntrySpec("20 Bug Fixes", "Restock Race", "Keeps you stocking a shelf a customer takes from while you are at it. The game counts what is missing when you start and calls the shelf stocked once that many are placed, so whatever the customer took is left for you to come back for. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[28] = new ConfigEntrySpec("20 Bug Fixes", "Missing Text Fallback", "Shows the name of a missing line rather than a placeholder. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[29] = new ConfigEntrySpec("20 Bug Fixes", "Night Stall Watch", "Writes one line to the log when a night has not moved on for five minutes while everybody is up and nothing scripted is playing, naming where it stopped. It only reads; it never moves the night on. Host only. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes, hostOnly: true); array[30] = new ConfigEntrySpec("20 Bug Fixes", "Report Player Count", "Keeps the end of day report's idea of how many of you there are up to date. The game only recounts when somebody presses continue, so anybody who quits on the report screen leaves the rest of you waiting on a press that can never come. Host only. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes, hostOnly: true); array[31] = new ConfigEntrySpec("20 Bug Fixes", "Player List On Join", "Rebuilds the store's player list when somebody joins mid shift, the way the game rebuilds it when somebody leaves. Without it a late joiner is missing from the list the finale reads to revive people. Host only. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes, hostOnly: true); array[32] = new ConfigEntrySpec("20 Bug Fixes", "Tutorial Gate", "Keeps night one's register from staying shut on \"ALL players must complete the tutorial first\" after somebody joins or leaves partway through the ID check. The count is topped up through the game's own three calls and never finishes a tutorial nobody has done. Host only. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes, hostOnly: true); array[33] = new ConfigEntrySpec("20 Bug Fixes", "Car Computer Release", "Lets go of the licence plate scanner when you go down at it. The game's own clean up stops the store computer, the shelves, the ID cards and the conversations, but not the scanner, so you come back up looking at its screen. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[34] = new ConfigEntrySpec("20 Bug Fixes", "Question Pause", "Gives the pause menu back after you leave a customer's questions. The game holds the menu shut for another second while the canvas fades, and leaving inside that second leaves it shut for the rest of the night. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[35] = new ConfigEntrySpec("20 Bug Fixes", "Video Back Button", "Stops the Back button on the main menu's Video settings crashing the game when ModSettingsMenu is installed. The button is pointed straight at the game's own back code. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[36] = new ConfigEntrySpec("20 Bug Fixes", "Guest Host Rpcs", "Stops a guest's game trying to send the host's own commands (spin wheel tokens, the hunt lights, the chase), which the network refuses with an error every time. The host's copy already does the work. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[37] = new ConfigEntrySpec("20 Bug Fixes", "Shot Speaker Guard", "Stops the error when you shoot a customer who walks without the usual shopping script, like Pubert. The rest of the hit now happens instead of stopping halfway. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[38] = new ConfigEntrySpec("20 Bug Fixes", "Report Close Guard", "Stops the error when the end of day report closes after the player list it tidies up has already gone. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[39] = new ConfigEntrySpec("20 Bug Fixes", "Leaving Customer Guard", "Lets a customer walk out when their conversation has already been taken off the network, instead of freezing on an error at the door. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[40] = new ConfigEntrySpec("20 Bug Fixes", "Other Customer Exit", "Keeps you in what you are doing when a customer somewhere else in the store dies or leaves. The game let go of your camera and movement for every conversation that ended, not just yours. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[41] = new ConfigEntrySpec("20 Bug Fixes", "Host Left Message", "When the host leaves, the menu says you were disconnected from the host instead of \"Failed to join the session\". Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[42] = new ConfigEntrySpec("20 Bug Fixes", "Mic Picker Quiet", "Stops the microphone picker on every player logging an error at each scene load because it wakes before voice is running. It stays off exactly as the game leaves it. Needs Bug Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Fixes); array[43] = new ConfigEntrySpec("21 Network Integrity", "Validate Rpc Senders", "Refuses the network commands a guest has no business sending, like setting the money or ending the night. Turn it off if you are running CheatsForever on purpose.", ConfigValueKind.Bool, true, OvertimePhase.CapAndIntegrity, hostOnly: true); array[44] = new ConfigEntrySpec("21 Network Integrity", "Block Cheat Menu Rpcs", "Ignores the developer cheat commands when they arrive from anyone but the host.", ConfigValueKind.Bool, true, OvertimePhase.CapAndIntegrity, hostOnly: true); array[45] = new ConfigEntrySpec("21 Network Integrity", "Rate Limit Resync Requests", "Stops one client asking the host to resend the whole save over and over.", ConfigValueKind.Bool, true, OvertimePhase.CapAndIntegrity, hostOnly: true); array[46] = new ConfigEntrySpec("21 Network Integrity", "Log Rejected Rpcs", "Writes a line whenever a command is turned away, so you can see what was blocked.", ConfigValueKind.Bool, true, OvertimePhase.CapAndIntegrity, hostOnly: true); array[47] = new ConfigEntrySpec("30 Performance", "Obi Rope Culling", "Stops simulating ropes you are nowhere near. Needs Performance Fixes on.", ConfigValueKind.Bool, true, OvertimePhase.Polish); array[48] = new ConfigEntrySpec("30 Performance", "Obi Rope Cull Distance", "How far away a rope has to be, in metres, before it stops simulating.", ConfigValueKind.Float, 40f, OvertimePhase.Polish, hostOnly: false, 10.0, 200.0); array[49] = new ConfigEntrySpec("30 Performance", "LOD Bias", "How eagerly the game swaps to simpler models in the distance. Higher keeps detail for longer and costs more.", ConfigValueKind.Float, 5f, OvertimePhase.Polish, hostOnly: false, 0.5, 10.0); array[50] = new ConfigEntrySpec("30 Performance", "Shadow Distance", "How far away, in metres, shadows are still drawn.", ConfigValueKind.Float, 80f, OvertimePhase.Polish, hostOnly: false, 10.0, 150.0); array[51] = new ConfigEntrySpec("40 Scares", "Night Scare Budget", "How many extra scares a night can hold.", ConfigValueKind.Int, 2, OvertimePhase.Polish, hostOnly: true, 0.0, 10.0); array[52] = new ConfigEntrySpec("40 Scares", "Minimum Gap Seconds", "The shortest gap between two extra scares.", ConfigValueKind.Int, 120, OvertimePhase.Polish, hostOnly: true, 30.0, 600.0); array[53] = new ConfigEntrySpec("40 Scares", "Reuse Vanilla Toggles", "Lets a shipped scare turn up again on a later night, once the story has already shown it.", ConfigValueKind.Bool, true, OvertimePhase.Polish, hostOnly: true); array[54] = new ConfigEntrySpec("40 Scares", "Scale Difficulty With Players", "Gives the hunt more to spend once there are more than three of you. At three or fewer the night is exactly as the game shipped it.", ConfigValueKind.Bool, true, OvertimePhase.CapAndIntegrity, hostOnly: true); array[55] = new ConfigEntrySpec("45 Story Variety", "Replay Same Night", "A night that is loaded again plays out the way it did the first time, so a restart cannot reroll who comes in. A new save still gets nights of its own. Needs Story Variety on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[56] = new ConfigEntrySpec("45 Story Variety", "Alternate Tells", "Some people were written more than once, each version giving itself away on a different question, and the game only ever sends the first. This lets any of them come in, on the nights their tell can be caught. Needs Story Variety on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[57] = new ConfigEntrySpec("45 Story Variety", "Shuffle Queue", "Mixes up the order the everyday customers come in. Story customers keep their places. Needs Story Variety on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[58] = new ConfigEntrySpec("45 Story Variety", "Doppel Or Human", "Deals out again which of tonight's everyday customers are doppelgangers, keeping how many there are, so remembering who was one last time does not help. Needs Story Variety on Full.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[59] = new ConfigEntrySpec("45 Story Variety", "Story Beats", "Moves the story's own beats where nothing needs them: Bartholomew and Julietta may come on night 2 or 3 instead of night 1, Roger on night 3 or 4, Esteban on night 5 or 6, story customers drift up to two slots, the real Tommy Delgado may come before his double, and the events on nights 5, 6 and 10 move up to two places. Clyde, Ryan, Pubert, Aria, Vanessa and the whole last night stay as written. Needs Story Variety on Full.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[60] = new ConfigEntrySpec("47 Veteran Moments", "Return Visits", "Lets somebody you let through come back on a later night, with lines written as a second visit. Needs Veteran Moments on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[61] = new ConfigEntrySpec("47 Veteran Moments", "Copies Of The Uncopied", "On rare runs the one who came back is not them. These are faces the game only ever wrote as human, so this takes away something a returning player worked out for themselves. Off until you want that. Needs Veteran Moments on.", ConfigValueKind.Bool, false, OvertimePhase.Content, hostOnly: true); array[62] = new ConfigEntrySpec("47 Veteran Moments", "Second Tells", "Two of the doppelgangers get a second version that answers the question you always ask, correctly, and gives itself away somewhere else. Only ever on a night the save has already had that person in. Needs Veteran Moments on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[63] = new ConfigEntrySpec("47 Veteran Moments", "Family Of The Dead", "Somebody comes in asking after a person this save killed. Needs Veteran Moments on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[64] = new ConfigEntrySpec("47 Veteran Moments", "Karen Whitfield Returns", "Lets Karen Whitfield's second visit in. The studio wrote it, shipped the lines and the report row, and the game can never send her, because coming back needs her to have been in already and that uses her up. Needs Veteran Moments on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[65] = new ConfigEntrySpec("47 Veteran Moments", "Review Echoes", "Lets a review be about what was actually in the store that night, like the ambulance out front. Only ever drawn while the thing it mentions is there. Needs Veteran Moments on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[66] = new ConfigEntrySpec("50 Humour", "Cow Behaviour", "Lets the cow turn up as an odd moment. It walks up to somebody and talks, which stops whatever they were doing. Only in a save where the game has already shown the cow. Needs Humour on.", ConfigValueKind.Bool, true, OvertimePhase.Polish, hostOnly: true); array[67] = new ConfigEntrySpec("50 Humour", "Wandering Customers", "Lets a browsing customer forget why they came in and wander. Needs Humour on.", ConfigValueKind.Bool, true, OvertimePhase.Polish, hostOnly: true); array[68] = new ConfigEntrySpec("50 Humour", "Odd Moments", "Lets rare oddities turn up during a shift, all of them things the store already does. Needs Humour on.", ConfigValueKind.Bool, true, OvertimePhase.Polish, hostOnly: true); array[69] = new ConfigEntrySpec("50 Humour", "Spin Wheel Surprises", "Very occasionally the prize wheel drops a second coin. Only once somebody has spun it in this save. Needs Humour on.", ConfigValueKind.Bool, true, OvertimePhase.Polish, hostOnly: true); array[70] = new ConfigEntrySpec("55 Voice", "Studio Mic", "Master switch for the microphone settings below. On, your voice is sent at the settings below instead of the game's 48 kHz VOIP defaults, and the Opus encoder is built in audio mode at its highest effort. Off puts the game's recorder back the way it set it up.", ConfigValueKind.Bool, true, OvertimePhase.Polish); array[71] = new ConfigEntrySpec("55 Voice", "Mic Bitrate", "Bits per second for your voice. 510000 is the most Opus accepts. Speech is already transparent from about 128000, so the extra is headroom, not a lever. Lower it only if your upload is thin.", ConfigValueKind.Int, 510000, OvertimePhase.Polish, hostOnly: false, 6000.0, 510000.0); lateJoinModeChoices = MicSampleRateChoices; array[72] = new ConfigEntrySpec("55 Voice", "Mic Sample Rate", "Sample rate of your voice in Hz. 48000 is full band, the most Opus takes. Lower only to save bandwidth.", ConfigValueKind.Choice, "48000", OvertimePhase.Polish, hostOnly: false, null, null, lateJoinModeChoices); lateJoinModeChoices = MicFrameMsChoices; array[73] = new ConfigEntrySpec("55 Voice", "Mic Frame Ms", "Milliseconds of audio per network packet. 20 is the encoder's quality sweet spot. 10 trims about 10 ms of delay at a small quality cost and twice the packets. 40 and 60 save packets and add delay.", ConfigValueKind.Choice, "20", OvertimePhase.Polish, hostOnly: false, null, null, lateJoinModeChoices); array[74] = new ConfigEntrySpec("55 Voice", "Mic Always Send", "Send continuously instead of letting Photon's voice detector decide when you are talking, so the first syllable of a word is never clipped. The game's Voice Activation mode sends like Open Mic while this is on. Push To Talk and mute still work. Off returns to the detector.", ConfigValueKind.Bool, true, OvertimePhase.Polish); array[75] = new ConfigEntrySpec("55 Voice", "Mic Noise Suppression", "Run WebRTC noise suppression on your microphone before encoding. Leave off if the mic already goes through RNNoise or another suppressor (two in a row dull the voice). Any of the four WebRTC options routes the mic through a 16-bit processing hop; all four off is the purest chain.", ConfigValueKind.Bool, false, OvertimePhase.Polish); array[76] = new ConfigEntrySpec("55 Voice", "Mic High Pass", "WebRTC high-pass on your microphone: cuts desk thumps, fan rumble and breath pops below the voice. On if listeners hear rumble; off for the purest chain.", ConfigValueKind.Bool, false, OvertimePhase.Polish); array[77] = new ConfigEntrySpec("55 Voice", "Mic Auto Gain", "WebRTC automatic gain: levels your voice so quiet and loud moments come out the same. Can pump on a noisy mic. On if people say you swing loud and quiet.", ConfigValueKind.Bool, false, OvertimePhase.Polish); array[78] = new ConfigEntrySpec("55 Voice", "Mic Echo Cancel", "WebRTC echo cancellation: removes game audio your microphone picks up. Only for speakers; a headset does not need it.", ConfigValueKind.Bool, false, OvertimePhase.Polish); array[79] = new ConfigEntrySpec("60 Content", "Extra Customers", "Adds new people to the queue, written and scheduled the way the shipped ones are. Needs New Content on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[80] = new ConfigEntrySpec("60 Content", "Extra Customers Per Night", "The most new people a night can hold. The night decides within that: one on the first night, two on the next two, three through the sixth, four after. They are added to the queue the game already built, so a higher number is a longer shift.", ConfigValueKind.Int, 4, OvertimePhase.Content, hostOnly: true, 0.0, 8.0); array[81] = new ConfigEntrySpec("60 Content", "Extra Reviews", "Adds new customer reviews and reviewer names to the board, and lets every row the board holds be drawn. Needs New Content on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[82] = new ConfigEntrySpec("60 Content", "Extra EOD Lines", "Adds new end of day report rows, including the ones the new customers are reported under. Needs New Content on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[83] = new ConfigEntrySpec("60 Content", "Extra ID Records", "Adds new records to the identity database on the computer. Needs New Content on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[84] = new ConfigEntrySpec("60 Content", "Random Event Roller", "Rolls the random events the game never wired up, so nights vary more. Needs New Content on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[85] = new ConfigEntrySpec("60 Content", "Harold Brant 55 56", "Restores the two Harold Brant variants the game has report text for but no dialogue. Needs New Content on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[86] = new ConfigEntrySpec("60 Content", "Reserved Eod Ids", "Uses the blank end of day report rows the game ships with. Needs New Content on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[87] = new ConfigEntrySpec("65 Cut Content", "Cut Customers", "Lets the people the game finished and then never scheduled walk in: the second Marjorie and Diana, the Karen Whitfield copy, the human Dimitri, and Doug Kramer, whose entry in the game's own customer list points at the wrong man. Each one is weighted and gated the way the studio weighted their other visits. Needs New Content on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[88] = new ConfigEntrySpec("65 Cut Content", "New Thieves", "Lets a thief the game sends be one of the pack's two instead of one of the six the game ships. Needs New Content on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[89] = new ConfigEntrySpec("65 Cut Content", "New Drivers", "Lets a car the game pulls up carry one of the pack's drivers, with its own plate on the registry. Needs New Content on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[90] = new ConfigEntrySpec("65 Cut Content", "Norbert Remembers", "Gives the lemonade seller the line for the branch the game built and left unwritten: if you shot him, the one who turns up in the car on the ninth night says so. Needs New Content on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[91] = new ConfigEntrySpec("65 Cut Content", "Extra Phone Lines", "Lets the phone ring with something new now and then, on nights the story has room for it and never while the night's own call is due. Needs New Content on.", ConfigValueKind.Bool, true, OvertimePhase.Content, hostOnly: true); array[92] = new ConfigEntrySpec("65 Cut Content", "Cut Hints", "Shows the hints the game wrote and never queued, like the one for when the thing in the vents gets irritated and the one for your first token, plus a few new ones, each once per save at the moment it explains.", ConfigValueKind.Bool, true, OvertimePhase.Polish); array[93] = new ConfigEntrySpec("70 Polish", "Spectate Cycling", "Lets you move between the other players while you are dead, instead of being stuck on one.", ConfigValueKind.Bool, true, OvertimePhase.Polish); array[94] = new ConfigEntrySpec("70 Polish", "Ping Marker", "Lets you mark something for the others, using the game's own arrow and alert sound.", ConfigValueKind.Bool, true, OvertimePhase.Polish); array[95] = new ConfigEntrySpec("70 Polish", "Ping Key", "The key that drops a marker where you are looking.", ConfigValueKind.Text, "Mouse2", OvertimePhase.Polish); array[96] = new ConfigEntrySpec("70 Polish", "Text Chat", "Adds typed messages to the dialogue history the game already draws. Stands down if another chat mod is loaded.", ConfigValueKind.Bool, true, OvertimePhase.Polish); array[97] = new ConfigEntrySpec("70 Polish", "Chat Key", "The key that opens the typing box. The game already uses T for the dialogue history.", ConfigValueKind.Text, "Y", OvertimePhase.Polish); array[98] = new ConfigEntrySpec("70 Polish", "Console", "A typing box for commands such as unstuck, opened with Console Key. Typing a line that starts with a slash into the chat does the same.", ConfigValueKind.Bool, true, OvertimePhase.Polish); array[99] = new ConfigEntrySpec("70 Polish", "Console Key", "The key that opens the command box. BackQuote is the key left of 1, under Escape.", ConfigValueKind.Text, "BackQuote", OvertimePhase.Polish); array[100] = new ConfigEntrySpec("70 Polish", "Unstuck Key", "The key that runs unstuck without typing anything. Press it twice within ten seconds to be put on the nearest store floor. Leave it empty to turn the key off.", ConfigValueKind.Text, "F8", OvertimePhase.Polish); array[101] = new ConfigEntrySpec("70 Polish", "Shelf Feedback", "Tells you when a shelf you were filling is full, through the game's own alert.", ConfigValueKind.Bool, true, OvertimePhase.NotYetBuilt); lateJoinModeChoices = TutorialSkip.Choices; array[102] = new ConfigEntrySpec("70 Polish", "Skip Tutorials", "Skips what the game teaches, for everyone, through the game's own calls: the night 1 ID check and clean up list, the car tutorial on the first car night, and the cutscene the lobby plays after Start. Returning Players skips a tutorial only when every player has finished that night before, and a player without Overtime cannot say so and counts as new; the cutscene only needs the host to have finished night 1 and nobody known to be new. Always skips all of it for everybody. A change mid shift applies from the next tutorial that starts.", ConfigValueKind.Choice, "Returning Players", OvertimePhase.Polish, hostOnly: true, null, null, lateJoinModeChoices); array[103] = new ConfigEntrySpec("90 Advanced", "Config Version", "Which version of this file Overtime last wrote. Overtime keeps it up to date.", ConfigValueKind.Text, "1.0.3", OvertimePhase.Skeleton); lateJoinModeChoices = LogLevelChoices; array[104] = new ConfigEntrySpec("90 Advanced", "Log Level", "How much Overtime writes to the log. Warning only speaks up when something went wrong.", ConfigValueKind.Choice, "Warning", OvertimePhase.Skeleton, hostOnly: false, null, null, lateJoinModeChoices); array[105] = new ConfigEntrySpec("90 Advanced", "Self Check On Load", "Runs the start up checks and writes a pass or fail line for each. Worth turning on once after a game update.", ConfigValueKind.Bool, false, OvertimePhase.Channel); array[106] = new ConfigEntrySpec("90 Advanced", "Diagnostics File", "Writes every line Overtime would say at Verbose to Overtime-diagnostics.log in the BepInEx folder, whatever Log Level is set to. Read it after a session to see what fired and what never did.", ConfigValueKind.Bool, true, OvertimePhase.Polish); array[107] = new ConfigEntrySpec("90 Advanced", "Disable All Patches", "Stops Overtime touching the game at all, without uninstalling it. Takes effect on the next launch.", ConfigValueKind.Bool, false, OvertimePhase.Skeleton); All = array; } } public static class ConsoleBesideChatter { public static bool ChatterMayTick(bool chatterOpen, bool boxOpen, int frame, int boxClosedFrame) { if (chatterOpen) { return true; } if (!boxOpen) { return frame != boxClosedFrame; } return false; } public static bool ConsoleMayOpen(bool chatterOpen) { return !chatterOpen; } } public enum CommandKind { None, Help, Unstuck, LateJoin, Scares, Cap, Diag, Night } public sealed class CommandSpec { public CommandKind Kind { get; } public string Name { get; } public string Usage { get; } public string Summary { get; } public bool HostOnly { get; } public CommandSpec(CommandKind kind, string name, string usage, string summary, bool hostOnly) { Kind = kind; Name = name; Usage = usage; Summary = summary; HostOnly = hostOnly; } } public sealed class CommandPlan { public CommandSpec Spec { get; } public CommandKind Kind => Spec?.Kind ?? CommandKind.None; public bool Ok { get { if (Spec != null) { return Error == null; } return false; } } public string Error { get; } public string Section { get; } public string Key { get; } public string Value { get; } public bool Move { get; } private CommandPlan(CommandSpec spec, string error, string section, string key, string value, bool move) { Spec = spec; Error = error; Section = section; Key = key; Value = value; Move = move; } internal static CommandPlan Run(CommandSpec spec, bool move = false) { return new CommandPlan(spec, null, null, null, null, move); } internal static CommandPlan Write(CommandSpec spec, string section, string key, string value) { return new CommandPlan(spec, null, section, key, value, move: false); } internal static CommandPlan Refuse(CommandSpec spec, string error) { return new CommandPlan(spec, error, null, null, null, move: false); } } public static class ConsoleCommands { public const char Prefix = '/'; private static readonly CommandSpec[] Specs = new CommandSpec[7] { new CommandSpec(CommandKind.Unstuck, "unstuck", "unstuck [move]", "lets go of whatever interaction is holding you; run it twice, or add move, to be put on the nearest store floor", hostOnly: false), new CommandSpec(CommandKind.LateJoin, "latejoin", "latejoin off|invites|open", "who can join a shift that has started", hostOnly: true), new CommandSpec(CommandKind.Scares, "scares", "scares off|vanilla|more|muchmore", "how much extra horror is added", hostOnly: true), new CommandSpec(CommandKind.Cap, "cap", "cap " + 3 + "-" + 7, "how many the lobby holds, from the next lobby you host", hostOnly: true), new CommandSpec(CommandKind.Night, "night", "night", "where tonight's running order is and what it is waiting on", hostOnly: true), new CommandSpec(CommandKind.Diag, "diag", "diag", "the last diagnostics lines and what each module has said", hostOnly: false), new CommandSpec(CommandKind.Help, "help", "help", "this list", hostOnly: false) }; private static readonly Dictionary<string, string> LateJoinWords = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["off"] = "Off", ["invites"] = "Invites", ["invite"] = "Invites", ["open"] = "Open" }; private static readonly Dictionary<string, string> ScareWords = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["off"] = "Off", ["vanilla"] = "Vanilla", ["more"] = "More", ["muchmore"] = "Much More" }; public static IReadOnlyList<CommandSpec> All => Specs; public static CommandSpec Find(string name) { return Specs.FirstOrDefault((CommandSpec spec) => string.Equals(spec.Name, name, StringComparison.OrdinalIgnoreCase)); } public static bool IsCommandLine(string typed) { if (string.IsNullOrEmpty(typed)) { return false; } string text = typed.TrimStart(); if (text.Length > 0) { return text[0] == '/'; } return false; } public