From a4d3cd162101322417b90618b0797ca97ae7dae5 Mon Sep 17 00:00:00 2001 From: seton Date: Wed, 17 Jun 2026 20:45:54 -0400 Subject: [PATCH] feat(map): S3 v0.2.1 - in-game overlay, M/F10, popout restore Replace the bare-bones stock map with a full Dear ImGui in-game overlay (M key). The overlay shares the same toolbar and compass as the OS popout window: Follow, Pop Out, Gear, rotation compass. Pop Out button is now in the bottom toolbar where it is actually visible. Pressing it (or F10) closes the overlay, waits 0.5 s for the camera to release, then opens the OS popout window. Closing the popout restores the overlay if it was running when the launch was triggered. Pressing M while the popout is open closes the popout and reopens the overlay. Harmony patches intercept MapWindow.Toggle and Show so base-game "Show on map" links and the map hotkey both route through the S3 overlay. MapBypass lets internal S3 calls through without triggering the patch recursively. Camera ownership is enforced in LateUpdate so no base-game script can reset targetTexture before the camera renders. Object.Destroy replaces Release() on both RT teardown paths so the D3D address stays live until end-of-frame and cannot be recycled into a new RT mid-frame. --- Info.json | 2 +- dist/build-common.ps1 | 35 +- dist/build-release.ps1 | 2 +- native/CMakeLists.txt | 20 +- native/include/shared_types.h | 3 +- native/src/d3d11_renderer.cpp | 713 +++++++++++++++++-------- native/src/d3d11_renderer.h | 38 ++ native/src/exports.cpp | 62 +++ native/src/popout_window.h | 6 +- native/src/popout_windows.cpp | 38 +- native/src/popout_windows.h | 6 + src/Core/Ui/UiService.cs | 640 ++++++++++++++++++++++ src/Main.cs | 5 + src/Modules/Popout/DetachedPanel.cs | 36 +- src/Modules/Popout/NativeInterop.cs | 58 +- src/Modules/Popout/NativeLoader.cs | 8 +- src/Modules/Popout/PanelFinder.cs | 19 + src/Modules/Popout/PopoutModule.cs | 149 +++++- src/Modules/Popout/PopoutSettings.cs | 4 + src/Modules/Popout/PopoutSettingsUI.cs | 6 + 20 files changed, 1556 insertions(+), 294 deletions(-) create mode 100644 src/Core/Ui/UiService.cs diff --git a/Info.json b/Info.json index 134b362..43f8f7e 100644 --- a/Info.json +++ b/Info.json @@ -2,7 +2,7 @@ "Id": "S3", "DisplayName": "S³ - Seton's Special Sauce", "Author": "seton", - "Version": "0.2.0", + "Version": "0.2.1", "ManagerVersion": "0.27.0", "GameVersionPoint": "0", "AssemblyName": "S3.dll", diff --git a/dist/build-common.ps1 b/dist/build-common.ps1 index 094ff0e..e5ff4f7 100644 --- a/dist/build-common.ps1 +++ b/dist/build-common.ps1 @@ -37,31 +37,52 @@ function Invoke-NativeBuild { return $null } - Write-Host "=== Building native (RRPopout.dll, $Configuration) ===" -ForegroundColor Cyan + Write-Host "=== Building native (S3Native.dll, $Configuration) ===" -ForegroundColor Cyan $buildDir = Join-Path $RepoRoot "native\build" if (-not (Test-Path $buildDir)) { cmake -B $buildDir -A x64 (Join-Path $RepoRoot "native") | Out-Host } cmake --build $buildDir --config $Configuration | Out-Host if ($LASTEXITCODE -ne 0) { throw "Native build failed." } # MSVC multi-config generators place the DLL in a per-config subfolder. - $dll = Join-Path $buildDir "bin\$Configuration\RRPopout.dll" - if (-not (Test-Path $dll)) { $dll = Join-Path $buildDir "bin\RRPopout.dll" } # single-config fallback - if (-not (Test-Path $dll)) { throw "RRPopout.dll not found under $buildDir\bin" } + $dll = Join-Path $buildDir "bin\$Configuration\S3Native.dll" + if (-not (Test-Path $dll)) { $dll = Join-Path $buildDir "bin\S3Native.dll" } # single-config fallback + if (-not (Test-Path $dll)) { throw "S3Native.dll not found under $buildDir\bin" } return $dll } # Assembles the Mods\S3 layout into $DestModDir (the S3 folder itself). +# +# Overwrites in place rather than wiping the folder. A full wipe is dangerous: if +# the game is running, a loaded DLL is locked, so the wipe deletes Info.json and +# the managed DLL, then aborts on the locked native DLL — leaving the mod with no +# manifest, so it silently vanishes from the loader. Overwriting in place keeps +# the last-good install if a copy fails, and preserves the user's settings files. function New-ModLayout { param([Parameter(Mandatory)][string]$DestModDir, [Parameter(Mandatory)][string]$ManagedDll, [string]$NativeDll = $null) - if (Test-Path $DestModDir) { Remove-Item $DestModDir -Recurse -Force } New-Item -ItemType Directory -Force -Path $DestModDir | Out-Null - Copy-Item $ManagedDll (Join-Path $DestModDir "S3.dll") -Force + # Fail fast with a clear message if a DLL we're about to overwrite is locked + # (game still running) — before touching anything. + foreach ($name in @("S3.dll", "S3Native.dll")) { + $path = Join-Path $DestModDir $name + if (Test-Path $path) { + try { [System.IO.File]::Open($path, 'Open', 'ReadWrite', 'None').Close() } + catch { throw "$name is locked - close Railroader before installing." } + } + } + + # Drop stale artifacts from older builds (the pre-rename native DLL, ngen + # caches) so they don't linger, but leave Info.json and settings intact. + $stale = Join-Path $DestModDir "RRPopout.dll" + if (Test-Path $stale) { Remove-Item $stale -Force } + Get-ChildItem $DestModDir -Filter "*.cache" -ErrorAction SilentlyContinue | Remove-Item -Force + Copy-Item (Join-Path $RepoRoot "Info.json") (Join-Path $DestModDir "Info.json") -Force + Copy-Item $ManagedDll (Join-Path $DestModDir "S3.dll") -Force if ($NativeDll) { - Copy-Item $NativeDll (Join-Path $DestModDir "RRPopout.dll") -Force + Copy-Item $NativeDll (Join-Path $DestModDir "S3Native.dll") -Force } } diff --git a/dist/build-release.ps1 b/dist/build-release.ps1 index 43b31e2..053465e 100644 --- a/dist/build-release.ps1 +++ b/dist/build-release.ps1 @@ -3,7 +3,7 @@ # Produces dist\SetonsSpecialSauce-.zip with the layout: # S3/Info.json # S3/S3.dll -# S3/RRPopout.dll (when the native module is present) +# S3/S3Native.dll (when the native module is present) param( [string]$Configuration = "Release" diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 33f0928..01fdf80 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.21) -project(RRPopout CXX) +project(S3Native CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -37,21 +37,21 @@ if(MSVC) endif() # --------------------------------------------------------------------------- -# RRPopout native plugin DLL +# S3Native native plugin DLL # --------------------------------------------------------------------------- -add_library(RRPopout SHARED +add_library(S3Native SHARED src/dllmain.cpp src/exports.cpp src/popout_windows.cpp src/d3d11_renderer.cpp ) -target_include_directories(RRPopout PRIVATE +target_include_directories(S3Native PRIVATE include external ) -target_link_libraries(RRPopout PRIVATE +target_link_libraries(S3Native PRIVATE imgui d3d11 dxgi @@ -60,13 +60,13 @@ target_link_libraries(RRPopout PRIVATE shell32 # ExtractIconExW (EXE icon loading) ) -set_target_properties(RRPopout PROPERTIES - OUTPUT_NAME "RRPopout" +set_target_properties(S3Native PROPERTIES + OUTPUT_NAME "S3Native" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" ) if(MSVC) - target_compile_options(RRPopout PRIVATE /W3 /WX- /GR- /EHsc) - target_compile_options(RRPopout PRIVATE $<$:/Zi>) - target_link_options(RRPopout PRIVATE $<$:/DEBUG /OPT:REF /OPT:ICF>) + target_compile_options(S3Native PRIVATE /W3 /WX- /GR- /EHsc) + target_compile_options(S3Native PRIVATE $<$:/Zi>) + target_link_options(S3Native PRIVATE $<$:/DEBUG /OPT:REF /OPT:ICF>) endif() diff --git a/native/include/shared_types.h b/native/include/shared_types.h index 8e570b3..0137846 100644 --- a/native/include/shared_types.h +++ b/native/include/shared_types.h @@ -1,7 +1,7 @@ #pragma once #include -// Shared between RRPopout.dll (C++) and RRPopout.Managed.dll (C#). +// Shared between S3Native.dll (C++) and S3.dll (C#). // The C# struct in NativeInterop.cs MUST match this layout exactly. enum InputEventType : int32_t { @@ -28,6 +28,7 @@ enum UICmd : int32_t { RotateReset = 8, // reset map rotation to 0 RotateSyncPlayer = 9, // toggle sync-rotation-to-player-camera mode ToggleFollowPlayer = 10, // toggle continuous follow of player world position + PopOut = 11, // (in-game only) close the overlay and open the OS popout }; #pragma pack(push, 4) diff --git a/native/src/d3d11_renderer.cpp b/native/src/d3d11_renderer.cpp index 25e3ba8..bcd8025 100644 --- a/native/src/d3d11_renderer.cpp +++ b/native/src/d3d11_renderer.cpp @@ -9,6 +9,7 @@ #include #include "d3d11_renderer.h" #include "popout_window.h" +#include "popout_windows.h" #include "../include/shared_types.h" // Dear ImGui + D3D11 backend @@ -76,6 +77,23 @@ static DXGI_FORMAT TypedFormat(DXGI_FORMAT f) { } } +// Non-sRGB (linear) render-target view format for a backbuffer, so ImGui's writes +// are NOT sRGB-encoded by the GPU (which brightens). Returns UNKNOWN for formats +// where no such fix is needed/possible (e.g. float HDR has no encode anyway). +static DXGI_FORMAT UnormRtvFormat(DXGI_FORMAT f) { + switch (f) { + case DXGI_FORMAT_R8G8B8A8_TYPELESS: + case DXGI_FORMAT_R8G8B8A8_UNORM: + case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: return DXGI_FORMAT_R8G8B8A8_UNORM; + case DXGI_FORMAT_B8G8R8A8_TYPELESS: + case DXGI_FORMAT_B8G8R8A8_UNORM: + case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: return DXGI_FORMAT_B8G8R8A8_UNORM; + case DXGI_FORMAT_R10G10B10A2_TYPELESS: + case DXGI_FORMAT_R10G10B10A2_UNORM: return DXGI_FORMAT_R10G10B10A2_UNORM; + default: return DXGI_FORMAT_UNKNOWN; + } +} + // --------------------------------------------------------------------------- // D3D11 state save/restore — prevents corrupting Unity's render state // --------------------------------------------------------------------------- @@ -147,6 +165,9 @@ static void InitImGui(ID3D11Device* device, ID3D11DeviceContext* context) { ImGuiStyle& style = ImGui::GetStyle(); style.WindowRounding = 0.f; style.WindowBorderSize = 0.f; + // Allow the 22px toolbar to be exactly 22px. The default 32px minimum makes it + // overhang the bottom of the in-game map window as a stray dark strip. + style.WindowMinSize = ImVec2(4.f, 4.f); style.FrameRounding = 3.f; style.GrabRounding = 3.f; style.ScrollbarRounding = 3.f; @@ -286,6 +307,260 @@ void Renderer_ReleaseSwapchain(PopoutWindow* win) { if (win->swapChain) { win->swapChain->Release(); win->swapChain = nullptr; } } +// --------------------------------------------------------------------------- +// Shared map UI chrome (compass rose + bottom toolbar), drawn for window `win` +// at logical size w x h. Pure ImGui: reads win-> state atomics and pushes +// UICommands onto win->inputQueue. The caller owns the frame lifecycle — it must +// set up io, call ImGui_ImplDX11_NewFrame()/ImGui::NewFrame() before this, and +// ImGui::Render() after. Used by the popout (Renderer_Present) and, in time, by +// the in-game overlay so the map UI is authored once and rendered on any surface. +// --------------------------------------------------------------------------- +// origin = top-left screen position of the map region; w,h = its size. For the +// popout the region is the whole window (origin 0,0); for the in-game overlay it +// is the floating window's map image rect, so the chrome tracks that window. +// reserveResizeGrip: shorten the toolbar so the in-game ImGui window's bottom-right +// resize grip stays grabbable (the popout uses its OS window border instead). +static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h, + bool reserveResizeGrip, bool showPopOutBtn) { + ImGuiIO& io = ImGui::GetIO(); + + // Shared command helper — usable from any window in this frame + auto pushCmd = [&](UICmd cmd, float idx = 0.f) { + InputEvent ev{}; ev.type = UICommand; + ev.x = static_cast(cmd); ev.y = idx; + win->inputQueue.push(ev); + }; + + const float kBarH = 22.f; + + // ── Compass overlay (floating in the bottom-right of the map area) ── + const float kCR = 36.f; // compass circle radius + const float kCWin = kCR * 2.f + 20.f; // window size (leaves room for N label) + const float kCPad = 10.f; // margin from window edge + + if (h > kBarH + kCWin + kCPad * 2.f) { // skip if window too small + ImGui::SetNextWindowPos( + ImVec2(origin.x + w - kCWin - kCPad, origin.y + h - kBarH - kCWin - kCPad), + ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(kCWin, kCWin)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); + ImGui::Begin("##compass_overlay", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoBackground); + + ImGui::SetCursorPos(ImVec2(0.f, 0.f)); + ImGui::InvisibleButton("##compassHit", ImVec2(kCWin, kCWin)); + bool cHov = ImGui::IsItemHovered(); + bool cAct = ImGui::IsItemActive(); + + // Drag to rotate: angle from compass centre → new bearing + if (cAct) { + ImVec2 wp = ImGui::GetWindowPos(); + ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f }; + float ddx = io.MousePos.x - cc.x, ddy = io.MousePos.y - cc.y; + if (ddx*ddx + ddy*ddy > 9.f) { + float ang = atan2f(ddx, -ddy) * (180.f / 3.14159265f); + if (ang < 0.f) ang += 360.f; + win->imMapRotationDeg.store(ang); + win->imMapSyncPlayer.store(false); + pushCmd(UICmd::SetRotation, ang); + } + } + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + win->imMapRotationDeg.store(0.f); + win->imMapSyncPlayer.store(false); + pushCmd(UICmd::RotateReset); + } + + // Draw compass rose + { + float rotDeg = win->imMapRotationDeg.load(); + float rotRad = rotDeg * (3.14159265f / 180.f); + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec2 wp = ImGui::GetWindowPos(); + ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f }; + + // Background disk + dl->AddCircleFilled(cc, kCR + 2.f, + cAct ? IM_COL32(40,40,40,230) + : cHov ? IM_COL32(32,32,32,230) + : IM_COL32(22,22,22,200), 48); + dl->AddCircle(cc, kCR + 2.f, IM_COL32(100,100,100,200), 48, 1.5f); + + // Tick marks: 8 directions — cardinals longer, inter-cardinals shorter + for (int i = 0; i < 8; ++i) { + float a = (i * 45.f - rotDeg) * (3.14159265f / 180.f); + float ox = sinf(a), oy = -cosf(a); + bool card = (i % 2 == 0); + float inner = kCR - (card ? 9.f : 5.f); + dl->AddLine({ cc.x + ox*inner, cc.y + oy*inner }, + { cc.x + ox*(kCR - 1.f), cc.y + oy*(kCR - 1.f) }, + IM_COL32(170,170,170, card ? 220 : 130), + card ? 1.5f : 1.f); + } + + // Needle: diamond split red (N) / white (S) + float nx = sinf(rotRad), ny = -cosf(rotRad); + float bx = -ny * 7.f, by = nx * 7.f; // perpendicular half-width + ImVec2 nTip = { cc.x + nx*(kCR-5.f), cc.y + ny*(kCR-5.f) }; + ImVec2 sTail = { cc.x - nx*(kCR-5.f)*0.55f, cc.y - ny*(kCR-5.f)*0.55f }; + ImVec2 bleft = { cc.x + bx, cc.y + by }; + ImVec2 brght = { cc.x - bx, cc.y - by }; + + dl->AddTriangleFilled(nTip, bleft, brght, IM_COL32(205,50,50,255)); // N red + dl->AddTriangleFilled(sTail, bleft, brght, IM_COL32(205,205,205,220));// S white + dl->AddTriangle(nTip, bleft, brght, IM_COL32(0,0,0,100), 0.5f); + dl->AddTriangle(sTail, bleft, brght, IM_COL32(0,0,0, 80), 0.5f); + + // Centre cap + dl->AddCircleFilled(cc, 5.5f, IM_COL32(25,25,25,255)); + dl->AddCircleFilled(cc, 3.5f, IM_COL32(210,210,210,255)); + + // "N" label at needle tip + ImVec2 nPos = { cc.x + nx*(kCR + 4.f) - 4.f, cc.y + ny*(kCR + 4.f) - 7.f }; + dl->AddText(ImGui::GetFont(), 14.f, nPos, IM_COL32(220,55,55,255), "N"); + } + + if (cHov) + ImGui::SetTooltip("Rotation: %.0f\xc2\xb0\nDrag to rotate | Right-click to reset", + win->imMapRotationDeg.load()); + + ImGui::End(); + ImGui::PopStyleVar(2); + } + + // ── Toolbar strip ──────────────────────────────────────────────────── + // Leave the bottom-right corner clear for the ImGui resize grip when in-game. + const float kGripReserve = reserveResizeGrip ? 18.f : 0.f; + ImGui::SetNextWindowPos(ImVec2(origin.x, origin.y + (float)h - kBarH)); + ImGui::SetNextWindowSize(ImVec2((float)w - kGripReserve, kBarH)); + ImGui::SetNextWindowBgAlpha(1.f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.f, 2.f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 2.f)); + ImGui::Begin("##toolbar", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoFocusOnAppearing); + + // Status label (left) + { + char buf[256]; + { std::lock_guard lk(win->imStatusMutex); strncpy_s(buf, win->imStatusText, _TRUNCATE); } + ImGui::TextUnformatted(buf); + } + + const float kBtnH = kBarH - 4.f; + const float kBtnW = 22.f; + const float kGap = 4.f; + + // Toolbar buttons use the title-bar / resize-grip blue so the UI reads as one + // theme; the follow toggle brightens to that blue's lit shade when active. + const ImVec4 kBtnBlue = ImGui::GetStyleColorVec4(ImGuiCol_TitleBgActive); + const ImVec4 kBtnBlueActive = ImVec4(0.26f, 0.45f, 0.72f, 1.f); + + // >> Pop Out (in-game overlay only) — switches map to the OS popout window + if (showPopOutBtn) { + ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW - kGap - kBtnW - kGap - kBtnW); + ImGui::PushStyleColor(ImGuiCol_Button, kBtnBlue); + if (ImGui::Button(">>##popout", ImVec2(kBtnW, kBtnH))) + pushCmd(UICmd::PopOut); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Open in popout window"); + } + + // ◎ Follow-player position toggle + ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW - kGap - kBtnW); + { + bool fOn = win->imFollowPlayer.load(); + ImGui::PushStyleColor(ImGuiCol_Button, fOn ? kBtnBlueActive : kBtnBlue); + if (ImGui::Button("\xe2\x97\x8e##follow", ImVec2(kBtnW, kBtnH))) + pushCmd(UICmd::ToggleFollowPlayer); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(fOn ? "Following player position\nClick to stop" + : "Follow player position"); + } + + // ⚙ Settings gear + ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW); + ImGui::PushStyleColor(ImGuiCol_Button, kBtnBlue); + if (ImGui::Button("\xe2\x9a\x99##cfg", ImVec2(kBtnW, kBtnH))) + ImGui::OpenPopup("##settings"); + ImGui::PopStyleColor(); + + if (ImGui::BeginPopup("##settings")) { + if (ImGui::MenuItem("Center on player")) + pushCmd(UICmd::Recenter); + + // Sync rotation — checkmark shows live state + if (ImGui::MenuItem("Sync rotation to player camera", nullptr, + win->imMapSyncPlayer.load())) + pushCmd(UICmd::RotateSyncPlayer); + + ImGui::Separator(); + + bool meOn = win->imMapEnhancerInstalled.load(); + if (meOn) { + if (ImGui::BeginMenu("Map Enhancer")) { + if (ImGui::MenuItem("Toggle follow mode")) + pushCmd(UICmd::FollowMode); + ImGui::Separator(); + if (ImGui::BeginMenu("Follow")) { + if (ImGui::MenuItem("Player camera")) + pushCmd(UICmd::FollowPlayerCam); + if (ImGui::MenuItem("Selected loco")) + pushCmd(UICmd::FollowSelectedLoco); + if (ImGui::MenuItem("Stop following")) + pushCmd(UICmd::FollowMode); + { + std::lock_guard lk(win->imLocoMutex); + if (!win->imLocoList.empty()) { + ImGui::Separator(); + for (int i = 0; i < (int)win->imLocoList.size(); ++i) + if (ImGui::MenuItem(win->imLocoList[i].label)) + pushCmd(UICmd::FollowLoco, (float)i); + } + } + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Jump to location")) { + std::lock_guard lk(win->imLocationMutex); + if (win->imLocationList.empty()) + ImGui::TextDisabled("(loading...)"); + else + for (int i = 0; i < (int)win->imLocationList.size(); ++i) + if (ImGui::MenuItem(win->imLocationList[i].label)) + pushCmd(UICmd::JumpToLocation, (float)i); + ImGui::EndMenu(); + } + ImGui::EndMenu(); + } + } else { + if (ImGui::BeginMenu("Jump to location")) { + std::lock_guard lk(win->imLocationMutex); + if (win->imLocationList.empty()) + ImGui::TextDisabled("(loading...)"); + else + for (int i = 0; i < (int)win->imLocationList.size(); ++i) + if (ImGui::MenuItem(win->imLocationList[i].label)) + pushCmd(UICmd::JumpToLocation, (float)i); + ImGui::EndMenu(); + } + } + + ImGui::EndPopup(); + } + + ImGui::PopStyleVar(3); + ImGui::End(); +} + void Renderer_Present(PopoutWindow* win) { if (!win || win->resizing.load()) return; void* rawTex = win->pendingTexture.load(); @@ -367,220 +642,7 @@ void Renderer_Present(PopoutWindow* win) { ImGui_ImplDX11_NewFrame(); ImGui::NewFrame(); - // Shared command helper — usable from any window in this frame - auto pushCmd = [&](UICmd cmd, float idx = 0.f) { - InputEvent ev{}; ev.type = UICommand; - ev.x = static_cast(cmd); ev.y = idx; - win->inputQueue.push(ev); - }; - - const float kBarH = 22.f; - - // ── Compass overlay (floating in the bottom-right of the map area) ── - const float kCR = 36.f; // compass circle radius - const float kCWin = kCR * 2.f + 20.f; // window size (leaves room for N label) - const float kCPad = 10.f; // margin from window edge - - if (h > kBarH + kCWin + kCPad * 2.f) { // skip if window too small - ImGui::SetNextWindowPos( - ImVec2(w - kCWin - kCPad, h - kBarH - kCWin - kCPad), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(kCWin, kCWin)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); - ImGui::Begin("##compass_overlay", nullptr, - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoBackground); - - ImGui::SetCursorPos(ImVec2(0.f, 0.f)); - ImGui::InvisibleButton("##compassHit", ImVec2(kCWin, kCWin)); - bool cHov = ImGui::IsItemHovered(); - bool cAct = ImGui::IsItemActive(); - - // Drag to rotate: angle from compass centre → new bearing - if (cAct) { - ImVec2 wp = ImGui::GetWindowPos(); - ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f }; - float ddx = io.MousePos.x - cc.x, ddy = io.MousePos.y - cc.y; - if (ddx*ddx + ddy*ddy > 9.f) { - float ang = atan2f(ddx, -ddy) * (180.f / 3.14159265f); - if (ang < 0.f) ang += 360.f; - win->imMapRotationDeg.store(ang); - win->imMapSyncPlayer.store(false); - pushCmd(UICmd::SetRotation, ang); - } - } - if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { - win->imMapRotationDeg.store(0.f); - win->imMapSyncPlayer.store(false); - pushCmd(UICmd::RotateReset); - } - - // Draw compass rose - { - float rotDeg = win->imMapRotationDeg.load(); - float rotRad = rotDeg * (3.14159265f / 180.f); - ImDrawList* dl = ImGui::GetWindowDrawList(); - ImVec2 wp = ImGui::GetWindowPos(); - ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f }; - - // Background disk - dl->AddCircleFilled(cc, kCR + 2.f, - cAct ? IM_COL32(40,40,40,230) - : cHov ? IM_COL32(32,32,32,230) - : IM_COL32(22,22,22,200), 48); - dl->AddCircle(cc, kCR + 2.f, IM_COL32(100,100,100,200), 48, 1.5f); - - // Tick marks: 8 directions — cardinals longer, inter-cardinals shorter - for (int i = 0; i < 8; ++i) { - float a = (i * 45.f - rotDeg) * (3.14159265f / 180.f); - float ox = sinf(a), oy = -cosf(a); - bool card = (i % 2 == 0); - float inner = kCR - (card ? 9.f : 5.f); - dl->AddLine({ cc.x + ox*inner, cc.y + oy*inner }, - { cc.x + ox*(kCR - 1.f), cc.y + oy*(kCR - 1.f) }, - IM_COL32(170,170,170, card ? 220 : 130), - card ? 1.5f : 1.f); - } - - // Needle: diamond split red (N) / white (S) - float nx = sinf(rotRad), ny = -cosf(rotRad); - float bx = -ny * 7.f, by = nx * 7.f; // perpendicular half-width - ImVec2 nTip = { cc.x + nx*(kCR-5.f), cc.y + ny*(kCR-5.f) }; - ImVec2 sTail = { cc.x - nx*(kCR-5.f)*0.55f, cc.y - ny*(kCR-5.f)*0.55f }; - ImVec2 bleft = { cc.x + bx, cc.y + by }; - ImVec2 brght = { cc.x - bx, cc.y - by }; - - dl->AddTriangleFilled(nTip, bleft, brght, IM_COL32(205,50,50,255)); // N red - dl->AddTriangleFilled(sTail, bleft, brght, IM_COL32(205,205,205,220));// S white - dl->AddTriangle(nTip, bleft, brght, IM_COL32(0,0,0,100), 0.5f); - dl->AddTriangle(sTail, bleft, brght, IM_COL32(0,0,0, 80), 0.5f); - - // Centre cap - dl->AddCircleFilled(cc, 5.5f, IM_COL32(25,25,25,255)); - dl->AddCircleFilled(cc, 3.5f, IM_COL32(210,210,210,255)); - - // "N" label at needle tip - ImVec2 nPos = { cc.x + nx*(kCR + 4.f) - 4.f, cc.y + ny*(kCR + 4.f) - 7.f }; - dl->AddText(ImGui::GetFont(), 14.f, nPos, IM_COL32(220,55,55,255), "N"); - } - - if (cHov) - ImGui::SetTooltip("Rotation: %.0f\xc2\xb0\nDrag to rotate | Right-click to reset", - win->imMapRotationDeg.load()); - - ImGui::End(); - ImGui::PopStyleVar(2); - } - - // ── Toolbar strip ──────────────────────────────────────────────────── - ImGui::SetNextWindowPos(ImVec2(0.f, (float)h - kBarH)); - ImGui::SetNextWindowSize(ImVec2((float)w, kBarH)); - ImGui::SetNextWindowBgAlpha(1.f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.f, 2.f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 2.f)); - ImGui::Begin("##toolbar", nullptr, - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_NoFocusOnAppearing); - - // Status label (left) - { - char buf[256]; - { std::lock_guard lk(win->imStatusMutex); strncpy_s(buf, win->imStatusText, _TRUNCATE); } - ImGui::TextUnformatted(buf); - } - - const float kBtnH = kBarH - 4.f; - const float kBtnW = 22.f; - const float kGap = 4.f; - - // ◎ Follow-player position toggle - ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW - kGap - kBtnW); - { - bool fOn = win->imFollowPlayer.load(); - if (fOn) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.18f, 0.52f, 0.18f, 1.f)); - if (ImGui::Button("\xe2\x97\x8e##follow", ImVec2(kBtnW, kBtnH))) - pushCmd(UICmd::ToggleFollowPlayer); - if (fOn) ImGui::PopStyleColor(); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip(fOn ? "Following player position\nClick to stop" - : "Follow player position"); - } - - // ⚙ Settings gear - ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW); - if (ImGui::Button("\xe2\x9a\x99##cfg", ImVec2(kBtnW, kBtnH))) - ImGui::OpenPopup("##settings"); - - if (ImGui::BeginPopup("##settings")) { - if (ImGui::MenuItem("Center on player")) - pushCmd(UICmd::Recenter); - - // Sync rotation — checkmark shows live state - if (ImGui::MenuItem("Sync rotation to player camera", nullptr, - win->imMapSyncPlayer.load())) - pushCmd(UICmd::RotateSyncPlayer); - - ImGui::Separator(); - - bool meOn = win->imMapEnhancerInstalled.load(); - if (meOn) { - if (ImGui::BeginMenu("Map Enhancer")) { - if (ImGui::MenuItem("Toggle follow mode")) - pushCmd(UICmd::FollowMode); - ImGui::Separator(); - if (ImGui::BeginMenu("Follow")) { - if (ImGui::MenuItem("Player camera")) - pushCmd(UICmd::FollowPlayerCam); - if (ImGui::MenuItem("Selected loco")) - pushCmd(UICmd::FollowSelectedLoco); - if (ImGui::MenuItem("Stop following")) - pushCmd(UICmd::FollowMode); - { - std::lock_guard lk(win->imLocoMutex); - if (!win->imLocoList.empty()) { - ImGui::Separator(); - for (int i = 0; i < (int)win->imLocoList.size(); ++i) - if (ImGui::MenuItem(win->imLocoList[i].label)) - pushCmd(UICmd::FollowLoco, (float)i); - } - } - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Jump to location")) { - std::lock_guard lk(win->imLocationMutex); - if (win->imLocationList.empty()) - ImGui::TextDisabled("(loading...)"); - else - for (int i = 0; i < (int)win->imLocationList.size(); ++i) - if (ImGui::MenuItem(win->imLocationList[i].label)) - pushCmd(UICmd::JumpToLocation, (float)i); - ImGui::EndMenu(); - } - ImGui::EndMenu(); - } - } else { - if (ImGui::BeginMenu("Jump to location")) { - std::lock_guard lk(win->imLocationMutex); - if (win->imLocationList.empty()) - ImGui::TextDisabled("(loading...)"); - else - for (int i = 0; i < (int)win->imLocationList.size(); ++i) - if (ImGui::MenuItem(win->imLocationList[i].label)) - pushCmd(UICmd::JumpToLocation, (float)i); - ImGui::EndMenu(); - } - } - - ImGui::EndPopup(); - } - - ImGui::PopStyleVar(3); - ImGui::End(); + BuildMapUI(win, ImVec2(0.f, 0.f), (float)w, (float)h, /*reserveResizeGrip=*/false, /*showPopOutBtn=*/false); ImGui::Render(); ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData()); @@ -592,3 +654,226 @@ void Renderer_Present(PopoutWindow* win) { // Restore Unity's render state saved.Restore(g_context); } + +// =========================================================================== +// In-game overlay (spike) +// =========================================================================== +// Renders an ImGui test window into Unity's currently bound backbuffer (no +// swapchain, no Present) to prove the on-top render path. Reuses the SAME ImGui +// context + DX11 backend the popout uses (set up once in InitImGui): the popout +// and overlay each do a complete NewFrame/Render inside their own plugin event +// on the render thread, so sequential reuse of one context is safe. A second +// context tripped the DX11 backend's per-context init in release builds. +// --------------------------------------------------------------------------- +static std::atomic g_ovDeviceTex {nullptr}; +static std::atomic g_ovW {0.f}, g_ovH {0.f}; +static std::atomic g_ovMouseX {-1.f}, g_ovMouseY {-1.f}; +static std::atomic g_ovLButton {false}, g_ovRButton {false}; +static std::atomic g_ovWheelRaw {0}; +static std::atomic g_ovWantMouse {false}; +static std::atomic g_ovVisible {false}; + +// Map texture to show in the in-game window (set from C# each frame) + UV rect. +static std::atomic g_ovMapTex {nullptr}; +static std::atomic g_ovMapU0 {0.f}, g_ovMapV0 {1.f}; +static std::atomic g_ovMapU1 {1.f}, g_ovMapV1 {0.f}; +// SRV over the map texture for ImGui::Image. Rebuilt each frame; must outlive +// RenderDrawData, so it lives at file scope rather than as a frame-local. +static ComPtr g_ovMapSRV; + +// Size (px) of the map image region this frame. C# reads it back to set the map +// camera's aspect to the window shape, so the map fills the window (no letterbox). +static std::atomic g_ovViewW {0.f}, g_ovViewH {0.f}; +void Overlay_GetMapView(float* outW, float* outH) { + if (outW) *outW = g_ovViewW.load(); + if (outH) *outH = g_ovViewH.load(); +} + +// Map-image interaction (drag/zoom) queued here for C# to forward to the map +// camera. Same InputEvent contract the popout uses, so C# can share the logic. +static InputQueue g_ovInput; +int Overlay_PollInput(InputEvent* out, int maxEvents) { return g_ovInput.drain(out, maxEvents); } + +void Overlay_SetDeviceTexture(void* texturePtr) { g_ovDeviceTex.store(texturePtr); } +void Overlay_SetVisible(bool visible) { g_ovVisible.store(visible); } +bool Overlay_WantsMouse() { return g_ovWantMouse.load(); } + +void Overlay_SetMapTexture(void* texturePtr, float u0, float v0, float u1, float v1) { + g_ovMapTex.store(texturePtr); + g_ovMapU0.store(u0); g_ovMapV0.store(v0); + g_ovMapU1.store(u1); g_ovMapV1.store(v1); +} + +void Overlay_SetInput(float displayW, float displayH, + float mouseX, float mouseY, + bool lButton, bool rButton, int wheel) { + g_ovW.store(displayW); g_ovH.store(displayH); + g_ovMouseX.store(mouseX); g_ovMouseY.store(mouseY); + g_ovLButton.store(lButton); g_ovRButton.store(rButton); + if (wheel) g_ovWheelRaw.fetch_add(wheel); +} + +void Renderer_PresentOverlay() { + if (!g_ovVisible.load()) return; + + // Lazy device init — same path the popout uses, but from a throwaway texture + // since the overlay has no map RT of its own. Renderer_OnDeviceInit also runs + // InitImGui (shared context + DX11 backend), so g_imguiInited is set here too. + if (!g_device) { + void* t = g_ovDeviceTex.load(); + if (!t) return; + auto* tex = static_cast(t); + ID3D11Device* dev = nullptr; + tex->GetDevice(&dev); + if (!dev) return; + Renderer_OnDeviceInit(dev); + dev->Release(); + } + if (!g_device || !g_context || !g_imguiInited) return; + + float w = g_ovW.load(), h = g_ovH.load(); + if (w <= 0.f || h <= 0.f) return; + + // Save Unity's render state — we draw into its currently bound RTV and leave + // it bound (no OMSetRenderTargets), so ImGui composites onto the backbuffer. + D3D11StateBlock saved; + saved.Capture(g_context); + + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(w, h); + io.MousePos = ImVec2(g_ovMouseX.load(), g_ovMouseY.load()); + io.MouseDown[0] = g_ovLButton.load(); + io.MouseDown[1] = g_ovRButton.load(); + io.MouseWheel = (float)g_ovWheelRaw.exchange(0) / WHEEL_DELTA; + + ImGui_ImplDX11_NewFrame(); + ImGui::NewFrame(); + + g_ovMapSRV.Reset(); // release last frame's view before building this frame's + + // Captured inside the window, used to position the shared chrome (toolbar + + // compass) over the map image after the window's End(). + ImVec2 mapScreenPos(0.f, 0.f), mapSize(0.f, 0.f); + bool mapShown = false; + + // Shared UI-state holder (status, lists, rotation) + command queue for chrome. + PopoutWindow* stateWin = GetPopoutWindow(GetOrCreateOverlayStateHandle()); + + // Zero padding so the map fills the window edge-to-edge and the toolbar sits + // flush at the bottom (acting as the border) with no dark frame around it. + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); + ImGui::SetNextWindowPos(ImVec2(80.f, 80.f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(720.f, 480.f), ImGuiCond_FirstUseEver); + // NoBringToFrontOnFocus: keep this window behind the chrome windows (toolbar + + // compass) that BuildMapUI creates after it, so its opaque map image does not + // cover them when the map area is clicked/focused. + if (ImGui::Begin("Railroader Map##s3ingame", nullptr, + ImGuiWindowFlags_NoBringToFrontOnFocus)) { + void* mapTex = g_ovMapTex.load(); + ImVec2 avail = ImGui::GetContentRegionAvail(); + + if (mapTex && avail.x > 4.f && avail.y > 4.f) { + auto* tex = static_cast(mapTex); + D3D11_TEXTURE2D_DESC td; tex->GetDesc(&td); + + D3D11_SHADER_RESOURCE_VIEW_DESC sd = {}; + sd.Format = TypedFormat(td.Format); + sd.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D; + sd.Texture2D.MostDetailedMip = 0; + sd.Texture2D.MipLevels = 1; + if (SUCCEEDED(g_device->CreateShaderResourceView(tex, &sd, &g_ovMapSRV))) { + // Fill the whole content region (no letterbox). C# reads this size + // back and sets camera.aspect to match, so the map renders the right + // slice to fill the window instead of leaving bars top/bottom. + ImVec2 sz = avail; + g_ovViewW.store(sz.x); + g_ovViewH.store(sz.y); + + // Transparent hit area over the map for drag/zoom. Queue events in + // normalized [0,1] image space (top-left origin) so C# can forward + // them to the map camera with the same math the popout uses. + ImVec2 imgPos = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton("##mapHit", sz, ImGuiButtonFlags_MouseButtonLeft); + bool hov = ImGui::IsItemHovered(); + ImVec2 nrm = { (io.MousePos.x - imgPos.x) / sz.x, + (io.MousePos.y - imgPos.y) / sz.y }; + auto pushOv = [&](int type, float d) { + InputEvent e{}; e.type = type; e.x = nrm.x; e.y = nrm.y; e.delta = d; + g_ovInput.push(e); + }; + if (ImGui::IsItemActivated()) pushOv(LButtonDown, 0.f); + if (ImGui::IsItemActive() && + (io.MouseDelta.x != 0.f || io.MouseDelta.y != 0.f)) + pushOv(MouseMove, 0.f); + if (ImGui::IsItemDeactivated()) pushOv(LButtonUp, 0.f); + if (hov && io.MouseWheel != 0.f) pushOv(MouseWheel, io.MouseWheel); + + // Draw the map over the same rect the hit-button occupies. + ImGui::SetCursorScreenPos(imgPos); + ImVec2 uv0(g_ovMapU0.load(), g_ovMapV0.load()); + ImVec2 uv1(g_ovMapU1.load(), g_ovMapV1.load()); + ImGui::Image((ImTextureID)g_ovMapSRV.Get(), sz, uv0, uv1); + + // Visible resize-grip indicator. ImGui draws its own grip during + // Begin() — behind our opaque map image, so invisible. We draw one + // on top in the corner only while the window is focused (the dark + // unfocused version was more distracting than no grip at all). + if (ImGui::IsWindowFocused()) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec2 br(imgPos.x + sz.x, imgPos.y + sz.y); + const float gripSz = 16.f; + ImU32 col = ImGui::GetColorU32(ImGuiCol_TitleBgActive, 1.0f); + dl->AddTriangleFilled(ImVec2(br.x, br.y), + ImVec2(br.x - gripSz, br.y), + ImVec2(br.x, br.y - gripSz), col); + } + + mapScreenPos = imgPos; mapSize = sz; mapShown = true; + } + } else { + ImGui::TextWrapped("Load a save and open the in-game map, then it will " + "appear here. (F10 toggles this window.)"); + } + } + ImGui::End(); + ImGui::PopStyleVar(); // WindowPadding + + // Shared map chrome (toolbar + compass), positioned over the map image so the + // in-game map and the popout present the same UI. Reads the shared state holder. + if (mapShown && stateWin) + BuildMapUI(stateWin, mapScreenPos, mapSize.x, mapSize.y, /*reserveResizeGrip=*/true, /*showPopOutBtn=*/true); + + ImGui::Render(); + + // Bind a linear (non-sRGB) view of Unity's backbuffer so the GPU does not + // sRGB-encode our writes (which washes the map/UI out). This makes the overlay + // match the popout, which renders into its own UNORM swapchain. Falls back to + // Unity's bound RTV if a UNORM view can't be created (no regression). + ComPtr linearRtv; + if (saved.rtv[0]) { + ComPtr res; + saved.rtv[0]->GetResource(&res); + ComPtr bb; + if (res && SUCCEEDED(res.As(&bb))) { + D3D11_TEXTURE2D_DESC bbDesc; bb->GetDesc(&bbDesc); + DXGI_FORMAT linear = UnormRtvFormat(bbDesc.Format); + if (linear != DXGI_FORMAT_UNKNOWN) { + D3D11_RENDER_TARGET_VIEW_DESC rd = {}; + rd.Format = linear; + rd.ViewDimension = (bbDesc.SampleDesc.Count > 1) + ? D3D11_RTV_DIMENSION_TEXTURE2DMS : D3D11_RTV_DIMENSION_TEXTURE2D; + g_device->CreateRenderTargetView(bb.Get(), &rd, &linearRtv); + } + } + } + if (linearRtv) { + ID3D11RenderTargetView* rtv = linearRtv.Get(); + g_context->OMSetRenderTargets(1, &rtv, nullptr); + } + + ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData()); + g_ovWantMouse.store(io.WantCaptureMouse); + + // Restore Unity's render state + saved.Restore(g_context); +} diff --git a/native/src/d3d11_renderer.h b/native/src/d3d11_renderer.h index 63e94bc..f611cd3 100644 --- a/native/src/d3d11_renderer.h +++ b/native/src/d3d11_renderer.h @@ -13,3 +13,41 @@ void Renderer_ReleaseSwapchain(PopoutWindow* win); // Blits pendingTexture into the swapchain and calls Present. Render thread only. void Renderer_Present(PopoutWindow* win); + +// --------------------------------------------------------------------------- +// In-game overlay (spike): draw a Dear ImGui frame directly into whatever +// render target Unity has bound (the final backbuffer when issued after +// WaitForEndOfFrame). No swapchain, no Present — composites on top of the game. +// All functions below run on Unity's render thread except the *Set* setters, +// which are called from the C# main thread and only touch atomics. +// --------------------------------------------------------------------------- + +// Stash a texture whose owning device we lazily init from (mirrors the popout's +// tex->GetDevice() path) when UnityPluginLoad never fired. Call once from C#. +void Overlay_SetDeviceTexture(void* texturePtr); + +// Per-frame input snapshot from C# (top-left origin, pixels). wheel in WHEEL_DELTA units. +void Overlay_SetInput(float displayW, float displayH, + float mouseX, float mouseY, + bool lButton, bool rButton, int wheel); + +// The Unity map render texture to display inside the in-game ImGui window, plus +// the UV sub-rect (V flipped: v0=1,v1=0 for a Unity RT). Pass nullptr to clear. +void Overlay_SetMapTexture(void* texturePtr, float u0, float v0, float u1, float v1); + +// Drains queued in-game map input (image drag/zoom) for C# to forward to the map +// camera. Returns the number of events written (<= maxEvents). +int Overlay_PollInput(InputEvent* out, int maxEvents); + +// Size (px) of the map image region last frame, so C# can set the map camera's +// aspect to the window shape (map fills the window, no letterbox bars). +void Overlay_GetMapView(float* outW, float* outH); + +// Show/hide the overlay UI. +void Overlay_SetVisible(bool visible); + +// True when ImGui wants the mouse (hovering a widget) — C# uses this to swallow input. +bool Overlay_WantsMouse(); + +// Render the overlay into the currently bound RTV. Render thread only. +void Renderer_PresentOverlay(); diff --git a/native/src/exports.cpp b/native/src/exports.cpp index d62eca6..4b55bee 100644 --- a/native/src/exports.cpp +++ b/native/src/exports.cpp @@ -43,7 +43,12 @@ UnityPluginUnload() { // --------------------------------------------------------------------------- // Render event callback — called by Unity on the render thread // --------------------------------------------------------------------------- +// Sentinel event id for the in-game overlay. Window handles start at 1 and grow +// by one (see CreatePopoutWindow), so this high value can never collide. +static const int kOverlayEventId = 0x05E70001; + static void UNITY_INTERFACE_API OnRenderEvent(int eventId) { + if (eventId == kOverlayEventId) { Renderer_PresentOverlay(); return; } PopoutWindow* win = GetPopoutWindow(eventId); if (win) Renderer_Present(win); } @@ -76,6 +81,63 @@ void RRPOPOUT_GetWindowSize(int windowHandle, int* outWidth, int* outHeight) { GetPopoutWindowSize(windowHandle, outWidth, outHeight); } +// --------------------------------------------------------------------------- +// In-game overlay (spike) — exports +// --------------------------------------------------------------------------- + +// Returns the event id to pass to GL.IssuePluginEvent for the overlay render. +extern "C" __declspec(dllexport) +int RRPOPOUT_GetOverlayEventId() { return kOverlayEventId; } + +// One-time: hand native a texture so it can lazily acquire Unity's D3D device. +extern "C" __declspec(dllexport) +void RRPOPOUT_SetOverlayDeviceTexture(void* texturePtr) { + Overlay_SetDeviceTexture(texturePtr); +} + +// Per-frame input snapshot (top-left origin, pixels). +extern "C" __declspec(dllexport) +void RRPOPOUT_SetOverlayInput(float displayW, float displayH, + float mouseX, float mouseY, + int lButton, int rButton, int wheel) { + Overlay_SetInput(displayW, displayH, mouseX, mouseY, + lButton != 0, rButton != 0, wheel); +} + +extern "C" __declspec(dllexport) +void RRPOPOUT_SetOverlayVisible(int visible) { Overlay_SetVisible(visible != 0); } + +// Map render texture to show in the in-game window (+ UV rect; V flipped for a +// Unity RT). Call each frame before issuing the overlay render event. +extern "C" __declspec(dllexport) +void RRPOPOUT_SetOverlayMapTexture(void* texturePtr, + float u0, float v0, float u1, float v1) { + Overlay_SetMapTexture(texturePtr, u0, v0, u1, v1); +} + +extern "C" __declspec(dllexport) +int RRPOPOUT_OverlayWantsMouse() { return Overlay_WantsMouse() ? 1 : 0; } + +// Drain queued in-game map input (drag/zoom over the map image) for C# to apply. +extern "C" __declspec(dllexport) +int RRPOPOUT_PollOverlayInput(InputEvent* outEvents, int maxEvents) { + return Overlay_PollInput(outEvents, maxEvents); +} + +// Read back the map image region size so C# can match the camera aspect to it. +extern "C" __declspec(dllexport) +void RRPOPOUT_GetOverlayMapView(float* outW, float* outH) { + Overlay_GetMapView(outW, outH); +} + +// Handle of the in-game overlay's shared UI-state holder. C# pushes status text, +// loco/location lists, rotation, follow flags, etc. to this handle with the same +// RRPOPOUT_Set* / RRPOPOUT_PollInputEvents functions the popout uses. +extern "C" __declspec(dllexport) +int RRPOPOUT_GetOverlayWindowHandle() { + return GetOrCreateOverlayStateHandle(); +} + // Poll input events — mouse-type events are suppressed while ImGui has capture // (e.g. cursor is over the toolbar) to prevent accidental map pan/zoom. extern "C" __declspec(dllexport) diff --git a/native/src/popout_window.h b/native/src/popout_window.h index 1149553..1ee8359 100644 --- a/native/src/popout_window.h +++ b/native/src/popout_window.h @@ -83,7 +83,9 @@ struct PopoutWindow { std::atomic lastWinW {900}, lastWinH {700}; PopoutWindow() { - strncpy_s(imStatusText, sizeof(imStatusText), - "Railroader \xe2\x80\x94 Map", _TRUNCATE); // UTF-8 em-dash + // Status starts empty (filled live with zoom/coords). Avoids a default label + // that would be redundant with the window title bar and would render any + // non-ASCII glyph as '?' in ImGui's default font. + imStatusText[0] = '\0'; } }; diff --git a/native/src/popout_windows.cpp b/native/src/popout_windows.cpp index 64b4351..110ad26 100644 --- a/native/src/popout_windows.cpp +++ b/native/src/popout_windows.cpp @@ -15,15 +15,18 @@ #include "d3d11_renderer.h" // --------------------------------------------------------------------------- -// Persistent state — saved to /RRPopout/window_state.ini on close, -// loaded and applied in MessagePumpThread before ShowWindow. +// Persistent state — saved to /Mods/S3/popout_window.ini on close, +// loaded and applied in MessagePumpThread before ShowWindow. Deliberately NOT +// /RRPopout — that is the old standalone PopOut's folder and may still +// be present, so we keep our state in our own mod folder (which always exists) +// to avoid reading its stale config. // --------------------------------------------------------------------------- static std::wstring GetStatePath() { wchar_t buf[MAX_PATH] = {}; GetModuleFileNameW(nullptr, buf, MAX_PATH); // Railroader.exe full path wchar_t* sep = wcsrchr(buf, L'\\'); if (sep) sep[1] = L'\0'; // trim to directory (keep slash) - return std::wstring(buf) + L"RRPopout\\config.ini"; + return std::wstring(buf) + L"Mods\\S3\\popout_window.ini"; } static void SaveWindowState(HWND hwnd, PopoutWindow* win) { @@ -76,8 +79,8 @@ static std::unordered_map g_windows; static std::mutex g_windowsMutex; static std::atomic g_nextHandle {1}; -static const wchar_t* kWindowClass = L"RRPopout_FloatWindow"; -static const wchar_t* kContentClass = L"RRPopout_Content"; +static const wchar_t* kWindowClass = L"S3Native_FloatWindow"; +static const wchar_t* kContentClass = L"S3Native_Content"; static bool g_classRegistered = false; static bool g_contentClassRegistered = false; @@ -477,6 +480,31 @@ PopoutWindow* GetPopoutWindow(int handle) { return (it != g_windows.end()) ? it->second : nullptr; } +// Registers a PopoutWindow with no OS window or message pump — a pure UI-state +// holder. Safe to query/update via the same handle-based exports as real windows. +int CreateHeadlessWindow() { + int handle = g_nextHandle.fetch_add(1); + auto* win = new PopoutWindow(); + win->alive.store(true); + { std::lock_guard lock(g_windowsMutex); g_windows[handle] = win; } + return handle; +} + +// Lazily creates the single shared state holder for the in-game overlay and +// returns its handle. Callable from both the render thread and the main thread. +static std::atomic g_overlayStateHandle {0}; +static std::mutex g_overlayStateMutex; +int GetOrCreateOverlayStateHandle() { + int h = g_overlayStateHandle.load(); + if (h != 0) return h; + std::lock_guard lk(g_overlayStateMutex); + h = g_overlayStateHandle.load(); + if (h != 0) return h; + h = CreateHeadlessWindow(); + g_overlayStateHandle.store(h); + return h; +} + void GetPopoutWindowSize(int handle, int* w, int* h) { PopoutWindow* win = GetPopoutWindow(handle); if (win && win->alive.load()) { *w = win->width.load(); *h = win->height.load(); } diff --git a/native/src/popout_windows.h b/native/src/popout_windows.h index dae789d..c97cf83 100644 --- a/native/src/popout_windows.h +++ b/native/src/popout_windows.h @@ -9,3 +9,9 @@ void DestroyPopoutWindow(int handle); PopoutWindow* GetPopoutWindow(int handle); void GetPopoutWindowSize(int handle, int* w, int* h); int PollPopoutInputEvents(int handle, InputEvent* outEvents, int maxEvents); + +// A registered PopoutWindow with no OS window/thread — used as a shared UI-state +// holder for the in-game overlay so it can reuse all the popout's state exports +// (status text, loco/location lists, rotation, follow, etc.) and BuildMapUI. +int CreateHeadlessWindow(); +int GetOrCreateOverlayStateHandle(); diff --git a/src/Core/Ui/UiService.cs b/src/Core/Ui/UiService.cs new file mode 100644 index 0000000..03ca2f9 --- /dev/null +++ b/src/Core/Ui/UiService.cs @@ -0,0 +1,640 @@ +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; + 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]); + } + } + } + + // 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; + } + } + + // 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; + + UiService.MapBypass = true; + MapWindow.Show(); + UiService.MapBypass = false; + if (!PanelFinder.IsMapReady()) return; // not ready yet; retry next frame + + _mapCamera = MapBuilder.Shared!.mapCamera; + + // Scale the in-game map panel to zero (invisible, still active) so its + // full-screen UI cost AND its chrome (Pop Out button, close X) go away + // while we render the camera ourselves. localScale hides children too, + // unlike sizeDelta which left the corner-anchored buttons visible. + _hiddenWindow = PanelFinder.GetMapWindowUI(); + if (_hiddenWindow != null && _hiddenWindow.transform is RectTransform rect) + { + _savedWinScale = rect.localScale; + rect.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); + + 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; + } + // Always restore the scale so the next map open starts from normal state. + if (_hiddenWindow.transform is RectTransform rect) + rect.localScale = _savedWinScale; + _hiddenWindow = null; + } + + _drag = false; + _mapActive = false; + } +} diff --git a/src/Main.cs b/src/Main.cs index 63a415f..280db42 100644 --- a/src/Main.cs +++ b/src/Main.cs @@ -1,4 +1,5 @@ using S3.Core; +using S3.Core.Ui; using UnityModManagerNet; namespace S3; @@ -33,6 +34,10 @@ public static class Main _registry.EnableConfigured(); ModConflicts.CheckAtLoad(); + // Always-on core UI service: hosts Dear ImGui inside the game, intercepts + // the base-game map hotkey, and enforces popout<->in-game mutual exclusion. + UiService.Install(); + modEntry.OnGUI = _ => SettingsPanel.Draw(_registry); modEntry.OnSaveGUI = _ => _registry.SaveAll(); diff --git a/src/Modules/Popout/DetachedPanel.cs b/src/Modules/Popout/DetachedPanel.cs index 5b86374..c71e106 100644 --- a/src/Modules/Popout/DetachedPanel.cs +++ b/src/Modules/Popout/DetachedPanel.cs @@ -1,6 +1,5 @@ using System; using System.Runtime.InteropServices; -using Helpers; // WorldTransformer (in Assembly-CSharp) using S3.Core; // Log using UI.Map; using UnityEngine; @@ -96,6 +95,7 @@ namespace S3.Modules.Popout { 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(); } @@ -176,25 +176,7 @@ namespace S3.Modules.Popout { // --------------------------------------------------------------------------- private void UpdateStatusText() { - string follow = MapEnhancerBridge.IsInstalled - ? (MapEnhancerBridge.FollowMode ? " · Following" : "") - : ""; - - string mapCoord = ""; - string camCoord = ""; - try { - // Map camera position → game coordinates. - var mapPt = WorldTransformer.WorldToGame(_mapCamera!.transform.position); - mapCoord = $" · Map ({(int)mapPt.x}, {(int)mapPt.z})"; - - // Player/main camera position → game coordinates. - if (Camera.main != null) { - var camPt = WorldTransformer.WorldToGame(Camera.main.transform.position); - camCoord = $" · Cam ({(int)camPt.x}, {(int)camPt.z})"; - } - } catch { /* WorldTransformer not yet ready — silently omit coords */ } - - string status = $"Zoom: {(int)_mapCamera!.orthographicSize}{follow}{mapCoord}{camCoord}"; + string status = PanelFinder.BuildStatusText(_mapCamera!); if (status == _lastStatusText) return; Native.RRPOPOUT_SetStatusText(_windowHandle, status); Native.RRPOPOUT_SetMapZoom(_windowHandle, _mapCamera!.orthographicSize); @@ -247,7 +229,9 @@ namespace S3.Modules.Popout { _mapCamera = null; } if (_ownRT != null) { - _ownRT.Release(); + // 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; } @@ -323,9 +307,15 @@ namespace S3.Modules.Popout { case InputEventType.MouseMove: if (!_isDragging) break; - if (Mathf.Abs(e.x - _dragStartX) > kClickThreshold || - Mathf.Abs(e.y - _dragStartY) > kClickThreshold) + 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; diff --git a/src/Modules/Popout/NativeInterop.cs b/src/Modules/Popout/NativeInterop.cs index d8d2274..cc89a38 100644 --- a/src/Modules/Popout/NativeInterop.cs +++ b/src/Modules/Popout/NativeInterop.cs @@ -33,13 +33,16 @@ namespace S3.Modules.Popout { RotateReset = 8, // reset map rotation to 0 RotateSyncPlayer = 9, // toggle sync-rotation-to-player-camera mode ToggleFollowPlayer = 10, // toggle continuous follow of player world position + PopOut = 11, // (in-game only) close the overlay and open the OS popout } internal static class Native { - // RRPopout.dll lives in the mod folder (Mods/S3) and is loaded by full path + // S3Native.dll lives in the mod folder (Mods/S3) and is loaded by full path // via NativeLoader before any of these are called; once loaded, [DllImport] // binds to it by name. (Pure-UMM install — no game-root copy, no winhttp proxy.) - private const string Dll = "RRPopout"; + // Named S3Native (not RRPopout) so it can never collide with a leftover copy + // of the old standalone PopOut's RRPopout.dll still injected in someone's game. + private const string Dll = "S3Native"; // Create a floating window. Returns a handle (> 0) or 0 on failure. // Native loads saved position/size from its state file; width/height are the first-run defaults. @@ -115,5 +118,56 @@ namespace S3.Modules.Popout { public static extern void RRPOPOUT_GetPersistedState(int windowHandle, out int followPlayer, out int syncRotation, out float mapRotation, out float mapZoom); + + // ------------------------------------------------------------------- + // In-game overlay. Shares the native engine's shared ImGui context + // and D3D11 device; renders into Unity's bound backbuffer after frame. + // ------------------------------------------------------------------- + + // Event id to pass to GL.IssuePluginEvent to drive the overlay render. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern int RRPOPOUT_GetOverlayEventId(); + + // One-time: hand native a texture so it can acquire Unity's D3D device. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetOverlayDeviceTexture(IntPtr texturePtr); + + // Per-frame input snapshot (top-left origin, pixels; wheel in WHEEL_DELTA units). + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetOverlayInput( + float displayW, float displayH, + float mouseX, float mouseY, + int lButton, int rButton, int wheel); + + // Show or hide the overlay. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetOverlayVisible(int visible); + + // Map render texture to display inside the in-game overlay window, plus the + // UV sub-rect (V flipped for a Unity RT: v0=1, v1=0). IntPtr.Zero clears it. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetOverlayMapTexture( + IntPtr texturePtr, float u0, float v0, float u1, float v1); + + // 1 when ImGui wants the mouse this frame (hovering a widget). + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern int RRPOPOUT_OverlayWantsMouse(); + + // Drains queued in-game map input (drag/zoom over the map image). Events are + // in normalized [0,1] image space (top-left origin); forward to the map camera. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern int RRPOPOUT_PollOverlayInput( + [Out] InputEvent[] outEvents, int maxEvents); + + // Reads the map image region size (px) so C# can set camera.aspect to match, + // filling the window with no letterbox bars. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_GetOverlayMapView(out float outW, out float outH); + + // Handle of the in-game overlay's shared UI-state holder. Push state to it + // (status/loco/location/rotation/follow) and poll it for toolbar commands + // with the same handle-based functions the popout uses. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern int RRPOPOUT_GetOverlayWindowHandle(); } } diff --git a/src/Modules/Popout/NativeLoader.cs b/src/Modules/Popout/NativeLoader.cs index 52d64ff..c5dc30d 100644 --- a/src/Modules/Popout/NativeLoader.cs +++ b/src/Modules/Popout/NativeLoader.cs @@ -6,11 +6,11 @@ using S3.Core; namespace S3.Modules.Popout; /// -/// Preloads the native RRPopout.dll from the mod folder before any P/Invoke. +/// Preloads the native S3Native.dll from the mod folder before any P/Invoke. /// /// S³ is a pure-UMM install: there is no winhttp proxy and Unity never calls /// UnityPluginLoad for our plugin. By loading the DLL ourselves via its full path, -/// every [DllImport("RRPopout")] in then binds to it by name. +/// every [DllImport("S3Native")] in then binds to it by name. /// The native renderer lazily initializes its D3D device from the supplied texture, /// so the missing UnityPluginLoad is fine — that lazy path becomes the normal one. /// @@ -25,7 +25,7 @@ internal static class NativeLoader { if (_loaded) return true; - string dll = Path.Combine(Main.ModEntry.Path, "RRPopout.dll"); + string dll = Path.Combine(Main.ModEntry.Path, "S3Native.dll"); if (!File.Exists(dll)) { Log.Error($"[popout] native DLL not found: {dll}"); @@ -40,7 +40,7 @@ internal static class NativeLoader } _loaded = true; - Log.Info($"[popout] native RRPopout.dll loaded from {dll}"); + Log.Info($"[popout] native S3Native.dll loaded from {dll}"); return true; } } diff --git a/src/Modules/Popout/PanelFinder.cs b/src/Modules/Popout/PanelFinder.cs index 3dbf739..661beda 100644 --- a/src/Modules/Popout/PanelFinder.cs +++ b/src/Modules/Popout/PanelFinder.cs @@ -1,4 +1,5 @@ using HarmonyLib; +using Helpers; using UI.Common; using UI.Map; using UnityEngine; @@ -55,5 +56,23 @@ namespace S3.Modules.Popout { if (mb != null) Traverse.Create(mb).Method("UpdateForZoom").GetValue(); } + + // Builds the toolbar status string shared by the popout and the in-game overlay. + // ASCII separators only — ImGui's default font has no non-ASCII glyphs. + public static string BuildStatusText(Camera mapCamera) { + string follow = MapEnhancerBridge.IsInstalled + ? (MapEnhancerBridge.FollowMode ? " | Following" : "") + : ""; + string mapCoord = "", camCoord = ""; + try { + var mapPt = WorldTransformer.WorldToGame(mapCamera.transform.position); + mapCoord = $" | Map ({(int)mapPt.x}, {(int)mapPt.z})"; + if (Camera.main != null) { + var camPt = WorldTransformer.WorldToGame(Camera.main.transform.position); + camCoord = $" | Cam ({(int)camPt.x}, {(int)camPt.z})"; + } + } catch { /* WorldTransformer not ready yet — omit coords */ } + return $"Zoom: {(int)mapCamera.orthographicSize}{follow}{mapCoord}{camCoord}"; + } } } diff --git a/src/Modules/Popout/PopoutModule.cs b/src/Modules/Popout/PopoutModule.cs index 549670e..e3c10b2 100644 --- a/src/Modules/Popout/PopoutModule.cs +++ b/src/Modules/Popout/PopoutModule.cs @@ -1,4 +1,5 @@ using S3.Core; +using S3.Core.Ui; using UI.Common; using UI.Map; using UnityEngine; @@ -10,7 +11,7 @@ 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 RRPopout.dll on enable, then spawns a +/// 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 @@ -25,10 +26,25 @@ public sealed class PopoutModule : IModule private static DetachedPanel? _activePanel; private static Window? _hiddenWindow; - private static Vector2 _savedWindowSize; + 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() @@ -66,7 +82,9 @@ public sealed class PopoutModule : IModule public void OnDisable() { // Reserved for live toggling. When wired, destroy the active panel + host here. - if (_activePanel != null) { _activePanel.Destroy(); _activePanel = null; RestoreInGameWindow(); } + if (_activePanel != null) CloseActivePanel("[popout] Module disabled."); + _pendingOpen = false; + _pendingOverlayOpen = false; if (_host != null) { Object.Destroy(_host); _host = null; } } @@ -78,12 +96,57 @@ public sealed class PopoutModule : IModule 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(); @@ -91,10 +154,7 @@ public sealed class PopoutModule : IModule if (_activePanel != null && (!_activePanel.IsAlive || _activePanel.CloseRequested)) { - _activePanel.Destroy(); - _activePanel = null; - RestoreInGameWindow(); - Log.Info("[popout] Map popout closed."); + CloseActivePanel("[popout] Map popout closed."); } } @@ -102,35 +162,72 @@ public sealed class PopoutModule : IModule { if (_activePanel != null) { - _activePanel.Destroy(); - _activePanel = null; - RestoreInGameWindow(); - Log.Info("[popout] Map re-attached."); + CloseActivePanel("[popout] Map re-attached."); return; } - // Remember whether the map was already open — if we opened it ourselves, we - // close it again when the popout closes. + // 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."); + Log.Warn("[popout] Map not ready - ensure a save is loaded."); return; } - // Collapse the in-game panel to zero size (invisible, all components active). + // 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) { - _savedWindowSize = rect.sizeDelta; - rect.sizeDelta = Vector2.zero; + _savedWindowScale = rect.localScale; + rect.localScale = Vector3.zero; } - _activePanel = new DetachedPanel("Railroader — Map"); + _activePanel = new DetachedPanel("Railroader Map"); if (!_activePanel.IsAlive) { Log.Error("[popout] RRPOPOUT_CreateWindow returned 0."); @@ -146,14 +243,18 @@ public sealed class PopoutModule : IModule { if (_hiddenWindow == null) return; - if (_hiddenWindow.transform is RectTransform rect) - rect.sizeDelta = _savedWindowSize; - - // If the map wasn't open before the popout, close it logically. Toggle() is - // used instead of SetActive(false) — SetActive can make MapWindow unfindable - // by FindObjectOfType on the next open. 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; } diff --git a/src/Modules/Popout/PopoutSettings.cs b/src/Modules/Popout/PopoutSettings.cs index 731e11d..4681067 100644 --- a/src/Modules/Popout/PopoutSettings.cs +++ b/src/Modules/Popout/PopoutSettings.cs @@ -14,4 +14,8 @@ public class PopoutSettings // UMM modifier bitmask: 1=Shift, 2=Ctrl, 4=Alt. Default: F9, no modifier. public int hotkeyModifiers = 0; public int hotkeyKeyCode = (int)KeyCode.F9; + + // When true, dragging the map cancels follow modes (MapEnhancer follow + + // follow-player) so the drag takes over — like grab-to-pan in Google Maps. + public bool panDisablesFollow = true; } diff --git a/src/Modules/Popout/PopoutSettingsUI.cs b/src/Modules/Popout/PopoutSettingsUI.cs index 11aaca1..e2a224a 100644 --- a/src/Modules/Popout/PopoutSettingsUI.cs +++ b/src/Modules/Popout/PopoutSettingsUI.cs @@ -41,6 +41,12 @@ static class PopoutSettingsUI GUILayout.Space(8f); + // ── Behaviour ─────────────────────────────────────────────────────────── + bool pan = GUILayout.Toggle(s.panDisablesFollow, " Dragging the map cancels follow mode"); + if (pan != s.panDisablesFollow) { s.panDisablesFollow = pan; PopoutModule.Persist(); } + + GUILayout.Space(8f); + // ── Status + detach button ────────────────────────────────────────────── GUILayout.Label(PopoutModule.IsDetached ? "Status: Detached" : "Status: Docked"); if (GUILayout.Button(PopoutModule.IsDetached ? "Re-attach map" : "Detach map", GUILayout.Width(160f)))