using System; using System.Collections.Generic; using System.Diagnostics; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Text; using BepInEx; using BepInEx.Bootstrap; using BepInEx.Configuration; using BepInEx.Logging; using Dissonance; using Dissonance.Integrations.Unity_NFGO; using GameNetcodeStuff; using HarmonyLib; using LethalCompanyInputUtils.Api; using LethalSpectator.Config; using LethalSpectator.Core; using LethalSpectator.Core.Abstractions; using LethalSpectator.Modules.Compat; using LethalSpectator.Modules.Danger; using LethalSpectator.Modules.Hud; using LethalSpectator.Modules.Input; using LethalSpectator.Modules.SpectateCamera; using LethalSpectator.Modules.VoiceActivity; using Microsoft.CodeAnalysis; using TMPro; using Unity.Netcode; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.InputSystem.Controls; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName = ".NET Standard 2.1")] [assembly: AssemblyCompany("LethalSpectator")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyDescription("Activity-based auto spectate camera for Lethal Company")] [assembly: AssemblyFileVersion("0.2.0.0")] [assembly: AssemblyInformationalVersion("0.2.0+16c075f6f4cfce48c86c16a0d303d7979c7c76f1")] [assembly: AssemblyProduct("LethalSpectator")] [assembly: AssemblyTitle("LethalSpectator")] [assembly: AssemblyVersion("0.2.0.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace LethalSpectator { [BepInPlugin("Haaylo.LethalSpectator", "LethalSpectator", "0.2.0")] [BepInDependency(/*Could not decode attribute arguments.*/)] [BepInDependency(/*Could not decode attribute arguments.*/)] public class Plugin : BaseUnityPlugin { private Harmony _harmony; private GameObject _runnerObject; public static Plugin Instance { get; private set; } public static ManualLogSource Log { get; private set; } private void Awake() { //IL_003d: Unknown result type (might be due to invalid IL or missing references) //IL_0047: Expected O, but got Unknown //IL_017f: Unknown result type (might be due to invalid IL or missing references) //IL_0189: Expected O, but got Unknown Instance = this; Log = ((BaseUnityPlugin)this).Logger; try { ModConfig.Bind(((BaseUnityPlugin)this).Config); } catch (Exception arg) { Log.LogError((object)$"Config binding failed; using defaults where possible: {arg}"); } try { _harmony = new Harmony("Haaylo.LethalSpectator"); _harmony.PatchAll(typeof(Plugin).Assembly); int num = 0; foreach (MethodBase patchedMethod in _harmony.GetPatchedMethods()) { _ = patchedMethod; num++; } Log.LogInfo((object)string.Format("{0} {1}: {2} game methods patched.", "LethalSpectator", "0.2.0", num)); } catch (Exception arg2) { Log.LogError((object)$"Harmony patching failed; the mod will stay idle: {arg2}"); return; } bool flag = Chainloader.PluginInfos.ContainsKey("com.rune580.LethalCompanyInputUtils"); bool flag2 = Chainloader.PluginInfos.ContainsKey("SpectateEnemy"); Log.LogInfo((object)$"InputUtils present: {flag}; SpectateEnemies present: {flag2}; config: {((BaseUnityPlugin)this).Config.ConfigFilePath}"); LobbyCompatibilityBridge.TryRegister(); try { SpectateCameraGateway gateway = new SpectateCameraGateway(); SpectateEnemiesBridge external = new SpectateEnemiesBridge(); VoiceActivitySampler sampler = new VoiceActivitySampler(); EnemyProximitySource dangerSource = new EnemyProximitySource(); KillerCamDriver killerCam = new KillerCamDriver(); HudStatus hud = new HudStatus(); ToggleInput input = new ToggleInput(gateway, external); AutoSpectateController controller = new AutoSpectateController(); SpectatorRunner.Wire(gateway, sampler, dangerSource, killerCam, external, input, hud, controller); _runnerObject = new GameObject("LethalSpectator.Runner"); Object.DontDestroyOnLoad((Object)(object)_runnerObject); ((Object)_runnerObject).hideFlags = (HideFlags)61; _runnerObject.AddComponent(); } catch (Exception arg3) { Log.LogError((object)$"Could not create the runner object; the mod will stay idle: {arg3}"); } } } public static class PluginInfo { public const string GUID = "Haaylo.LethalSpectator"; public const string NAME = "LethalSpectator"; public const string VERSION = "0.2.0"; public const string INPUT_UTILS_GUID = "com.rune580.LethalCompanyInputUtils"; public const string SPECTATE_ENEMIES_GUID = "SpectateEnemy"; } } namespace LethalSpectator.Modules.VoiceActivity { public sealed class VoiceActivitySampler : IVoiceActivitySource { private sealed class SlotCache { public PlayerControllerB PlayerRef; public NfgoPlayer Nfgo; public VoicePlayerState State; } private SlotCache[] _cache = new SlotCache[0]; private float _lastRefreshTime = -999f; private float _lastUnavailableLog = -999f; private const float RefreshIntervalSeconds = 2f; private const float MinVolumeForNormalize = 0.05f; public void Sample(CandidateSnapshot[] candidates, int count, VoiceSample[] output, float now) { EnsureCapacity(candidates, count); bool flag = false; bool flag2 = false; for (int i = 0; i < count; i++) { output[i] = VoiceSample.Unavailable; try { int slot = candidates[i].Slot; SlotCache slotCache = _cache[slot]; PlayerControllerB player = candidates[i].Player; if (slotCache.PlayerRef != player) { slotCache.PlayerRef = player; slotCache.Nfgo = null; slotCache.State = null; } if ((Object)(object)slotCache.Nfgo == (Object)null) { slotCache.Nfgo = ((Component)player).GetComponentInChildren(); } if ((Object)(object)slotCache.Nfgo == (Object)null) { flag = true; continue; } string playerId = slotCache.Nfgo.PlayerId; if (string.IsNullOrEmpty(playerId)) { flag = true; continue; } VoicePlayerState val = slotCache.State; if (val == null || !string.Equals(val.Name, playerId, StringComparison.Ordinal)) { val = null; VoicePlayerState voicePlayerState = player.voicePlayerState; if (voicePlayerState != null && string.Equals(voicePlayerState.Name, playerId, StringComparison.Ordinal)) { val = voicePlayerState; } else if (now - _lastRefreshTime >= 2f || flag2) { flag2 = true; _lastRefreshTime = now; DissonanceComms val2 = StartOfRound.Instance?.voiceChatModule; if ((Object)(object)val2 != (Object)null) { val = val2.FindPlayer(playerId); } } slotCache.State = val; } if (val == null || !val.IsConnected) { flag = true; continue; } float volume = val.Volume; float amplitude = val.Amplitude / Mathf.Max(0.05f, volume); output[i].Available = true; output[i].IsSpeaking = val.IsSpeaking; output[i].Amplitude = amplitude; output[i].IsMuted = val.IsLocallyMuted; } catch (Exception) { flag = true; } } if (flag && now - _lastUnavailableLog > 60f) { _lastUnavailableLog = now; Plugin.Log.LogDebug((object)"Voice state unavailable for one or more candidates (normal during joins/level loads)."); } } private void EnsureCapacity(CandidateSnapshot[] candidates, int count) { int num = 0; for (int i = 0; i < count; i++) { if (candidates[i].Slot + 1 > num) { num = candidates[i].Slot + 1; } } if (_cache.Length < num) { SlotCache[] array = new SlotCache[num]; for (int j = 0; j < _cache.Length; j++) { array[j] = _cache[j]; } for (int k = _cache.Length; k < num; k++) { array[k] = new SlotCache(); } _cache = array; } } } } namespace LethalSpectator.Modules.SpectateCamera { public sealed class KillerCamDriver : IKillerCamera { internal static KillerCamDriver Instance; private static readonly MethodInfo RaycastPivot = AccessTools.Method(typeof(PlayerControllerB), "RaycastSpectateCameraAroundPivot", (Type[])null, (Type[])null); private readonly object[] _noArgs = new object[0]; private bool _hasPending; private PlayerControllerB _pendingVictim; private EnemyAI _pendingAnimEnemy; private Vector3 _pendingPosition; private int _pendingCause; private float _pendingTime; private EnemyAI _killer; private float _engageEndTime; private int _errorCount; private float _lastErrorLog = -999f; public bool Engaged { get; private set; } public string KillerName { get { try { EnemyAI killer = _killer; return ((Object)(object)killer != (Object)null && (Object)(object)killer.enemyType != (Object)null) ? killer.enemyType.enemyName : "Enemy"; } catch (Exception) { return "Enemy"; } } } public KillerCamDriver() { Instance = this; if (RaycastPivot == null) { Plugin.Log.LogWarning((object)"RaycastSpectateCameraAroundPivot not found (game update?); killer cam disabled."); } } internal static void NotifyPlayerKilled(int playerId, int causeOfDeath) { //IL_0063: Unknown result type (might be due to invalid IL or missing references) //IL_0068: Unknown result type (might be due to invalid IL or missing references) KillerCamDriver instance = Instance; if (instance == null) { return; } try { StartOfRound instance2 = StartOfRound.Instance; if (!((Object)(object)instance2 == (Object)null) && instance2.allPlayerScripts != null && playerId >= 0 && playerId < instance2.allPlayerScripts.Length) { PlayerControllerB val = instance2.allPlayerScripts[playerId]; if (!((Object)(object)val == (Object)null)) { instance._pendingVictim = val; instance._pendingAnimEnemy = val.inAnimationWithEnemy; instance._pendingPosition = ((Component)val).transform.position; instance._pendingCause = causeOfDeath; instance._pendingTime = Time.unscaledTime; instance._hasPending = true; } } } catch (Exception ex) { instance.LogOnce(ex); } } public bool TryEngage(PlayerControllerB currentTarget, float now) { if (Engaged || !_hasPending || RaycastPivot == null) { return false; } try { if (!ModConfig.KillerCamEnabled.Value) { _hasPending = false; return false; } if (now - _pendingTime > 1.5f) { _hasPending = false; return false; } if ((Object)(object)currentTarget == (Object)null) { return false; } if (_pendingVictim != currentTarget) { _hasPending = false; return false; } _hasPending = false; string attribution; EnemyAI val = ResolveKiller(out attribution); if ((Object)(object)val == (Object)null) { return false; } _killer = val; Engaged = true; float num = Mathf.Max(0.5f, ModConfig.KillerCamDurationSeconds.Value); _engageEndTime = now + num; Plugin.Log.LogInfo((object)$"Killer cam: following '{KillerName}' for {num:F1}s ({attribution}, cause {_pendingCause})."); return true; } catch (Exception ex) { LogOnce(ex); _hasPending = false; return false; } } private EnemyAI ResolveKiller(out string attribution) { //IL_00c5: Unknown result type (might be due to invalid IL or missing references) //IL_00cb: Unknown result type (might be due to invalid IL or missing references) //IL_00d0: Unknown result type (might be due to invalid IL or missing references) //IL_00d5: Unknown result type (might be due to invalid IL or missing references) attribution = "animation"; EnemyAI pendingAnimEnemy = _pendingAnimEnemy; if ((Object)(object)pendingAnimEnemy != (Object)null && !pendingAnimEnemy.isEnemyDead) { return pendingAnimEnemy; } if (!IsEnemyishCause(_pendingCause)) { attribution = "none"; return null; } EnemyAI val = null; float num = 0f; float num2 = Mathf.Max(1f, ModConfig.KillerCamMaxAttributionDistance.Value); float num3 = num2 * num2; List list = (((Object)(object)RoundManager.Instance != (Object)null) ? RoundManager.Instance.SpawnedEnemies : null); if (list == null) { attribution = "none"; return null; } for (int i = 0; i < list.Count; i++) { EnemyAI val2 = list[i]; if ((Object)(object)val2 == (Object)null || val2.isEnemyDead) { continue; } EnemyType enemyType = val2.enemyType; if (!((Object)(object)enemyType == (Object)null) && !enemyType.isDaytimeEnemy) { Vector3 val3 = ((Component)val2).transform.position - _pendingPosition; float sqrMagnitude = ((Vector3)(ref val3)).sqrMagnitude; if (sqrMagnitude <= num3 && ((Object)(object)val == (Object)null || sqrMagnitude < num)) { val = val2; num = sqrMagnitude; } } } attribution = (((Object)(object)val != (Object)null) ? $"nearest {Mathf.Sqrt(num):F1}m" : "none"); return val; } private static bool IsEnemyishCause(int cause) { switch (cause) { case 0: case 1: case 4: case 5: case 6: case 7: case 8: case 12: case 13: case 14: case 17: case 18: return true; default: return false; } } public void Tick(float now) { //IL_00b8: Unknown result type (might be due to invalid IL or missing references) //IL_00b0: Unknown result type (might be due to invalid IL or missing references) if (!Engaged) { return; } try { PlayerControllerB val = (((Object)(object)GameNetworkManager.Instance != (Object)null) ? GameNetworkManager.Instance.localPlayerController : null); EnemyAI killer = _killer; if (now >= _engageEndTime) { Release("duration elapsed"); return; } if ((Object)(object)killer == (Object)null || killer.isEnemyDead || !((Component)killer).gameObject.activeInHierarchy) { Release("killer gone"); return; } if ((Object)(object)val == (Object)null || (Object)(object)val.spectateCameraPivot == (Object)null) { Release("no pivot"); return; } Transform eye = killer.eye; val.spectateCameraPivot.position = (((Object)(object)eye != (Object)null) ? eye.position : ((Component)killer).transform.position); RaycastPivot.Invoke(val, _noArgs); } catch (Exception ex) { LogOnce(ex); Release("error"); } } public void Release(string reason) { if (Engaged) { Engaged = false; _killer = null; Plugin.Log.LogDebug((object)("Killer cam released: " + reason + ".")); } } public void ResetSession() { Engaged = false; _killer = null; _hasPending = false; _pendingVictim = null; _pendingAnimEnemy = null; } private void LogOnce(Exception ex) { _errorCount++; float unscaledTime = Time.unscaledTime; if (unscaledTime - _lastErrorLog > 60f) { _lastErrorLog = unscaledTime; Plugin.Log.LogError((object)$"Killer cam error #{_errorCount}: {ex}"); } } } [HarmonyPatch(typeof(PlayerControllerB), "KillPlayerClientRpc")] internal static class PlayerControllerB_KillPlayerClientRpc_Patch { private static void Prefix(int playerId, int causeOfDeath) { try { KillerCamDriver.NotifyPlayerKilled(playerId, causeOfDeath); } catch (Exception) { } } } [HarmonyPatch(typeof(PlayerControllerB), "SetWhoToSpectate")] internal static class PlayerControllerB_SetWhoToSpectate_Patch { private static bool Prefix() { try { KillerCamDriver instance = KillerCamDriver.Instance; return instance == null || !instance.Engaged; } catch (Exception) { return true; } } } [HarmonyPatch(typeof(PlayerControllerB), "SpectateNextPlayer")] internal static class PlayerControllerB_SpectateNextPlayer_Patch { private static void Postfix(PlayerControllerB __instance, bool getClosest) { try { SpectateCameraGateway.NotifyVanillaSpectateNext(__instance, getClosest); } catch (Exception) { } } } public sealed class SpectateCameraGateway : ISpectateCameraGateway { internal static SpectateCameraGateway Instance; private static readonly FieldRef DeadTimerRef = ResolveDeadTimer(); private static bool _resolveFailed; private bool _faulted; private int _faultCount; private float _lastFaultLog = -999f; private bool _applyingSwitch; private bool _pendingVanillaSwitch; private PlayerControllerB _pendingTarget; private bool _pendingManual; public bool Faulted => _faulted; public bool IsLocalPlayerSpectating { get { try { GameNetworkManager instance = GameNetworkManager.Instance; if ((Object)(object)instance == (Object)null) { return false; } PlayerControllerB localPlayerController = instance.localPlayerController; if ((Object)(object)localPlayerController == (Object)null || (Object)(object)StartOfRound.Instance == (Object)null) { return false; } return ((NetworkBehaviour)localPlayerController).IsOwner && localPlayerController.isPlayerDead && (!((NetworkBehaviour)localPlayerController).IsServer || localPlayerController.isHostPlayerObject); } catch (Exception e) { Fault(e); return false; } } } public bool IsVanillaCameraLocked { get { try { StartOfRound instance = StartOfRound.Instance; if ((Object)(object)instance == (Object)null) { return true; } if (instance.overrideSpectateCamera || instance.shipIsLeaving || instance.allPlayersDead) { return true; } PlayerControllerB val = GameNetworkManager.Instance?.localPlayerController; if ((Object)(object)val == (Object)null) { return true; } return val.isInGameOverAnimation > 0f && ((Object)(object)val.deadBody != (Object)null || (Object)(object)val.overrideGameOverSpectatePivot != (Object)null); } catch (Exception e) { Fault(e); return true; } } } public PlayerControllerB CurrentTarget { get { try { return GameNetworkManager.Instance?.localPlayerController?.spectatedPlayerScript; } catch (Exception e) { Fault(e); return null; } } } public int SlotCount { get { try { return (StartOfRound.Instance?.allPlayerScripts?.Length).GetValueOrDefault(); } catch (Exception e) { Fault(e); return 0; } } } public SpectateCameraGateway() { Instance = this; if (_resolveFailed) { _faulted = true; } } private static FieldRef ResolveDeadTimer() { try { return AccessTools.FieldRefAccess("spectatedPlayerDeadTimer"); } catch (Exception ex) { _resolveFailed = true; Plugin.Log.LogError((object)("Could not resolve PlayerControllerB.spectatedPlayerDeadTimer (game update?): " + ex.Message)); return null; } } public int GetCandidates(CandidateSnapshot[] buffer) { //IL_009f: Unknown result type (might be due to invalid IL or missing references) //IL_00a4: Unknown result type (might be due to invalid IL or missing references) try { StartOfRound instance = StartOfRound.Instance; PlayerControllerB val = GameNetworkManager.Instance?.localPlayerController; if ((Object)(object)instance == (Object)null || (Object)(object)val == (Object)null || instance.allPlayerScripts == null) { return 0; } PlayerControllerB[] allPlayerScripts = instance.allPlayerScripts; int num = 0; for (int i = 0; i < allPlayerScripts.Length; i++) { if (num >= buffer.Length) { break; } PlayerControllerB val2 = allPlayerScripts[i]; if (!((Object)(object)val2 == (Object)null) && !val2.isPlayerDead && val2.isPlayerControlled && val2 != val) { buffer[num].Player = val2; buffer[num].Slot = i; buffer[num].Position = ((Component)val2).transform.position; buffer[num].InsideFactory = val2.isInsideFactory; buffer[num].InElevator = val2.isInElevator; buffer[num].InShipRoom = val2.isInHangarShipRoom; num++; } } return num; } catch (Exception e) { Fault(e); return 0; } } public bool SwitchTo(PlayerControllerB target) { if (_faulted || DeadTimerRef == null) { return false; } try { PlayerControllerB val = GameNetworkManager.Instance?.localPlayerController; if ((Object)(object)val == (Object)null || !IsLocalPlayerSpectating) { return false; } if ((Object)(object)target == (Object)null || target.isPlayerDead || !target.isPlayerControlled) { return false; } if (target == val.spectatedPlayerScript) { return false; } _applyingSwitch = true; try { val.spectatedPlayerScript = target; DeadTimerRef.Invoke(val) = 0f; StartOfRound.Instance.SetPlayerSafeInShip(); val.SetSpectatedPlayerEffects(false); } finally { _applyingSwitch = false; } return true; } catch (Exception e) { Fault(e); return false; } } public bool ConsumeVanillaSwitch(out PlayerControllerB newTarget, out bool wasManual) { newTarget = _pendingTarget; wasManual = _pendingManual; bool pendingVanillaSwitch = _pendingVanillaSwitch; _pendingVanillaSwitch = false; _pendingTarget = null; return pendingVanillaSwitch; } internal static void NotifyVanillaSpectateNext(PlayerControllerB instance, bool getClosest) { SpectateCameraGateway instance2 = Instance; if (instance2 == null || instance2._applyingSwitch) { return; } try { PlayerControllerB val = GameNetworkManager.Instance?.localPlayerController; if (!((Object)(object)val == (Object)null) && instance == val) { instance2._pendingVanillaSwitch = true; instance2._pendingTarget = val.spectatedPlayerScript; instance2._pendingManual = !getClosest; } } catch (Exception e) { instance2.Fault(e); } } private void Fault(Exception e) { _faultCount++; _faulted = true; float unscaledTime = Time.unscaledTime; if (unscaledTime - _lastFaultLog > 60f) { _lastFaultLog = unscaledTime; Plugin.Log.LogError((object)$"Spectate camera gateway fault #{_faultCount}; auto-spectate paused: {e}"); } } } } namespace LethalSpectator.Modules.Input { internal static class InputUtilsBindings { private static LethalSpectatorInputActions _actions; public static void Init(Action onRawPress) { if (_actions != null) { return; } _actions = new LethalSpectatorInputActions(); _actions.Toggle.performed += delegate(CallbackContext ctx) { if (((CallbackContext)(ref ctx)).performed) { onRawPress(); } }; } public static string GetDisplayName() { //IL_0016: Unknown result type (might be due to invalid IL or missing references) //IL_001b: Unknown result type (might be due to invalid IL or missing references) //IL_0028: Unknown result type (might be due to invalid IL or missing references) //IL_002d: 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_0036: Unknown result type (might be due to invalid IL or missing references) InputAction val = _actions?.Toggle; if (val == null || val.bindings.Count == 0) { return null; } InputBinding val2 = val.bindings[0]; return InputControlPath.ToHumanReadableString(((InputBinding)(ref val2)).effectivePath, (HumanReadableStringOptions)2, (InputControl)null); } } public class LethalSpectatorInputActions : LcInputActions { [InputAction("/h", Name = "Toggle auto spectate", GamepadPath = "/leftStickPress")] public InputAction Toggle { get; set; } } public sealed class ToggleInput : IToggleInput { private readonly ISpectateCameraGateway _gateway; private readonly IExternalCameraOwner _external; private readonly bool _viaInputUtils; private Key _fallbackKey = (Key)22; private bool _fallbackDisabled; private string _displayName; private float _displayNameRefreshedAt = -999f; private float _lastGuardErrorLog = -999f; public string BindingDisplayName { get { float unscaledTime = Time.unscaledTime; if (_displayName == null || unscaledTime - _displayNameRefreshedAt >= 1f) { _displayNameRefreshedAt = unscaledTime; string text = null; if (_viaInputUtils) { try { text = InputUtilsBindings.GetDisplayName(); } catch (Exception) { } } _displayName = ((!string.IsNullOrEmpty(text)) ? text : (ModConfig.ToggleKey?.Value ?? "H")); } return _displayName; } } public event Action TogglePressed; public ToggleInput(ISpectateCameraGateway gateway, IExternalCameraOwner external) { //IL_0003: Unknown result type (might be due to invalid IL or missing references) _gateway = gateway; _external = external; if (Chainloader.PluginInfos.ContainsKey("com.rune580.LethalCompanyInputUtils")) { try { InputUtilsBindings.Init(OnRawPress); _viaInputUtils = true; Plugin.Log.LogInfo((object)"Toggle bound through InputUtils (rebindable in the Keybinds menu)."); } catch (Exception ex) { Plugin.Log.LogWarning((object)("InputUtils binding failed; falling back to raw keyboard polling: " + ex.Message)); } } if (!_viaInputUtils) { string text = ModConfig.ToggleKey?.Value ?? "H"; if (!Enum.TryParse(text, ignoreCase: true, out _fallbackKey)) { _fallbackDisabled = true; Plugin.Log.LogError((object)("General/ToggleKey '" + text + "' is not a UnityEngine.InputSystem.Key name; the toggle is disabled (IH-1.2).")); } } } public void Poll() { //IL_001d: Unknown result type (might be due to invalid IL or missing references) if (_viaInputUtils || _fallbackDisabled) { return; } try { Keyboard current = Keyboard.current; if (current != null && ((ButtonControl)current[_fallbackKey]).wasPressedThisFrame) { OnRawPress(); } } catch (Exception ex) { _fallbackDisabled = true; Plugin.Log.LogError((object)("Keyboard polling failed; the toggle is disabled: " + ex.Message)); } } private void OnRawPress() { try { if (_gateway.IsLocalPlayerSpectating) { PlayerControllerB val = GameNetworkManager.Instance?.localPlayerController; if (!((Object)(object)val == (Object)null) && (!((Object)(object)val.quickMenuManager != (Object)null) || !val.quickMenuManager.isMenuOpen) && !val.isTypingChat && !val.inTerminalMenu && !val.inSpecialMenu && !_external.BlocksInput) { this.TogglePressed?.Invoke(); } } } catch (Exception arg) { float unscaledTime = Time.unscaledTime; if (unscaledTime - _lastGuardErrorLog > 60f) { _lastGuardErrorLog = unscaledTime; Plugin.Log.LogError((object)$"Toggle guard evaluation failed: {arg}"); } } } } } namespace LethalSpectator.Modules.Hud { [HarmonyPatch(typeof(HUDManager), "Update")] [HarmonyAfter(new string[] { "SpectateEnemy" })] internal static class HUDManager_Update_Patch { private static void Postfix(HUDManager __instance) { HudStatus.AppendStatus(__instance); } } [HarmonyPatch(typeof(HUDManager), "SetSpectatingTextToPlayer")] internal static class HUDManager_SetSpectatingTextToPlayer_Patch { private static void Postfix(HUDManager __instance, PlayerControllerB playerScript) { HudStatus.AppendAutoMarker(__instance, playerScript); } } public sealed class HudStatus : IHudStatus { internal static HudStatus Instance; internal string CachedAppend; internal bool AutoMarker; private static readonly MethodInfo DisplaySpectatorTip = AccessTools.Method(typeof(HUDManager), "DisplaySpectatorTip", (Type[])null, (Type[])null); private readonly object[] _tipArgs = new object[1]; private float _lastTipTime = -999f; private float _lastErrorLog = -999f; private int _errorCount; public HudStatus() { Instance = this; if (DisplaySpectatorTip == null) { Plugin.Log.LogWarning((object)"HUDManager.DisplaySpectatorTip not found (game update?); tips disabled."); } } public void SetStatusLine(string text) { CachedAppend = (string.IsNullOrEmpty(text) ? null : ("\n" + text)); } public void SetAutoMarker(bool targetChosenByMod) { AutoMarker = targetChosenByMod; } public void ShowTip(string text) { try { if (DisplaySpectatorTip == null || !ModConfig.ShowTips.Value) { return; } float unscaledTime = Time.unscaledTime; if (!(unscaledTime - _lastTipTime < 2f)) { HUDManager instance = HUDManager.Instance; if (!((Object)(object)instance == (Object)null)) { _lastTipTime = unscaledTime; _tipArgs[0] = text; DisplaySpectatorTip.Invoke(instance, _tipArgs); } } } catch (Exception e) { LogOnce(e); } } public void SetSpectatingLabel(string text) { try { if (!string.IsNullOrEmpty(text)) { HUDManager instance = HUDManager.Instance; if (!((Object)(object)instance == (Object)null) && !((Object)(object)instance.spectatingPlayerText == (Object)null)) { ((TMP_Text)instance.spectatingPlayerText).text = text; } } } catch (Exception e) { LogOnce(e); } } internal static void AppendStatus(HUDManager hud) { HudStatus instance = Instance; if (instance == null || instance.CachedAppend == null) { return; } try { if (!ModConfig.ShowHudStatus.Value) { return; } PlayerControllerB val = GameNetworkManager.Instance?.localPlayerController; if ((Object)(object)val == (Object)null || !val.isPlayerDead) { return; } StartOfRound instance2 = StartOfRound.Instance; if (!((Object)(object)instance2 == (Object)null) && !instance2.shipIsLeaving) { TextMeshProUGUI holdButtonToEndGameEarlyText = hud.holdButtonToEndGameEarlyText; if (!((Object)(object)holdButtonToEndGameEarlyText == (Object)null)) { ((TMP_Text)holdButtonToEndGameEarlyText).text = ((TMP_Text)holdButtonToEndGameEarlyText).text + instance.CachedAppend; } } } catch (Exception e) { instance.LogOnce(e); } } internal static void AppendAutoMarker(HUDManager hud, PlayerControllerB playerScript) { HudStatus instance = Instance; if (instance == null || !instance.AutoMarker) { return; } try { if (ModConfig.ShowHudStatus.Value && !((Object)(object)playerScript == (Object)null)) { TextMeshProUGUI spectatingPlayerText = hud.spectatingPlayerText; if (!((Object)(object)spectatingPlayerText == (Object)null)) { ((TMP_Text)spectatingPlayerText).text = ((TMP_Text)spectatingPlayerText).text + " [auto]"; } } } catch (Exception e) { instance.LogOnce(e); } } private void LogOnce(Exception e) { _errorCount++; float unscaledTime = Time.unscaledTime; if (unscaledTime - _lastErrorLog > 60f) { _lastErrorLog = unscaledTime; Plugin.Log.LogError((object)$"HUD status error #{_errorCount} (HUD output degraded): {e}"); } } } } namespace LethalSpectator.Modules.Danger { public sealed class EnemyProximitySource : IDangerSource { private const float RecomputeIntervalSeconds = 0.25f; private DangerSample[] _slotCache = new DangerSample[0]; private float _lastComputeTime = -999f; private int _errorCount; private float _lastErrorLog = -999f; public void Sample(CandidateSnapshot[] candidates, int count, DangerSample[] output, float now) { if (!ModConfig.DangerEnabled.Value) { for (int i = 0; i < count; i++) { output[i] = default(DangerSample); } return; } EnsureSlots(candidates, count); if (now - _lastComputeTime >= 0.25f) { _lastComputeTime = now; Recompute(candidates, count); } for (int j = 0; j < count; j++) { output[j] = _slotCache[candidates[j].Slot]; } } private void Recompute(CandidateSnapshot[] candidates, int count) { //IL_00a9: Unknown result type (might be due to invalid IL or missing references) //IL_00ae: Unknown result type (might be due to invalid IL or missing references) //IL_00d1: Unknown result type (might be due to invalid IL or missing references) //IL_00d6: Unknown result type (might be due to invalid IL or missing references) //IL_00d8: Unknown result type (might be due to invalid IL or missing references) //IL_00dd: Unknown result type (might be due to invalid IL or missing references) for (int i = 0; i < count; i++) { _slotCache[candidates[i].Slot] = default(DangerSample); } try { RoundManager instance = RoundManager.Instance; List list = (((Object)(object)instance != (Object)null) ? instance.SpawnedEnemies : null); if (list == null) { return; } for (int j = 0; j < list.Count; j++) { EnemyAI val = list[j]; if ((Object)(object)val == (Object)null || val.isEnemyDead) { continue; } EnemyType enemyType = val.enemyType; if ((Object)(object)enemyType == (Object)null || enemyType.isDaytimeEnemy) { continue; } bool flag = !val.isOutside; Vector3 position = ((Component)val).transform.position; for (int k = 0; k < count; k++) { if (candidates[k].InsideFactory == flag) { Vector3 val2 = candidates[k].Position - position; float sqrMagnitude = ((Vector3)(ref val2)).sqrMagnitude; int slot = candidates[k].Slot; if (!_slotCache[slot].HasThreat || sqrMagnitude < _slotCache[slot].NearestEnemyDistance) { _slotCache[slot].HasThreat = true; _slotCache[slot].NearestEnemyDistance = sqrMagnitude; } } } } for (int l = 0; l < count; l++) { int slot2 = candidates[l].Slot; if (_slotCache[slot2].HasThreat) { _slotCache[slot2].NearestEnemyDistance = Mathf.Sqrt(_slotCache[slot2].NearestEnemyDistance); } } } catch (Exception ex) { for (int m = 0; m < count; m++) { _slotCache[candidates[m].Slot] = default(DangerSample); } _errorCount++; float unscaledTime = Time.unscaledTime; if (unscaledTime - _lastErrorLog > 60f) { _lastErrorLog = unscaledTime; Plugin.Log.LogError((object)$"Danger scan error #{_errorCount} (degrading to no-threat, LC-5.2): {ex.Message}"); } } } private void EnsureSlots(CandidateSnapshot[] candidates, int count) { int num = 0; for (int i = 0; i < count; i++) { if (candidates[i].Slot + 1 > num) { num = candidates[i].Slot + 1; } } if (_slotCache.Length < num) { DangerSample[] array = new DangerSample[num]; for (int j = 0; j < _slotCache.Length; j++) { array[j] = _slotCache[j]; } _slotCache = array; } } } } namespace LethalSpectator.Modules.Compat { public static class LobbyCompatibilityBridge { private const string GUID = "BMX.LobbyCompatibility"; public static void TryRegister() { try { if (!Chainloader.PluginInfos.TryGetValue("BMX.LobbyCompatibility", out var value) || (Object)(object)value.Instance == (Object)null) { return; } Assembly assembly = ((object)value.Instance).GetType().Assembly; Type type = assembly.GetType("LobbyCompatibility.Features.PluginHelper"); Type type2 = assembly.GetType("LobbyCompatibility.Enums.CompatibilityLevel"); Type type3 = assembly.GetType("LobbyCompatibility.Enums.VersionStrictness"); if (!(type == null) && !(type2 == null) && !(type3 == null)) { MethodInfo method = type.GetMethod("RegisterPlugin", BindingFlags.Static | BindingFlags.Public, null, new Type[4] { typeof(string), typeof(Version), type2, type3 }, null); if (!(method == null)) { method.Invoke(null, new object[4] { "Haaylo.LethalSpectator", Version.Parse("0.2.0"), Enum.Parse(type2, "ClientOnly"), Enum.Parse(type3, "None") }); Plugin.Log.LogInfo((object)"Registered with LobbyCompatibility (ClientOnly / None)."); } } } catch (Exception ex) { Plugin.Log.LogDebug((object)("LobbyCompatibility registration skipped: " + ex.Message)); } } } public sealed class SpectateEnemiesBridge : IExternalCameraOwner { private bool _presenceChecked; private bool _present; private bool _resolved; private int _throwCount; private FieldInfo _instanceField; private FieldRef _spectatingRef; private MethodInfo _isMenuOpenMethod; private object _cachedInstance; private Func _isMenuOpenDelegate; private int _sampledFrame = -1; private bool _ownsCamera; private bool _blocksInput; public string Name => "SpectateEnemies"; public bool IsPresent { get { EnsureResolved(); return _present; } } public bool OwnsCamera { get { SampleFrame(); return _ownsCamera; } } public bool BlocksInput { get { SampleFrame(); return _blocksInput; } } private void EnsureResolved() { if (_presenceChecked) { return; } _presenceChecked = true; try { if (!Chainloader.PluginInfos.TryGetValue("SpectateEnemy", out var value) || (Object)(object)value.Instance == (Object)null) { _present = false; return; } Type type = ((object)value.Instance).GetType().Assembly.GetType("SpectateEnemy.SpectateEnemies"); if (type == null) { throw new MissingMemberException("type SpectateEnemy.SpectateEnemies"); } _instanceField = type.GetField("Instance", BindingFlags.Static | BindingFlags.Public) ?? throw new MissingMemberException("SpectateEnemies.Instance"); _spectatingRef = AccessTools.FieldRefAccess(type, "SpectatingEnemies"); _isMenuOpenMethod = type.GetMethod("IsMenuOpen", BindingFlags.Instance | BindingFlags.Public) ?? throw new MissingMemberException("SpectateEnemies.IsMenuOpen"); _present = true; _resolved = true; Plugin.Log.LogInfo((object)"SpectateEnemies bridge bound (yielding the camera while it spectates enemies)."); } catch (Exception ex) { _present = false; Plugin.Log.LogWarning((object)("SpectateEnemies present but its API could not be bound; treating as absent: " + ex.Message)); } } private void SampleFrame() { int frameCount = Time.frameCount; if (frameCount == _sampledFrame) { return; } _sampledFrame = frameCount; _ownsCamera = false; _blocksInput = false; EnsureResolved(); if (!_present || !_resolved) { return; } try { object value = _instanceField.GetValue(null); if (value != null) { _ownsCamera = _spectatingRef.Invoke(value); if (value != _cachedInstance) { _cachedInstance = value; _isMenuOpenDelegate = (Func)Delegate.CreateDelegate(typeof(Func), value, _isMenuOpenMethod); } _blocksInput = _isMenuOpenDelegate(); } } catch (Exception ex) { _throwCount++; if (_throwCount >= 3) { _present = false; Plugin.Log.LogError((object)$"SpectateEnemies bridge failed {_throwCount} times; treating as absent: {ex.Message}"); } } } } } namespace LethalSpectator.Core { public sealed class ActivityScorer { private CandidateState[] _states = new CandidateState[0]; public float[] Scores = new float[0]; public int[] Neighbors = new int[0]; private const float StaleSeconds = 5f; private const float TalkTimeClamp = 3f; public void EnsureCapacity(int slotCount, int candidateCapacity) { if (_states.Length < slotCount) { CandidateState[] array = new CandidateState[slotCount]; for (int i = 0; i < _states.Length; i++) { array[i] = _states[i]; } for (int j = _states.Length; j < slotCount; j++) { array[j] = new CandidateState(); } _states = array; } if (Scores.Length < candidateCapacity) { Scores = new float[candidateCapacity]; Neighbors = new int[candidateCapacity]; } } public void ResetAll(float now) { for (int i = 0; i < _states.Length; i++) { _states[i].Reset(null, now); } } public CandidateState GetState(int slot) { if (slot < 0 || slot >= _states.Length) { return null; } return _states[slot]; } public void Update(CandidateSnapshot[] candidates, int count, VoiceSample[] samples, DangerSample[] danger, float now, float dt, in DecisionSettings s) { //IL_0229: Unknown result type (might be due to invalid IL or missing references) //IL_0236: Unknown result type (might be due to invalid IL or missing references) //IL_023b: Unknown result type (might be due to invalid IL or missing references) //IL_0240: Unknown result type (might be due to invalid IL or missing references) EnsureCapacity(RequiredSlots(candidates, count), count); for (int i = 0; i < count; i++) { int slot = candidates[i].Slot; CandidateState candidateState = _states[slot]; if (candidateState.PlayerRef != candidates[i].Player || now - candidateState.LastSeenTime > 5f) { candidateState.Reset(candidates[i].Player, now); } candidateState.LastSeenTime = now; VoiceSample voiceSample = samples[i]; float num = (voiceSample.Available ? voiceSample.Amplitude : 0f); if (num >= candidateState.Envelope) { candidateState.Envelope = num; candidateState.HoldUntil = now + s.EnvelopeHoldSeconds; } else if (now >= candidateState.HoldUntil) { candidateState.Envelope *= Mathf.Exp((0f - dt) / Mathf.Max(0.01f, s.SmoothingReleaseSeconds)); if (candidateState.Envelope < 1E-05f) { candidateState.Envelope = 0f; } } bool flag = voiceSample.IsMuted && !s.IncludeLocallyMuted; bool flag2 = voiceSample.Available && voiceSample.IsSpeaking && !flag; if (!candidateState.Talking) { if (flag2 && candidateState.Envelope >= s.SpeakingOnThreshold) { candidateState.Talking = true; candidateState.TalkStartTime = now; } } else if (!flag2 || candidateState.Envelope < s.SpeakingOffThreshold) { candidateState.Talking = false; } if (candidateState.Talking) { candidateState.TalkTime = Mathf.Min(3f, candidateState.TalkTime + dt); candidateState.QuietTime = 0f; continue; } candidateState.QuietTime += dt; if (candidateState.QuietTime >= s.InactivityTimeoutSeconds) { candidateState.TalkTime = 0f; } } float num2 = s.GroupRadiusMeters * s.GroupRadiusMeters; for (int j = 0; j < count; j++) { int num3 = 0; for (int k = 0; k < count; k++) { if (k != j && (!s.RequireSameInterior || candidates[k].InsideFactory == candidates[j].InsideFactory)) { Vector3 val = candidates[k].Position - candidates[j].Position; if (((Vector3)(ref val)).sqrMagnitude <= num2) { num3++; } } } Neighbors[j] = num3; CandidateState candidateState2 = _states[candidates[j].Slot]; float num4 = Mathf.Min(s.GroupBonusCap, s.GroupBonusPerNeighbor * (float)num3); float num5 = (candidateState2.Talking ? (candidateState2.Envelope * (1f + num4)) : 0f); if (num5 > 0f && s.DangerEnabled && s.DangerWeight > 0f && danger[j].HasThreat) { float num6 = Mathf.Clamp01(1f - danger[j].NearestEnemyDistance / Mathf.Max(1f, s.DangerRadiusMeters)); num5 *= 1f + s.DangerWeight * num6; } Scores[j] = num5; } } private static int RequiredSlots(CandidateSnapshot[] candidates, int count) { int num = 0; for (int i = 0; i < count; i++) { if (candidates[i].Slot + 1 > num) { num = candidates[i].Slot + 1; } } return num; } } public sealed class AutoSpectateController { public readonly ActivityScorer Scorer = new ActivityScorer(); private float _lastSwitchTime; private float _lastManualSwitchTime; private float _lastDangerSwitchTime; private bool _pendingResume; private float _pendingResumeUntil; private const float NegInf = -1E+09f; public bool TargetChosenByMod { get; private set; } public void StartSession(float now) { _lastSwitchTime = now; _lastManualSwitchTime = -1E+09f; _lastDangerSwitchTime = -1E+09f; _pendingResume = false; TargetChosenByMod = false; Scorer.ResetAll(now); } public void NotifyVanillaSwitch(bool wasManual, float now) { _lastSwitchTime = now; if (wasManual) { _lastManualSwitchTime = now; } TargetChosenByMod = false; } public void NotifyExternalReleased(float now) { _lastSwitchTime = -1E+09f; _lastManualSwitchTime = -1E+09f; _pendingResume = true; _pendingResumeUntil = now + 1.5f; } public void CommitSwitch(float now, DecisionReason reason) { _lastSwitchTime = now; TargetChosenByMod = true; _pendingResume = false; if (reason == DecisionReason.Danger) { _lastDangerSwitchTime = now; } } public float ManualHoldRemaining(float now, in DecisionSettings s) { float num = s.ManualHoldSeconds - (now - _lastManualSwitchTime); if (!(num > 0f)) { return 0f; } return num; } public SpectateDecision Tick(float now, float dt, CandidateSnapshot[] candidates, int count, VoiceSample[] samples, DangerSample[] danger, PlayerControllerB currentTarget, in DecisionSettings s) { Scorer.Update(candidates, count, samples, danger, now, dt, in s); int num = -1; if (currentTarget != null) { for (int i = 0; i < count; i++) { if (candidates[i].Player == currentTarget) { num = i; break; } } } bool flag = now - _lastManualSwitchTime < s.ManualHoldSeconds; bool flag2 = now - _lastSwitchTime < s.MinHoldSeconds; SpectateDecision none = SpectateDecision.None; if (num < 0) { int num2 = BestCandidate(candidates, count, -1, requireSustained: false, in s); if (num2 < 0) { num2 = MostEndangered(candidates, count, danger, -1, in s); } if (num2 >= 0) { none.SwitchTo = candidates[num2].Player; none.SwitchSlot = candidates[num2].Slot; none.Reason = DecisionReason.Retarget; none.ChallengerScore = Scorer.Scores[num2]; } } else if (!flag && !flag2) { CandidateState state = Scorer.GetState(candidates[num].Slot); float num3 = Scorer.Scores[num]; bool num4 = state.QuietTime >= s.InactivityTimeoutSeconds; int num5 = BestCandidate(candidates, count, num, requireSustained: false, in s); int num6 = BestCandidate(candidates, count, num, requireSustained: true, in s); if (num4) { int num7 = (s.SnapToFirstSpeaker ? num5 : num6); if (num7 >= 0) { none.Reason = DecisionReason.Inactivity; } else if (num5 < 0 && s.DangerEnabled && now - _lastDangerSwitchTime >= s.DangerHoldSeconds) { num7 = MostEndangered(candidates, count, danger, num, in s); if (num7 >= 0 && danger[num].HasThreat && danger[num].NearestEnemyDistance <= danger[num7].NearestEnemyDistance) { num7 = -1; } if (num7 >= 0) { none.Reason = DecisionReason.Danger; } } if (none.Reason != DecisionReason.None) { none.SwitchTo = candidates[num7].Player; none.SwitchSlot = candidates[num7].Slot; none.ChallengerScore = Scorer.Scores[num7]; none.IncumbentScore = num3; none.IncumbentQuietTime = state.QuietTime; } } else if (state.Talking && num6 >= 0 && Scorer.Scores[num6] >= num3 * s.LouderSwitchRatio) { none.Reason = DecisionReason.Louder; none.SwitchTo = candidates[num6].Player; none.SwitchSlot = candidates[num6].Slot; none.ChallengerScore = Scorer.Scores[num6]; none.IncumbentScore = num3; none.IncumbentQuietTime = state.QuietTime; } } if (none.Reason != DecisionReason.None && none.Reason != DecisionReason.Danger && _pendingResume && now <= _pendingResumeUntil) { none.Reason = DecisionReason.ResumeFromExternal; } if (_pendingResume && now > _pendingResumeUntil) { _pendingResume = false; } return none; } private static int MostEndangered(CandidateSnapshot[] candidates, int count, DangerSample[] danger, int excludeIdx, in DecisionSettings s) { if (!s.DangerEnabled) { return -1; } int num = -1; for (int i = 0; i < count; i++) { if (i != excludeIdx && danger[i].HasThreat && !(danger[i].NearestEnemyDistance > s.DangerSwitchDistance) && (num < 0 || danger[i].NearestEnemyDistance < danger[num].NearestEnemyDistance || (danger[i].NearestEnemyDistance == danger[num].NearestEnemyDistance && candidates[i].Slot < candidates[num].Slot))) { num = i; } } return num; } private int BestCandidate(CandidateSnapshot[] candidates, int count, int excludeIdx, bool requireSustained, in DecisionSettings s) { int num = -1; float num2 = 0f; float num3 = 0f; for (int i = 0; i < count; i++) { if (i == excludeIdx) { continue; } CandidateState state = Scorer.GetState(candidates[i].Slot); if (state != null && state.Talking && (!requireSustained || !(state.TalkTime < s.MinSpeechBeforeSwitchSeconds))) { float num4 = Scorer.Scores[i]; if (!(num4 <= 0f) && (num < 0 || num4 > num2 || (num4 == num2 && (state.TalkStartTime > num3 || (state.TalkStartTime == num3 && candidates[i].Slot < candidates[num].Slot))))) { num = i; num2 = num4; num3 = state.TalkStartTime; } } } return num; } } public sealed class CandidateState { public PlayerControllerB PlayerRef; public float Envelope; public float HoldUntil; public bool Talking; public float TalkTime; public float QuietTime; public float TalkStartTime; public float LastSeenTime; public void Reset(PlayerControllerB player, float now) { PlayerRef = player; Envelope = 0f; HoldUntil = 0f; Talking = false; TalkTime = 0f; QuietTime = 0f; TalkStartTime = 0f; LastSeenTime = now; } } public enum DecisionReason { None, Retarget, Inactivity, Louder, ResumeFromExternal, Danger } public struct DecisionSettings { public float SmoothingReleaseSeconds; public float EnvelopeHoldSeconds; public float SpeakingOnThreshold; public float SpeakingOffThreshold; public float MinSpeechBeforeSwitchSeconds; public bool IncludeLocallyMuted; public float MinHoldSeconds; public float InactivityTimeoutSeconds; public float LouderSwitchRatio; public float ManualHoldSeconds; public bool SnapToFirstSpeaker; public bool DangerEnabled; public float DangerRadiusMeters; public float DangerWeight; public float DangerSwitchDistance; public float DangerHoldSeconds; public float GroupRadiusMeters; public float GroupBonusPerNeighbor; public float GroupBonusCap; public bool RequireSameInterior; public static DecisionSettings FromConfig() { DecisionSettings result = default(DecisionSettings); result.SmoothingReleaseSeconds = ModConfig.SmoothingReleaseSeconds.Value; result.EnvelopeHoldSeconds = ModConfig.EnvelopeHoldSeconds.Value; result.SpeakingOnThreshold = ModConfig.SpeakingOnThreshold.Value; result.SpeakingOffThreshold = ModConfig.SpeakingOffThreshold.Value; result.MinSpeechBeforeSwitchSeconds = ModConfig.MinSpeechBeforeSwitchSeconds.Value; result.IncludeLocallyMuted = ModConfig.IncludeLocallyMuted.Value; result.MinHoldSeconds = ModConfig.MinHoldSeconds.Value; result.InactivityTimeoutSeconds = ModConfig.InactivityTimeoutSeconds.Value; result.LouderSwitchRatio = ModConfig.LouderSwitchRatio.Value; result.ManualHoldSeconds = ModConfig.ManualHoldSeconds.Value; result.SnapToFirstSpeaker = ModConfig.SnapToFirstSpeaker.Value; result.DangerEnabled = ModConfig.DangerEnabled.Value; result.DangerRadiusMeters = ModConfig.DangerRadiusMeters.Value; result.DangerWeight = ModConfig.DangerWeight.Value; result.DangerSwitchDistance = ModConfig.DangerSwitchDistance.Value; result.DangerHoldSeconds = ModConfig.DangerHoldSeconds.Value; result.GroupRadiusMeters = ModConfig.GroupRadiusMeters.Value; result.GroupBonusPerNeighbor = ModConfig.GroupBonusPerNeighbor.Value; result.GroupBonusCap = ModConfig.GroupBonusCap.Value; result.RequireSameInterior = ModConfig.RequireSameInterior.Value; return result; } } public struct SpectateDecision { public PlayerControllerB SwitchTo; public int SwitchSlot; public DecisionReason Reason; public float ChallengerScore; public float IncumbentScore; public float IncumbentQuietTime; public static readonly SpectateDecision None = new SpectateDecision { SwitchSlot = -1 }; } public class SpectatorRunner : MonoBehaviour { private enum StatusKind { None, On, Off, ManualHold, PausedExternal, KillerCam, Unavailable } private static ISpectateCameraGateway _sGateway; private static IVoiceActivitySource _sSampler; private static IDangerSource _sDanger; private static IKillerCamera _sKillerCam; private static IExternalCameraOwner _sExternal; private static IToggleInput _sInput; private static IHudStatus _sHud; private static AutoSpectateController _sController; private ISpectateCameraGateway _gateway; private IVoiceActivitySource _sampler; private IDangerSource _danger; private IKillerCamera _killerCam; private IExternalCameraOwner _external; private IToggleInput _input; private IHudStatus _hud; private AutoSpectateController _controller; private CandidateSnapshot[] _candidates = new CandidateSnapshot[0]; private VoiceSample[] _samples = new VoiceSample[0]; private DangerSample[] _dangerSamples = new DangerSample[0]; private bool _enabled; private bool _wasSpectating; private bool _sessionStarted; private bool _prevOwnsCamera; private bool _loggedGameVersion; private bool _faultAnnounced; private StatusKind _statusKind; private int _statusSeconds; private string _statusBinding; private float _lastScoreDump; private float _lastTickErrorLog = -999f; private int _tickErrorCount; private readonly StringBuilder _dump = new StringBuilder(512); public static void Wire(ISpectateCameraGateway gateway, IVoiceActivitySource sampler, IDangerSource dangerSource, IKillerCamera killerCam, IExternalCameraOwner external, IToggleInput input, IHudStatus hud, AutoSpectateController controller) { _sGateway = gateway; _sSampler = sampler; _sDanger = dangerSource; _sKillerCam = killerCam; _sExternal = external; _sInput = input; _sHud = hud; _sController = controller; } private void Awake() { _gateway = _sGateway; _sampler = _sSampler; _danger = _sDanger; _killerCam = _sKillerCam; _external = _sExternal; _input = _sInput; _hud = _sHud; _controller = _sController; _enabled = !ModConfig.IsBound || ModConfig.EnabledByDefault.Value; if (_input != null) { _input.TogglePressed += OnTogglePressed; } Plugin.Log.LogInfo((object)("Runner alive; auto spectate starts " + (_enabled ? "ON" : "OFF") + ".")); } private void OnTogglePressed() { _enabled = !_enabled; Plugin.Log.LogInfo((object)("Auto spectate toggled " + (_enabled ? "ON" : "OFF") + ".")); _hud.ShowTip(_enabled ? "Auto spectate ON" : "Auto spectate OFF"); } private void Update() { try { LogGameVersionOnce(); if (_gateway == null) { return; } if (_gateway.Faulted) { if (!_faultAnnounced) { _faultAnnounced = true; _hud.SetStatusLine("Auto spectate: unavailable"); Plugin.Log.LogError((object)"Gateway faulted; auto spectate is unavailable for this session."); } return; } if (!_gateway.IsLocalPlayerSpectating) { if (_wasSpectating) { EndSession(); } return; } _wasSpectating = true; float unscaledTime = Time.unscaledTime; float unscaledDeltaTime = Time.unscaledDeltaTime; _input.Poll(); bool isVanillaCameraLocked = _gateway.IsVanillaCameraLocked; if (!_sessionStarted) { if (isVanillaCameraLocked) { return; } _sessionStarted = true; _controller.StartSession(unscaledTime); _killerCam.ResetSession(); _hud.SetAutoMarker(targetChosenByMod: false); } PlayerControllerB newTarget; bool wasManual; bool flag = _gateway.ConsumeVanillaSwitch(out newTarget, out wasManual); if (flag) { _controller.NotifyVanillaSwitch(wasManual, unscaledTime); if (wasManual) { Plugin.Log.LogInfo((object)"Manual spectate cycle observed; holding auto switching (DC-4.1)."); } } bool ownsCamera = _external.OwnsCamera; if (_prevOwnsCamera && !ownsCamera) { _controller.NotifyExternalReleased(unscaledTime); } _prevOwnsCamera = ownsCamera; if (isVanillaCameraLocked) { _killerCam.Release("vanilla lock"); UpdateStatus(StatusKindFor(unscaledTime), unscaledTime); return; } if (ownsCamera) { _killerCam.Release("SpectateEnemies"); UpdateStatus(StatusKind.PausedExternal, unscaledTime); return; } if (!_enabled) { _killerCam.Release("toggle off"); UpdateStatus(StatusKind.Off, unscaledTime); return; } if (_killerCam.Engaged) { if (flag) { _killerCam.Release("manual/vanilla switch"); } else { _killerCam.Tick(unscaledTime); } if (_killerCam.Engaged) { UpdateStatus(StatusKind.KillerCam, unscaledTime); return; } } else if (_killerCam.TryEngage(_gateway.CurrentTarget, unscaledTime)) { _hud.SetSpectatingLabel("(Watching: " + _killerCam.KillerName + ")"); _killerCam.Tick(unscaledTime); UpdateStatus(StatusKind.KillerCam, unscaledTime); return; } DecisionSettings s = DecisionSettings.FromConfig(); EnsureBuffers(); int candidates = _gateway.GetCandidates(_candidates); if (candidates > 0) { _sampler.Sample(_candidates, candidates, _samples, unscaledTime); _danger.Sample(_candidates, candidates, _dangerSamples, unscaledTime); SpectateDecision spectateDecision = _controller.Tick(unscaledTime, unscaledDeltaTime, _candidates, candidates, _samples, _dangerSamples, _gateway.CurrentTarget, in s); if (spectateDecision.SwitchTo != null) { _hud.SetAutoMarker(targetChosenByMod: true); bool num = _gateway.SwitchTo(spectateDecision.SwitchTo); _hud.SetAutoMarker(targetChosenByMod: false); if (num) { _controller.CommitSwitch(unscaledTime, spectateDecision.Reason); Plugin.Log.LogInfo((object)($"Auto switch -> {spectateDecision.SwitchTo.playerUsername} ({spectateDecision.Reason}); " + $"challenger {spectateDecision.ChallengerScore:F4} vs incumbent {spectateDecision.IncumbentScore:F4}, " + $"incumbent quiet {spectateDecision.IncumbentQuietTime:F1}s")); } } DumpScores(unscaledTime, candidates, in s); } UpdateStatus(StatusKindFor(unscaledTime), unscaledTime); } catch (Exception arg) { _tickErrorCount++; float unscaledTime2 = Time.unscaledTime; if (unscaledTime2 - _lastTickErrorLog > 60f) { _lastTickErrorLog = unscaledTime2; Plugin.Log.LogError((object)$"Runner tick error #{_tickErrorCount}: {arg}"); } } } private StatusKind StatusKindFor(float now) { if (!_enabled) { return StatusKind.Off; } DecisionSettings s = DecisionSettings.FromConfig(); if (!(_controller.ManualHoldRemaining(now, in s) > 0f)) { return StatusKind.On; } return StatusKind.ManualHold; } private void EndSession() { _wasSpectating = false; _sessionStarted = false; _prevOwnsCamera = false; _killerCam.ResetSession(); _hud.SetStatusLine(null); _hud.SetAutoMarker(targetChosenByMod: false); _statusKind = StatusKind.None; } private void EnsureBuffers() { int slotCount = _gateway.SlotCount; if (slotCount > _candidates.Length) { _candidates = new CandidateSnapshot[slotCount]; _samples = new VoiceSample[slotCount]; _dangerSamples = new DangerSample[slotCount]; } } private void UpdateStatus(StatusKind kind, float now) { string bindingDisplayName = _input.BindingDisplayName; int num = 0; if (kind == StatusKind.ManualHold) { DecisionSettings s = DecisionSettings.FromConfig(); num = Mathf.CeilToInt(_controller.ManualHoldRemaining(now, in s)); if (num <= 0) { kind = StatusKind.On; } } if (kind != _statusKind || num != _statusSeconds || !string.Equals(bindingDisplayName, _statusBinding)) { _statusKind = kind; _statusSeconds = num; _statusBinding = bindingDisplayName; switch (kind) { case StatusKind.On: _hud.SetStatusLine("Auto spectate: ON [" + bindingDisplayName + "]"); break; case StatusKind.Off: _hud.SetStatusLine("Auto spectate: OFF [" + bindingDisplayName + "]"); break; case StatusKind.ManualHold: _hud.SetStatusLine($"Auto spectate: manual hold ({num}s) [{bindingDisplayName}]"); break; case StatusKind.PausedExternal: _hud.SetStatusLine("Auto spectate: paused (SpectateEnemies) [" + bindingDisplayName + "]"); break; case StatusKind.KillerCam: _hud.SetStatusLine("Auto spectate: killer cam [" + bindingDisplayName + "]"); break; default: _hud.SetStatusLine(null); break; } } } private void DumpScores(float now, int count, in DecisionSettings settings) { if (!ModConfig.LogScores.Value || now - _lastScoreDump < Mathf.Max(0.2f, ModConfig.LogScoresIntervalSeconds.Value)) { return; } _lastScoreDump = now; _dump.Length = 0; _dump.Append("scores:"); for (int i = 0; i < count; i++) { CandidateState state = _controller.Scorer.GetState(_candidates[i].Slot); if (state != null) { _dump.Append(" [").Append(_candidates[i].Player.playerUsername).Append(" amp=") .Append(_samples[i].Available ? _samples[i].Amplitude.ToString("F4") : "n/a") .Append(" env=") .Append(state.Envelope.ToString("F4")) .Append(state.Talking ? " talk=" : " quiet=") .Append((state.Talking ? state.TalkTime : state.QuietTime).ToString("F1")) .Append(" score=") .Append(_controller.Scorer.Scores[i].ToString("F4")) .Append(" nb=") .Append(_controller.Scorer.Neighbors[i]) .Append(" enemy=") .Append(_dangerSamples[i].HasThreat ? _dangerSamples[i].NearestEnemyDistance.ToString("F1") : "-") .Append(']'); } } Plugin.Log.LogDebug((object)_dump.ToString()); } private void LogGameVersionOnce() { if (!_loggedGameVersion) { GameNetworkManager instance = GameNetworkManager.Instance; if (!((Object)(object)instance == (Object)null)) { _loggedGameVersion = true; Plugin.Log.LogInfo((object)$"Game version (GameNetworkManager.gameVersionNum): {instance.gameVersionNum}"); } } } } } namespace LethalSpectator.Core.Abstractions { public struct CandidateSnapshot { public PlayerControllerB Player; public int Slot; public Vector3 Position; public bool InsideFactory; public bool InElevator; public bool InShipRoom; } public struct DangerSample { public bool HasThreat; public float NearestEnemyDistance; } public interface IDangerSource { void Sample(CandidateSnapshot[] candidates, int count, DangerSample[] output, float now); } public interface IExternalCameraOwner { string Name { get; } bool IsPresent { get; } bool OwnsCamera { get; } bool BlocksInput { get; } } public interface IHudStatus { void SetStatusLine(string text); void SetAutoMarker(bool targetChosenByMod); void ShowTip(string text); void SetSpectatingLabel(string text); } public interface IKillerCamera { bool Engaged { get; } string KillerName { get; } bool TryEngage(PlayerControllerB currentTarget, float now); void Tick(float now); void Release(string reason); void ResetSession(); } public interface ISpectateCameraGateway { bool Faulted { get; } bool IsLocalPlayerSpectating { get; } bool IsVanillaCameraLocked { get; } PlayerControllerB CurrentTarget { get; } int SlotCount { get; } int GetCandidates(CandidateSnapshot[] buffer); bool SwitchTo(PlayerControllerB target); bool ConsumeVanillaSwitch(out PlayerControllerB newTarget, out bool wasManual); } public interface IToggleInput { string BindingDisplayName { get; } event Action TogglePressed; void Poll(); } public interface IVoiceActivitySource { void Sample(CandidateSnapshot[] candidates, int count, VoiceSample[] output, float now); } public struct VoiceSample { public bool Available; public bool IsSpeaking; public float Amplitude; public bool IsMuted; public static readonly VoiceSample Unavailable; } } namespace LethalSpectator.Config { public static class ModConfig { public static ConfigEntry EnabledByDefault; public static ConfigEntry ToggleKey; public static ConfigEntry ShowHudStatus; public static ConfigEntry ShowTips; public static ConfigEntry LogScores; public static ConfigEntry LogScoresIntervalSeconds; public static ConfigEntry SmoothingReleaseSeconds; public static ConfigEntry EnvelopeHoldSeconds; public static ConfigEntry SpeakingOnThreshold; public static ConfigEntry SpeakingOffThreshold; public static ConfigEntry MinSpeechBeforeSwitchSeconds; public static ConfigEntry IncludeLocallyMuted; public static ConfigEntry MinHoldSeconds; public static ConfigEntry InactivityTimeoutSeconds; public static ConfigEntry LouderSwitchRatio; public static ConfigEntry ManualHoldSeconds; public static ConfigEntry SnapToFirstSpeaker; public static ConfigEntry DangerEnabled; public static ConfigEntry DangerRadiusMeters; public static ConfigEntry DangerWeight; public static ConfigEntry DangerSwitchDistance; public static ConfigEntry DangerHoldSeconds; public static ConfigEntry KillerCamEnabled; public static ConfigEntry KillerCamDurationSeconds; public static ConfigEntry KillerCamMaxAttributionDistance; public static ConfigEntry GroupRadiusMeters; public static ConfigEntry GroupBonusPerNeighbor; public static ConfigEntry GroupBonusCap; public static ConfigEntry RequireSameInterior; public static bool IsBound { get; private set; } public static void Bind(ConfigFile config) { EnabledByDefault = config.Bind("General", "EnabledByDefault", true, "Auto spectate starts ON when the game launches. The toggle key changes it for the rest of the session."); ToggleKey = config.Bind("General", "ToggleKey", "H", "Fallback toggle key when InputUtils is not installed (UnityEngine.InputSystem.Key name, e.g. H, F6). With InputUtils, rebind in the in-game Keybinds menu instead."); ShowHudStatus = config.Bind("General", "ShowHudStatus", true, "Show the 'Auto spectate: ...' status line under the spectator controls and the [auto] marker next to the spectated name."); ShowTips = config.Bind("General", "ShowTips", true, "Show a short on-screen tip when auto spectate is toggled or paused."); LogScores = config.Bind("Debug", "LogScores", false, "Periodically log every candidate's voice level and score to the BepInEx log (for tuning thresholds)."); LogScoresIntervalSeconds = config.Bind("Debug", "LogScoresIntervalSeconds", 1f, "Seconds between score dumps when Debug/LogScores is on."); BindDecisionSections(config); IsBound = true; } private static void BindDecisionSectionsImpl(ConfigFile config) { SmoothingReleaseSeconds = config.Bind("Voice", "SmoothingReleaseSeconds", 0.4f, "How quickly a player's smoothed voice level falls after they stop making sound (exponential release time constant)."); EnvelopeHoldSeconds = config.Bind("Voice", "EnvelopeHoldSeconds", 0.6f, "How long the smoothed voice level holds its peak before starting to fall (bridges the gaps between words)."); SpeakingOnThreshold = config.Bind("Voice", "SpeakingOnThreshold", 0.008f, "Smoothed, volume-normalized voice level above which a player counts as talking (the game's own HUD icon uses 0.005 raw)."); SpeakingOffThreshold = config.Bind("Voice", "SpeakingOffThreshold", 0.004f, "Level below which a talking player counts as quiet again (lower than the on-threshold so the state does not flicker)."); MinSpeechBeforeSwitchSeconds = config.Bind("Voice", "MinSpeechBeforeSwitchSeconds", 0.35f, "A player must have been talking this long before they can take the camera (rejects coughs and one-word blips)."); IncludeLocallyMuted = config.Bind("Voice", "IncludeLocallyMuted", false, "Count players you have locally muted as talking (their voice level usually reads zero anyway)."); MinHoldSeconds = config.Bind("Switching", "MinHoldSeconds", 2.5f, "Never switch away from the current player sooner than this after any switch."); InactivityTimeoutSeconds = config.Bind("Switching", "InactivityTimeoutSeconds", 2f, "Quiet time on the current player before another talker may take the camera."); LouderSwitchRatio = config.Bind("Switching", "LouderSwitchRatio", 2f, "While the current player is still talking, a challenger takes the camera early only when their score is at least this many times higher."); ManualHoldSeconds = config.Bind("Switching", "ManualHoldSeconds", 8f, "After you cycle players manually (left click), auto switching waits this long before taking over again."); SnapToFirstSpeaker = config.Bind("Switching", "SnapToFirstSpeaker", true, "When everyone has been quiet, jump to the first person who starts talking immediately so you hear the word from the start (an isolated cough can briefly take the camera)."); DangerEnabled = config.Bind("Danger", "Enabled", true, "Prefer players who have an enemy close to them (most enemies attack by closing in)."); DangerRadiusMeters = config.Bind("Danger", "DangerRadiusMeters", 20f, "An enemy within this distance starts boosting a talking player's priority (closer = stronger)."); DangerWeight = config.Bind("Danger", "DangerWeight", 0.5f, "How strongly enemy proximity boosts a talking player's score (0 disables the boost, 1 doubles it at point blank)."); DangerSwitchDistance = config.Bind("Danger", "DangerSwitchDistance", 8f, "During total silence, switch to a player whose nearest enemy is within this distance."); DangerHoldSeconds = config.Bind("Danger", "DangerHoldSeconds", 3f, "Minimum time between danger-only switches so two chased players do not ping-pong."); KillerCamEnabled = config.Bind("KillerCam", "Enabled", true, "When the player you are watching is killed by an enemy, follow that enemy for a moment before moving on."); KillerCamDurationSeconds = config.Bind("KillerCam", "DurationSeconds", 3f, "How long the camera follows the killer."); KillerCamMaxAttributionDistance = config.Bind("KillerCam", "MaxAttributionDistance", 10f, "When the killer is not directly known, blame the nearest living enemy within this distance of the victim."); GroupRadiusMeters = config.Bind("Grouping", "GroupRadiusMeters", 10f, "Other players within this distance of a speaker count toward the group bonus (10 is the game's own near-player radius)."); GroupBonusPerNeighbor = config.Bind("Grouping", "GroupBonusPerNeighbor", 0.15f, "Score bonus per nearby player, making the camera prefer and hold speakers who are in a group."); GroupBonusCap = config.Bind("Grouping", "GroupBonusCap", 0.45f, "Maximum total group bonus."); RequireSameInterior = config.Bind("Grouping", "RequireSameInterior", true, "Only count nearby players who are on the same side of the factory walls (inside vs outside)."); } private static void BindDecisionSections(ConfigFile config) { BindDecisionSectionsImpl(config); } } }