using System; using System.Runtime.InteropServices; using S3.Core; // Log using UI.Map; using UnityEngine; using UnityEngine.Rendering; namespace S3.Modules.Popout { // Manages one detached map popout window. internal class DetachedPanel { public bool IsAlive => _windowHandle > 0; private static IntPtr _renderEventFunc = IntPtr.Zero; private int _windowHandle; private Camera? _mapCamera; private RenderTexture? _ownRT; private RenderTexture? _savedTargetTexture; private Rect _savedRect; private float _savedAspect; // Drag / click state private bool _isDragging; private bool _didDrag; private float _dragStartX, _dragStartY; private float _camStartX, _camStartZ; private const float kClickThreshold = 0.02f; private const float kZoomFactor = 0.9f; private const float kZoomMin = 100f; private const float kZoomMax = 10000f; // GetAsyncKeyState works regardless of which window has focus. [DllImport("user32.dll")] private static extern short GetAsyncKeyState(int vKey); private bool _prevHotkeyDown; public bool CloseRequested { get; private set; } // Status bar: only push to native when the string changes. private string _lastStatusText = ""; // Menu list refresh — locos every 3 s, locations once. private float _listTimer = 3f; // trigger immediately on first Update private bool _locationsSent = false; // Map rotation private float _mapRotationDeg = 0f; private float _mapCamEulerX = 0f; private float _mapCamEulerZ = 0f; private bool _mapSyncPlayer = false; // Follow player position (independent of MapEnhancer) private bool _followPlayer = false; private bool _meDirty = false; private const int kMaxInputEvents = 64; private static readonly InputEvent[] s_eventBuffer = new InputEvent[kMaxInputEvents]; public DetachedPanel(string title) { if (_renderEventFunc == IntPtr.Zero) _renderEventFunc = Native.RRPOPOUT_GetRenderEventFunc(); _mapCamera = MapBuilder.Shared?.mapCamera; _windowHandle = Native.RRPOPOUT_CreateWindow(title, 900, 700); if (_windowHandle == 0 || _mapCamera == null) return; _mapCamEulerX = _mapCamera.transform.eulerAngles.x; _mapCamEulerZ = _mapCamera.transform.eulerAngles.z; Native.RRPOPOUT_SetMapEnhancerInstalled(_windowHandle, MapEnhancerBridge.IsInstalled); Native.RRPOPOUT_SetPanDisablesFollow(_windowHandle, PopoutModule.Settings.panDisablesFollow); Native.RRPOPOUT_SetRightClickRecenter(_windowHandle, PopoutModule.Settings.rightClickRecenter); Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha); var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme); bgc.a *= PopoutModule.Settings.overlayMapBgAlpha; _mapCamera.backgroundColor = bgc; if (MapEnhancerBridge.IsInstalled) PushMEState(); // Native loaded window_state.ini before showing the window (position/size/topmost // already applied). Read back the C#-side state and apply it here. Native.RRPOPOUT_GetPersistedState(_windowHandle, out int followPlayer, out int syncRotation, out float mapRotation, out float mapZoom); // Preserve the game's original RT dimensions exactly so Camera.pixelWidth and // Camera.pixelHeight are identical to the in-game map. Icon scripts that scale // based on pixelWidth/Height will then produce the same sizes as in-game. // We still set camera.aspect = window ratio so the correct FOV fills the window; // the RT-vs-window aspect mismatch is cancelled by the camera's horizontal FOV // expansion and the native blit's corresponding horizontal stretch. _savedTargetTexture = _mapCamera.targetTexture; _savedRect = _mapCamera.rect; _savedAspect = _mapCamera.aspect; Log.Info($"[popout] mapCam RT={((_mapCamera.targetTexture != null) ? $"{_mapCamera.targetTexture.width}x{_mapCamera.targetTexture.height}" : "null(screen)")} screen={Screen.width}x{Screen.height} rect={_mapCamera.rect} aspect={_mapCamera.aspect:F3}"); _mapCamera.orthographicSize = Mathf.Clamp(mapZoom, 100f, 10000f); if (mapRotation != 0f) ApplyMapRotation(mapRotation); if (syncRotation != 0) { _mapSyncPlayer = true; Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, true); } if (followPlayer != 0) { _followPlayer = true; Native.RRPOPOUT_SetFollowPlayer(_windowHandle, true); } CreateOwnRT(); _mapCamera.enabled = true; // SetVisible(false) may have disabled it; re-enable now _prevHotkeyDown = IsHotkeyDown(); } public void Update() { if (_windowHandle == 0 || _mapCamera == null || _ownRT == null) return; Native.RRPOPOUT_GetWindowSize(_windowHandle, out int w, out int h); if (w == 0 && h == 0) { _windowHandle = 0; return; } // RT dimensions are fixed at the game's original map RT size — never resized. // Only aspect changes as the window is resized, so the camera renders the // correct FOV for the current window shape. _mapCamera.enabled = true; _mapCamera.targetTexture = _ownRT; _mapCamera.rect = new Rect(0f, 0f, 1f, 1f); _mapCamera.aspect = (float)w / h; var inGameRT = PanelFinder.GetMapRenderTexture(); if (inGameRT != null && inGameRT != _ownRT) Graphics.Blit(_ownRT, inGameRT); Native.RRPOPOUT_SetFrameTexture(_windowHandle, _ownRT.GetNativeTexturePtr(), 0f, 1f, 1f, 0f); GL.IssuePluginEvent(_renderEventFunc, _windowHandle); // --- Status bar --- UpdateStatusText(); // --- Menu list refresh --- _listTimer += Time.deltaTime; if (_listTimer >= 3f) { _listTimer = 0f; PushLocoList(); if (!_locationsSent) { PushLocationList(); _locationsSent = true; } } // --- Follow player position --- 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(_windowHandle, false); } } // --- Map rotation sync --- if (_mapSyncPlayer) { if (Camera.main != null) ApplyMapRotation(Camera.main.transform.eulerAngles.y); else { _mapSyncPlayer = false; Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, false); } } // --- Input --- int count = Native.RRPOPOUT_PollInputEvents(_windowHandle, s_eventBuffer, kMaxInputEvents); for (int i = 0; i < count; i++) ForwardInputEvent(s_eventBuffer[i]); if (_meDirty) { PushMEState(); _meDirty = false; } // Hotkey mirror bool hotkeyNow = IsHotkeyDown(); if (hotkeyNow && !_prevHotkeyDown) CloseRequested = true; _prevHotkeyDown = hotkeyNow; } // --------------------------------------------------------------------------- private void PushMEState() { if (!MapEnhancerBridge.IsInstalled) return; Native.RRPOPOUT_SetMEState(_windowHandle, MapEnhancerBridge.GetMEBoolFlags(), MapEnhancerBridge.GetMEFloat(0), MapEnhancerBridge.GetMEFloat(1), MapEnhancerBridge.GetMEFloat(2), MapEnhancerBridge.GetMEFloat(3)); } private void ApplyMapRotation(float deg) { _mapRotationDeg = ((deg % 360f) + 360f) % 360f; if (_mapCamera != null) _mapCamera.transform.rotation = Quaternion.Euler(_mapCamEulerX, _mapRotationDeg, _mapCamEulerZ); Native.RRPOPOUT_SetMapRotation(_windowHandle, _mapRotationDeg); } // --------------------------------------------------------------------------- private void UpdateStatusText() { string status = PanelFinder.BuildStatusText(_mapCamera!); if (status == _lastStatusText) return; Native.RRPOPOUT_SetStatusText(_windowHandle, status); Native.RRPOPOUT_SetMapZoom(_windowHandle, _mapCamera!.orthographicSize); _lastStatusText = status; } private void PushLocoList() { try { var names = MapEnhancerBridge.GetLocoNames(); Native.RRPOPOUT_SetLocoList(_windowHandle, names.Length > 0 ? string.Join("\n", names) : ""); } catch { } } private void PushLocationList() { try { var names = MapEnhancerBridge.GetLocationNames(); Native.RRPOPOUT_SetLocationList(_windowHandle, names.Length > 0 ? string.Join("\n", names) : ""); } catch { } } // Returns true while the configured hotkey combination is held. private static bool IsHotkeyDown() { var kb = PopoutModule.Hotkey; bool wantShift = (kb.modifiers & 1) != 0; bool wantCtrl = (kb.modifiers & 2) != 0; bool wantAlt = (kb.modifiers & 4) != 0; if (IsDown(0x10) != wantShift) return false; if (IsDown(0x11) != wantCtrl ) return false; if (IsDown(0x12) != wantAlt ) return false; return IsDown(ToVirtualKey(kb.keyCode)); } private static bool IsDown(int vk) => (GetAsyncKeyState(vk) & 0x8000) != 0; private static int ToVirtualKey(KeyCode k) { int c = (int)k; if (c >= 97 && c <= 122) return c - 32; if (c >= 282 && c <= 296) return 0x70 + (c - 282); return c; } public void Destroy() { if (_mapCamera != null) { _mapCamera.targetTexture = _savedTargetTexture; _mapCamera.rect = _savedRect; _mapCamera.aspect = _savedAspect; _mapCamera.transform.rotation = Quaternion.Euler(_mapCamEulerX, 0f, _mapCamEulerZ); _mapCamera = null; } if (_ownRT != null) { // Skip Release() — let Destroy handle GPU teardown so the address stays // live until end-of-frame. This prevents the pool from reissuing the same // D3D handle to whoever creates a new RT in the same Update call. UnityEngine.Object.Destroy(_ownRT); _ownRT = null; } if (_windowHandle != 0) { Native.RRPOPOUT_DestroyWindow(_windowHandle); _windowHandle = 0; } } // --------------------------------------------------------------------------- private void CreateOwnRT() { if (_ownRT != null) { _ownRT.Release(); UnityEngine.Object.Destroy(_ownRT); } // Use the game's original RT dimensions so Camera.pixelWidth AND pixelHeight // are identical to the in-game map. camera.aspect is updated each frame in // Update() to match the current window shape. int rtW = _savedTargetTexture != null ? _savedTargetTexture.width : Mathf.Min(Screen.width, 1920); int rtH = _savedTargetTexture != null ? _savedTargetTexture.height : Mathf.Min(Screen.height, 1080); _ownRT = new RenderTexture(rtW, rtH, 24, RenderTextureFormat.ARGB32) { name = "RRPopout_MapRT" }; _ownRT.Create(); if (_mapCamera != null) { _mapCamera.targetTexture = _ownRT; _mapCamera.rect = new Rect(0f, 0f, 1f, 1f); } Canvas.ForceUpdateCanvases(); // Apply the correct icon scale for the current zoom now that the map renders // into our RT. MapBuilder.UpdateForZoom() recomputes MapBuilder.IconScale from // the camera's orthographic size and rescales every map icon, matching how the // game refreshes the map after a zoom. PanelFinder.UpdateMapForZoom(); } private void ForwardInputEvent(in InputEvent e) { var cam = _mapCamera; if (cam == null) return; var viewportPos = new Vector2(e.x, 1f - e.y); switch ((InputEventType)e.type) { case InputEventType.MouseWheel: cam.orthographicSize = Mathf.Clamp( cam.orthographicSize * Mathf.Pow(kZoomFactor, e.delta), kZoomMin, kZoomMax); // The game rescales every map icon (locos, junctions, stations, flares) // inside MapBuilder.UpdateForZoom(), which is normally driven by // MapWindow.OnZoom(). Because we set orthographicSize directly, we must // trigger it ourselves — otherwise icons keep their previous pixel size // as you zoom in/out. This is the same call MapEnhancer makes after it // adjusts the orthographic size. PanelFinder.UpdateMapForZoom(); break; case InputEventType.LButtonDown: _isDragging = true; _didDrag = false; _dragStartX = e.x; _dragStartY = e.y; _camStartX = cam.transform.position.x; _camStartZ = cam.transform.position.z; // Dragging cancels position follow (same behaviour as Google Maps grab-to-pan) if (_followPlayer) { _followPlayer = false; Native.RRPOPOUT_SetFollowPlayer(_windowHandle, false); } break; case InputEventType.LButtonUp: if (!_didDrag) PanelFinder.GetMapDrag()?.OnClick?.Invoke(viewportPos); _isDragging = false; _didDrag = false; break; case InputEventType.MouseMove: if (!_isDragging) break; if (!_didDrag && (Mathf.Abs(e.x - _dragStartX) > kClickThreshold || Mathf.Abs(e.y - _dragStartY) > kClickThreshold)) { _didDrag = true; if (PopoutModule.Settings.panDisablesFollow && MapEnhancerBridge.IsInstalled && MapEnhancerBridge.FollowMode) MapEnhancerBridge.ToggleFollowMode(); } if (!_didDrag) break; float orthoSize = cam.orthographicSize; float aspect = cam.aspect > 0f ? cam.aspect : 1.7f; // Screen-space drag scaled to world units float dxScreen = (e.x - _dragStartX) * orthoSize * 2f * aspect; float dzScreen = (e.y - _dragStartY) * orthoSize * 2f; // Rotate screen drag into world space using camera orientation. // For top-down camera: right and up are horizontal (Y≈0) so no projection needed. // Formula preserves original sign conventions at rotDeg=0. Vector3 worldDelta = -cam.transform.right * dxScreen + cam.transform.up * dzScreen; cam.transform.position = new Vector3( _camStartX + worldDelta.x, cam.transform.position.y, _camStartZ + worldDelta.z); break; case InputEventType.RButtonUp: if (PopoutModule.Settings.rightClickRecenter) UnityEngine.Object.FindObjectOfType()?.ClickLocateMe(); break; case InputEventType.UICommand: 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(_windowHandle, false); ApplyMapRotation(e.y); break; case UICmd.RotateReset: _mapSyncPlayer = false; Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, false); ApplyMapRotation(0f); break; case UICmd.RotateSyncPlayer: _mapSyncPlayer = !_mapSyncPlayer; Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, _mapSyncPlayer); break; case UICmd.ToggleFollowPlayer: _followPlayer = !_followPlayer; // Cancel any ME follow mode to avoid conflicting camera updates if (_followPlayer && MapEnhancerBridge.IsInstalled && MapEnhancerBridge.FollowMode) MapEnhancerBridge.ToggleFollowMode(); Native.RRPOPOUT_SetFollowPlayer(_windowHandle, _followPlayer); break; case UICmd.Close: CloseRequested = true; break; case UICmd.SetTheme: MapThemes.Apply((MapTheme)(int)e.y); if (_mapCamera != null) { var c = MapThemes.GetMapBgColor((MapTheme)(int)e.y); c.a *= PopoutModule.Settings.overlayMapBgAlpha; _mapCamera.backgroundColor = c; } 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.SetMapAlpha: float mapAlpha = Mathf.Clamp(e.y, 0.0f, 1.0f); PopoutModule.Settings.overlayMapAlpha = mapAlpha; PopoutModule.Persist(); Native.RRPOPOUT_SetOverlayMapAlpha(mapAlpha); break; case UICmd.SetMEBool: MapEnhancerBridge.SetMEBool((int)e.y, e.delta != 0f); if ((int)e.y == 6) _locationsSent = false; // EnableModdedSpawnPoints — re-push location list _meDirty = true; break; case UICmd.SetMEFloat: MapEnhancerBridge.SetMEFloat((int)e.y, e.delta); _meDirty = true; break; case UICmd.MEResetSwitchesNormal: MapEnhancerBridge.ResetAllSwitchesToNormal(); break; case UICmd.MEResetSwitchesThrown: MapEnhancerBridge.ResetAllSwitchesToThrown(); break; case UICmd.TogglePanDisablesFollow: PopoutModule.Settings.panDisablesFollow = !PopoutModule.Settings.panDisablesFollow; PopoutModule.Persist(); Native.RRPOPOUT_SetPanDisablesFollow(_windowHandle, PopoutModule.Settings.panDisablesFollow); break; case UICmd.ToggleRightClickRecenter: PopoutModule.Settings.rightClickRecenter = !PopoutModule.Settings.rightClickRecenter; PopoutModule.Persist(); Native.RRPOPOUT_SetRightClickRecenter(_windowHandle, PopoutModule.Settings.rightClickRecenter); break; case UICmd.SetMapBgAlpha: float bgAlpha = Mathf.Clamp(e.y, 0f, 1f); PopoutModule.Settings.overlayMapBgAlpha = bgAlpha; PopoutModule.Persist(); if (_mapCamera != null) { var bgc2 = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme); bgc2.a *= bgAlpha; _mapCamera.backgroundColor = bgc2; } break; } break; } } } }