From 14fea4e4a437fc29ca00b72f42fc877d5ddea086 Mon Sep 17 00:00:00 2001
From: ZypherusKhan <188088240+ZypherusKhan@users.noreply.github.com>
Date: Mon, 24 Aug 2026 12:32:12 +0530
Subject: [PATCH] Add LevelImposter compatibility layer (#684)
Co-authored-by: Gurge44 <90392754+Gurge44@users.noreply.github.com>
---
.../LevelImposterCompatibility.cs | 301 ++++++++++++++++++
.../SubmergedCompatibility.cs | 0
2 files changed, 301 insertions(+)
create mode 100644 Modules/ModCompatibility/LevelImposterCompatibility.cs
rename SubmergedCompatibility.cs => Modules/ModCompatibility/SubmergedCompatibility.cs (100%)
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