using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.IO.Pipes; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Security; using System.Security.Permissions; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using Microsoft.CodeAnalysis; using Unity.Netcode; using UnityEngine; using UnityEngine.SceneManagement; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: IgnoresAccessChecksTo("Assembly-CSharp")] [assembly: TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName = ".NET Standard 2.1")] [assembly: AssemblyCompany("Sparroh")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyFileVersion("1.2.4.0")] [assembly: AssemblyInformationalVersion("1.2.4")] [assembly: AssemblyProduct("LiveSplitHooks")] [assembly: AssemblyTitle("LiveSplitHooks")] [assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)] [assembly: AssemblyVersion("1.2.4.0")] [module: UnverifiableCode] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } public static class ConfigManager { private const float DebounceSeconds = 0.25f; private static ConfigFile _config; private static ManualLogSource _logger; private static FileSystemWatcher _configWatcher; private static volatile bool _reloadPending; private static float _lastReloadTime; public static ConfigEntry Enabled { get; private set; } public static ConfigEntry AutoReconnect { get; private set; } public static ConfigEntry ReconnectInterval { get; private set; } public static ConfigEntry DebugLogging { get; private set; } public static ConfigEntry StartOnMissionBegin { get; private set; } public static ConfigEntry ResetBeforeStartOnMissionBegin { get; private set; } public static ConfigEntry UseStartOrSplit { get; private set; } public static ConfigEntry SplitOnMissionComplete { get; private set; } public static ConfigEntry SplitOnObjectiveComplete { get; private set; } public static ConfigEntry SplitOnExtractObjective { get; private set; } public static ConfigEntry ResetOnMissionFail { get; private set; } public static ConfigEntry PauseGameTimeDuringLoads { get; private set; } public static ConfigEntry SplitOnSubMissionTransition { get; private set; } public static ConfigEntry FloorChangeSplitMissionIds { get; private set; } public static ConfigEntry IncursionFloorSplitMax { get; private set; } public static ConfigEntry IncursionStopSplitsAtMaxFloor { get; private set; } public static ConfigEntry IncursionEndRunAtMaxFloor { get; private set; } public static ConfigEntry SplitProfilesEnabled { get; private set; } public static ConfigEntry SplitProfilesDirectory { get; private set; } public static ConfigEntry SplitProfilesDefaultSplits { get; private set; } public static ConfigEntry SplitProfilesMappings { get; private set; } public static ConfigEntry SplitProfilesMapFile { get; private set; } public static ConfigEntry SplitProfilesAllowSubstringMatch { get; private set; } public static void Initialize(ConfigFile configFile, ManualLogSource log) { _config = configFile; _logger = log; Enabled = _config.Bind("General", "Enabled", true, "Master switch for sending commands to LiveSplit."); AutoReconnect = _config.Bind("Connection", "AutoReconnect", true, "Automatically retry connecting to LiveSplit if the pipe is unavailable."); ReconnectInterval = _config.Bind("Connection", "ReconnectIntervalSeconds", 3f, "Seconds between reconnect attempts."); DebugLogging = _config.Bind("General", "DebugLogging", true, "Log LiveSplit commands, mission identity, and connection status to the BepInEx console."); StartOnMissionBegin = _config.Bind("Triggers", "StartOnMissionBegin", true, "Send start when a mission begins on the local client."); ResetBeforeStartOnMissionBegin = _config.Bind("Triggers", "ResetBeforeStartOnMissionBegin", true, "Send reset before start when a mission begins, so each mission is a fresh LiveSplit run (IL-style). Disable for multi-mission full-game runs."); UseStartOrSplit = _config.Bind("Triggers", "UseStartOrSplit", false, "If true, mission begin sends 'startorsplit' instead of 'start'."); SplitOnMissionComplete = _config.Bind("Triggers", "SplitOnMissionComplete", true, "Send split when a mission is completed successfully."); SplitOnObjectiveComplete = _config.Bind("Triggers", "SplitOnObjectiveComplete", false, "Send split when each main (non-side) objective completes successfully."); SplitOnExtractObjective = _config.Bind("Triggers", "SplitOnExtractObjective", false, "If objective splits are enabled, also split when the extract objective completes. Usually leave off if SplitOnMissionComplete is on."); ResetOnMissionFail = _config.Bind("Triggers", "ResetOnMissionFail", true, "Send reset when a mission fails or is aborted."); PauseGameTimeDuringLoads = _config.Bind("Triggers", "PauseGameTimeDuringLoads", true, "Pause LiveSplit game time while the game is loading / waiting for players."); SplitOnSubMissionTransition = _config.Bind("Triggers", "SplitOnSubMissionTransition", true, "When a different mission starts while a run is active (e.g. Cranius after Moldy Tundra setup), split the current run, switch profiles, and start a new attempt."); FloorChangeSplitMissionIds = _config.Bind("Triggers", "FloorChangeSplitMissionIds", "incursion", "Comma-separated mission ids that split when a floor is cleared (Incursion). Empty disables floor splits."); IncursionFloorSplitMax = _config.Bind("Triggers", "IncursionFloorSplitMax", 30, "Only emit floor-clear splits up to this floor number (Incursion speedrun target)."); IncursionStopSplitsAtMaxFloor = _config.Bind("Triggers", "IncursionStopSplitsAtMaxFloor", true, "Stop further floor-clear splits once IncursionFloorSplitMax is reached."); IncursionEndRunAtMaxFloor = _config.Bind("Triggers", "IncursionEndRunAtMaxFloor", true, "When the max floor is cleared, end the LiveSplit run (set run inactive). Needed because Incursion continues forever; leaves final time on screen without reset."); SplitProfilesEnabled = _config.Bind("SplitProfiles", "Enabled", false, "When true, switch LiveSplit .lss files based on mission identity (requires LiveSplit switchsplits support)."); SplitProfilesDirectory = _config.Bind("SplitProfiles", "Directory", "", "Folder containing your per-mission .lss files. Relative mapping paths are resolved from here."); SplitProfilesDefaultSplits = _config.Bind("SplitProfiles", "DefaultSplits", "", "Fallback .lss path (absolute or relative to Directory) when no mapping matches. Leave empty to keep the current splits."); SplitProfilesMappings = _config.Bind("SplitProfiles", "Mappings", "", "Inline mappings, one per line: MissionKey => file.lss (also accepts | or =). Prefix key with ~ for substring match. Prefer MapFile for long lists."); SplitProfilesMapFile = _config.Bind("SplitProfiles", "MapFile", "", "Optional path to an external mapping text file (see profiles.example.txt). Reloaded when the file changes."); SplitProfilesAllowSubstringMatch = _config.Bind("SplitProfiles", "AllowSubstringMatch", true, "If true, mapping keys may match when contained in the mission name/id (in addition to exact matches)."); try { SetupFileWatcher(); } catch (Exception ex) { _logger.LogError((object)("Error setting up config file watcher: " + ex.Message)); } } public static void Tick() { if (!_reloadPending || Time.unscaledTime - _lastReloadTime < 0.25f) { return; } _reloadPending = false; _lastReloadTime = Time.unscaledTime; try { _config.Reload(); _logger.LogInfo((object)"Config reloaded from disk."); } catch (Exception ex) { _logger.LogError((object)("Error reloading config: " + ex.Message)); } } public static void Dispose() { if (_configWatcher != null) { _configWatcher.EnableRaisingEvents = false; _configWatcher.Changed -= OnConfigFileChanged; _configWatcher.Created -= OnConfigFileChanged; _configWatcher.Renamed -= OnConfigFileChanged; _configWatcher.Dispose(); _configWatcher = null; } } private static void SetupFileWatcher() { _configWatcher = new FileSystemWatcher(Paths.ConfigPath, "sparroh.livesplithooks.cfg"); _configWatcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.LastWrite; _configWatcher.Changed += OnConfigFileChanged; _configWatcher.Created += OnConfigFileChanged; _configWatcher.Renamed += OnConfigFileChanged; _configWatcher.EnableRaisingEvents = true; } private static void OnConfigFileChanged(object sender, FileSystemEventArgs e) { _reloadPending = true; } } public sealed class GameLoadTracker { private bool _gameTimePaused; private bool _loading; public void Update(LiveSplitClient client) { if (client == null) { return; } if (!ConfigManager.Enabled.Value || !ConfigManager.PauseGameTimeDuringLoads.Value) { if (_gameTimePaused) { client.UnpauseGameTime(); _gameTimePaused = false; } return; } bool flag = IsGameLoading(); if (flag != _loading) { _loading = flag; if (flag) { client.PauseGameTime(); _gameTimePaused = true; } else { client.UnpauseGameTime(); _gameTimePaused = false; } } } private static bool IsGameLoading() { //IL_000b: Unknown result type (might be due to invalid IL or missing references) //IL_0010: Unknown result type (might be due to invalid IL or missing references) int sceneCount = SceneManager.sceneCount; for (int i = 0; i < sceneCount; i++) { Scene sceneAt = SceneManager.GetSceneAt(i); if (((Scene)(ref sceneAt)).IsValid() && !((Scene)(ref sceneAt)).isLoaded) { return true; } } GameManager instance = GameManager.Instance; if ((Object)(object)instance != (Object)null && (Object)(object)NetworkManager.Singleton != (Object)null && NetworkManager.Singleton.IsListening && !instance.AreAllPlayersLoaded) { return true; } return false; } } public sealed class IncursionFloorTracker { private readonly HashSet _floorSplitMissionIds = new HashSet(StringComparer.OrdinalIgnoreCase); private bool _active; private string _floorSplitIdsRaw; private int _lastFloor = -1; public void BeginIfNeeded(string missionKey) { ReloadFloorSplitIdsIfNeeded(); _active = IsFloorSplitMission(missionKey); _lastFloor = -1; if (!_active) { return; } try { if ((Object)(object)IncursionObjective.Instance != (Object)null) { _lastFloor = IncursionObjective.Instance.CurrentFloor; } } catch { } if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogInfo((object)$"Incursion floor splits enabled for '{missionKey}' (seed floor={_lastFloor})."); } } public void Reset() { _active = false; _lastFloor = -1; } public bool OnFloorChanged(int currentFloor, bool runInProgress, string activeMissionKey, LiveSplitClient client) { if (!ConfigManager.Enabled.Value || !runInProgress || client == null) { return false; } if (!_active && !IsFloorSplitMission(activeMissionKey)) { return false; } if (currentFloor < 0) { return false; } if (_lastFloor < 0) { _lastFloor = currentFloor; if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogInfo((object)$"Incursion floor tracking seeded at floor {currentFloor}."); } return false; } if (currentFloor <= _lastFloor) { return false; } int num = currentFloor - _lastFloor; int num2 = Math.Max(1, ConfigManager.IncursionFloorSplitMax.Value); bool flag = false; for (int i = 0; i < num; i++) { int num3 = _lastFloor + 1 + i; if (num3 > num2) { break; } if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogInfo((object)$"Incursion floor {num3} cleared → split."); } client.Split(); if (num3 >= num2) { flag = true; break; } } _lastFloor = currentFloor; if (flag || (ConfigManager.IncursionStopSplitsAtMaxFloor.Value && currentFloor > num2)) { if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogInfo((object)$"Incursion floor {num2} complete (now on {currentFloor}); ending LiveSplit run."); } _active = false; return ConfigManager.IncursionEndRunAtMaxFloor.Value; } return false; } private bool IsFloorSplitMission(string missionKey) { if (string.IsNullOrEmpty(missionKey)) { return false; } ReloadFloorSplitIdsIfNeeded(); if (_floorSplitMissionIds.Contains(missionKey)) { return true; } foreach (string floorSplitMissionId in _floorSplitMissionIds) { if (missionKey.IndexOf(floorSplitMissionId, StringComparison.OrdinalIgnoreCase) >= 0 || floorSplitMissionId.IndexOf(missionKey, StringComparison.OrdinalIgnoreCase) >= 0) { return true; } } return false; } private void ReloadFloorSplitIdsIfNeeded() { string text = ConfigManager.FloorChangeSplitMissionIds.Value ?? string.Empty; if (!string.Equals(text, _floorSplitIdsRaw, StringComparison.Ordinal)) { _floorSplitIdsRaw = text; _floorSplitMissionIds.Clear(); string[] array = text.Split(new char[6] { ',', ';', ' ', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries); foreach (string text2 in array) { _floorSplitMissionIds.Add(text2.Trim()); } } } } public sealed class LiveSplitClient : IDisposable { private static readonly UTF8Encoding Utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); private readonly Func _debugLogging; private readonly object _sync = new object(); private bool _disposed; private NamedPipeClientStream _pipe; public bool IsConnected { get { lock (_sync) { return _pipe != null && _pipe.IsConnected; } } } public LiveSplitClient(Func debugLogging) { _debugLogging = debugLogging ?? ((Func)(() => false)); } public void Dispose() { lock (_sync) { _disposed = true; DisposePipe_NoLock(); } } public bool TryConnect() { lock (_sync) { if (_disposed) { return false; } if (_pipe != null && _pipe.IsConnected) { return true; } DisposePipe_NoLock(); NamedPipeClientStream namedPipeClientStream = new NamedPipeClientStream(".", "LiveSplit", PipeDirection.InOut, PipeOptions.Asynchronous); try { namedPipeClientStream.Connect(250); _pipe = namedPipeClientStream; StartReadLoop(namedPipeClientStream); return true; } catch { try { namedPipeClientStream.Dispose(); } catch { } _pipe = null; return false; } } } public void Send(string command) { if (string.IsNullOrEmpty(command)) { return; } byte[] bytes = Utf8.GetBytes(command + "\n"); lock (_sync) { if (_disposed || _pipe == null || !_pipe.IsConnected) { if (_debugLogging()) { ManualLogSource log = LiveSplitHooksPlugin.Log; if (log != null) { log.LogDebug((object)("LiveSplit not connected; dropped command: " + command)); } } return; } try { _pipe.Write(bytes, 0, bytes.Length); _pipe.Flush(); if (_debugLogging()) { ManualLogSource log2 = LiveSplitHooksPlugin.Log; if (log2 != null) { log2.LogInfo((object)("LiveSplit <= " + command)); } } } catch (Exception ex) { if (_debugLogging()) { ManualLogSource log3 = LiveSplitHooksPlugin.Log; if (log3 != null) { log3.LogWarning((object)("Failed to send '" + command + "': " + ex.Message)); } } DisposePipe_NoLock(); } } } public void Start() { Send("start"); } public void StartOrSplit() { Send("startorsplit"); } public void Split() { Send("split"); } public void Reset() { Send("reset"); } public void PauseGameTime() { Send("pausegametime"); } public void UnpauseGameTime() { Send("unpausegametime"); } public void SwitchSplits(string path) { if (!string.IsNullOrWhiteSpace(path)) { Send("switchsplits " + path.Trim()); } } public void SwitchLayout(string path) { if (!string.IsNullOrWhiteSpace(path)) { Send("switchlayout " + path.Trim()); } } private void StartReadLoop(NamedPipeClientStream pipe) { Task.Run(async delegate { byte[] buffer = new byte[256]; try { while (pipe.IsConnected && await pipe.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(continueOnCapturedContext: false) > 0) { } } catch (Exception) { } finally { lock (_sync) { if (_pipe == pipe) { DisposePipe_NoLock(); } } } }); } private void DisposePipe_NoLock() { if (_pipe != null) { try { _pipe.Dispose(); } catch { } _pipe = null; } } } internal static class LiveSplitPatches { [HarmonyPatch(typeof(MissionManager), "OnMissionStarted_Client")] private static class MissionStartedPatch { private static void Postfix() { LiveSplitHooksPlugin.Splits?.OnMissionStarted(); } } [HarmonyPatch(typeof(MissionManager), "OnMissionCompleted_ClientRpc")] private static class MissionCompletedPatch { private static void Postfix() { LiveSplitHooksPlugin.Splits?.OnMissionCompleted(); } } [HarmonyPatch(typeof(MissionManager), "OnFailMission_Client")] private static class MissionFailedPatch { private static void Postfix(MissionState state) { //IL_000a: Unknown result type (might be due to invalid IL or missing references) LiveSplitHooksPlugin.Splits?.OnMissionFailed(state); } } [HarmonyPatch(typeof(ObjectiveBase), "Complete")] private static class ObjectiveCompletePatch { private static void Postfix(ObjectiveBase __instance, bool success) { if (!((Object)(object)__instance == (Object)null)) { LiveSplitHooksPlugin.Splits?.OnObjectiveCompleted(__instance, success); } } } [HarmonyPatch(typeof(ObjectiveBase), "Complete_ClientRpc")] private static class ObjectiveCompleteClientRpcPatch { private static void Postfix(ObjectiveBase __instance, bool success) { if (!((Object)(object)__instance == (Object)null)) { NetworkManager networkManager = ((NetworkBehaviour)__instance).NetworkManager; if (!((Object)(object)networkManager != (Object)null) || (!networkManager.IsServer && !networkManager.IsHost)) { LiveSplitHooksPlugin.Splits?.OnObjectiveCompleted(__instance, success); } } } } [HarmonyPatch(typeof(IncursionObjective), "SpawnRooms_ClientRpc")] private static class IncursionSpawnRoomsPatch { private static void Postfix(int currentFloor, int floorsReached) { LiveSplitHooksPlugin.Splits?.OnIncursionFloorChanged(currentFloor); } } [HarmonyPatch(typeof(IncursionHUD), "SetFloor")] private static class IncursionHudSetFloorPatch { private static void Postfix(int floor) { LiveSplitHooksPlugin.Splits?.OnIncursionFloorChanged(floor); } } } public static class MissionIdentity { public readonly struct Info { public string PrimaryKey { get; } public string DisplayName { get; } public string Detail { get; } public bool HasKey => !string.IsNullOrWhiteSpace(PrimaryKey); public Info(string primaryKey, string displayName, string detail) { PrimaryKey = primaryKey ?? string.Empty; DisplayName = displayName ?? string.Empty; Detail = detail ?? string.Empty; } public IEnumerable MatchCandidates() { HashSet seen = new HashSet(StringComparer.OrdinalIgnoreCase); List list = new List(); Add(PrimaryKey); Add(DisplayName); if (!string.IsNullOrWhiteSpace(Detail)) { string[] array = Detail.Split(new char[4] { '|', ';', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); for (int i = 0; i < array.Length; i++) { Add(array[i]); } } return list; void Add(string value) { if (!string.IsNullOrWhiteSpace(value)) { value = StripRichText(value.Trim()); if (value.Length != 0 && !NoiseTokens.Contains(value) && seen.Add(value)) { list.Add(value); } } } } } private static readonly Regex ColorTagRegex = new Regex("]*>|||||", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly string[] MissionObjectMemberNames = new string[7] { "Mission", "_mission", "CurrentMission", "ActiveMission", "SelectedMission", "LoadedMission", "mission" }; private static readonly string[] IdMemberNames = new string[5] { "ID", "Id", "id", "MissionId", "missionId" }; private static readonly string[] NameMemberNames = new string[6] { "MissionName", "missionName", "DisplayName", "Title", "ColoredMissionName", "Name" }; private static readonly string[] TypeMemberNames = new string[2] { "MissionTypeName", "missionTypeName" }; private static readonly HashSet NoiseTokens = new HashSet(StringComparer.OrdinalIgnoreCase) { "MissionManager", "True", "False", "None", "null", "Untagged", "Empty Event", "DefaultMissionContainer", "Players" }; private static readonly HashSet PlaceholderIds = new HashSet(StringComparer.OrdinalIgnoreCase) { "???", "?", "redacted", "unknown", "none", "null", "n/a", "na" }; public static Info Capture() { try { MissionManager instance = MissionManager.Instance; if ((Object)(object)instance == (Object)null) { return new Info(string.Empty, string.Empty, "MissionManager.Instance is null"); } return CaptureFrom(instance); } catch (Exception ex) { return new Info(string.Empty, string.Empty, "Capture failed: " + ex.Message); } } public static Info CaptureFrom(object missionManager) { if (missionManager == null) { return new Info(string.Empty, string.Empty, "null"); } object obj = null; string[] missionObjectMemberNames = MissionObjectMemberNames; foreach (string name in missionObjectMemberNames) { if (TryGetMemberValue(missionManager, name, out var value) && value != null && !(value is string)) { obj = value; break; } } string text = null; string displayName = null; string value2 = null; string value3 = null; string value4 = null; if (obj != null) { text = FirstStringMember(obj, IdMemberNames); displayName = FirstStringMember(obj, NameMemberNames); value3 = FirstStringMember(obj, TypeMemberNames); value4 = obj.GetType().Name; Object val = (Object)((obj is Object) ? obj : null); value2 = ((val == null || !(val != (Object)null)) ? FirstStringMember(obj, new string[1] { "name" }) : val.name); } if (string.IsNullOrWhiteSpace(text)) { text = FirstStringMember(missionManager, IdMemberNames); } if (string.IsNullOrWhiteSpace(displayName)) { displayName = FirstStringMember(missionManager, NameMemberNames); } string text2 = (string.IsNullOrWhiteSpace(text) ? null : text.Trim()); text = Clean(text); displayName = Clean(displayName); value2 = Clean(value2); value3 = Clean(value3); value4 = Clean(value4); if (string.Equals(displayName, "MissionManager", StringComparison.OrdinalIgnoreCase)) { displayName = null; } if (IsPlaceholderId(displayName) || string.IsNullOrWhiteSpace(displayName)) { displayName = value2; } string text3 = null; if (!string.IsNullOrEmpty(value4) && (value4.IndexOf("FlatTundra", StringComparison.OrdinalIgnoreCase) >= 0 || value4.IndexOf("Cranius", StringComparison.OrdinalIgnoreCase) >= 0)) { text3 = "cranius"; } if (text3 == null && !string.IsNullOrEmpty(value2) && (value2.IndexOf("FlatTundra", StringComparison.OrdinalIgnoreCase) >= 0 || value2.IndexOf("Cranius", StringComparison.OrdinalIgnoreCase) >= 0)) { text3 = "cranius"; } string primary; if (!string.IsNullOrWhiteSpace(text) && !IsPlaceholderId(text)) { primary = text; } else if (!string.IsNullOrWhiteSpace(text3)) { primary = text3; } else if (!string.IsNullOrWhiteSpace(value2)) { primary = value2; } else if (!string.IsNullOrWhiteSpace(displayName) && !IsPlaceholderId(displayName)) { primary = displayName; } else if (!string.IsNullOrWhiteSpace(text2)) { primary = text2; } else { primary = string.Empty; } List aliases = new List(); Alias(value2); Alias(text3); Alias(value4); Alias(value3); Alias(text); Alias(text2); Alias(displayName); if (!string.IsNullOrEmpty(text3)) { Alias("FlatTundra Mission"); Alias("FlatTundraMission"); } string detail = string.Join(" | ", aliases); return new Info(primary ?? string.Empty, displayName ?? string.Empty, detail); void Alias(string text4) { if (!string.IsNullOrWhiteSpace(text4)) { if (!IsPlaceholderId(text4)) { text4 = Clean(text4); if (string.IsNullOrWhiteSpace(text4) || NoiseTokens.Contains(text4)) { return; } } else { text4 = text4.Trim(); } if (!string.Equals(text4, primary, StringComparison.OrdinalIgnoreCase) && !string.Equals(text4, displayName, StringComparison.OrdinalIgnoreCase)) { foreach (string item in aliases) { if (string.Equals(item, text4, StringComparison.OrdinalIgnoreCase)) { return; } } aliases.Add(text4); } } } } private static bool IsPlaceholderId(string value) { if (string.IsNullOrWhiteSpace(value)) { return true; } value = value.Trim(); if (PlaceholderIds.Contains(value)) { return true; } bool flag = true; string text = value; foreach (char c in text) { if (c != '?' && c != '*' && c != '.' && c != '-' && c != '_' && !char.IsWhiteSpace(c)) { flag = false; break; } } if (flag) { return value.Length > 0; } return false; } private static string FirstStringMember(object obj, string[] names) { if (obj == null) { return null; } foreach (string name in names) { if (TryGetMemberValue(obj, name, out var value) && value != null) { string text = Stringify(value); if (!string.IsNullOrWhiteSpace(text)) { return text; } } } return null; } private static bool TryGetMemberValue(object obj, string name, out object value) { value = null; if (obj == null || string.IsNullOrEmpty(name)) { return false; } Type type = obj.GetType(); try { PropertyInfo property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (property != null && property.CanRead && property.GetIndexParameters().Length == 0) { value = property.GetValue(obj, null); return true; } } catch { } try { FieldInfo field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field != null) { value = field.GetValue(obj); return true; } } catch { } try { PropertyInfo[] properties = type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); foreach (PropertyInfo propertyInfo in properties) { if (string.Equals(propertyInfo.Name, name, StringComparison.OrdinalIgnoreCase) && propertyInfo.CanRead && propertyInfo.GetIndexParameters().Length == 0) { value = propertyInfo.GetValue(obj, null); return true; } } FieldInfo[] fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); foreach (FieldInfo fieldInfo in fields) { if (string.Equals(fieldInfo.Name, name, StringComparison.OrdinalIgnoreCase)) { value = fieldInfo.GetValue(obj); return true; } } } catch { } return false; } private static string Stringify(object value) { if (value == null) { return null; } if (!(value is string result)) { Object val = (Object)((value is Object) ? value : null); if (val == null) { if (value is Enum obj) { return obj.ToString(); } } else if (val != (Object)null) { return val.name; } return null; } return result; } private static string Clean(string value) { if (string.IsNullOrWhiteSpace(value)) { return null; } value = StripRichText(value.Trim()); if (value.Length == 0 || NoiseTokens.Contains(value)) { return null; } if (bool.TryParse(value, out var _)) { return null; } if (double.TryParse(value, out var _)) { return null; } return value; } private static string StripRichText(string value) { if (string.IsNullOrEmpty(value)) { return value; } return ColorTagRegex.Replace(value, string.Empty).Trim(); } public static string FormatForLog(Info info) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("key='").Append(info.PrimaryKey).Append('\''); if (!string.IsNullOrEmpty(info.DisplayName) && !string.Equals(info.DisplayName, info.PrimaryKey, StringComparison.OrdinalIgnoreCase)) { stringBuilder.Append(" name='").Append(info.DisplayName).Append('\''); } if (!string.IsNullOrEmpty(info.Detail)) { stringBuilder.Append(" detail=[").Append(info.Detail).Append(']'); } return stringBuilder.ToString(); } } [BepInPlugin("sparroh.livesplithooks", "LiveSplitHooks", "1.2.4")] [MycoMod(/*Could not decode attribute arguments.*/)] public class LiveSplitHooksPlugin : BaseUnityPlugin { public const string PluginGuid = "sparroh.livesplithooks"; public const string PluginName = "LiveSplitHooks"; public const string PluginVersion = "1.2.4"; private LiveSplitClient _client; private Harmony _harmony; private SplitProfileSwitcher _profiles; private float _reconnectTimer; private SplitController _splits; internal static LiveSplitHooksPlugin Instance { get; private set; } internal static ManualLogSource Log { get; private set; } internal static SplitController Splits => Instance?._splits; internal static LiveSplitClient Client => Instance?._client; internal static SplitProfileSwitcher Profiles => Instance?._profiles; private void Awake() { //IL_0079: Unknown result type (might be due to invalid IL or missing references) //IL_0083: Expected O, but got Unknown Instance = this; Log = ((BaseUnityPlugin)this).Logger; ConfigManager.Initialize(((BaseUnityPlugin)this).Config, Log); _client = new LiveSplitClient(() => ConfigManager.DebugLogging.Value); _profiles = new SplitProfileSwitcher(_client); _splits = new SplitController(_client, _profiles); _harmony = new Harmony("sparroh.livesplithooks"); _harmony.PatchAll(typeof(LiveSplitHooksPlugin).Assembly); if (ConfigManager.Enabled.Value) { TryConnect(); } Log.LogInfo((object)"LiveSplitHooks v1.2.4 loaded."); } private void Update() { ConfigManager.Tick(); if (!ConfigManager.Enabled.Value) { return; } if (!_client.IsConnected) { if (ConfigManager.AutoReconnect.Value) { _reconnectTimer -= Time.unscaledDeltaTime; if (_reconnectTimer <= 0f) { _reconnectTimer = Mathf.Max(0.5f, ConfigManager.ReconnectInterval.Value); TryConnect(); } } } else { _splits.UpdateLoadingState(); } } private void OnDestroy() { try { Harmony harmony = _harmony; if (harmony != null) { harmony.UnpatchSelf(); } } catch { } ConfigManager.Dispose(); _client?.Dispose(); Instance = null; } private void TryConnect() { try { if (_client.TryConnect()) { if (ConfigManager.DebugLogging.Value) { Log.LogInfo((object)"Connected to LiveSplit named pipe."); } if (ConfigManager.PauseGameTimeDuringLoads.Value) { _client.Send("initgametime"); } } } catch (Exception ex) { if (ConfigManager.DebugLogging.Value) { Log.LogDebug((object)("LiveSplit connect failed: " + ex.Message)); } } } } public sealed class SplitController { private readonly LiveSplitClient _client; private readonly IncursionFloorTracker _incursionFloors = new IncursionFloorTracker(); private readonly GameLoadTracker _loadTracker = new GameLoadTracker(); private readonly SplitProfileSwitcher _profiles; private string _activeMissionKey = string.Empty; public bool RunInProgress { get; private set; } public SplitController(LiveSplitClient client, SplitProfileSwitcher profiles = null) { _client = client ?? throw new ArgumentNullException("client"); _profiles = profiles; } public void OnMissionStarted() { if (!ConfigManager.Enabled.Value) { return; } MissionIdentity.Info info = MissionIdentity.Capture(); if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogInfo((object)("Mission identity " + MissionIdentity.FormatForLog(info))); } string text = info.PrimaryKey ?? string.Empty; bool value = ConfigManager.StartOnMissionBegin.Value; bool value2 = ConfigManager.ResetBeforeStartOnMissionBegin.Value; bool flag = ConfigManager.SplitProfilesEnabled.Value && _profiles != null; if (RunInProgress && !string.IsNullOrEmpty(_activeMissionKey) && !string.IsNullOrEmpty(text) && !string.Equals(_activeMissionKey, text, StringComparison.OrdinalIgnoreCase) && ConfigManager.SplitOnSubMissionTransition.Value) { if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogInfo((object)("Sub-mission transition '" + _activeMissionKey + "' → '" + text + "': split, reset, swap profile, start.")); } _client.Split(); RunInProgress = false; _incursionFloors.Reset(); _client.Reset(); if (flag) { _profiles.TrySwitchForMission(info); } _activeMissionKey = text; RunInProgress = true; _incursionFloors.BeginIfNeeded(text); _client.Start(); return; } if (!value && !flag) { _incursionFloors.BeginIfNeeded(text); return; } if (value) { if (value2 || flag) { _client.Reset(); RunInProgress = false; _incursionFloors.Reset(); } else if (RunInProgress) { if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogDebug((object)"Mission started ignored (run already in progress)."); } return; } } else if (flag && RunInProgress) { if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogDebug((object)"Split profile switch skipped (run already in progress)."); } return; } if (flag) { if (!value) { _client.Reset(); RunInProgress = false; _incursionFloors.Reset(); } _profiles.TrySwitchForMission(info); } if (!value) { _activeMissionKey = text; _incursionFloors.BeginIfNeeded(text); return; } _activeMissionKey = text; RunInProgress = true; _incursionFloors.BeginIfNeeded(text); if (ConfigManager.UseStartOrSplit.Value) { _client.StartOrSplit(); } else { _client.Start(); } } public void OnMissionCompleted() { if (!ConfigManager.Enabled.Value) { return; } if (!RunInProgress) { if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogDebug((object)"Mission complete ignored (no run in progress / already handled)."); } return; } RunInProgress = false; _incursionFloors.Reset(); if (ConfigManager.SplitOnMissionComplete.Value) { _client.Split(); } } public void OnMissionFailed(MissionState state) { //IL_002b: Unknown result type (might be due to invalid IL or missing references) if (!ConfigManager.Enabled.Value) { return; } if (!RunInProgress) { if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogDebug((object)$"Mission {state} ignored (no run in progress / already handled)."); } return; } RunInProgress = false; _incursionFloors.Reset(); if (ConfigManager.ResetOnMissionFail.Value) { _client.Reset(); } } public void OnObjectiveCompleted(ObjectiveBase objective, bool success) { if (ConfigManager.Enabled.Value && ConfigManager.SplitOnObjectiveComplete.Value && success && !((Object)(object)objective == (Object)null) && !objective.IsSideObjective && !objective.IsOptional && (!(objective is ExtractObjective) || ConfigManager.SplitOnExtractObjective.Value) && RunInProgress) { _client.Split(); } } public void OnIncursionFloorChanged(int currentFloor) { if (_incursionFloors.OnFloorChanged(currentFloor, RunInProgress, _activeMissionKey, _client)) { RunInProgress = false; } } public void UpdateLoadingState() { _loadTracker.Update(_client); } } public sealed class SplitProfileSwitcher { private readonly struct Mapping { public string Key { get; } public string Path { get; } public bool Substring { get; } public Mapping(string key, string path, bool substring) { Key = key; Path = path; Substring = substring; } } private readonly LiveSplitClient _client; private string _lastLoadedPath; private string _mapFilePath; private DateTime _mapFileWriteTimeUtc = DateTime.MinValue; private List _mappings = new List(); private string _mappingsRaw; public SplitProfileSwitcher(LiveSplitClient client) { _client = client ?? throw new ArgumentNullException("client"); } public string TrySwitchForMission(MissionIdentity.Info mission) { if (!ConfigManager.SplitProfilesEnabled.Value) { return null; } ReloadMappingsIfNeeded(); string text = ResolvePath(mission); if (string.IsNullOrWhiteSpace(text)) { if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogInfo((object)("Split profile: no mapping/default for mission " + MissionIdentity.FormatForLog(mission))); } return null; } text = NormalizePath(text); if (!File.Exists(text)) { ManualLogSource log = LiveSplitHooksPlugin.Log; if (log != null) { log.LogWarning((object)("Split profile file not found: " + text + " (mission " + mission.PrimaryKey + ")")); } return text; } if (string.Equals(_lastLoadedPath, text, StringComparison.OrdinalIgnoreCase)) { if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogDebug((object)("Split profile already loaded: " + text)); } return text; } _client.SwitchSplits(text); _lastLoadedPath = text; if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogInfo((object)("Split profile switched → " + text + " for " + MissionIdentity.FormatForLog(mission))); } return text; } private string ResolvePath(MissionIdentity.Info mission) { string directory = ConfigManager.SplitProfilesDirectory.Value?.Trim() ?? string.Empty; foreach (string item in mission.MatchCandidates()) { foreach (Mapping mapping in _mappings) { if (!mapping.Substring && string.Equals(item, mapping.Key, StringComparison.OrdinalIgnoreCase)) { return MakeAbsolute(mapping.Path, directory); } } } foreach (string item2 in mission.MatchCandidates()) { foreach (Mapping mapping2 in _mappings) { if (((!mapping2.Substring && ConfigManager.SplitProfilesAllowSubstringMatch.Value) || mapping2.Substring) && (item2.IndexOf(mapping2.Key, StringComparison.OrdinalIgnoreCase) >= 0 || mapping2.Key.IndexOf(item2, StringComparison.OrdinalIgnoreCase) >= 0)) { return MakeAbsolute(mapping2.Path, directory); } } } if (ConfigManager.SplitProfilesAllowSubstringMatch.Value) { foreach (string item3 in mission.MatchCandidates()) { foreach (Mapping mapping3 in _mappings) { if (item3.IndexOf(mapping3.Key, StringComparison.OrdinalIgnoreCase) >= 0) { return MakeAbsolute(mapping3.Path, directory); } } } } string text = ConfigManager.SplitProfilesDefaultSplits.Value?.Trim(); if (!string.IsNullOrEmpty(text)) { return MakeAbsolute(text, directory); } return null; } private void ReloadMappingsIfNeeded() { string text = ConfigManager.SplitProfilesMappings.Value ?? string.Empty; string text2 = ConfigManager.SplitProfilesMapFile.Value?.Trim() ?? string.Empty; bool flag = false; DateTime dateTime = DateTime.MinValue; if (!string.IsNullOrEmpty(text2) && File.Exists(text2)) { dateTime = File.GetLastWriteTimeUtc(text2); flag = !string.Equals(text2, _mapFilePath, StringComparison.OrdinalIgnoreCase) || dateTime != _mapFileWriteTimeUtc; } else if (!string.Equals(text2, _mapFilePath, StringComparison.OrdinalIgnoreCase)) { flag = true; } if (flag || !string.Equals(text, _mappingsRaw, StringComparison.Ordinal)) { _mappingsRaw = text; _mapFilePath = text2; _mapFileWriteTimeUtc = dateTime; _mappings = ParseAll(text, text2); if (ConfigManager.DebugLogging.Value) { LiveSplitHooksPlugin.Log.LogInfo((object)string.Format("Split profile mappings loaded: {0} entr{1}.", _mappings.Count, (_mappings.Count == 1) ? "y" : "ies")); } } } private static List ParseAll(string rawConfig, string mapFile) { List list = new List(); ParseInto(list, rawConfig); if (!string.IsNullOrEmpty(mapFile) && File.Exists(mapFile)) { try { ParseInto(list, File.ReadAllText(mapFile, Encoding.UTF8)); } catch (Exception ex) { ManualLogSource log = LiveSplitHooksPlugin.Log; if (log != null) { log.LogWarning((object)("Failed to read split profile map file: " + ex.Message)); } } } return list; } private static void ParseInto(List list, string text) { if (string.IsNullOrWhiteSpace(text)) { return; } using StringReader stringReader = new StringReader(text); string text2; while ((text2 = stringReader.ReadLine()) != null) { text2 = text2.Trim(); if (text2.Length == 0 || text2.StartsWith("#") || text2.StartsWith("//")) { continue; } bool substring = false; if (text2.StartsWith("~", StringComparison.Ordinal)) { substring = true; text2 = text2.Substring(1).TrimStart(); } string text3 = null; string text4 = null; int num = text2.IndexOf("=>", StringComparison.Ordinal); if (num >= 0) { text3 = text2.Substring(0, num).Trim(); text4 = text2.Substring(num + 2).Trim(); } else { int num2 = text2.IndexOf('|'); int num3 = text2.IndexOf('='); int num4 = -1; if (num2 >= 0 && (num3 < 0 || num2 < num3)) { num4 = num2; } else if (num3 >= 0) { num4 = num3; } if (num4 >= 0) { text3 = text2.Substring(0, num4).Trim(); text4 = text2.Substring(num4 + 1).Trim(); } } if (!string.IsNullOrEmpty(text3) && !string.IsNullOrEmpty(text4)) { text4 = Unquote(text4); text3 = Unquote(text3); list.Add(new Mapping(text3, text4, substring)); } } } private static string Unquote(string s) { if (s.Length >= 2 && ((s[0] == '"' && s[s.Length - 1] == '"') || (s[0] == '\'' && s[s.Length - 1] == '\''))) { return s.Substring(1, s.Length - 2); } return s; } private static string MakeAbsolute(string path, string directory) { path = path.Trim(); if (Path.IsPathRooted(path)) { return path; } if (!string.IsNullOrEmpty(directory)) { return Path.GetFullPath(Path.Combine(directory, path)); } return Path.GetFullPath(path); } private static string NormalizePath(string path) { try { return Path.GetFullPath(path); } catch { return path; } } } namespace LiveSplitHooks { public static class MyPluginInfo { public const string PLUGIN_GUID = "LiveSplitHooks"; public const string PLUGIN_NAME = "LiveSplitHooks"; public const string PLUGIN_VERSION = "1.2.4"; } } namespace System.Runtime.CompilerServices { [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] internal sealed class IgnoresAccessChecksToAttribute : Attribute { public IgnoresAccessChecksToAttribute(string assemblyName) { } } }