using S3.Core; using S3.Core.Ui; using UI.Common; using UI.Map; using UnityEngine; using UnityModManagerNet; namespace S3.Modules.Popout; /// /// S³ module that detaches the in-game map into a resizable native OS window /// (rendered by the shared Win32 + D3D11 + Dear ImGui native engine). /// /// Loads the native S3Native.dll on enable, then spawns a /// MonoBehaviour that drives the per-frame map button, hotkey, and panel lifecycle. /// public sealed class PopoutModule : IModule { private const string SettingsFile = "S3.popout.json"; public static PopoutSettings Settings { get; private set; } = new(); // Persistent KeyBinding instance (not rebuilt per frame) so .Down() edge detection works. private static KeyBinding _hotkey = new(); public static KeyBinding Hotkey => _hotkey; private static DetachedPanel? _activePanel; private static Window? _hiddenWindow; private static Vector3 _savedWindowScale; private static bool _mapWasOpen; private static GameObject? _host; // Delayed-open state: after the in-game overlay closes we wait before handing the // camera to DetachedPanel, giving Unity a full half-second to tear down the RT. private static bool _pendingOpen; private static float _pendingTimer; // True when the in-game overlay was active at the moment the popout was triggered. // Restored when the popout closes so the user gets their overlay back automatically. private static bool _inGameWasOpen; // Short delay before reopening the overlay after a popout closes. // Gives Unity time to finish its end-of-frame Destroy for the popout's RT so the // D3D address is fully gone before UiHost.ActivateMap allocates a new one. private static bool _pendingOverlayOpen; private static float _pendingOverlayTimer; public static bool IsDetached => _activePanel != null; public PopoutModule() { Settings = SettingsStore.Load(SettingsFile); RebuildHotkey(); } public string Id => "popout"; public string DisplayName => "Map Popout"; public string Description => "Detaches the in-game map into a separate resizable window you can put on a " + "second monitor. Open a save, then press the hotkey or use the Pop Out button " + "on the map window. Optional MapEnhancer integration for follow modes."; public bool Enabled { get => Settings.enabled; set => Settings.enabled = value; } public void OnEnable() { if (!NativeLoader.EnsureLoaded()) { Log.Error("[popout] native load failed — module will not run this session."); return; } _host = new GameObject("S3.Popout.Host"); Object.DontDestroyOnLoad(_host); _host.AddComponent(); } public void OnDisable() { // Reserved for live toggling. When wired, destroy the active panel + host here. if (_activePanel != null) CloseActivePanel("[popout] Module disabled."); _pendingOpen = false; _pendingOverlayOpen = false; if (_host != null) { Object.Destroy(_host); _host = null; } } public void SaveSettings() => Persist(); internal static void Persist() => SettingsStore.Save(SettingsFile, Settings); public void DrawSettings() => PopoutSettingsUI.Draw(); internal static void RebuildHotkey() => _hotkey = new KeyBinding { modifiers = (byte)Settings.hotkeyModifiers, keyCode = (KeyCode)Settings.hotkeyKeyCode }; // Called by UiService (Pop Out toolbar button or F10) to transition the map from // the in-game overlay to the OS popout window. Captures the overlay state BEFORE // closing so the overlay can be restored when the popout is later dismissed. internal static void ScheduleExternalLaunch() { if (_activePanel != null || _pendingOpen) return; // already detached or pending if (_host == null) { if (!NativeLoader.EnsureLoaded()) return; _host = new GameObject("S3.Popout.Host"); Object.DontDestroyOnLoad(_host); _host.AddComponent(); } _inGameWasOpen = UiService.IsOverlayVisible; // capture before closing UiService.CloseOverlay(); // release camera + RT now _pendingOpen = true; _pendingTimer = 0.5f; } // Called each frame by PopoutHost. internal static void Tick() { MapWindowButton.TryInstall(); MapWindowButton.UpdateLabel(_activePanel != null); // Delayed open: wait for the overlay's RT to fully tear down before DetachedPanel // grabs the camera. if (_pendingOpen) { _pendingTimer -= UnityEngine.Time.deltaTime; if (_pendingTimer <= 0f) { _pendingOpen = false; DoOpenPopout(); } } // Delayed overlay restore: wait one frame for the popout's RT Destroy to process // at end-of-frame before UiHost.ActivateMap allocates a new RT. if (_pendingOverlayOpen) { _pendingOverlayTimer -= UnityEngine.Time.deltaTime; if (_pendingOverlayTimer <= 0f) { _pendingOverlayOpen = false; UiService.OpenOverlay(); } } if (_hotkey.Down()) Toggle(); _activePanel?.Update(); if (_activePanel != null && (!_activePanel.IsAlive || _activePanel.CloseRequested)) { CloseActivePanel("[popout] Map popout closed."); } } internal static void Toggle() { if (_activePanel != null) { CloseActivePanel("[popout] Map re-attached."); return; } // If the in-game overlay owns the camera, schedule a delayed open so the RT // has time to fully tear down before DetachedPanel grabs the camera. if (UiService.IsOverlayVisible) { _inGameWasOpen = true; UiService.CloseOverlay(); _pendingOpen = true; _pendingTimer = 0.5f; return; } DoOpenPopout(); } // Closes the active popout panel and optionally restores the in-game overlay. private static void CloseActivePanel(string logMsg) { _activePanel!.Destroy(); _activePanel = null; bool wasInGame = _inGameWasOpen; _inGameWasOpen = false; RestoreInGameWindow(); if (wasInGame) { // Delay overlay reopen by ~0.1 s so Unity's end-of-frame Destroy for the // popout's RT finishes before UiHost.ActivateMap creates a new one. Without // this, the pool can reissue the same D3D address and the deferred Destroy // then invalidates the brand-new RT from under ActivateMap. _pendingOverlayOpen = true; _pendingOverlayTimer = 0.1f; } Log.Info(logMsg); } // Creates the DetachedPanel after the camera is free. private static void DoOpenPopout() { // Remember whether the map was already open so we can close it again on teardown. Window? win = PanelFinder.GetMapWindowUI(); _mapWasOpen = win != null && win.IsShown; UiService.MapBypass = true; MapWindow.Show(); UiService.MapBypass = false; if (!PanelFinder.IsMapReady()) { Log.Warn("[popout] Map not ready - ensure a save is loaded."); return; } // Scale the in-game panel to zero (invisible, all components active). Using // localScale (not sizeDelta) also scales away the corner-anchored chrome // and restores reliably to (1,1,1). _hiddenWindow = PanelFinder.GetMapWindowUI(); if (_hiddenWindow != null && _hiddenWindow.transform is RectTransform rect) { _savedWindowScale = rect.localScale; rect.localScale = Vector3.zero; } _activePanel = new DetachedPanel("Railroader Map"); if (!_activePanel.IsAlive) { Log.Error("[popout] RRPOPOUT_CreateWindow returned 0."); _activePanel = null; RestoreInGameWindow(); return; } Log.Info("[popout] Map detached."); } private static void RestoreInGameWindow() { if (_hiddenWindow == null) return; if (!_mapWasOpen) { // Close the window while localScale is still zero (invisible) to prevent a // 1-frame flash. Toggle() is used instead of SetActive(false) so MapWindow // remains findable by FindObjectOfType on the next open. UiService.MapBypass = true; MapWindow.Toggle(); UiService.MapBypass = false; } // Always restore scale so the next map open starts from normal state. if (_hiddenWindow.transform is RectTransform rect) rect.localScale = _savedWindowScale; _hiddenWindow = null; } }