using System.Collections;
using HarmonyLib;
using S3.Modules.Popout; // NativeLoader + Native + PanelFinder + MapEnhancerBridge
using UI; // GameInput
using UI.Common; // Window
using UI.Map; // MapBuilder, MapWindow
using UnityEngine;
namespace S3.Core.Ui;
///
/// Always-on core service that hosts Dear ImGui inside the game window,
/// reusing the same native Win32 + D3D11 + ImGui engine that powers the map popout.
/// Renders the map into a floating in-game window (F10 to toggle), with full chrome
/// (toolbar, compass, follow/jump menus) matching the OS popout surface. Also
/// intercepts the base-game map-open hotkey and "Show on map" links so our overlay
/// opens instead of the stock map panel.
///
public static class UiService
{
private static GameObject? _host;
private static UiHost? _uiHost;
// Set to true before calling MapWindow.Show/Toggle internally so the intercept
// patches let those calls through without redirecting to the overlay.
internal static bool MapBypass { get; set; }
// True while the in-game overlay is visible. Used by PopoutModule to track
// whether it should restore the overlay when the popout is later closed.
internal static bool IsOverlayVisible => _uiHost?.IsVisible ?? false;
/// Spawn the persistent host. Idempotent; called once from Main.
public static void Install()
{
if (_host != null) return;
var harmony = new Harmony("S3.ui");
harmony.CreateClassProcessor(typeof(GameInput_IsMouseOverUI_Patch)).Patch();
harmony.CreateClassProcessor(typeof(GameInput_IsMouseOverGameWindow_Patch)).Patch();
harmony.CreateClassProcessor(typeof(MapWindow_Toggle_Patch)).Patch();
harmony.CreateClassProcessor(typeof(MapWindow_Show_Patch)).Patch();
harmony.CreateClassProcessor(typeof(MapWindow_ShowPos_Patch)).Patch();
_host = new GameObject("S3.UiService");
Object.DontDestroyOnLoad(_host);
_uiHost = _host.AddComponent();
}
// Close the overlay if visible. Called by PopoutModule when it opens the popout
// (they share the one map camera — only one surface can own it at a time).
internal static void CloseOverlay() => _uiHost?.HideIfVisible();
// Open the overlay if hidden. Called by the map-open interceptor.
internal static void OpenOverlay() => _uiHost?.ShowIfHidden();
// Toggle the overlay. Called by the map hotkey interceptor.
internal static void ToggleOverlay() => _uiHost?.ToggleVisible();
}
// While the cursor is over our in-game overlay, tell the game the mouse is "over UI"
// and "not over the game window" so its camera pan/zoom and world-click input are
// suppressed (Railroader reads mouse input raw and gates it through these checks).
[HarmonyPatch(typeof(GameInput), nameof(GameInput.IsMouseOverUI))]
internal static class GameInput_IsMouseOverUI_Patch
{
private static void Postfix(ref bool __result)
{
if (UiHost.MouseOverOverlay) __result = true;
}
}
[HarmonyPatch(typeof(GameInput), nameof(GameInput.IsMouseOverGameWindow))]
internal static class GameInput_IsMouseOverGameWindow_Patch
{
private static void Postfix(ref bool __result)
{
if (UiHost.MouseOverOverlay) __result = false;
}
}
// Intercept the map hotkey (GameInput calls Toggle() when M / the bound key is pressed).
// Open our overlay instead of the stock map panel.
[HarmonyPatch(typeof(MapWindow), nameof(MapWindow.Toggle))]
internal static class MapWindow_Toggle_Patch
{
private static bool Prefix()
{
if (UiService.MapBypass) return true;
UiService.ToggleOverlay();
return false;
}
}
// Intercept no-arg Show (safety net — game currently only calls Toggle or Show(Vector3)).
[HarmonyPatch(typeof(MapWindow), nameof(MapWindow.Show), new System.Type[] { })]
internal static class MapWindow_Show_Patch
{
private static bool Prefix()
{
if (UiService.MapBypass) return true;
UiService.OpenOverlay();
return false;
}
}
// Intercept "Show on map" links (EngineRosterRow, EmployeesPanelBuilder).
// Open our overlay and jump the map camera to the requested position.
[HarmonyPatch(typeof(MapWindow), nameof(MapWindow.Show), new System.Type[] { typeof(Vector3) })]
internal static class MapWindow_ShowPos_Patch
{
private static bool Prefix(Vector3 gamePosition)
{
if (UiService.MapBypass) return true;
UiService.OpenOverlay();
MapBuilder.Shared?.SetMapCenter(gamePosition);
return false;
}
}
///
/// Per-frame driver for the in-game overlay. Lives on a DontDestroyOnLoad object
/// so it survives scene loads. Toggles visibility on F10 and, while visible,
/// pumps an input snapshot to native and issues the overlay render event at end
/// of frame so ImGui composites over the finished game image.
///
internal sealed class UiHost : MonoBehaviour
{
private System.IntPtr _renderFunc = System.IntPtr.Zero;
private int _eventId;
private int _overlayHandle;
private bool _nativeReady;
private bool _visible;
// Throwaway texture: its only job is to let native call tex->GetDevice() and
// acquire Unity's D3D11 device (UnityPluginLoad never fires for a manually
// LoadLibrary'd plugin). Kept referenced so the GC never collects it.
private Texture2D? _deviceTex;
// In-game map drag/zoom forwarding (events polled from native each frame).
private const int kMaxInput = 64;
private static readonly InputEvent[] s_inputBuf = new InputEvent[kMaxInput];
private bool _drag;
private bool _didDrag;
private float _dragStartX, _dragStartY, _camStartX, _camStartZ;
private const float kClickThreshold = 0.02f;
// True while the overlay is visible and the cursor is over an ImGui window. Read
// by the GameInput Harmony patches to suppress the game's world-mouse input.
internal static bool MouseOverOverlay { get; private set; }
// Map camera takeover (mirrors the popout's DetachedPanel). While the overlay is
// up we render the map camera into our own RT and collapse the base game map
// window, so the map works without the base map open and without its full-screen
// UI cost. Restored when the overlay is hidden.
private Camera? _mapCamera;
private RenderTexture? _ownRT;
private RenderTexture? _savedTarget;
private Rect _savedRect;
private float _savedAspect;
private Window? _hiddenWindow;
private Vector3 _savedWinScale;
private bool _mapWasOpen;
private bool _mapActive;
// Chrome state mirrored to the shared handle (status, rotation, follow modes) and
// commands forwarded back from the toolbar/compass. Mirrors DetachedPanel.
private float _mapRotationDeg;
private float _mapCamEulerX, _mapCamEulerZ;
private bool _mapSyncPlayer;
private bool _followPlayer;
private float _listTimer;
private bool _locationsSent;
private string _lastStatus = "";
private void Start()
{
if (!NativeLoader.EnsureLoaded())
{
Log.Error("[ui] native load failed - in-game overlay unavailable this session.");
enabled = false;
return;
}
_renderFunc = Native.RRPOPOUT_GetRenderEventFunc();
_eventId = Native.RRPOPOUT_GetOverlayEventId();
_deviceTex = new Texture2D(4, 4, TextureFormat.RGBA32, false);
_deviceTex.Apply(false, false);
Native.RRPOPOUT_SetOverlayDeviceTexture(_deviceTex.GetNativeTexturePtr());
// Shared UI-state holder for the in-game map chrome (toolbar + compass).
// Created up front so native can render the chrome; state/commands are
// pushed/polled through this handle.
_overlayHandle = Native.RRPOPOUT_GetOverlayWindowHandle();
_nativeReady = true;
// Apply saved theme and both alpha settings so the overlay opens with the right look.
MapThemes.ApplyFromSettings();
Native.RRPOPOUT_SetOverlayAlpha(PopoutModule.Settings.overlayAlpha);
Native.RRPOPOUT_SetOverlayMapAlpha(PopoutModule.Settings.overlayMapAlpha);
StartCoroutine(RenderLoop());
Log.Info("[ui] in-game overlay ready - press F10 to toggle.");
}
internal bool IsVisible => _visible;
private void Update()
{
if (!_nativeReady) return;
// Suppress game world-mouse input only while the cursor is actually over our
// window, so the rest of the screen stays interactive while the map floats.
MouseOverOverlay = _visible && Native.RRPOPOUT_OverlayWantsMouse() == 1;
// F10: switch the in-game map to the OS popout window. Only fires while the
// in-game overlay is active (no-op if neither map surface is open).
if (_visible && Input.GetKeyDown(KeyCode.F10))
PopoutModule.ScheduleExternalLaunch();
}
private void LateUpdate()
{
// The base game may reset mapCamera.targetTexture or enabled in its own scripts.
// Re-assert our RT and enabled state here — LateUpdate runs immediately before
// camera rendering, so this wins over anything the game set in Update/LateUpdate
// earlier in the same frame.
if (_mapActive && _mapCamera != null && _ownRT != null)
{
_mapCamera.enabled = true;
_mapCamera.targetTexture = _ownRT;
}
}
// ----- Called by UiService static methods (map-intercept + mutual exclusion) -----
internal void HideIfVisible()
{
if (!_nativeReady || !_visible) return;
_visible = false;
Native.RRPOPOUT_SetOverlayVisible(0);
MouseOverOverlay = false;
DeactivateMap();
Log.Info("[ui] overlay closed (external request).");
}
internal void ShowIfHidden()
{
if (!_nativeReady || _visible) return;
// Claim visible now so any re-entrant call from PopoutModule.RestoreInGameWindow
// -> UiService.OpenOverlay -> ShowIfHidden sees us as already open and no-ops.
_visible = true;
Native.RRPOPOUT_SetOverlayVisible(1);
if (PopoutModule.IsDetached)
{
// M pressed while popout owns the camera: close popout first,
// then fall through to ActivateMap below.
PopoutModule.Toggle();
}
ActivateMap();
Log.Info("[ui] overlay opened.");
}
internal void ToggleVisible()
{
if (_visible) HideIfVisible();
else ShowIfHidden();
}
// ---------------------------------------------------------------------------------
private IEnumerator RenderLoop()
{
var endOfFrame = new WaitForEndOfFrame();
while (true)
{
yield return endOfFrame;
if (!_visible) continue;
// Drive our own map camera render into our RT and hand it to native.
// If the map isn't ready yet (no save), keep retrying so it appears as
// soon as it becomes available. UV V is flipped (v0=1, v1=0) to convert
// Unity's bottom-up RT to D3D top-down.
if (!_mapActive) ActivateMap();
if (_mapActive && _mapCamera != null && _ownRT != null)
{
// Match the camera aspect to the overlay window's content region so
// the map fills it (no letterbox). Falls back to the RT aspect until
// native has reported a region size.
Native.RRPOPOUT_GetOverlayMapView(out float viewW, out float viewH);
float aspect = (viewW > 0f && viewH > 0f)
? viewW / viewH
: (float)_ownRT.width / _ownRT.height;
_mapCamera.enabled = true;
_mapCamera.targetTexture = _ownRT;
_mapCamera.rect = new Rect(0f, 0f, 1f, 1f);
_mapCamera.aspect = aspect;
Native.RRPOPOUT_SetOverlayMapTexture(
_ownRT.GetNativeTexturePtr(), 0f, 1f, 1f, 0f);
}
else
{
Native.RRPOPOUT_SetOverlayMapTexture(System.IntPtr.Zero, 0f, 1f, 1f, 0f);
}
// Unity mouse origin is bottom-left; ImGui wants top-left.
Vector3 mp = Input.mousePosition;
float mouseY = Screen.height - mp.y;
int wheel = (int)(Input.mouseScrollDelta.y * 120f); // WHEEL_DELTA units
Native.RRPOPOUT_SetOverlayInput(
Screen.width, Screen.height,
mp.x, mouseY,
Input.GetMouseButton(0) ? 1 : 0,
Input.GetMouseButton(1) ? 1 : 0,
wheel);
// After WaitForEndOfFrame the final backbuffer is bound, so the native
// callback draws ImGui on top of the completed game frame.
GL.IssuePluginEvent(_renderFunc, _eventId);
// Apply any drag/zoom the user did over the map image. We forward to the
// live map camera, so the in-game map and our mirror move together.
int n = Native.RRPOPOUT_PollOverlayInput(s_inputBuf, kMaxInput);
for (int i = 0; i < n; i++)
ForwardToMap(s_inputBuf[i]);
// Chrome: push live status/lists, apply follow/sync, run toolbar commands.
if (_mapActive && _mapCamera != null)
{
UpdateMapStatus();
RefreshMenuLists();
ApplyFollowAndSync();
int cn = Native.RRPOPOUT_PollInputEvents(_overlayHandle, s_inputBuf, kMaxInput);
for (int i = 0; i < cn; i++)
ForwardCommand(s_inputBuf[i]);
}
}
}
// Hides `win` by zeroing its localScale, saving the original for later restore.
// Safe to call multiple times — skips if _hiddenWindow is already set.
private void PreHideWindow(Window win)
{
if (_hiddenWindow != null) return;
if (win.transform is RectTransform rect)
{
_savedWinScale = rect.localScale;
rect.localScale = Vector3.zero;
}
_hiddenWindow = win;
}
// Restores _hiddenWindow's localScale on the next frame so any close
// animation the game plays runs while the window is still invisible (scale=0),
// then scale is returned to normal once it is fully hidden.
private System.Collections.IEnumerator DeferredScaleRestore(Window win, Vector3 scale)
{
yield return null;
if (win != null && win.transform is RectTransform rect)
rect.localScale = scale;
}
// Dragging cancels follow modes so the pan takes over (grab-to-pan).
private void CancelFollowForPan()
{
if (_followPlayer)
{
_followPlayer = false;
Native.RRPOPOUT_SetFollowPlayer(_overlayHandle, false);
}
if (MapEnhancerBridge.IsInstalled && MapEnhancerBridge.FollowMode)
MapEnhancerBridge.ToggleFollowMode();
}
// Rotates the map camera to `deg` and pushes the angle back so the compass needle
// reflects it. Mirrors DetachedPanel.ApplyMapRotation.
private void ApplyMapRotation(float deg)
{
_mapRotationDeg = ((deg % 360f) + 360f) % 360f;
if (_mapCamera != null)
_mapCamera.transform.rotation =
Quaternion.Euler(_mapCamEulerX, _mapRotationDeg, _mapCamEulerZ);
Native.RRPOPOUT_SetMapRotation(_overlayHandle, _mapRotationDeg);
}
// Pushes the toolbar status line (zoom + map/cam coordinates) when it changes.
private void UpdateMapStatus()
{
string status = PanelFinder.BuildStatusText(_mapCamera!);
if (status == _lastStatus) return;
Native.RRPOPOUT_SetStatusText(_overlayHandle, status);
Native.RRPOPOUT_SetMapZoom(_overlayHandle, _mapCamera!.orthographicSize);
_lastStatus = status;
}
// Refreshes the Follow/Jump menus: locos every 3 s, locations once.
private void RefreshMenuLists()
{
_listTimer += Time.deltaTime;
if (_listTimer < 3f) return;
_listTimer = 0f;
try
{
var locos = MapEnhancerBridge.GetLocoNames();
Native.RRPOPOUT_SetLocoList(_overlayHandle,
locos.Length > 0 ? string.Join("\n", locos) : "");
}
catch { }
if (!_locationsSent)
{
try
{
var locs = MapEnhancerBridge.GetLocationNames();
Native.RRPOPOUT_SetLocationList(_overlayHandle,
locs.Length > 0 ? string.Join("\n", locs) : "");
_locationsSent = true;
}
catch { }
}
}
// Drives continuous follow-player-position and sync-rotation-to-player modes.
private void ApplyFollowAndSync()
{
if (_followPlayer)
{
if (Camera.main != null)
{
var p = _mapCamera!.transform.position;
p.x = Camera.main.transform.position.x;
p.z = Camera.main.transform.position.z;
_mapCamera.transform.position = p;
}
else { _followPlayer = false; Native.RRPOPOUT_SetFollowPlayer(_overlayHandle, false); }
}
if (_mapSyncPlayer)
{
if (Camera.main != null) ApplyMapRotation(Camera.main.transform.eulerAngles.y);
else { _mapSyncPlayer = false; Native.RRPOPOUT_SetMapSyncPlayer(_overlayHandle, false); }
}
}
// Routes a toolbar/compass command (pushed by BuildMapUI into the state handle's
// queue) to the live map. Mirrors DetachedPanel.ForwardInputEvent's UICommand arm.
private void ForwardCommand(in InputEvent e)
{
if ((InputEventType)e.type != InputEventType.UICommand) return;
switch ((UICmd)(int)e.x)
{
case UICmd.Recenter:
UnityEngine.Object.FindObjectOfType()?.ClickLocateMe();
break;
case UICmd.FollowMode: MapEnhancerBridge.ToggleFollowMode(); break;
case UICmd.FollowPlayerCam: MapEnhancerBridge.FollowPlayerCamera(); break;
case UICmd.FollowSelectedLoco: MapEnhancerBridge.FollowSelectedLoco(); break;
case UICmd.FollowLoco: MapEnhancerBridge.FollowLoco((int)e.y); break;
case UICmd.JumpToLocation: MapEnhancerBridge.JumpToLocation((int)e.y); break;
case UICmd.SetRotation:
_mapSyncPlayer = false;
Native.RRPOPOUT_SetMapSyncPlayer(_overlayHandle, false);
ApplyMapRotation(e.y);
break;
case UICmd.RotateReset:
_mapSyncPlayer = false;
Native.RRPOPOUT_SetMapSyncPlayer(_overlayHandle, false);
ApplyMapRotation(0f);
break;
case UICmd.RotateSyncPlayer:
_mapSyncPlayer = !_mapSyncPlayer;
Native.RRPOPOUT_SetMapSyncPlayer(_overlayHandle, _mapSyncPlayer);
break;
case UICmd.ToggleFollowPlayer:
_followPlayer = !_followPlayer;
if (_followPlayer && MapEnhancerBridge.IsInstalled && MapEnhancerBridge.FollowMode)
MapEnhancerBridge.ToggleFollowMode();
Native.RRPOPOUT_SetFollowPlayer(_overlayHandle, _followPlayer);
break;
case UICmd.PopOut:
// Hand the map off to the OS popout window. ScheduleExternalLaunch
// captures our visible=true state, closes the overlay (DeactivateMap),
// and starts a 0.5 s timer before DetachedPanel takes the camera,
// ensuring the RT is fully torn down before the next owner grabs it.
PopoutModule.ScheduleExternalLaunch();
break;
case UICmd.SetTheme:
MapThemes.Apply((MapTheme)(int)e.y);
// Also update the map camera background colour immediately so it
// matches the new theme even before the next ActivateMap.
if (_mapCamera != null)
_mapCamera.backgroundColor =
MapThemes.GetMapBgColor((MapTheme)(int)e.y);
break;
case UICmd.SetAlpha:
float alpha = Mathf.Clamp(e.y, 0.1f, 1.0f);
PopoutModule.Settings.overlayAlpha = alpha;
PopoutModule.Persist();
Native.RRPOPOUT_SetOverlayAlpha(alpha);
break;
case UICmd.Close:
HideIfVisible();
break;
case UICmd.SetMapAlpha:
float mapAlpha = Mathf.Clamp(e.y, 0.0f, 1.0f);
PopoutModule.Settings.overlayMapAlpha = mapAlpha;
PopoutModule.Persist();
Native.RRPOPOUT_SetOverlayMapAlpha(mapAlpha);
break;
}
}
// Applies one map-image input event to the live map camera. Mirrors the pan/zoom
// math the popout uses (DetachedPanel.ForwardInputEvent). Events arrive in
// normalized [0,1] image space, top-left origin.
private void ForwardToMap(in InputEvent e)
{
var cam = MapBuilder.Shared?.mapCamera;
if (cam == null) return;
switch ((InputEventType)e.type)
{
case InputEventType.MouseWheel:
// Lower floor than the popout's 100 so you can zoom in close like the
// base game map; UpdateForZoom rescales icons to match.
cam.orthographicSize = Mathf.Clamp(
cam.orthographicSize * Mathf.Pow(0.9f, e.delta), 25f, 10000f);
PanelFinder.UpdateMapForZoom();
break;
case InputEventType.LButtonDown:
_drag = true;
_didDrag = false;
_dragStartX = e.x; _dragStartY = e.y;
_camStartX = cam.transform.position.x;
_camStartZ = cam.transform.position.z;
break;
case InputEventType.LButtonUp:
// A click (no meaningful drag) is forwarded to the map's click handler
// so switches, signals, etc. respond just like in the base game map.
if (_drag && !_didDrag)
PanelFinder.GetMapDrag()?.OnClick?.Invoke(new Vector2(e.x, 1f - e.y));
_drag = false;
break;
case InputEventType.MouseMove:
if (!_drag) break;
if (!_didDrag &&
(Mathf.Abs(e.x - _dragStartX) > kClickThreshold ||
Mathf.Abs(e.y - _dragStartY) > kClickThreshold))
{
_didDrag = true; // first frame of a real drag
if (PopoutModule.Settings.panDisablesFollow)
CancelFollowForPan();
}
if (!_didDrag) break;
float ortho = cam.orthographicSize;
float aspect = cam.aspect > 0f ? cam.aspect : 1.7f;
// Screen-space drag (normalized) scaled to world units, then rotated
// into world space by the camera's orientation (handles map rotation).
float dx = (e.x - _dragStartX) * ortho * 2f * aspect;
float dz = (e.y - _dragStartY) * ortho * 2f;
Vector3 wd = -cam.transform.right * dx + cam.transform.up * dz;
cam.transform.position = new Vector3(
_camStartX + wd.x, cam.transform.position.y, _camStartZ + wd.z);
break;
}
}
// Takes over the map camera: collapse the base game map window and redirect the
// camera into our own RT. Cheap no-op until a save is loaded, so it is safe to
// retry every frame. Skipped while the popout owns the camera (mutually exclusive).
private void ActivateMap()
{
if (_mapActive) return;
if (MapBuilder.Shared?.mapCamera == null) return; // no save yet; retry later
if (PopoutModule.IsDetached) return; // popout owns the map camera
try
{
var winUI = PanelFinder.GetMapWindowUI();
_mapWasOpen = winUI != null && winUI.IsShown;
// Pre-hide the window BEFORE Show() so it never has a frame at full
// scale. Belt 1: catches the common case and prevents flash even if
// IsMapReady() returns false and we bail out early below.
if (winUI != null) PreHideWindow(winUI);
UiService.MapBypass = true;
MapWindow.Show();
UiService.MapBypass = false;
if (!PanelFinder.IsMapReady()) return; // still hidden thanks to pre-hide
_mapCamera = MapBuilder.Shared!.mapCamera;
// Belt 2: if pre-hide missed the window (it was null before Show) or
// Show() reset the localScale, zero it again now.
winUI = PanelFinder.GetMapWindowUI();
if (winUI != null)
{
PreHideWindow(winUI); // no-op if _hiddenWindow already set
if (_hiddenWindow?.transform is RectTransform rect2 && rect2.localScale != Vector3.zero)
rect2.localScale = Vector3.zero;
}
_savedTarget = _mapCamera.targetTexture;
_savedRect = _mapCamera.rect;
_savedAspect = _mapCamera.aspect;
int rtW = _savedTarget != null ? _savedTarget.width : Mathf.Min(Screen.width, 1920);
int rtH = _savedTarget != null ? _savedTarget.height : Mathf.Min(Screen.height, 1080);
_ownRT = new RenderTexture(rtW, rtH, 24, RenderTextureFormat.ARGB32) { name = "S3_InGameMapRT" };
_ownRT.Create();
_mapCamera.enabled = true; // counteract SetVisible(false) from prior map close
_mapCamera.targetTexture = _ownRT;
_mapCamera.rect = new Rect(0f, 0f, 1f, 1f);
// Apply the theme's map camera background colour (visible as empty-space
// colour behind terrain/water). Safe to set anytime after camera takeover.
_mapCamera.backgroundColor =
MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
Canvas.ForceUpdateCanvases();
PanelFinder.UpdateMapForZoom();
// Chrome: reset rotation/follow, seed native with MapEnhancer status.
_mapCamEulerX = _mapCamera.transform.eulerAngles.x;
_mapCamEulerZ = _mapCamera.transform.eulerAngles.z;
_mapRotationDeg = 0f;
_mapSyncPlayer = false;
_followPlayer = false;
_listTimer = 3f; // refresh lists on the first frame
_locationsSent = false;
_lastStatus = "";
Native.RRPOPOUT_SetMapEnhancerInstalled(_overlayHandle, MapEnhancerBridge.IsInstalled);
Native.RRPOPOUT_SetMapRotation(_overlayHandle, 0f);
Native.RRPOPOUT_SetMapSyncPlayer(_overlayHandle, false);
Native.RRPOPOUT_SetFollowPlayer(_overlayHandle, false);
_mapActive = true;
Log.Info("[ui] in-game map activated.");
}
catch (System.Exception ex)
{
UiService.MapBypass = false; // ensure bypass is cleared if we threw mid-call
Log.Error($"[ui] map activation failed: {ex.Message}");
}
}
// Restores the map camera and the base game map window to their pre-overlay state.
private void DeactivateMap()
{
Native.RRPOPOUT_SetOverlayMapTexture(System.IntPtr.Zero, 0f, 1f, 1f, 0f);
if (_mapCamera != null)
{
_mapCamera.transform.rotation = Quaternion.Euler(_mapCamEulerX, 0f, _mapCamEulerZ);
_mapCamera.targetTexture = _savedTarget;
_mapCamera.rect = _savedRect;
_mapCamera.aspect = _savedAspect;
_mapCamera = null;
}
if (_ownRT != null)
{
// Do not call Release() before Destroy: if the new owner creates a RT in the
// same frame, Unity's pool may return the same D3D handle, and the end-of-frame
// Destroy would then free it from under the new owner. Let Destroy handle both
// GPU teardown and C# cleanup atomically at end-of-frame.
Object.Destroy(_ownRT);
_ownRT = null;
}
if (_hiddenWindow != null)
{
if (!_mapWasOpen)
{
// Close the window while localScale is still zero (invisible) to prevent
// a 1-frame flash before the window hides itself.
UiService.MapBypass = true;
MapWindow.Toggle();
UiService.MapBypass = false;
}
// Defer the scale restore by one frame so Unity can finish any close animation
// at scale=0 before we put the window back to its normal size.
Window winToRestore = _hiddenWindow;
Vector3 scaleToRestore = _savedWinScale;
_hiddenWindow = null;
StartCoroutine(DeferredScaleRestore(winToRestore, scaleToRestore));
}
_drag = false;
_mapActive = false;
}
}