Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
Decompiled source of LethalSpectator v0.2.0
LethalSpectator.dll
Decompiled 4 hours agousing 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<SpectatorRunner>(); } 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<NfgoPlayer>(); } 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<EnemyAI> 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<PlayerControllerB, float> 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<PlayerControllerB, float> ResolveDeadTimer() { try { return AccessTools.FieldRefAccess<PlayerControllerB, float>("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("<Keyboard>/h", Name = "Toggle auto spectate", GamepadPath = "<Gamepad>/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<Key>(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<EnemyAI> 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<object, bool> _spectatingRef; private MethodInfo _isMenuOpenMethod; private object _cachedInstance; private Func<bool> _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<bool>(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<bool>)Delegate.CreateDelegate(typeof(Func<bool>), 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<bool> EnabledByDefault; public static ConfigEntry<string> ToggleKey; public static ConfigEntry<bool> ShowHudStatus; public static ConfigEntry<bool> ShowTips; public static ConfigEntry<bool> LogScores; public static ConfigEntry<float> LogScoresIntervalSeconds; public static ConfigEntry<float> SmoothingReleaseSeconds; public static ConfigEntry<float> EnvelopeHoldSeconds; public static ConfigEntry<float> SpeakingOnThreshold; public static ConfigEntry<float> SpeakingOffThreshold; public static ConfigEntry<float> MinSpeechBeforeSwitchSeconds; public static ConfigEntry<bool> IncludeLocallyMuted; public static ConfigEntry<float> MinHoldSeconds; public static ConfigEntry<float> InactivityTimeoutSeconds; public static ConfigEntry<float> LouderSwitchRatio; public static ConfigEntry<float> ManualHoldSeconds; public static ConfigEntry<bool> SnapToFirstSpeaker; public static ConfigEntry<bool> DangerEnabled; public static ConfigEntry<float> DangerRadiusMeters; public static ConfigEntry<float> DangerWeight; public static ConfigEntry<float> DangerSwitchDistance; public static ConfigEntry<float> DangerHoldSeconds; public static ConfigEntry<bool> KillerCamEnabled; public static ConfigEntry<float> KillerCamDurationSeconds; public static ConfigEntry<float> KillerCamMaxAttributionDistance; public static ConfigEntry<float> GroupRadiusMeters; public static ConfigEntry<float> GroupBonusPerNeighbor; public static ConfigEntry<float> GroupBonusCap; public static ConfigEntry<bool> RequireSameInterior; public static bool IsBound { get; private set; } public static void Bind(ConfigFile config) { EnabledByDefault = config.Bind<bool>("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<string>("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<bool>("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<bool>("General", "ShowTips", true, "Show a short on-screen tip when auto spectate is toggled or paused."); LogScores = config.Bind<bool>("Debug", "LogScores", false, "Periodically log every candidate's voice level and score to the BepInEx log (for tuning thresholds)."); LogScoresIntervalSeconds = config.Bind<float>("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<float>("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<float>("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<float>("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<float>("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<float>("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<bool>("Voice", "IncludeLocallyMuted", false, "Count players you have locally muted as talking (their voice level usually reads zero anyway)."); MinHoldSeconds = config.Bind<float>("Switching", "MinHoldSeconds", 2.5f, "Never switch away from the current player sooner than this after any switch."); InactivityTimeoutSeconds = config.Bind<float>("Switching", "InactivityTimeoutSeconds", 2f, "Quiet time on the current player before another talker may take the camera."); LouderSwitchRatio = config.Bind<float>("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<float>("Switching", "ManualHoldSeconds", 8f, "After you cycle players manually (left click), auto switching waits this long before taking over again."); SnapToFirstSpeaker = config.Bind<bool>("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<bool>("Danger", "Enabled", true, "Prefer players who have an enemy close to them (most enemies attack by closing in)."); DangerRadiusMeters = config.Bind<float>("Danger", "DangerRadiusMeters", 20f, "An enemy within this distance starts boosting a talking player's priority (closer = stronger)."); DangerWeight = config.Bind<float>("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<float>("Danger", "DangerSwitchDistance", 8f, "During total silence, switch to a player whose nearest enemy is within this distance."); DangerHoldSeconds = config.Bind<float>("Danger", "DangerHoldSeconds", 3f, "Minimum time between danger-only switches so two chased players do not ping-pong."); KillerCamEnabled = config.Bind<bool>("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<float>("KillerCam", "DurationSeconds", 3f, "How long the camera follows the killer."); KillerCamMaxAttributionDistance = config.Bind<float>("KillerCam", "MaxAttributionDistance", 10f, "When the killer is not directly known, blame the nearest living enemy within this distance of the victim."); GroupRadiusMeters = config.Bind<float>("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<float>("Grouping", "GroupBonusPerNeighbor", 0.15f, "Score bonus per nearby player, making the camera prefer and hold speakers who are in a group."); GroupBonusCap = config.Bind<float>("Grouping", "GroupBonusCap", 0.45f, "Maximum total group bonus."); RequireSameInterior = config.Bind<bool>("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); } } }