diff --git a/Modules/ModCompatibility/LevelImposterCompatibility.cs b/Modules/ModCompatibility/LevelImposterCompatibility.cs new file mode 100644 index 000000000..2a0b093a7 --- /dev/null +++ b/Modules/ModCompatibility/LevelImposterCompatibility.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using BepInEx; +using BepInEx.Unity.IL2CPP; +using HarmonyLib; +using UnityEngine; + +namespace EHR.Modules; + +/// +/// Reflection-based wrapper for LevelImposter's MapInfo API. +/// Call once after IL2CPPChainloader finishes loading plugins. +/// See for the API this wraps. +/// +public static class LevelImposterCompatibility +{ + private const string LevelImposterGuid = "com.DigiWorm.LevelImposter"; + private const string MapInfoTypeName = "LevelImposter.Core.ModCompatibility.MapInfo"; + + private static bool _initialized; + private static bool _isInstalled; + + private static PropertyInfo _mapIdProp; + private static PropertyInfo _hasCamsProp; + private static PropertyInfo _hasBinocularsProp; + private static PropertyInfo _hasAdminTableProp; + private static PropertyInfo _hasVitalsProp; + private static PropertyInfo _hasSporesProp; + private static PropertyInfo _hasMovingPlatformProp; + private static PropertyInfo _hasLadderProp; + private static PropertyInfo _hasDoorsProp; + private static PropertyInfo _hasVentsProp; + private static PropertyInfo _hasCustomEjectAnimationProp; + private static PropertyInfo _hasTeleporterProp; + private static PropertyInfo _hasDeathTriggerProp; + private static PropertyInfo _hasDecontaminationProp; + private static PropertyInfo _hasDoorLogsProp; + private static PropertyInfo _allCameraPanelsProp; + private static PropertyInfo _allBinocularsProp; + private static PropertyInfo _allAdminTablesProp; + private static PropertyInfo _allVitalsProp; + private static PropertyInfo _allSporesProp; + private static PropertyInfo _allMovingPlatformsProp; + private static PropertyInfo _allLaddersProp; + private static PropertyInfo _allDoorsProp; + private static PropertyInfo _allVentsProp; + private static PropertyInfo _allCustomEjectAnimationsProp; + private static PropertyInfo _allTeleportersProp; + private static PropertyInfo _allDeathTriggersProp; + private static MethodInfo _getDoorTypeMethod; + + /// Whether LevelImposter is currently loaded. + public static bool IsInstalled => _isInstalled; + + /// + /// The current LI map ID, or null if LI is not installed or no map is loaded. + /// Note: LI may keep a randomized fallback map loaded at all times for mods that do map + /// randomization, so a non-null value here does not mean the player is on a LI map mid-game. + /// + public static string MapID => GetString(_mapIdProp); + + /// True if a LI map is currently selected (MapID is non-null). + public static bool IsLevelImposterMap => MapID != null; + + public static bool HasCams => GetBool(_hasCamsProp); + public static bool HasBinoculars => GetBool(_hasBinocularsProp); + public static bool HasAdminTable => GetBool(_hasAdminTableProp); + public static bool HasVitals => GetBool(_hasVitalsProp); + public static bool HasSpores => GetBool(_hasSporesProp); + public static bool HasMovingPlatform => GetBool(_hasMovingPlatformProp); + public static bool HasLadder => GetBool(_hasLadderProp); + public static bool HasDoors => GetBool(_hasDoorsProp); + public static bool HasVents => GetBool(_hasVentsProp); + public static bool HasCustomEjectAnimation => GetBool(_hasCustomEjectAnimationProp); + public static bool HasTeleporter => GetBool(_hasTeleporterProp); + public static bool HasDeathTrigger => GetBool(_hasDeathTriggerProp); + public static bool HasDecontamination => GetBool(_hasDecontaminationProp); + + /// Always false - door logs are not implemented in LI. + public static bool HasDoorLogs => GetBool(_hasDoorLogsProp); + + /// These return empty until after ShipStatus.Awake. + public static IEnumerable AllCameraPanels => GetObjects(_allCameraPanelsProp); + public static IEnumerable AllBinoculars => GetObjects(_allBinocularsProp); + public static IEnumerable AllAdminTables => GetObjects(_allAdminTablesProp); + public static IEnumerable AllVitals => GetObjects(_allVitalsProp); + public static IEnumerable AllSpores => GetObjects(_allSporesProp); + public static IEnumerable AllMovingPlatforms => GetObjects(_allMovingPlatformsProp); + public static IEnumerable AllLadders => GetObjects(_allLaddersProp); + public static IEnumerable AllDoors => GetObjects(_allDoorsProp); + public static IEnumerable AllVents => GetObjects(_allVentsProp); + public static IEnumerable AllCustomEjectAnimations => GetObjects(_allCustomEjectAnimationsProp); + public static IEnumerable AllTeleporters => GetObjects(_allTeleportersProp); + public static IEnumerable AllDeathTriggers => GetObjects(_allDeathTriggersProp); + + /// Fired when LI calls its OnMapChange hook, i.e. when the selected map changes in the lobby. + public static event Action OnMapChanged; + + /// + /// Returns the door type used by the current LI map. + /// Possible values: "none", "skeld" (auto timer), "polus" (manual switches), "airship" (card swipe). + /// Returns "none" if LI is not installed. + /// + public static string GetDoorType() + { + if (!_isInstalled || _getDoorTypeMethod == null) + return "none"; + + try + { + return (string)_getDoorTypeMethod.Invoke(null, null) ?? "none"; + } + catch (Exception ex) + { + Logger.Error($"[LI] GetDoorType failed: {ex}", "LI"); + return "none"; + } + } + + /// Called by our Harmony postfix on LI's OnMapChange stub. + internal static void HandleMapChange() + { + try + { + OnMapChanged?.Invoke(); + } + catch (Exception ex) + { + Logger.Error($"[LI] OnMapChanged handler threw: {ex}", "LI"); + } + } + + /// True if the map has cameras or binoculars. + public static bool HasAnySurveillance => HasCams || HasBinoculars; + + /// True if the map has doors or spores. + public static bool HasSabotageFeatures => HasDoors || HasSpores; + + /// + /// Returns all vents on the current map. + /// Falls back to vanilla ShipStatus vents if LI is not installed or has no LI vents. + /// + public static IEnumerable GetAllVents() + { + if (_isInstalled && AllVents.Any()) + return AllVents.Select(go => go.GetComponent()).Where(v => v != null); + + return ShipStatus.Instance != null ? ShipStatus.Instance.AllVents : []; + } + + /// Logs all detected LI map features to the console. Useful for debugging role enable logic on custom maps. + public static void LogMapFeatures() + { + if (!_isInstalled) + { + Logger.Info("[LI] Not installed", "LI"); + return; + } + + Logger.Info($"[LI] Map: {MapID ?? "(none)"}", "LI"); + Logger.Info($"[LI] Cams: {HasCams}, Binoculars: {HasBinoculars}, Admin: {HasAdminTable}, Vitals: {HasVitals}", "LI"); + Logger.Info($"[LI] Spores: {HasSpores}, Platform: {HasMovingPlatform}, Ladder: {HasLadder}", "LI"); + Logger.Info($"[LI] Doors: {HasDoors} ({GetDoorType()}), Vents: {HasVents}", "LI"); + Logger.Info($"[LI] CustomEject: {HasCustomEjectAnimation}, Teleporter: {HasTeleporter}, DeathTrigger: {HasDeathTrigger}, Decontam: {HasDecontamination}", "LI"); + } + + /// + /// Initializes the LI compatibility layer. + /// Call this once after IL2CPPChainloader finishes loading plugins, same as SubmergedCompatibility.Initialize(). + /// + public static void Init() + { + if (_initialized) return; + _initialized = true; + + if (!IL2CPPChainloader.Instance.Plugins.TryGetValue(LevelImposterGuid, out PluginInfo plugin)) + { + Logger.Info("[LI] Not found, skipping", "LI"); + return; + } + + // Try LI's own assembly first, then fall back to scanning everything + Type mapInfoType = plugin.Instance?.GetType().Assembly.GetType(MapInfoTypeName, throwOnError: false); + + if (mapInfoType == null) + { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + mapInfoType = assembly.GetType(MapInfoTypeName, throwOnError: false); + if (mapInfoType != null) break; + } + catch { } + } + } + + if (mapInfoType == null) + { + Logger.Warn("[LI] Plugin found but MapInfo type is missing", "LI"); + return; + } + + _isInstalled = true; + Logger.Info("[LI] Loaded, binding MapInfo API", "LI"); + + var flags = BindingFlags.Public | BindingFlags.Static; + + _mapIdProp = mapInfoType.GetProperty("MapID", flags); + _hasCamsProp = mapInfoType.GetProperty("HasCams", flags); + _hasBinocularsProp = mapInfoType.GetProperty("HasBinoculars", flags); + _hasAdminTableProp = mapInfoType.GetProperty("HasAdminTable", flags); + _hasVitalsProp = mapInfoType.GetProperty("HasVitals", flags); + _hasSporesProp = mapInfoType.GetProperty("HasSpores", flags); + _hasMovingPlatformProp = mapInfoType.GetProperty("HasMovingPlatform", flags); + _hasLadderProp = mapInfoType.GetProperty("HasLadder", flags); + _hasDoorsProp = mapInfoType.GetProperty("HasDoors", flags); + _hasVentsProp = mapInfoType.GetProperty("HasVents", flags); + _hasCustomEjectAnimationProp = mapInfoType.GetProperty("HasCustomEjectAnimation", flags); + _hasTeleporterProp = mapInfoType.GetProperty("HasTeleporter", flags); + _hasDeathTriggerProp = mapInfoType.GetProperty("HasDeathTrigger", flags); + _hasDecontaminationProp = mapInfoType.GetProperty("HasDecontamination", flags); + _hasDoorLogsProp = mapInfoType.GetProperty("HasDoorLogs", flags); + _allCameraPanelsProp = mapInfoType.GetProperty("AllCameraPanels", flags); + _allBinocularsProp = mapInfoType.GetProperty("AllBinoculars", flags); + _allAdminTablesProp = mapInfoType.GetProperty("AllAdminTables", flags); + _allVitalsProp = mapInfoType.GetProperty("AllVitals", flags); + _allSporesProp = mapInfoType.GetProperty("AllSpores", flags); + _allMovingPlatformsProp = mapInfoType.GetProperty("AllMovingPlatforms", flags); + _allLaddersProp = mapInfoType.GetProperty("AllLadders", flags); + _allDoorsProp = mapInfoType.GetProperty("AllDoors", flags); + _allVentsProp = mapInfoType.GetProperty("AllVents", flags); + _allCustomEjectAnimationsProp = mapInfoType.GetProperty("AllCustomEjectAnimations", flags); + _allTeleportersProp = mapInfoType.GetProperty("AllTeleporters", flags); + _allDeathTriggersProp = mapInfoType.GetProperty("AllDeathTriggers", flags); + _getDoorTypeMethod = mapInfoType.GetMethod("GetDoorType", flags); + + // LI calls OnMapChange() internally whenever the map changes. It's an empty stub + // designed for other mods to patch into. We hook it here to fire our OnMapChanged event. + MethodInfo onMapChange = mapInfoType.GetMethod("OnMapChange", flags); + if (onMapChange != null) + { + var harmony = new Harmony("ehr.levelimposter.patch"); + harmony.Patch(onMapChange, postfix: new HarmonyMethod(typeof(LevelImposterCompatibility), nameof(HandleMapChange))); + } + } + + private static bool GetBool(PropertyInfo prop) + { + if (!_isInstalled || prop == null) return false; + + try + { + return (bool)prop.GetValue(null); + } + catch (Exception ex) + { + Logger.Error($"[LI] Failed to read {prop.Name}: {ex}", "LI"); + return false; + } + } + + private static string GetString(PropertyInfo prop) + { + if (!_isInstalled || prop == null) return null; + + try + { + return prop.GetValue(null) as string; + } + catch (Exception ex) + { + Logger.Error($"[LI] Failed to read {prop.Name}: {ex}", "LI"); + return null; + } + } + + private static IEnumerable GetObjects(PropertyInfo prop) + { + if (!_isInstalled || prop == null) return []; + + try + { + // Direct IEnumerable cast fails across IL2CPP assembly boundaries even + // when the objects are valid GameObjects, so go through non-generic IEnumerable instead. + var value = prop.GetValue(null); + if (value is IEnumerable enumerable) + return enumerable.Cast().OfType(); + + return []; + } + catch (Exception ex) + { + Logger.Error($"[LI] Failed to read {prop.Name}: {ex}", "LI"); + return []; + } + } +} diff --git a/SubmergedCompatibility.cs b/Modules/ModCompatibility/SubmergedCompatibility.cs similarity index 100% rename from SubmergedCompatibility.cs rename to Modules/ModCompatibility/SubmergedCompatibility.cs