diff --git a/README.md b/README.md index 74e1fdd..9961642 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ I originally planned on releasing individual mods, but considering my workflow o | Module | What it does | |---|---| -| Map Module | In-game map overlay and detachable popout window for a second monitor. Themes, custom colors, opacity controls, map rotation, track & industry labels, and optional MapEnhancer integration. | +| Map Module | In-game map overlay and detachable popout window for a second monitor. Themes, custom colors, opacity, rotation, track labels, view presets, waypoint pins, and optional MapEnhancer integration. | | Physics Optimizer | Cuts CPU spent on train physics (LOD fast-path + auto-freeze), with debug car tinting. Console: `/rpf` | | Mesh LOD | Adds level-of-detail to rolling stock: distant cars progressively shed detail and finally collapse to a cheap proxy box, cutting triangle count on large saves. | | Base Game Performance | Smooths Unity's incremental garbage collector and Nature Renderer grass streaming to reduce camera-motion hitches without lowering visual quality. | @@ -91,6 +91,10 @@ The map labels named tracks and industries, read live from the game's industry d ![Track and industry labels on the map](img/map/map_track_and_industry_labels/lables_still.png) +### View Presets and Waypoints + +Save named camera bookmarks from the map and jump back to them later. Optional waypoint pins show Auto Engineer and WaypointQueue stops on the map camera, with matching loco icon tints. `/s3wq dump` writes a consist and queue dump when you need to inspect a session. + ### Popout Window Pop the map into a detached native OS window. Drag it to any monitor, resize it freely, and pin it always-on-top via the window's right-click title bar menu. Re-attach it back into the game overlay at any time from the settings panel without losing your position, zoom, or rotation. diff --git a/native/include/shared_types.h b/native/include/shared_types.h index f5ed44b..a11c2e4 100644 --- a/native/include/shared_types.h +++ b/native/include/shared_types.h @@ -67,6 +67,29 @@ enum UICmd : int32_t { TrackLabelSetAllZoom = 45, // y = orthographicSize beyond which ALL labels hide ToggleAvoidTrackLabels = 46, // push labels off their own track line TrackLabelSetFontSizeMin = 47, // y = minimum font size px [4, max] + PresetAdd = 48, // save current camera as a new view preset + PresetApply = 49, // jump to preset; y = 0-based index + PresetDelete = 50, // delete preset; y = 0-based index + PresetRename = 51, // rename preset; y = index, name via GetPresetRenameName + PresetPreview = 52, // stash current view and jump to preset; y = index + PresetCommitEdit = 53, // write current camera into preset and restore stash + PresetCancelEdit = 54, // restore stash without saving camera + ToggleWaypoints = 55, // toggle AE waypoint pins on the map + ToggleWaypointsSelectedOnly = 56, // filter waypoint pins to the selected loco + ToggleRadio = 57, // radio-control map mode + RadioPin = 58, // pin currently selected consist loco + RadioSelect = 59, // select pinned loco; y = index + RadioUnpin = 60, // unpin; y = index + RadioRename = 61, // rename pin; y = index + RadioSetTool = 62, // y = 0 idle, 1 waypoint-place mode + RadioSetForward = 63, // y = 0 reverse, 1 forward + RadioSetSpeed = 64, // y = mph + RadioStop = 65, // AE Off on selected pin + RadioFollow = 66, // follow selected pin + RadioJump = 67, // jump map to pin; y = index + RadioWpChoose = 68, // y = 0 Go, 1 Couple, 2 Pickup, 3 Dropoff, 4 Cut + RadioWpCount = 69, // y = car count (>= 1) + RadioWpCancel = 70, // close the waypoint order popup }; // Bit indices for ME bool settings packed into PopoutWindow::imMEFlags. diff --git a/native/src/d3d11_renderer.cpp b/native/src/d3d11_renderer.cpp index 99419c7..e6df6bf 100644 --- a/native/src/d3d11_renderer.cpp +++ b/native/src/d3d11_renderer.cpp @@ -8,6 +8,9 @@ #include #include #include +#include +#include +#include #include "d3d11_renderer.h" #include "popout_window.h" #include "popout_windows.h" @@ -50,6 +53,8 @@ static std::atomic g_currentThemePreset {0}; static std::atomic g_ovAlpha {1.0f}; // chrome (window + toolbar + compass) alpha static std::atomic g_mapAlpha {1.0f}; // map Image() alpha, independent of chrome static std::atomic g_mapBgAlpha {1.0f}; // camera clear colour opacity +static std::atomic g_ovPieBlock {false}; // dim + no mouse while map pie is open +static constexpr float kPieDim = 0.4f; // keep this fraction of current alpha // cbuffer layout — must be 16-byte aligned struct alignas(16) UVRectCB { float u0, v0, u1, v1; }; @@ -236,6 +241,51 @@ static MapThemeData SnapshotTheme() { return g_theme; } +// Shared ImGui keyboard feed for overlay (Unity) and popout (Win32). +// keyDown bits: 0 Backspace, 1 Delete, 2 Enter, 3 Escape, 4 Left, 5 Right, +// 6 Home, 7 End, 8 Tab, 9 A, 10 C, 11 V, 12 X. +// mods: 1 Ctrl, 2 Shift, 4 Alt. +static void FeedImGuiKeys(ImGuiIO& io, const char* utf8, uint32_t down, uint32_t mods, + uint32_t& prevDown, uint32_t& prevMods) +{ + auto edge = [&](ImGuiKey key, bool now, bool was) { + if (now != was) io.AddKeyEvent(key, now); + }; + const bool ctrl = (mods & 1u) != 0, shift = (mods & 2u) != 0, alt = (mods & 4u) != 0; + const bool pctrl = (prevMods & 1u) != 0, pshift = (prevMods & 2u) != 0, palt = (prevMods & 4u) != 0; + edge(ImGuiKey_LeftCtrl, ctrl, pctrl); + edge(ImGuiKey_RightCtrl, ctrl, pctrl); + edge(ImGuiKey_ModCtrl, ctrl, pctrl); + edge(ImGuiKey_LeftShift, shift, pshift); + edge(ImGuiKey_ModShift, shift, pshift); + edge(ImGuiKey_LeftAlt, alt, palt); + edge(ImGuiKey_ModAlt, alt, palt); + + struct Map { uint32_t bit; ImGuiKey key; }; + const Map map[] = { + { 1u << 0, ImGuiKey_Backspace }, + { 1u << 1, ImGuiKey_Delete }, + { 1u << 2, ImGuiKey_Enter }, + { 1u << 3, ImGuiKey_Escape }, + { 1u << 4, ImGuiKey_LeftArrow }, + { 1u << 5, ImGuiKey_RightArrow }, + { 1u << 6, ImGuiKey_Home }, + { 1u << 7, ImGuiKey_End }, + { 1u << 8, ImGuiKey_Tab }, + { 1u << 9, ImGuiKey_A }, + { 1u << 10, ImGuiKey_C }, + { 1u << 11, ImGuiKey_V }, + { 1u << 12, ImGuiKey_X }, + }; + for (const auto& m : map) + edge(m.key, (down & m.bit) != 0, (prevDown & m.bit) != 0); + + prevDown = down; + prevMods = mods; + if (utf8 && utf8[0]) + io.AddInputCharactersUTF8(utf8); +} + // Default S3 Dark theme — identical to what InitImGui used to hardcode. static const MapThemeData kS3DarkTheme = { 0.12f, 0.12f, 0.12f, 1.00f, // windowBg @@ -306,6 +356,10 @@ static void InitImGui(ID3D11Device* device, ID3D11DeviceContext* context) { static const ImWchar kGlyphRanges[] = { 0x2699, 0x2699, // ⚙ gear (settings button) 0x25CE, 0x25CE, // ◎ bullseye (follow-player button) + 0x270E, 0x270E, // ✎ pencil (preset edit) + 0x2715, 0x2715, // ✕ delete + 0x2316, 0x2316, // ⌖ pin (preset preview) + 0x2713, 0x2713, // ✓ check (preset commit) 0 }; io.Fonts->AddFontFromFileTTF("C:\\Windows\\Fonts\\seguisym.ttf", 14.f, &fc, kGlyphRanges); @@ -647,6 +701,509 @@ static void DrawTrackLabels(PopoutWindow* win, ImDrawList* dl, } } +// Left-side named camera bookmarks. Last row is always "+". Edit shows an +// InputText + pin (preview) / check (commit). Trash opens a confirm popup. +static void DrawPresetRail(PopoutWindow* win, ImVec2 origin, float w, float h, + float barH, const MapThemeData& theme, float mapAlpha) { + 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); + }; + + std::vector presets; + { + std::lock_guard lk(win->imPresetMutex); + presets = win->imPresetList; + } + + const float kPad = 6.f; + const float kRowH = 22.f; + const float kBtnW = 20.f; + const float kRailW = 168.f; + const float kMaxH = std::max(kRowH + 8.f, h - barH - kPad * 2.f); + const int n = (int)presets.size(); + const float contentH = (n + 1) * (kRowH + 2.f) + 8.f; + const float winH = std::min(contentH, kMaxH); + const bool scroll = contentH > kMaxH + 0.5f; + + if (h < barH + kRowH + kPad * 2.f) return; + + auto col32 = [&](float r, float g, float b, float a) -> ImU32 { + return IM_COL32((int)(r*255), (int)(g*255), (int)(b*255), (int)(a * mapAlpha * 255)); + }; + ImVec4 winBg(theme.wBgR, theme.wBgG, theme.wBgB, theme.wBgA * mapAlpha); + ImVec4 acc (theme.accR, theme.accG, theme.accB, theme.accA); + ImVec4 txt (theme.txtR, theme.txtG, theme.txtB, theme.txtA); + + ImGui::SetNextWindowPos(ImVec2(origin.x + kPad, origin.y + kPad), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(kRailW, winH)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, winBg); + ImGui::PushStyleColor(ImGuiCol_Button, acc); + ImGui::PushStyleColor(ImGuiCol_Text, txt); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.f, 4.f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(3.f, 2.f)); + ImGui::Begin("##preset_rail", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + + ImGuiWindowFlags childFlags = ImGuiWindowFlags_NoSavedSettings; + if (scroll) childFlags |= ImGuiWindowFlags_AlwaysVerticalScrollbar; + ImGui::BeginChild("##preset_scroll", ImVec2(0.f, 0.f), false, childFlags); + + int editIdx = win->imPresetEditIndex.load(); + bool preview = win->imPresetPreviewing.load(); + bool openDel = false; + static int s_focusEdit = -1; + if (editIdx < 0) s_focusEdit = -1; + + if (ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) && + ImGui::IsKeyPressed(ImGuiKey_Escape) && editIdx >= 0) { + pushCmd(UICmd::PresetCancelEdit); + win->imPresetEditIndex.store(-1); + win->imPresetPreviewing.store(false); + editIdx = -1; + preview = false; + } + + const float nameW = ImGui::GetContentRegionAvail().x - (kBtnW + 3.f) * 2.f; + + for (int i = 0; i < n; ++i) { + ImGui::PushID(i); + bool editing = (editIdx == i); + + if (editing) { + if (s_focusEdit != i) { + ImGui::SetKeyboardFocusHere(); + s_focusEdit = i; + } + ImGui::SetNextItemWidth(nameW); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(theme.popR, theme.popG, theme.popB, theme.popA)); + if (ImGui::InputText("##rn", win->imPresetRenameBuf, sizeof(win->imPresetRenameBuf), + ImGuiInputTextFlags_EnterReturnsTrue | + ImGuiInputTextFlags_AutoSelectAll)) + pushCmd(UICmd::PresetRename, (float)i); + if (ImGui::IsItemDeactivatedAfterEdit()) + pushCmd(UICmd::PresetRename, (float)i); + ImGui::PopStyleColor(); + } else { + if (ImGui::Button(presets[i].label, ImVec2(nameW, kRowH - 4.f))) + pushCmd(UICmd::PresetApply, (float)i); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Jump to this view"); + } + + ImGui::SameLine(); + if (editing) { + if (preview) { + if (ImGui::Button("\xe2\x9c\x93##ok", ImVec2(kBtnW, kRowH - 4.f))) { + pushCmd(UICmd::PresetCommitEdit, (float)i); + win->imPresetEditIndex.store(-1); + win->imPresetPreviewing.store(false); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Save this view to the preset\nand return to where you were"); + } else { + if (ImGui::Button("\xe2\x8c\x96##pin", ImVec2(kBtnW, kRowH - 4.f))) { + pushCmd(UICmd::PresetPreview, (float)i); + win->imPresetPreviewing.store(true); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Show this preset's view\nthen adjust and confirm"); + } + } else { + if (ImGui::Button("\xe2\x9c\x8e##ed", ImVec2(kBtnW, kRowH - 4.f))) { + if (editIdx >= 0 && preview) + pushCmd(UICmd::PresetCancelEdit); + strncpy_s(win->imPresetRenameBuf, presets[i].label, _TRUNCATE); + win->imPresetEditIndex.store(i); + win->imPresetPreviewing.store(false); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Rename or recapture this view"); + } + + ImGui::SameLine(); + if (ImGui::Button("\xe2\x9c\x95##del", ImVec2(kBtnW, kRowH - 4.f))) { + win->imPresetPendingDelete.store(i); + openDel = true; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Delete this preset"); + + ImGui::PopID(); + } + + if (openDel) + ImGui::OpenPopup("##delPreset"); + + if (ImGui::Button("+##addpreset", ImVec2(nameW, kRowH - 4.f))) { + if (editIdx >= 0 && preview) + pushCmd(UICmd::PresetCancelEdit); + win->imPresetEditIndex.store(-1); + win->imPresetPreviewing.store(false); + pushCmd(UICmd::PresetAdd); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Save the current view as a preset"); + + int pendingDel = win->imPresetPendingDelete.load(); + if (pendingDel >= 0 && pendingDel < n) { + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + ImGui::PushStyleColor(ImGuiCol_PopupBg, ImVec4(theme.popR, theme.popG, theme.popB, theme.popA)); + if (ImGui::BeginPopupModal("##delPreset", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoSavedSettings)) { + ImGui::Text("Delete \"%s\"?", presets[pendingDel].label); + ImGui::Spacing(); + if (ImGui::Button("Delete", ImVec2(70.f, 0.f))) { + if (editIdx == pendingDel && preview) + pushCmd(UICmd::PresetCancelEdit); + pushCmd(UICmd::PresetDelete, (float)pendingDel); + win->imPresetPendingDelete.store(-1); + if (editIdx == pendingDel) { + win->imPresetEditIndex.store(-1); + win->imPresetPreviewing.store(false); + } + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(70.f, 0.f))) { + win->imPresetPendingDelete.store(-1); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + ImGui::PopStyleColor(); + } + + ImGui::EndChild(); + ImGui::End(); + ImGui::PopStyleVar(3); + ImGui::PopStyleColor(3); + (void)col32; (void)w; +} + +// Right-side pinned radio locos. Compact rows until selected; selected row +// expands with AE drive + waypoint-place mode (WQ orders come from a cursor popup). +static bool DrawRadioRail(PopoutWindow* win, ImVec2 origin, float w, float h, + float barH, const MapThemeData& theme, float mapAlpha, + bool embedded) { + 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); + }; + + std::vector pins; + std::vector colors; + { + std::lock_guard lk(win->imRadioMutex); + pins = win->imRadioList; + colors = win->imRadioColors; + } + + const float kPad = 6.f; + const float kRowH = 22.f; + const float kBtnW = 20.f; + const float kRailW = 188.f; + const float kExtra = 78.f; + const int n = (int)pins.size(); + int selected = win->imRadioSelected.load(); + if (selected < 0 || selected >= n) selected = -1; + const float extraH = (selected >= 0) ? kExtra : 0.f; + const float contentH = kRowH + 6.f + n * (kRowH + 2.f) + extraH + (kRowH + 2.f) + 10.f; + const float kMaxH = std::max(kRowH * 2.f + 8.f, h - barH - kPad * 2.f); + const float winH = std::min(contentH, kMaxH); + const bool scroll = contentH > kMaxH + 0.5f; + + if (h < barH + kRowH * 2.f + kPad * 2.f) return false; + + auto col32 = [&](float r, float g, float b, float a) -> ImU32 { + return IM_COL32((int)(r*255), (int)(g*255), (int)(b*255), (int)(a * mapAlpha * 255)); + }; + ImVec4 winBg(theme.wBgR, theme.wBgG, theme.wBgB, theme.wBgA * mapAlpha); + ImVec4 acc (theme.accR, theme.accG, theme.accB, theme.accA); + ImVec4 txt (theme.txtR, theme.txtG, theme.txtB, theme.txtA); + ImVec4 accOn(std::min(1.f, acc.x * 1.4f), std::min(1.f, acc.y * 1.4f), + std::min(1.f, acc.z * 1.4f), acc.w); + + if (!embedded) { + ImGui::SetNextWindowPos(ImVec2(origin.x + w - kRailW - kPad, origin.y + kPad), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(kRailW, winH)); + } + ImGui::PushStyleColor(ImGuiCol_WindowBg, winBg); + ImGui::PushStyleColor(ImGuiCol_ChildBg, winBg); + ImGui::PushStyleColor(ImGuiCol_Button, acc); + ImGui::PushStyleColor(ImGuiCol_Text, txt); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.f, 4.f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(3.f, 2.f)); + if (embedded) { + ImGui::SetCursorScreenPos(ImVec2(origin.x + w - kRailW - kPad, origin.y + kPad)); + ImGui::BeginChild("##radio_rail", ImVec2(kRailW, winH), true, + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoSavedSettings); + } else { + ImGui::Begin("##radio_rail", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + } + + ImGuiWindowFlags childFlags = ImGuiWindowFlags_NoSavedSettings; + if (scroll) childFlags |= ImGuiWindowFlags_AlwaysVerticalScrollbar; + ImGui::BeginChild("##radio_scroll", ImVec2(0.f, 0.f), false, childFlags); + + bool radioOn = win->imRadioOn.load(); + ImGui::PushStyleColor(ImGuiCol_Button, radioOn ? accOn : acc); + if (ImGui::Button(radioOn ? "Radio ON" : "Radio", ImVec2(-1.f, kRowH - 2.f))) + pushCmd(UICmd::ToggleRadio); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(radioOn + ? "Radio control is on\nLeft-click throws switches\nWP places a Waypoint Queue stop" + : "Turn on radio control for remote switching"); + + int editIdx = win->imRadioEditIndex.load(); + static int s_radioFocus = -1; + if (editIdx < 0) s_radioFocus = -1; + + if (ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) && + ImGui::IsKeyPressed(ImGuiKey_Escape) && editIdx >= 0) { + win->imRadioEditIndex.store(-1); + editIdx = -1; + } + + const float nameW = ImGui::GetContentRegionAvail().x - (kBtnW + 3.f) * 2.f - 14.f; + uint64_t aeBits = win->imRadioAeBits.load(); + int tool = win->imRadioTool.load(); + + for (int i = 0; i < n; ++i) { + ImGui::PushID(i + 100); + bool editing = (editIdx == i); + bool isSel = (selected == i); + + ImVec2 dotPos = ImGui::GetCursorScreenPos(); + dotPos.x += 2.f; dotPos.y += 7.f; + uint32_t packed = (i < (int)colors.size()) ? colors[i] : 0; + ImU32 dotCol = (aeBits & (1ull << i)) + ? IM_COL32((packed >> 16) & 255, (packed >> 8) & 255, packed & 255, (int)(mapAlpha * 255)) + : col32(theme.txtR, theme.txtG, theme.txtB, 0.25f); + ImGui::GetWindowDrawList()->AddCircleFilled(dotPos, 4.f, dotCol); + ImGui::Dummy(ImVec2(12.f, 1.f)); + ImGui::SameLine(); + + if (editing) { + if (s_radioFocus != i) { + ImGui::SetKeyboardFocusHere(); + s_radioFocus = i; + } + ImGui::SetNextItemWidth(nameW); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(theme.popR, theme.popG, theme.popB, theme.popA)); + if (ImGui::InputText("##rn", win->imRadioRenameBuf, sizeof(win->imRadioRenameBuf), + ImGuiInputTextFlags_EnterReturnsTrue | + ImGuiInputTextFlags_AutoSelectAll)) + pushCmd(UICmd::RadioRename, (float)i); + if (ImGui::IsItemDeactivatedAfterEdit()) + pushCmd(UICmd::RadioRename, (float)i); + ImGui::PopStyleColor(); + } else { + if (isSel) ImGui::PushStyleColor(ImGuiCol_Button, accOn); + if (ImGui::Button(pins[i].label, ImVec2(nameW, kRowH - 4.f))) + pushCmd(UICmd::RadioSelect, (float)i); + if (isSel) ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Select and jump to this locomotive"); + } + + ImGui::SameLine(); + if (editing) { + if (ImGui::Button("\xe2\x9c\x93##ok", ImVec2(kBtnW, kRowH - 4.f))) { + pushCmd(UICmd::RadioRename, (float)i); + win->imRadioEditIndex.store(-1); + } + } else if (ImGui::Button("\xe2\x9c\x8f##ed", ImVec2(kBtnW, kRowH - 4.f))) { + strncpy_s(win->imRadioRenameBuf, pins[i].label, _TRUNCATE); + win->imRadioEditIndex.store(i); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Rename"); + + ImGui::SameLine(); + if (ImGui::Button("x##un", ImVec2(kBtnW, kRowH - 4.f))) + pushCmd(UICmd::RadioUnpin, (float)i); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Unpin"); + + if (isSel && !editing) { + bool fwd = win->imRadioForward.load(); + float tw = (ImGui::GetContentRegionAvail().x - 4.f) * 0.5f; + if (fwd) ImGui::PushStyleColor(ImGuiCol_Button, accOn); + if (ImGui::Button("FWD", ImVec2(tw, kRowH - 4.f))) + pushCmd(UICmd::RadioSetForward, 1.f); + if (fwd) ImGui::PopStyleColor(); + ImGui::SameLine(); + if (!fwd) ImGui::PushStyleColor(ImGuiCol_Button, accOn); + if (ImGui::Button("REV", ImVec2(tw, kRowH - 4.f))) + pushCmd(UICmd::RadioSetForward, 0.f); + if (!fwd) ImGui::PopStyleColor(); + + float spd = win->imRadioSpeed.load(); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 52.f); + if (ImGui::SliderFloat("##spd", &spd, 1.f, 45.f, "%.0f")) + win->imRadioSpeed.store(spd); + if (ImGui::IsItemDeactivatedAfterEdit()) + pushCmd(UICmd::RadioSetSpeed, spd); + ImGui::SameLine(); + if (ImGui::Button("Stop", ImVec2(48.f, kRowH - 4.f))) + pushCmd(UICmd::RadioStop); + + float bw = (ImGui::GetContentRegionAvail().x - 4.f) * 0.5f; + if (ImGui::Button("Fol", ImVec2(bw, kRowH - 4.f))) + pushCmd(UICmd::RadioFollow); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Follow this locomotive"); + ImGui::SameLine(); + bool wpOn = tool != 0; + if (wpOn) ImGui::PushStyleColor(ImGuiCol_Button, accOn); + if (ImGui::Button("WP", ImVec2(bw, kRowH - 4.f))) + pushCmd(UICmd::RadioSetTool, wpOn ? 0.f : 1.f); + if (wpOn) ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(wpOn + ? "Waypoint mode on \xe2\x80\x94 click track or a free coupler" + : "Place a Waypoint Queue stop (hover snaps to track / free ends)"); + } + + ImGui::PopID(); + } + + if (ImGui::Button("+##addradio", ImVec2(-1.f, kRowH - 4.f))) + pushCmd(UICmd::RadioPin); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Pin the currently selected locomotive"); + + bool hovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows); + ImGui::EndChild(); + if (embedded) + ImGui::EndChild(); + else + ImGui::End(); + ImGui::PopStyleVar(3); + ImGui::PopStyleColor(4); + (void)col32; (void)w; + return hovered; +} + +static ImVec2 RadioUvToScreen(ImVec2 imgPos, ImVec2 imgSize, float u, float v) { + return { imgPos.x + u * imgSize.x, imgPos.y + (1.f - v) * imgSize.y }; +} + +static void DrawRadioGhost(PopoutWindow* win, ImDrawList* dl, ImVec2 imgPos, ImVec2 imgSize) { + if (!win || !dl || !win->imRadioGhostOn.load()) return; + float u = win->imRadioGhostU.load(); + float v = win->imRadioGhostV.load(); + if (u < -0.05f || u > 1.05f || v < -0.05f || v > 1.05f) return; + ImVec2 c = RadioUvToScreen(imgPos, imgSize, u, v); + uint32_t packed = win->imRadioGhostColor.load(); + ImU32 col = IM_COL32((packed >> 16) & 255, (packed >> 8) & 255, packed & 255, 255); + ImU32 outline = IM_COL32(0, 0, 0, 220); + float rad = win->imRadioGhostAngle.load() * 3.14159265f / 180.f; + ImVec2 dir(cosf(rad), sinf(rad)); + ImVec2 n(-dir.y, dir.x); + const float len = 22.f; + const float half = 10.f; + ImVec2 tip = ImVec2(c.x + dir.x * len, c.y + dir.y * len); + ImVec2 left = ImVec2(c.x - dir.x * 4.f + n.x * half, c.y - dir.y * 4.f + n.y * half); + ImVec2 right = ImVec2(c.x - dir.x * 4.f - n.x * half, c.y - dir.y * 4.f - n.y * half); + dl->AddTriangleFilled(tip, left, right, col); + dl->AddTriangle(tip, left, right, outline, 1.5f); + dl->AddCircleFilled(c, 3.f, col); + dl->AddCircle(c, 3.f, outline, 0, 1.25f); +} + +static void DrawRadioWpPopup(PopoutWindow* win, ImVec2 imgPos, ImVec2 imgSize) { + if (!win) return; + int stage = win->imRadioWpStage.load(); + if (stage <= 0) return; + + 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); + }; + + ImVec2 p = RadioUvToScreen(imgPos, imgSize, win->imRadioWpU.load(), win->imRadioWpV.load()); + p.x += 14.f; p.y += 14.f; + const float kW = 148.f; + const float kH = (stage == 2) ? 92.f : 168.f; + p.x = std::max(imgPos.x + 4.f, std::min(p.x, imgPos.x + imgSize.x - kW - 4.f)); + p.y = std::max(imgPos.y + 4.f, std::min(p.y, imgPos.y + imgSize.y - kH - 4.f)); + + ImGui::SetNextWindowPos(p, ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(kW, 0.f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.f, 6.f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 4.f)); + ImGui::Begin("##radio_wp_popup", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoNav); + + bool wq = win->imRadioWq.load(); + bool hasCar = (win->imRadioWpFlags.load() & 1) != 0; + static int s_wpCount = 1; + static int s_seenStage = 0; + if (stage != 2) s_seenStage = 0; + + if (stage == 1) { + ImGui::TextUnformatted("Waypoint"); + if (ImGui::Button("Go", ImVec2(-1.f, 0.f))) + pushCmd(UICmd::RadioWpChoose, 0.f); + if (!hasCar) ImGui::BeginDisabled(); + if (ImGui::Button("Couple", ImVec2(-1.f, 0.f))) + pushCmd(UICmd::RadioWpChoose, 1.f); + if (!hasCar) ImGui::EndDisabled(); + if (!wq || !hasCar) ImGui::BeginDisabled(); + if (ImGui::Button("Pickup", ImVec2(-1.f, 0.f))) + pushCmd(UICmd::RadioWpChoose, 2.f); + if (!wq || !hasCar) ImGui::EndDisabled(); + if (!wq) ImGui::BeginDisabled(); + if (ImGui::Button("Drop off", ImVec2(-1.f, 0.f))) + pushCmd(UICmd::RadioWpChoose, 3.f); + if (ImGui::Button("Cut", ImVec2(-1.f, 0.f))) + pushCmd(UICmd::RadioWpChoose, 4.f); + if (!wq) ImGui::EndDisabled(); + if (ImGui::Button("Cancel", ImVec2(-1.f, 0.f))) + pushCmd(UICmd::RadioWpCancel); + if (!wq && ImGui::IsWindowHovered()) + ImGui::SetTooltip("Pickup / Drop off / Cut need Waypoint Queue"); + } else { + ImGui::TextUnformatted("Cars"); + if (s_seenStage != 2) { + s_wpCount = win->imRadioWpCount.load(); + if (s_wpCount < 1) s_wpCount = 1; + s_seenStage = 2; + } + ImGui::SetNextItemWidth(-1.f); + if (ImGui::InputInt("##wpc", &s_wpCount)) { + if (s_wpCount < 1) s_wpCount = 1; + if (s_wpCount > 200) s_wpCount = 200; + } + ImGui::TextUnformatted("or click the far car"); + if (ImGui::Button("OK", ImVec2(-1.f, 0.f))) + pushCmd(UICmd::RadioWpCount, (float)s_wpCount); + if (ImGui::Button("Cancel", ImVec2(-1.f, 0.f))) + pushCmd(UICmd::RadioWpCancel); + } + + ImGui::End(); + ImGui::PopStyleVar(2); +} + static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h, bool reserveResizeGrip, bool showPopOutBtn, const MapThemeData& theme, float mapAlpha = 1.0f) { @@ -775,6 +1332,9 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h, ImGui::PopStyleVar(2); } + // ── View-preset rail (top-left) ────────────────────────────────────── + DrawPresetRail(win, origin, w, h, kBarH, theme, mapAlpha); + // ── Toolbar strip ──────────────────────────────────────────────────── // Leave the bottom-right corner clear for the ImGui resize grip when in-game. const float kGripReserve = reserveResizeGrip ? 18.f : 0.f; @@ -927,6 +1487,22 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h, ImGui::EndDisabled(); // !eotdOn ImGui::EndDisabled(); // !cullOn + ImGui::Separator(); + + bool wpOn = win->imWaypointsEnabled.load(); + if (ImGui::MenuItem("Show waypoints", nullptr, wpOn)) { + win->imWaypointsEnabled.store(!wpOn); + pushCmd(UICmd::ToggleWaypoints); + } + + ImGui::BeginDisabled(!win->imWaypointsEnabled.load()); + bool wpSel = win->imWaypointsSelectedOnly.load(); + if (ImGui::MenuItem("Selected locomotive only", nullptr, wpSel)) { + win->imWaypointsSelectedOnly.store(!wpSel); + pushCmd(UICmd::ToggleWaypointsSelectedOnly); + } + ImGui::EndDisabled(); + ImGui::EndMenu(); } @@ -1387,9 +1963,10 @@ void Renderer_Present(PopoutWindow* win) { g_context->Draw(3, 0); // ----------------------------------------------------------------------- - // ImGui overlay — compass rose on map + toolbar strip at bottom + // ImGui overlay — compass rose on map + toolbar strip at bottom. + // Plain-content windows (Car Cards) skip this so radio/presets do not appear. // ----------------------------------------------------------------------- - if (g_imguiInited) { + if (g_imguiInited && !win->imPlainContent.load()) { MapThemeData theme = SnapshotTheme(); ImGuiIO& io = ImGui::GetIO(); @@ -1400,6 +1977,15 @@ void Renderer_Present(PopoutWindow* win) { int rawWheel = win->imWheelRaw.exchange(0); io.MouseWheel = (float)rawWheel / WHEEL_DELTA; + char popChars[512]; + { + std::lock_guard lk(win->imKeyMutex); + strncpy_s(popChars, win->imCharsUtf8, _TRUNCATE); + win->imCharsUtf8[0] = 0; + } + FeedImGuiKeys(io, popChars, win->imKeyDown.load(), win->imKeyMods.load(), + win->imPrevKeyDown, win->imPrevKeyMods); + ImGui_ImplDX11_NewFrame(); ImGui::NewFrame(); @@ -1415,6 +2001,8 @@ void Renderer_Present(PopoutWindow* win) { ImGui::Render(); ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData()); win->imWantMouse.store(ImGui::GetIO().WantCaptureMouse); + } else { + win->imWantMouse.store(false); } win->swapChain->Present(0, 0); @@ -1439,6 +2027,13 @@ 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_ovWantKeyboard {false}; +static std::mutex g_ovCharMutex; +static char g_ovChars[512] {}; +static std::atomic g_ovKeyDown {0}; +static std::atomic g_ovKeyMods {0}; +static uint32_t g_ovPrevKeyDown = 0; +static uint32_t g_ovPrevKeyMods = 0; static std::atomic g_ovVisible {false}; // Map texture to show in the in-game window (set from C# each frame) + UV rect. @@ -1474,6 +2069,7 @@ void Overlay_GetMouseMapPos(float* outX, float* outY) { void Overlay_SetAlpha(float alpha) { g_ovAlpha.store(std::max(0.1f, std::min(1.0f, alpha))); } void Overlay_SetMapAlpha(float alpha) { g_mapAlpha.store(std::max(0.0f, std::min(1.0f, alpha))); } void Overlay_SetMapBgAlpha(float alpha) { g_mapBgAlpha.store(std::max(0.0f, std::min(1.0f, alpha))); } +void Overlay_SetPieBlock(bool blocked) { g_ovPieBlock.store(blocked); } // 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. @@ -1483,6 +2079,16 @@ int Overlay_PollInput(InputEvent* out, int maxEvents) { return g_ovInput.drain(o 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(); } +bool Overlay_WantsKeyboard() { return g_ovWantKeyboard.load(); } + +void Overlay_SetKeyboard(const wchar_t* chars, uint32_t keyDown, uint32_t mods) { + g_ovKeyDown.store(keyDown); + g_ovKeyMods.store(mods); + std::lock_guard lk(g_ovCharMutex); + g_ovChars[0] = 0; + if (chars && chars[0]) + WideCharToMultiByte(CP_UTF8, 0, chars, -1, g_ovChars, (int)sizeof(g_ovChars), nullptr, nullptr); +} void Overlay_SetMapTexture(void* texturePtr, float u0, float v0, float u1, float v1) { g_ovMapTex.store(texturePtr); @@ -1529,10 +2135,28 @@ void Renderer_PresentOverlay() { 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; + const bool pieBlock = g_ovPieBlock.load(); + if (pieBlock) { + io.MousePos = ImVec2(-1e8f, -1e8f); + io.MouseDown[0] = false; + io.MouseDown[1] = false; + io.MouseWheel = 0.f; + g_ovWheelRaw.store(0); + } else { + 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; + } + + char ovChars[512]; + { + std::lock_guard lk(g_ovCharMutex); + strncpy_s(ovChars, g_ovChars, _TRUNCATE); + g_ovChars[0] = 0; + } + FeedImGuiKeys(io, ovChars, g_ovKeyDown.load(), g_ovKeyMods.load(), + g_ovPrevKeyDown, g_ovPrevKeyMods); ImGui_ImplDX11_NewFrame(); ImGui::NewFrame(); @@ -1540,13 +2164,15 @@ void Renderer_PresentOverlay() { // Chrome (window bg, title bar, toolbar, compass) alpha. Pushed before Begin() // so all decorations are affected; popped around the map Image() so the map // image has its own independent alpha, then popped finally after BuildMapUI. - const float ovAlpha = g_ovAlpha.load(); - const float mapAlpha = g_mapAlpha.load(); + const float pieMul = pieBlock ? kPieDim : 1.f; + const float ovAlpha = g_ovAlpha.load() * pieMul; + const float mapAlpha = g_mapAlpha.load() * pieMul; const float mapBgA = g_mapBgAlpha.load(); // While the mouse is over the overlay, clamp chrome alpha to 50% so the UI // remains readable even when the user has set a very low window opacity. // Uses last frame's WantCaptureMouse (one-frame lag; imperceptible in practice). - const bool mouseOver = g_ovWantMouse.load(); + // Skip the floor while the pie is open — we want the map see-through. + const bool mouseOver = !pieBlock && g_ovWantMouse.load(); const float effectiveAlpha = mouseOver ? std::max(ovAlpha, 0.5f) : ovAlpha; const bool hasAlpha = effectiveAlpha < 0.999f; if (hasAlpha) @@ -1612,21 +2238,18 @@ void Renderer_PresentOverlay() { ImVec2 imgPos = ImGui::GetCursorScreenPos(); g_ovMapImgX.store(imgPos.x); g_ovMapImgY.store(imgPos.y); + ImGui::SetNextItemAllowOverlap(); ImGui::InvisibleButton("##mapHit", sz, ImGuiButtonFlags_MouseButtonLeft); - bool hov = ImGui::IsItemHovered(); + bool mapActivated = ImGui::IsItemActivated(); + bool mapActive = ImGui::IsItemActive(); + bool mapDeactivated = ImGui::IsItemDeactivated(); + bool mapHov = 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); - if (hov && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) pushOv(RButtonUp, 0.f); // Draw the map over the same rect the hit-button occupies. // Map alpha is independent of chrome alpha: pop ovAlpha so @@ -1636,6 +2259,7 @@ void Renderer_PresentOverlay() { ImVec2 uv1(g_ovMapU1.load(), g_ovMapV1.load()); ImVec4 tint(theme.mapR, theme.mapG, theme.mapB, theme.mapA * mapAlpha); if (hasAlpha) ImGui::PopStyleVar(); // lift chrome alpha + ImGui::SetNextItemAllowOverlap(); ImGui::Image((ImTextureID)g_ovMapSRV.Get(), sz, uv0, uv1, tint); if (hasAlpha) ImGui::PushStyleVar(ImGuiStyleVar_Alpha, effectiveAlpha); @@ -1643,6 +2267,16 @@ void Renderer_PresentOverlay() { if (stateWin) DrawTrackLabels(stateWin, ImGui::GetWindowDrawList(), imgPos, sz); + // Queue map pan/click from the map image. + if (mapActivated) pushOv(LButtonDown, 0.f); + if (mapActive && + (io.MouseDelta.x != 0.f || io.MouseDelta.y != 0.f)) + pushOv(MouseMove, 0.f); + if (mapDeactivated) pushOv(LButtonUp, 0.f); + if (mapHov && io.MouseWheel != 0.f) pushOv(MouseWheel, io.MouseWheel); + if (mapHov && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) + pushOv(RButtonUp, 0.f); + // 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 @@ -1713,7 +2347,8 @@ void Renderer_PresentOverlay() { } ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData()); - g_ovWantMouse.store(io.WantCaptureMouse); + g_ovWantMouse.store(!pieBlock && io.WantCaptureMouse); + g_ovWantKeyboard.store(io.WantTextInput); // Restore Unity's render state saved.Restore(g_context); diff --git a/native/src/d3d11_renderer.h b/native/src/d3d11_renderer.h index 5bbddde..23495cb 100644 --- a/native/src/d3d11_renderer.h +++ b/native/src/d3d11_renderer.h @@ -1,4 +1,5 @@ #pragma once +#include #include "popout_window.h" // Called from exports.cpp (render thread only) @@ -31,6 +32,13 @@ void Overlay_SetInput(float displayW, float displayH, float mouseX, float mouseY, bool lButton, bool rButton, int wheel); +// Overlay keyboard (C# pumps Unity Input System). chars is UTF-16. +// keyDown bits: 0 Backspace .. 12 X. mods: 1 Ctrl, 2 Shift, 4 Alt. +void Overlay_SetKeyboard(const wchar_t* chars, uint32_t keyDown, uint32_t mods); + +// True when an ImGui text field has focus. +bool Overlay_WantsKeyboard(); + // 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); @@ -67,5 +75,9 @@ void Overlay_SetMapAlpha(float alpha); // Set the map camera clear-colour opacity [0.0, 1.0]. Thread-safe via atomic. void Overlay_SetMapBgAlpha(float alpha); +// While true, overlay stays drawn at a fraction of current alpha and ignores mouse +// so a map-opened pie can show through and receive clicks. +void Overlay_SetPieBlock(bool blocked); + // 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 f60fb51..314854f 100644 --- a/native/src/exports.cpp +++ b/native/src/exports.cpp @@ -61,6 +61,13 @@ int RRPOPOUT_CreateWindow(const wchar_t* title, int width, int height) { return CreatePopoutWindow(title, width, height); } +extern "C" __declspec(dllexport) +void RRPOPOUT_SetPlainContent(int windowHandle, bool plain) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win) return; + win->imPlainContent.store(plain); +} + extern "C" __declspec(dllexport) void RRPOPOUT_SetFrameTexture(int windowHandle, void* texturePtr, float u0, float v0, float u1, float v1) { @@ -118,6 +125,14 @@ void RRPOPOUT_SetOverlayMapTexture(void* texturePtr, extern "C" __declspec(dllexport) int RRPOPOUT_OverlayWantsMouse() { return Overlay_WantsMouse() ? 1 : 0; } +extern "C" __declspec(dllexport) +int RRPOPOUT_OverlayWantsKeyboard() { return Overlay_WantsKeyboard() ? 1 : 0; } + +extern "C" __declspec(dllexport) +void RRPOPOUT_SetOverlayKeyboard(const wchar_t* chars, uint32_t keyDown, uint32_t mods) { + Overlay_SetKeyboard(chars, keyDown, mods); +} + // 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) { @@ -356,6 +371,11 @@ void RRPOPOUT_SetOverlayAlpha(float alpha) { Overlay_SetAlpha(alpha); } +extern "C" __declspec(dllexport) +void RRPOPOUT_SetOverlayPieBlock(bool blocked) { + Overlay_SetPieBlock(blocked); +} + // Set the map image alpha [0.0, 1.0]. Independent of chrome alpha. extern "C" __declspec(dllexport) void RRPOPOUT_SetOverlayMapAlpha(float alpha) { @@ -450,6 +470,97 @@ void RRPOPOUT_SetTrackLabelStyle(int windowHandle, win->imTrackLabelFontSizeMin.store(fontSizeMin); } +extern "C" __declspec(dllexport) +void RRPOPOUT_SetPresetList(int windowHandle, const wchar_t* names) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (win) SetNamedList(windowHandle, names, win->imPresetList, win->imPresetMutex); + if (win) { + int n = 0; + { std::lock_guard lk(win->imPresetMutex); n = (int)win->imPresetList.size(); } + int edit = win->imPresetEditIndex.load(); + if (edit >= n) { + win->imPresetEditIndex.store(-1); + win->imPresetPreviewing.store(false); + } + } +} + +extern "C" __declspec(dllexport) +void RRPOPOUT_GetPresetRenameName(int windowHandle, wchar_t* outBuf, int maxChars) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win || !outBuf || maxChars <= 0) return; + outBuf[0] = 0; + MultiByteToWideChar(CP_UTF8, 0, win->imPresetRenameBuf, -1, outBuf, maxChars); +} + +extern "C" __declspec(dllexport) +void RRPOPOUT_SetWaypointState(int windowHandle, bool enabled, bool selectedOnly) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win) return; + win->imWaypointsEnabled.store(enabled); + win->imWaypointsSelectedOnly.store(selectedOnly); +} + +extern "C" __declspec(dllexport) +void RRPOPOUT_SetRadioList(int windowHandle, const wchar_t* names, const uint32_t* colors, int colorCount) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win) return; + SetNamedList(windowHandle, names ? names : L"", win->imRadioList, win->imRadioMutex); + std::lock_guard lk(win->imRadioMutex); + win->imRadioColors.clear(); + if (colors && colorCount > 0) + win->imRadioColors.assign(colors, colors + colorCount); + int n = (int)win->imRadioList.size(); + int edit = win->imRadioEditIndex.load(); + if (edit >= n) win->imRadioEditIndex.store(-1); +} + +extern "C" __declspec(dllexport) +void RRPOPOUT_SetRadioState(int windowHandle, bool radioOn, int selected, int tool, + bool wqInstalled, uint64_t aeBits, bool forward, float speed) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win) return; + win->imRadioOn.store(radioOn); + win->imRadioSelected.store(selected); + win->imRadioTool.store(tool); + win->imRadioWq.store(wqInstalled); + win->imRadioAeBits.store(aeBits); + win->imRadioForward.store(forward); + win->imRadioSpeed.store(speed); +} + +extern "C" __declspec(dllexport) +void RRPOPOUT_GetRadioRenameName(int windowHandle, wchar_t* outBuf, int maxChars) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win || !outBuf || maxChars <= 0) return; + outBuf[0] = 0; + MultiByteToWideChar(CP_UTF8, 0, win->imRadioRenameBuf, -1, outBuf, maxChars); +} + +extern "C" __declspec(dllexport) +void RRPOPOUT_SetRadioGhost(int windowHandle, bool visible, float u, float v, + float angleDeg, uint32_t color) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win) return; + win->imRadioGhostOn.store(visible); + win->imRadioGhostU.store(u); + win->imRadioGhostV.store(v); + win->imRadioGhostAngle.store(angleDeg); + win->imRadioGhostColor.store(color); +} + +extern "C" __declspec(dllexport) +void RRPOPOUT_SetRadioWpPopup(int windowHandle, int stage, float u, float v, + int flags, int count) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win) return; + win->imRadioWpStage.store(stage); + win->imRadioWpU.store(u); + win->imRadioWpV.store(v); + win->imRadioWpFlags.store(flags); + win->imRadioWpCount.store(count < 1 ? 1 : count); +} + // --------------------------------------------------------------------------- // Plugin_Initialize / Plugin_Shutdown (called from dllmain.cpp) // --------------------------------------------------------------------------- diff --git a/native/src/popout_window.h b/native/src/popout_window.h index e6f3092..5e74966 100644 --- a/native/src/popout_window.h +++ b/native/src/popout_window.h @@ -3,8 +3,10 @@ #include #include #include +#include #include #include +#include #include "input_queue.h" struct PopoutWindow { @@ -50,6 +52,10 @@ struct PopoutWindow { // When true, PollInputEvents suppresses mouse events so they don't reach Unity. std::atomic imWantMouse {false}; + // When true, Present blits the frame texture only: no map toolbar, compass, + // radio rail, or view presets. Used by Car Cards (and any future non-map window). + std::atomic imPlainContent {false}; + // ----------------------------------------------------------------------- // Status bar label (UTF-8), shown in the ImGui toolbar. // Written by RRPOPOUT_SetStatusText (main thread), read by render thread. @@ -142,6 +148,52 @@ struct PopoutWindow { std::atomic imTrackLabelAvoidTrack {false}; std::atomic imTrackLabelFontSizeMin {8.f}; + // Named camera-view presets (C# owns the data; native draws the left rail). + std::vector imPresetList; + std::mutex imPresetMutex; + std::atomic imPresetEditIndex {-1}; + std::atomic imPresetPreviewing {false}; + std::atomic imPresetPendingDelete{-1}; + char imPresetRenameBuf[128] {}; + + // AE waypoint pins (gear-menu toggles). + std::atomic imWaypointsEnabled {true}; + std::atomic imWaypointsSelectedOnly {false}; + + // Radio-control rail (top-right). C# owns pin ids; native draws the list. + std::vector imRadioList; + std::vector imRadioColors; + std::mutex imRadioMutex; + std::atomic imRadioOn {false}; + std::atomic imRadioSelected {-1}; + std::atomic imRadioTool {0}; + std::atomic imRadioWq {false}; + std::atomic imRadioAeBits {0}; + std::atomic imRadioForward {true}; + std::atomic imRadioSpeed {15.f}; + std::atomic imRadioEditIndex {-1}; + char imRadioRenameBuf[128] {}; + + // Waypoint-mode ghost arrow (Unity viewport UV, v=0 at bottom) + near-cursor popup. + std::atomic imRadioGhostOn {false}; + std::atomic imRadioGhostU {0.f}; + std::atomic imRadioGhostV {0.f}; + std::atomic imRadioGhostAngle {0.f}; // screen deg, 0=right, +CW, y-down + std::atomic imRadioGhostColor {0xFFFFFFFFu}; + std::atomic imRadioWpStage {0}; // 0 off, 1 choose order, 2 enter count + std::atomic imRadioWpU {0.f}; + std::atomic imRadioWpV {0.f}; + std::atomic imRadioWpFlags {0}; // bit0 = has couple target + std::atomic imRadioWpCount {1}; + + // Keyboard for ImGui InputText (preset rename). Pump thread writes, render thread reads. + std::mutex imKeyMutex; + char imCharsUtf8[512] {}; + std::atomic imKeyDown {0}; + std::atomic imKeyMods {0}; + uint32_t imPrevKeyDown = 0; + uint32_t imPrevKeyMods = 0; + // Loaded geometry from the save file — consumed by MessagePumpThread before CreateWindowExW. std::atomic lastWinX {-1}, lastWinY {-1}; std::atomic lastWinW {900}, lastWinH {700}; diff --git a/native/src/popout_windows.cpp b/native/src/popout_windows.cpp index 110ad26..256679a 100644 --- a/native/src/popout_windows.cpp +++ b/native/src/popout_windows.cpp @@ -8,7 +8,8 @@ #include #include #include // FILE*, fopen_s, fprintf, fgets -#include // strcmp, strchr +#include // strcmp, strchr, strlen, memcpy +#include #include // std::wstring #include "popout_window.h" #include "popout_windows.h" // WM_RRPOPOUT_SET_TOPMOST constant @@ -94,6 +95,8 @@ static LRESULT CALLBACK ContentWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARA case WM_LBUTTONDOWN: case WM_LBUTTONUP: case WM_RBUTTONDOWN: case WM_RBUTTONUP: case WM_MOUSEMOVE: case WM_MOUSEWHEEL: + case WM_CHAR: case WM_KEYDOWN: case WM_KEYUP: + case WM_SYSKEYDOWN: case WM_SYSKEYUP: if (HWND parent = GetParent(hwnd)) return SendMessageW(parent, msg, wParam, lParam); break; @@ -281,6 +284,33 @@ static DWORD WINAPI MessagePumpThread(LPVOID param) { // --------------------------------------------------------------------------- // WndProc // --------------------------------------------------------------------------- +static uint32_t VkToKeyBit(WPARAM vk) { + switch (vk) { + case VK_BACK: return 1u << 0; + case VK_DELETE: return 1u << 1; + case VK_RETURN: return 1u << 2; + case VK_ESCAPE: return 1u << 3; + case VK_LEFT: return 1u << 4; + case VK_RIGHT: return 1u << 5; + case VK_HOME: return 1u << 6; + case VK_END: return 1u << 7; + case VK_TAB: return 1u << 8; + case 'A': case 'a': return 1u << 9; + case 'C': case 'c': return 1u << 10; + case 'V': case 'v': return 1u << 11; + case 'X': case 'x': return 1u << 12; + default: return 0; + } +} + +static void UpdateKeyMods(PopoutWindow* win) { + uint32_t m = 0; + if (GetKeyState(VK_CONTROL) & 0x8000) m |= 1u; + if (GetKeyState(VK_SHIFT) & 0x8000) m |= 2u; + if (GetKeyState(VK_MENU) & 0x8000) m |= 4u; + win->imKeyMods.store(m); +} + static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { PopoutWindow* win = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); @@ -366,6 +396,37 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPara return 0; } + case WM_CHAR: { + if (!win || wParam < 32) return 0; + wchar_t wc = (wchar_t)wParam; + char utf8[8] = {}; + int n = WideCharToMultiByte(CP_UTF8, 0, &wc, 1, utf8, (int)sizeof(utf8) - 1, nullptr, nullptr); + if (n <= 0) return 0; + std::lock_guard lk(win->imKeyMutex); + size_t have = strlen(win->imCharsUtf8); + if (have + (size_t)n < sizeof(win->imCharsUtf8) - 1) { + memcpy(win->imCharsUtf8 + have, utf8, (size_t)n); + win->imCharsUtf8[have + (size_t)n] = 0; + } + return 0; + } + + case WM_KEYDOWN: case WM_SYSKEYDOWN: { + if (!win) break; + UpdateKeyMods(win); + uint32_t bit = VkToKeyBit(wParam); + if (bit) win->imKeyDown.fetch_or(bit); + return 0; + } + + case WM_KEYUP: case WM_SYSKEYUP: { + if (!win) break; + UpdateKeyMods(win); + uint32_t bit = VkToKeyBit(wParam); + if (bit) win->imKeyDown.fetch_and(~bit); + return 0; + } + case WM_ENTERSIZEMOVE: if (win) win->resizing.store(true); return 0; diff --git a/src/Core/Ui/StockMapGuard.cs b/src/Core/Ui/StockMapGuard.cs new file mode 100644 index 0000000..b413edb --- /dev/null +++ b/src/Core/Ui/StockMapGuard.cs @@ -0,0 +1,85 @@ +using HarmonyLib; +using S3.Modules.Popout; +using UI.Common; +using UI.Map; +using UnityEngine; + +namespace S3.Core.Ui; + +/// +/// Keeps the stock Unity MapWindow invisible while the Map Module is enabled. +/// The overlay/popout still call MapWindow.Show (with MapBypass) so the camera +/// and icons initialize; this collapses the game's own panel so it can never +/// steal the view or flash over the ImGui map. +/// +internal static class StockMapGuard +{ + private static Vector3 _savedScale = Vector3.one; + private static bool _haveScale; + + public static void Tick() + { + if (!PopoutModule.Settings.enabled) + { + RestoreIfNeeded(); + return; + } + CollapseCurrent(); + } + + public static void CollapseCurrent() + { + var win = PanelFinder.GetMapWindowUI(); + if (win == null) return; + Collapse(win); + } + + public static void Collapse(Window? win) + { + if (win == null) return; + if (win.transform is not RectTransform rt) return; + if (!_haveScale && rt.localScale != Vector3.zero) + { + _savedScale = rt.localScale; + _haveScale = true; + } + if (rt.localScale != Vector3.zero) + rt.localScale = Vector3.zero; + } + + public static void Collapse(MapWindow? mw) + { + if (mw == null) return; + Collapse(Traverse.Create(mw).Field("_window").Value); + } + + private static void RestoreIfNeeded() + { + if (!_haveScale) return; + var win = PanelFinder.GetMapWindowUI(); + if (win != null && win.transform is RectTransform rt) + rt.localScale = _savedScale; + _haveScale = false; + } +} + +// Any Show/Toggle that reaches the stock window (bypass, other mods, animations) +// must stay at scale zero while the Map Module is on. +[HarmonyPatch(typeof(MapWindow), "OnWindowShown")] +internal static class MapWindow_OnWindowShown_Patch +{ + private static void Postfix(MapWindow __instance, bool shown) + { + if (!PopoutModule.Settings.enabled) return; + try + { + StockMapGuard.Collapse(__instance); + if (shown && !UiService.MapBypass && !UiService.IsOverlayVisible && !PopoutModule.IsDetached) + UiService.OpenOverlay(); + } + catch (System.Exception ex) + { + Log.Error($"[ui] stock map collapse: {ex.Message}"); + } + } +} diff --git a/src/Core/Ui/UiService.cs b/src/Core/Ui/UiService.cs index 5f9283a..cf7c837 100644 --- a/src/Core/Ui/UiService.cs +++ b/src/Core/Ui/UiService.cs @@ -1,15 +1,11 @@ using System.Collections; -using System.Collections.Generic; -using System.Text; -using Helpers; // WorldTransformer -using Model.Ops; // IndustryComponent -using Track; // TrackSpan using HarmonyLib; using S3.Modules.Popout; // NativeLoader + Native + PanelFinder + MapEnhancerBridge using UI; // GameInput using UI.Common; // Window using UI.Map; // MapBuilder, MapWindow using UnityEngine; +using UnityEngine.InputSystem; namespace S3.Core.Ui; @@ -45,6 +41,7 @@ public static class UiService harmony.CreateClassProcessor(typeof(MapWindow_Toggle_Patch)).Patch(); harmony.CreateClassProcessor(typeof(MapWindow_Show_Patch)).Patch(); harmony.CreateClassProcessor(typeof(MapWindow_ShowPos_Patch)).Patch(); + harmony.CreateClassProcessor(typeof(MapWindow_OnWindowShown_Patch)).Patch(); _host = new GameObject("S3.UiService"); Object.DontDestroyOnLoad(_host); @@ -70,7 +67,7 @@ internal static class GameInput_IsMouseOverUI_Patch { private static void Postfix(ref bool __result) { - if (UiHost.MouseOverOverlay) __result = true; + if (UiHost.MouseOverOverlay || UiHost.OverlayWantsKeyboard) __result = true; } } @@ -79,7 +76,7 @@ internal static class GameInput_IsMouseOverGameWindow_Patch { private static void Postfix(ref bool __result) { - if (UiHost.MouseOverOverlay) __result = false; + if (UiHost.MouseOverOverlay || UiHost.OverlayWantsKeyboard) __result = false; } } @@ -93,7 +90,8 @@ internal static class MapWindow_Toggle_Patch { if (UiService.MapBypass) return true; if (!PopoutModule.Settings.enabled) return true; - UiService.ToggleOverlay(); + try { UiService.ToggleOverlay(); } + catch (System.Exception ex) { S3.Core.Log.Error($"[ui] map toggle intercept: {ex}"); } return false; } } @@ -106,7 +104,8 @@ internal static class MapWindow_Show_Patch { if (UiService.MapBypass) return true; if (!PopoutModule.Settings.enabled) return true; - UiService.OpenOverlay(); + try { UiService.OpenOverlay(); } + catch (System.Exception ex) { S3.Core.Log.Error($"[ui] map show intercept: {ex}"); } return false; } } @@ -120,8 +119,12 @@ internal static class MapWindow_ShowPos_Patch { if (UiService.MapBypass) return true; if (!PopoutModule.Settings.enabled) return true; - UiService.OpenOverlay(); - MapBuilder.Shared?.SetMapCenter(gamePosition); + try + { + UiService.OpenOverlay(); + MapBuilder.Shared?.SetMapCenter(gamePosition); + } + catch (System.Exception ex) { S3.Core.Log.Error($"[ui] map show-pos intercept: {ex}"); } return false; } } @@ -157,6 +160,10 @@ internal sealed class UiHost : MonoBehaviour // by the GameInput Harmony patches to suppress the game's world-mouse input. internal static bool MouseOverOverlay { get; private set; } + // True while an ImGui text field (preset rename) has keyboard focus. Stops the + // game from eating WASD / hotkeys while the user is typing. + internal static bool OverlayWantsKeyboard { 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 @@ -181,40 +188,9 @@ internal sealed class UiHost : MonoBehaviour private bool _locationsSent; private bool _meDirty; private string _lastStatus = ""; - - // Track name labels: rebuilt every 5 s from IndustryComponent list. - // Stored in simulation ("game") space so GameToWorld() is applied each frame - // (WorldTransformer offset can shift between rebuilds). - // trackDir: game-space direction of the track, used to compute per-frame screen angle. - // anchors[]: individual span positions in a merged cluster (for leader lines). - private IndustryComponent[]? _industryComponents; - // Tracks the merge state used for the last rebuild so we can detect transitions - // caused by the auto-merge zoom threshold crossing and force a fresh rebuild. - private bool _effectiveMergeEnabled = true; - - private readonly List<(Vector3 centroid, Vector3 trackDir, Vector3[] anchors, string name, int utilityType)> _trackSpanLabels = new(); - private readonly List<(Vector3 centroid, string name, int utilityType)> _industryLabels = new(); - private float _spawnPointTimer = 99f; // force rebuild on first tick - private int _lastLabelCount = -1; - private readonly StringBuilder _labelNames = new(); - // Per-label world-space offset from centroid, computed by ComputeWorldSpaceOffsets() - // during rebuild. Applied in PushTrackLabels so labels stay locked to their tracks - // regardless of camera rotation or pan — the offset is rotation-invariant (world XZ). - private Vector3[] _labelWorldOffsets = System.Array.Empty(); - private float[] _labelUs = System.Array.Empty(); - private float[] _labelVs = System.Array.Empty(); - private float[] _labelAngles = System.Array.Empty(); // per-label screen-space angle (degrees) - private float[] _labelScales = System.Array.Empty(); // per-label font size multiplier - // Flat anchor arrays: all span anchor UVs packed end-to-end across all visible labels. - // _anchorStarts[i] / _anchorCounts[i] index into _anchorUs/Vs for label i. - private float[] _anchorUs = System.Array.Empty(); - private float[] _anchorVs = System.Array.Empty(); - private int[] _anchorStarts = System.Array.Empty(); - private int[] _anchorCounts = System.Array.Empty(); - private int _totalAnchorCount = 0; // sum of anchors across all cached labels (array size bound) - private const float kMergeDistGame = 250f; // sim units (≈ft); merge same-name spans within this radius - private static readonly float[] s_emptyFloat = System.Array.Empty(); - private static readonly int[] s_emptyInt = System.Array.Empty(); + private bool _gameActionsHeld; + private readonly System.Text.StringBuilder _overlayChars = new(); + private System.Action? _onTextInput; private void Start() { @@ -245,19 +221,56 @@ internal sealed class UiHost : MonoBehaviour Native.RRPOPOUT_SetOverlayMapAlpha(PopoutModule.Settings.overlayMapAlpha); Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha); + HookTextInput(); StartCoroutine(RenderLoop()); Log.Info("[ui] in-game overlay ready - press F10 to toggle."); } + private void OnDestroy() + { + UnhookTextInput(); + HoldGameActions(false); + OverlayWantsKeyboard = false; + MouseOverOverlay = false; + } + + private void HookTextInput() + { + if (_onTextInput != null) return; + if (Keyboard.current == null) return; + _onTextInput = c => + { + lock (_overlayChars) _overlayChars.Append(c); + }; + Keyboard.current.onTextInput += _onTextInput; + } + + private void UnhookTextInput() + { + if (_onTextInput == null) return; + try + { + if (Keyboard.current != null) + Keyboard.current.onTextInput -= _onTextInput; + } + catch { } + _onTextInput = null; + } + internal bool IsVisible => _visible; private void Update() { if (!_nativeReady) return; + HookTextInput(); // 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; + OverlayWantsKeyboard = _visible && Native.RRPOPOUT_OverlayWantsKeyboard() == 1; + HoldGameActions(OverlayWantsKeyboard); + if (PopoutModule.Settings.enabled) + StockMapGuard.Tick(); // T key ("Jump to Mouse"): our IsMouseOverGameWindow patch blocks the game's // StrategyCameraController.TeleportToMouse() while the overlay is up, so we @@ -292,6 +305,8 @@ internal sealed class UiHost : MonoBehaviour _visible = false; Native.RRPOPOUT_SetOverlayVisible(0); MouseOverOverlay = false; + OverlayWantsKeyboard = false; + HoldGameActions(false); DeactivateMap(); Log.Info("[ui] overlay closed (external request)."); } @@ -309,7 +324,8 @@ internal sealed class UiHost : MonoBehaviour // then fall through to ActivateMap below. PopoutModule.Toggle(); } - ActivateMap(); + try { ActivateMap(); } + catch (System.Exception ex) { Log.Error($"[ui] overlay ActivateMap: {ex}"); } Log.Info("[ui] overlay opened."); } @@ -333,7 +349,11 @@ internal sealed class UiHost : MonoBehaviour // 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) + { + try { ActivateMap(); } + catch (System.Exception ex) { Log.Error($"[ui] ActivateMap retry: {ex}"); } + } if (_mapActive && _mapCamera != null && _ownRT != null) { @@ -368,13 +388,13 @@ internal sealed class UiHost : MonoBehaviour Input.GetMouseButton(0) ? 1 : 0, Input.GetMouseButton(1) ? 1 : 0, wheel); + PushOverlayKeyboard(); // After WaitForEndOfFrame the final backbuffer is bound, so the native - // callback draws ImGui on top of the completed game frame. + // callback draws ImGui on top of the completed game frame. While the + // pie is open we still draw (dimmed) but do not forward map input. 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]); @@ -382,14 +402,21 @@ internal sealed class UiHost : MonoBehaviour // Chrome: push live status/lists, apply follow/sync, run toolbar commands. if (_mapActive && _mapCamera != null) { - UpdateMapStatus(); - PushTrackLabels(); - RefreshMenuLists(); - ApplyFollowAndSync(); - int cn = Native.RRPOPOUT_PollInputEvents(_overlayHandle, s_inputBuf, kMaxInput); - for (int i = 0; i < cn; i++) - ForwardCommand(s_inputBuf[i]); - if (_meDirty) { PushMEState(); _meDirty = false; } + try + { + UpdateMapStatus(); + TrackLabelService.Push(_overlayHandle, _mapCamera); + RefreshMenuLists(); + ApplyFollowAndSync(); + int cn = Native.RRPOPOUT_PollInputEvents(_overlayHandle, s_inputBuf, kMaxInput); + for (int i = 0; i < cn; i++) + ForwardCommand(s_inputBuf[i]); + if (_meDirty) { PushMEState(); _meDirty = false; } + } + catch (System.Exception ex) + { + Log.Error($"[ui] overlay chrome tick: {ex.Message}"); + } } } } @@ -429,6 +456,17 @@ internal sealed class UiHost : MonoBehaviour MapEnhancerBridge.ToggleFollowMode(); } + // Applying a view preset also cancels rotation-sync so the saved bearing sticks. + private void DisableFollowForPreset() + { + CancelFollowForPan(); + if (_mapSyncPlayer) + { + _mapSyncPlayer = false; + Native.RRPOPOUT_SetMapSyncPlayer(_overlayHandle, false); + } + } + // 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) @@ -646,99 +684,17 @@ internal sealed class UiHost : MonoBehaviour PopoutModule.Settings.eotdSizeScale = Mathf.Clamp(e.y, 1.0f, 10.0f); PopoutModule.Persist(); break; - case UICmd.ToggleTrackLabels: - PopoutModule.Settings.trackLabelsEnabled = !PopoutModule.Settings.trackLabelsEnabled; - PopoutModule.Persist(); - Native.RRPOPOUT_SetTrackLabelsEnabled(_overlayHandle, PopoutModule.Settings.trackLabelsEnabled); - if (!PopoutModule.Settings.trackLabelsEnabled) - { - // Clear immediately so no stale labels are shown while disabled - Native.RRPOPOUT_SetTrackLabels(_overlayHandle, "", - s_emptyFloat, s_emptyFloat, s_emptyFloat, s_emptyFloat, - s_emptyInt, s_emptyInt, s_emptyFloat, s_emptyFloat, 0); - _lastLabelCount = 0; - } - break; - case UICmd.TrackLabelSetFontSize: - PopoutModule.Settings.trackLabelFontSize = Mathf.Clamp(e.y, 8f, 24f); - PopoutModule.Persist(); - SeedTrackLabelStyle(_overlayHandle); - break; - case UICmd.TrackLabelSetLineThick: - PopoutModule.Settings.trackLabelLineThickness = Mathf.Clamp(e.y, 1f, 4f); - PopoutModule.Persist(); - SeedTrackLabelStyle(_overlayHandle); - break; - case UICmd.TrackLabelSetZoomLimit: - PopoutModule.Settings.trackLabelZoomLimit = Mathf.Clamp(e.y, 200f, 8000f); - PopoutModule.Persist(); - SeedTrackLabelStyle(_overlayHandle); - break; - case UICmd.ToggleLeaderLines: - PopoutModule.Settings.trackLeaderLinesEnabled = !PopoutModule.Settings.trackLeaderLinesEnabled; - PopoutModule.Persist(); - SeedTrackLabelStyle(_overlayHandle); - break; - case UICmd.ToggleCollision: - PopoutModule.Settings.trackCollisionEnabled = !PopoutModule.Settings.trackCollisionEnabled; - PopoutModule.Persist(); - SeedTrackLabelStyle(_overlayHandle); - break; - case UICmd.ToggleParallelLabels: - PopoutModule.Settings.trackLabelParallel = !PopoutModule.Settings.trackLabelParallel; - PopoutModule.Persist(); - SeedTrackLabelStyle(_overlayHandle); - break; - case UICmd.ToggleMergeLabels: - PopoutModule.Settings.trackLabelMergeEnabled = !PopoutModule.Settings.trackLabelMergeEnabled; - PopoutModule.Persist(); - SeedTrackLabelStyle(_overlayHandle); - _spawnPointTimer = 99f; - break; - case UICmd.TrackLabelSetMergeZoom: - PopoutModule.Settings.trackLabelMergeZoom = Mathf.Clamp(e.y, 50f, 8000f); - PopoutModule.Persist(); - break; - case UICmd.TrackLabelSetIndustryZoom: - PopoutModule.Settings.trackIndustryLabelZoom = Mathf.Clamp(e.y, 200f, 8000f); - PopoutModule.Persist(); - break; - case UICmd.ToggleUtilityRepairLabels: - PopoutModule.Settings.trackUtilityRepairEnabled = !PopoutModule.Settings.trackUtilityRepairEnabled; - PopoutModule.Persist(); - _spawnPointTimer = 99f; - break; - case UICmd.ToggleUtilityDieselLabels: - PopoutModule.Settings.trackUtilityDieselEnabled = !PopoutModule.Settings.trackUtilityDieselEnabled; - PopoutModule.Persist(); - _spawnPointTimer = 99f; - break; - case UICmd.ToggleUtilityLoaderLabels: - PopoutModule.Settings.trackUtilityLoaderEnabled = !PopoutModule.Settings.trackUtilityLoaderEnabled; - PopoutModule.Persist(); - _spawnPointTimer = 99f; - break; - case UICmd.ToggleUtilityInterchangeLabels: - PopoutModule.Settings.trackUtilityInterchangeEnabled = !PopoutModule.Settings.trackUtilityInterchangeEnabled; - PopoutModule.Persist(); - _spawnPointTimer = 99f; - break; - case UICmd.TrackLabelSetUtilityZoom: - PopoutModule.Settings.trackUtilityZoomLimit = Mathf.Clamp(e.y, 50f, 8000f); - PopoutModule.Persist(); - break; - case UICmd.TrackLabelSetAllZoom: - PopoutModule.Settings.trackAllLabelsZoomLimit = Mathf.Clamp(e.y, 200f, 8000f); - PopoutModule.Persist(); - break; - case UICmd.ToggleAvoidTrackLabels: - PopoutModule.Settings.trackLabelAvoidTrack = !PopoutModule.Settings.trackLabelAvoidTrack; - PopoutModule.Persist(); - break; - case UICmd.TrackLabelSetFontSizeMin: - PopoutModule.Settings.trackLabelFontSizeMin = Mathf.Clamp(e.y, 4f, PopoutModule.Settings.trackLabelFontSize); - PopoutModule.Persist(); + default: + { + var cmd = (UICmd)(int)e.x; + if (TrackLabelService.TryHandleCommand(cmd, e.y, _overlayHandle)) + break; + if (MapViewPresets.TryHandleCommand(cmd, e.y, _overlayHandle, _mapCamera, + ApplyMapRotation, DisableFollowForPreset)) + break; + MapWaypointSystem.TryHandleCommand(cmd, _overlayHandle); break; + } } } @@ -777,7 +733,10 @@ internal sealed class UiHost : MonoBehaviour // 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)); + { + var vp = new Vector2(e.x, 1f - e.y); + PanelFinder.GetMapDrag()?.OnClick?.Invoke(vp); + } _drag = false; break; @@ -825,8 +784,8 @@ internal sealed class UiHost : MonoBehaviour if (winUI != null) PreHideWindow(winUI); UiService.MapBypass = true; - MapWindow.Show(); - UiService.MapBypass = false; + try { MapWindow.Show(); } + finally { UiService.MapBypass = false; } if (!PanelFinder.IsMapReady()) return; // still hidden thanks to pre-hide @@ -874,24 +833,54 @@ internal sealed class UiHost : MonoBehaviour Native.RRPOPOUT_SetPanDisablesFollow(_overlayHandle, PopoutModule.Settings.panDisablesFollow); Native.RRPOPOUT_SetRightClickRecenter(_overlayHandle, PopoutModule.Settings.rightClickRecenter); SeedIconCullingState(_overlayHandle); - Native.RRPOPOUT_SetTrackLabelsEnabled(_overlayHandle, PopoutModule.Settings.trackLabelsEnabled); - SeedTrackLabelStyle(_overlayHandle); - _spawnPointTimer = 99f; // force IC/span rebuild on next PushTrackLabels - _trackSpanLabels.Clear(); - _lastLabelCount = -1; + SeedMapChrome(_overlayHandle); Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha); var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme); bgc.a *= PopoutModule.Settings.overlayMapBgAlpha; _mapCamera!.backgroundColor = bgc; PushMEState(); + MapCameraMemory.TryRestore(_mapCamera, ApplyMapRotation); + _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}"); + + var reported = ex is System.Reflection.TargetInvocationException { InnerException: { } inner } + ? inner + : ex; + Log.Error($"[ui] map activation failed: {reported}"); + + // Overlay is supposed to own the camera. Do NOT un-hide the stock + // MapWindow — that steals the camera from the ImGui overlay / popout. + // Keep it collapsed and let RenderLoop retry. + if (_visible) + { + if (_hiddenWindow?.transform is RectTransform hidden && + hidden.localScale != Vector3.zero) + hidden.localScale = Vector3.zero; + return; + } + + // Overlay is not visible: undo the PreHideWindow scale-zero so the + // base map isn't stuck invisible until restart. + if (_hiddenWindow != null) + { + if (!_mapWasOpen) + { + try + { + UiService.MapBypass = true; + MapWindow.Toggle(); + } + finally { UiService.MapBypass = false; } + } + StockMapGuard.Collapse(_hiddenWindow); + _hiddenWindow = null; + } } } @@ -900,6 +889,9 @@ internal sealed class UiHost : MonoBehaviour { Native.RRPOPOUT_SetOverlayMapTexture(System.IntPtr.Zero, 0f, 1f, 1f, 0f); + if (_mapCamera != null) + MapCameraMemory.Capture(_mapCamera, _mapRotationDeg); + if (_mapCamera != null) { _mapCamera.transform.rotation = Quaternion.Euler(_mapCamEulerX, 0f, _mapCamEulerZ); @@ -925,553 +917,97 @@ internal sealed class UiHost : MonoBehaviour { // 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; + try + { + UiService.MapBypass = true; + MapWindow.Toggle(); + } + finally { UiService.MapBypass = false; } } - // Defer the scale restore by one frame so Unity can finish any close animation - // at scale=0 before we put the window back to its normal size. - Window winToRestore = _hiddenWindow; - Vector3 scaleToRestore = _savedWinScale; + // While the Map Module is on, StockMapGuard keeps the stock window at + // scale zero. Restoring scale here is how it used to flash back in. _hiddenWindow = null; - StartCoroutine(DeferredScaleRestore(winToRestore, scaleToRestore)); } _drag = false; _mapActive = false; } - // Returns the midpoint and local direction of the straightest segment near the - // arc-length centre of a track-span polyline. Score = straightness² × proximity, - // so a clearly straight segment beats a curved one even if it's off-centre. - private static (Vector3 pos, Vector3 dir) FindStraightestNearMiddle(IList pts) + // Overlay keyboard bits — must match Overlay_SetKeyboard in native. + private const uint kKeyBackspace = 1u << 0; + private const uint kKeyDelete = 1u << 1; + private const uint kKeyEnter = 1u << 2; + private const uint kKeyEscape = 1u << 3; + private const uint kKeyLeft = 1u << 4; + private const uint kKeyRight = 1u << 5; + private const uint kKeyHome = 1u << 6; + private const uint kKeyEnd = 1u << 7; + private const uint kKeyTab = 1u << 8; + private const uint kKeyA = 1u << 9; + private const uint kKeyC = 1u << 10; + private const uint kKeyV = 1u << 11; + private const uint kKeyX = 1u << 12; + + private void PushOverlayKeyboard() { - int n = pts.Count; - Vector3 overallDir = (pts[n - 1] - pts[0]).normalized; - if (n == 2) return ((pts[0] + pts[1]) * 0.5f, overallDir); - - float totalLen = 0f; - for (int k = 1; k < n; k++) totalLen += Vector3.Distance(pts[k], pts[k - 1]); - if (totalLen < 0.01f) return (pts[n / 2], overallDir); - - float midLen = totalLen * 0.5f; - float bestScore = -1f; - Vector3 bestPos = pts[n / 2]; - Vector3 bestDir = overallDir; - float cumLen = 0f; - - for (int k = 1; k < n; k++) + string chars; + lock (_overlayChars) { - float segLen = Vector3.Distance(pts[k], pts[k - 1]); - if (segLen < 0.01f) { cumLen += segLen; continue; } - - float segMidLen = cumLen + segLen * 0.5f; - cumLen += segLen; - Vector3 segDir = (pts[k] - pts[k - 1]) / segLen; - float straight = Mathf.Abs(Vector3.Dot(segDir, overallDir)); // 1=parallel, 0=perpendicular - float distFromMid = Mathf.Abs(segMidLen - midLen) / midLen; // 0=at mid, 1=at end - float score = straight * straight * (1f - distFromMid * 0.5f); - - if (score > bestScore) - { - bestScore = score; - bestPos = (pts[k] + pts[k - 1]) * 0.5f; - bestDir = segDir; - } + chars = _overlayChars.ToString(); + _overlayChars.Clear(); } - return (bestPos, bestDir); + + uint keys = 0, mods = 0; + var kb = Keyboard.current; + if (kb != null) + { + if (kb.backspaceKey.isPressed) keys |= kKeyBackspace; + if (kb.deleteKey.isPressed) keys |= kKeyDelete; + if (kb.enterKey.isPressed) keys |= kKeyEnter; + if (kb.numpadEnterKey.isPressed) keys |= kKeyEnter; + if (kb.escapeKey.isPressed) keys |= kKeyEscape; + if (kb.leftArrowKey.isPressed) keys |= kKeyLeft; + if (kb.rightArrowKey.isPressed) keys |= kKeyRight; + if (kb.homeKey.isPressed) keys |= kKeyHome; + if (kb.endKey.isPressed) keys |= kKeyEnd; + if (kb.tabKey.isPressed) keys |= kKeyTab; + if (kb.aKey.isPressed) keys |= kKeyA; + if (kb.cKey.isPressed) keys |= kKeyC; + if (kb.vKey.isPressed) keys |= kKeyV; + if (kb.xKey.isPressed) keys |= kKeyX; + if (kb.leftCtrlKey.isPressed || kb.rightCtrlKey.isPressed) mods |= 1u; + if (kb.leftShiftKey.isPressed || kb.rightShiftKey.isPressed) mods |= 2u; + if (kb.leftAltKey.isPressed || kb.rightAltKey.isPressed) mods |= 4u; + } + + try { Native.RRPOPOUT_SetOverlayKeyboard(chars, keys, mods); } + catch (System.Exception ex) { Log.Error($"[ui] overlay keyboard: {ex.Message}"); } } - // Rebuilds the flat (centroid, trackDir, anchors, name) list every 5 s. - // trackDir: average game-space direction of all spans in the cluster, sign-aligned - // so anti-parallel spans don't cancel (span direction is arbitrary in the graph). - private void RebuildTrackSpanLabels() + private void HoldGameActions(bool hold) { - _industryComponents = Object.FindObjectsOfType(); - - // 1. Collect (pos, dir, name, utilityType) per visible span. - // A TrackSpan can be referenced by more than one IndustryComponent; deduplicate - // by instance so we never emit the same physical track twice. - // Interchange names are normalized ("East Whittier Interchange to X" → "East Whittier - // Interchange") before clustering, so all variants merge into one label. - var raw = new List<(Vector3 pos, Vector3 dir, string name, int utilityType)>(); - var seenSpans = new HashSet(); - var settings = PopoutModule.Settings; - foreach (var ic in _industryComponents) + if (hold == _gameActionsHeld) return; + try { - if (ic == null || !ic.IsVisible || ic.trackSpans.Length == 0) continue; - string[] names = ExpandSpanNames(ic); - for (int si = 0; si < ic.trackSpans.Length; si++) + var map = Traverse.Create(GameInput.shared).Field("_gameActionMap").Value; + if (map == null) return; + if (hold) { - var span = ic.trackSpans[si]; - if (!seenSpans.Add(span)) continue; // already added via another IC - var pts = span.GetPoints() as IList; - Vector3 pos, dir; - if (pts != null && pts.Count >= 2) - (pos, dir) = FindStraightestNearMiddle(pts); - else - { - pos = span.GetCenterPoint(); - dir = Vector3.right; - } - string spanName = NormalizeSpanName(si < names.Length ? names[si] : ic.DisplayName); - int utType = GetUtilityType(spanName); - if (utType == 1 && !settings.trackUtilityRepairEnabled) continue; - if (utType == 2 && !settings.trackUtilityDieselEnabled) continue; - if (utType == 3 && !settings.trackUtilityLoaderEnabled) continue; - if (utType == 4 && !settings.trackUtilityInterchangeEnabled) continue; - raw.Add((pos, dir, spanName, utType)); + if (map.enabled) map.Disable(); + _gameActionsHeld = true; } - } - - // 2a. Per-track mode: one label per span, no clustering. - _trackSpanLabels.Clear(); - if (!_effectiveMergeEnabled) - { - foreach (var (pos, dir, name, utType) in raw) - _trackSpanLabels.Add((pos, dir, new[] { pos }, name, utType)); - _totalAnchorCount = _trackSpanLabels.Count; - RebuildIndustryLabels(); - ComputeWorldSpaceOffsets(); - return; - } - - // 2b. Group by name; greedy single-linkage cluster within kMergeDistGame. - var grouped = new Dictionary>(); - foreach (var (pos, dir, name, utType) in raw) - { - if (!grouped.TryGetValue(name, out var list)) grouped[name] = list = new(); - list.Add((pos, dir, utType)); - } - - foreach (var (name, entries) in grouped) - { - var assigned = new bool[entries.Count]; - for (int i = 0; i < entries.Count; i++) - { - if (assigned[i]) continue; - var cluster = new List<(Vector3 pos, Vector3 dir, int utilityType)> { entries[i] }; - assigned[i] = true; - bool added; - do { - added = false; - for (int j = i + 1; j < entries.Count; j++) - { - if (assigned[j]) continue; - foreach (var (cp, _, _) in cluster) - if (Vector3.Distance(entries[j].pos, cp) < kMergeDistGame) - { cluster.Add(entries[j]); assigned[j] = true; added = true; break; } - } - } while (added); - - int clusterUtType = entries[0].utilityType; - Vector3 centroid = Vector3.zero; - Vector3 refDir = cluster[0].dir; - Vector3 avgDir = Vector3.zero; - var anchorPositions = new Vector3[cluster.Count]; - for (int k = 0; k < cluster.Count; k++) - { - centroid += cluster[k].pos; - anchorPositions[k] = cluster[k].pos; - // Align each dir with the reference so anti-parallel spans don't cancel - var d = cluster[k].dir; - avgDir += Vector3.Dot(d, refDir) >= 0f ? d : -d; - } - centroid /= cluster.Count; - _trackSpanLabels.Add((centroid, avgDir.normalized, anchorPositions, name, clusterUtType)); - } - } - - _totalAnchorCount = 0; - foreach (var (_, _, anchors, _, _) in _trackSpanLabels) - _totalAnchorCount += anchors.Length; - - RebuildIndustryLabels(); - ComputeWorldSpaceOffsets(); - } - - // Builds one label per distinct industry from all visible IndustryComponents. - // Groups ICs by their stripped base name (e.g., "Whittier Saw Mill S01/S02" → - // "Whittier Saw Mill") and averages their span positions into a single centroid. - // Called at the end of RebuildTrackSpanLabels so _industryComponents is already set. - private void RebuildIndustryLabels() - { - _industryLabels.Clear(); - var byName = new Dictionary(); - var settings = PopoutModule.Settings; - - foreach (var ic in _industryComponents) - { - if (ic == null || !ic.IsVisible || ic.trackSpans.Length == 0) continue; - - string industryName = GetIndustryName(ic); - int utType = GetUtilityType(industryName); - if (utType == 1 && !settings.trackUtilityRepairEnabled) continue; - if (utType == 2 && !settings.trackUtilityDieselEnabled) continue; - if (utType == 3 && !settings.trackUtilityLoaderEnabled) continue; - if (utType == 4 && !settings.trackUtilityInterchangeEnabled) continue; - - Vector3 centroid = Vector3.zero; - int n = 0; - - foreach (var span in ic.trackSpans) - { - var pts = span.GetPoints() as IList; - centroid += pts != null && pts.Count >= 2 - ? FindStraightestNearMiddle(pts).pos - : span.GetCenterPoint(); - n++; - } - if (n == 0) continue; - centroid /= n; - - if (byName.TryGetValue(industryName, out var entry)) - byName[industryName] = (entry.sum + centroid, entry.count + 1, utType); else - byName[industryName] = (centroid, 1, utType); - } - - foreach (var (name, (sum, count, utType)) in byName) - _industryLabels.Add((sum / count, name, utType)); - } - - // Spreads overlapping labels in world-space XZ so each label has a stable offset - // from its track centroid. By baking the offset into the projected UV (rather than - // letting the native solver run in screen-space), label positions become invariant - // to camera rotation and pan — they always sit in the same world direction relative - // to their track regardless of what the map is doing. - private void ComputeWorldSpaceOffsets() - { - int n = _trackSpanLabels.Count; - if (n == 0) { _labelWorldOffsets = System.Array.Empty(); return; } - - // Pixel→world conversion using current camera state. - float screenH = _mapCamera != null && _mapCamera.pixelHeight > 0 - ? _mapCamera.pixelHeight : 1080f; - float worldH = _mapCamera != null ? _mapCamera.orthographicSize * 2f : 200f; - float wpp = worldH / screenH; // world units per screen pixel - - var s = PopoutModule.Settings; - float fH = s.trackLabelFontSize * wpp; // font height in world units - float cW = fH * 0.55f; // approx char width (ProggyClean ~55%) - float pad = 3f * wpp; // 3 px padding - float gap = 4f * wpp; // extra clearance between label and track - - var cx = new float[n]; - var cz = new float[n]; - var hw = new float[n]; - var hh = new float[n]; - - for (int i = 0; i < n; i++) - { - var (centroid, trackDir, _, name, _) = _trackSpanLabels[i]; - - hw[i] = name.Length * cW * 0.5f + pad; - hh[i] = fH * 0.5f + pad; - - // Perpendicular to track in XZ (90° CCW: (-z, x)), normalised. - float lx = trackDir.x, lz = trackDir.z; - float len = Mathf.Sqrt(lx * lx + lz * lz); - if (len > 0.001f) { lx /= len; lz /= len; } - float px = -lz, pz = lx; - - // Place label above the track (in the world-perpendicular direction). - float initOff = hh[i] + gap; - cx[i] = centroid.x + px * initOff; - cz[i] = centroid.z + pz * initOff; - } - - // Iterative AABB spread — same algorithm as the native solver, run in world XZ. - const int kMaxIter = 40; - for (int iter = 0; iter < kMaxIter; iter++) - { - bool anyOverlap = false; - for (int i = 0; i < n; i++) { - for (int j = i + 1; j < n; j++) - { - float ox = (hw[i] + hw[j]) - Mathf.Abs(cx[i] - cx[j]); - float oz = (hh[i] + hh[j]) - Mathf.Abs(cz[i] - cz[j]); - if (ox <= 0f || oz <= 0f) continue; - anyOverlap = true; - float pushX = 0f, pushZ = 0f; - if (ox < oz) - pushX = ox * 0.55f * (cx[i] < cx[j] ? -1f : 1f); - else - pushZ = oz * 0.55f * (cz[i] < cz[j] ? -1f : 1f); - cx[i] += pushX; cz[i] += pushZ; - cx[j] -= pushX; cz[j] -= pushZ; - } + if (_gameActionsHeld && !map.enabled) map.Enable(); + _gameActionsHeld = false; } - if (!anyOverlap) break; } - - if (_labelWorldOffsets.Length < n) - _labelWorldOffsets = new Vector3[n]; - for (int i = 0; i < n; i++) + catch { - var (centroid, _, _, _, _) = _trackSpanLabels[i]; - _labelWorldOffsets[i] = new Vector3(cx[i] - centroid.x, 0f, cz[i] - centroid.z); + _gameActionsHeld = false; } } - // Extracts the base industry name from an IC's display name, then normalizes it. - // Multi-span: "Whittier Saw Mill S01/S02/S03" → "Whittier Saw Mill". - // Single-span with track code: "Whittier Saw Mill UFR1" → "Whittier Saw Mill". - // Interchange: "East Whittier Interchange to Atlantic..." → "East Whittier Interchange". - // Single-span without strippable suffix: "East Whittier Coal Loader" → unchanged. - private static string GetIndustryName(IndustryComponent ic) - { - string displayName = ic.DisplayName; - - if (ic.trackSpans.Length > 1 && displayName.Contains('/')) - { - string first = displayName.Split('/')[0].Trim(); - int sp = first.LastIndexOf(' '); - string baseName = sp >= 0 ? first.Substring(0, sp) : first; - return NormalizeSpanName(baseName); - } - - int slash = displayName.IndexOf('/'); - string name = slash >= 0 ? displayName.Substring(0, slash).Trim() : displayName; - name = NormalizeSpanName(name); - int lastSp = name.LastIndexOf(' '); - if (lastSp >= 0 && IsTrackCode(name.Substring(lastSp + 1))) - return name.Substring(0, lastSp); - return name; - } - - // Strips the " to " part from interchange track names so all variants - // of the same interchange cluster under one name. - // "East Whittier Interchange to Atlantic Locomotive Works" → "East Whittier Interchange" - private static string NormalizeSpanName(string name) - { - int idx = name.IndexOf(" Interchange to ", System.StringComparison.OrdinalIgnoreCase); - if (idx >= 0) - return name.Substring(0, idx + " Interchange".Length); - return name; - } - - // Returns the utility category for a (normalized) span name. - // 0=regular, 1=repair, 2=diesel, 3=loader, 4=interchange. - private static int GetUtilityType(string name) - { - if (name.IndexOf(" Interchange", System.StringComparison.OrdinalIgnoreCase) >= 0 || - name.StartsWith("Interchange", System.StringComparison.OrdinalIgnoreCase)) - return 4; - if (name.EndsWith(" Repair Track", System.StringComparison.OrdinalIgnoreCase) || - name.EndsWith(" Repair", System.StringComparison.OrdinalIgnoreCase)) - return 1; - if (name.EndsWith(" Diesel Stand", System.StringComparison.OrdinalIgnoreCase) || - name.EndsWith(" Diesel", System.StringComparison.OrdinalIgnoreCase)) - return 2; - if (name.EndsWith(" Coal Loader", System.StringComparison.OrdinalIgnoreCase) || - name.EndsWith(" Loader", System.StringComparison.OrdinalIgnoreCase) || - name.EndsWith(" Coaling Tower", System.StringComparison.OrdinalIgnoreCase)) - return 3; - return 0; - } - - // Returns true when s looks like a per-track identifier: 1-3 leading letters - // followed by 0-3 digits (e.g., S01, R1, PH1, WS1, C3, MP2, UFR1). - private static bool IsTrackCode(string s) - { - if (s.Length == 0 || s.Length > 4) return false; - int i = 0; - while (i < s.Length && char.IsLetter(s[i])) i++; - if (i == 0 || i > 3) return false; - while (i < s.Length && char.IsDigit(s[i])) i++; - return i == s.Length; - } - - // Expands a component's display name by "/": "Base A/B/C" with 3 spans → - // ["Base A", "Base B", "Base C"]. Falls back to the full name if counts mismatch. - private static string[] ExpandSpanNames(IndustryComponent ic) - { - if (ic.trackSpans.Length <= 1) return new[] { ic.DisplayName }; - string[] parts = ic.DisplayName.Split('/'); - if (parts.Length != ic.trackSpans.Length) return new[] { ic.DisplayName }; - // Extract the prefix from the first part (everything before the last space). - string first = parts[0].Trim(); - int sp = first.LastIndexOf(' '); - string prefix = sp >= 0 ? first.Substring(0, sp + 1) : ""; - var result = new string[parts.Length]; - result[0] = first; - for (int i = 1; i < parts.Length; i++) - result[i] = prefix + parts[i].Trim(); - return result; - } - - // Per-frame: projects cached (centroid, anchors, name) tuples through the map camera - // and pushes the visible subset to native. Native runs AABB collision avoidance and - // draws leader lines. All positions are in simulation space; GameToWorld() applied here. - private void PushTrackLabels() - { - if (_mapCamera == null) return; - - // Recompute effective merge: manual toggle OR auto-merge when zoomed out past threshold. - var s = PopoutModule.Settings; - bool shouldMerge = s.trackLabelMergeEnabled || - (_mapCamera.orthographicSize > s.trackLabelMergeZoom); - if (shouldMerge != _effectiveMergeEnabled) - { - _effectiveMergeEnabled = shouldMerge; - _spawnPointTimer = 99f; // zoom crossed the threshold — rebuild with new merge state - } - - _spawnPointTimer += Time.deltaTime; - if (_spawnPointTimer >= 5f || _industryComponents == null) - { - RebuildTrackSpanLabels(); - _spawnPointTimer = 0f; - } - - // Grow per-label arrays to cover both stages; done here (before the zoom branch) - // so Stage 3 industry label push can write into them safely. - int maxLabels = System.Math.Max(_trackSpanLabels.Count, _industryLabels.Count); - if (_labelUs.Length < maxLabels) - { - _labelUs = new float[maxLabels]; - _labelVs = new float[maxLabels]; - _labelAngles = new float[maxLabels]; - _labelScales = new float[maxLabels]; - _anchorStarts = new int [maxLabels]; - _anchorCounts = new int [maxLabels]; - } - if (_anchorUs.Length < _totalAnchorCount) - { - _anchorUs = new float[_totalAnchorCount]; - _anchorVs = new float[_totalAnchorCount]; - } - - // Global cutoff: beyond this zoom all labels (incl. Stage 3) are suppressed. - if (_mapCamera.orthographicSize > s.trackAllLabelsZoomLimit) - { - if (_lastLabelCount != 0) - { - Native.RRPOPOUT_SetTrackLabels(_overlayHandle, "", - s_emptyFloat, s_emptyFloat, s_emptyFloat, s_emptyFloat, - s_emptyInt, s_emptyInt, s_emptyFloat, s_emptyFloat, 0); - _lastLabelCount = 0; - } - return; - } - - // Stage 3: beyond the industry-label threshold — one big label per industry. - if (_mapCamera.orthographicSize > s.trackIndustryLabelZoom) - { - _labelNames.Clear(); - int icCount = 0; - foreach (var (centroid, name, _) in _industryLabels) - { - Vector3 vp = _mapCamera.WorldToViewportPoint(centroid.GameToWorld()); - if (vp.z < 0f || vp.x < -0.05f || vp.x > 1.05f || - vp.y < -0.05f || vp.y > 1.05f) continue; - - if (icCount > 0) _labelNames.Append('\n'); - _labelNames.Append(name); - _labelUs[icCount] = vp.x; - _labelVs[icCount] = vp.y; - _labelAngles[icCount] = 0f; // horizontal — labels an area, not a track - _labelScales[icCount] = 1.5f; // bigger than per-track labels - _anchorStarts[icCount] = 0; - _anchorCounts[icCount] = 0; // no leader lines at this zoom - icCount++; - } - Native.RRPOPOUT_SetTrackLabels(_overlayHandle, _labelNames.ToString(), - _labelUs, _labelVs, - s_emptyFloat, s_emptyFloat, - _anchorStarts, _anchorCounts, - _labelAngles, _labelScales, icCount); - _lastLabelCount = icCount; - return; - } - - // Dead zone: track labels are hidden but industry labels haven't kicked in yet. - if (_mapCamera.orthographicSize > s.trackLabelZoomLimit) - { - if (_lastLabelCount != 0) - { - Native.RRPOPOUT_SetTrackLabels(_overlayHandle, "", - s_emptyFloat, s_emptyFloat, s_emptyFloat, s_emptyFloat, - s_emptyInt, s_emptyInt, s_emptyFloat, s_emptyFloat, 0); - _lastLabelCount = 0; - } - return; - } - - // Stage 1 / Stage 2: per-track or merged labels. - _labelNames.Clear(); - int si = 0; // index into _trackSpanLabels / _labelWorldOffsets - int count = 0; - int anchorOffset = 0; - foreach (var (centroid, trackDir, anchors, name, utilityType) in _trackSpanLabels) - { - // Utility labels (repair, diesel, loader, interchange) have their own zoom limit - // and hide before the main track labels when the map is zoomed out. - if (utilityType != 0 && _mapCamera.orthographicSize > s.trackUtilityZoomLimit) { si++; continue; } - - // Apply world-space spread offset computed at rebuild time. The offset is in - // game-space XZ and is rotation-invariant, so labels stay locked to their tracks - // regardless of camera rotation or pan. - Vector3 labelCenter = si < _labelWorldOffsets.Length - ? centroid + _labelWorldOffsets[si] - : centroid; - - Vector3 vp = _mapCamera.WorldToViewportPoint(labelCenter.GameToWorld()); - if (vp.z < 0f || vp.x < -0.05f || vp.x > 1.05f || - vp.y < -0.05f || vp.y > 1.05f) { si++; continue; } - - if (count > 0) _labelNames.Append('\n'); - _labelNames.Append(name); - _labelUs[count] = vp.x; - _labelVs[count] = vp.y; - // Scale label size inversely with zoom so text appears constant-size relative - // to tracks. Reference zoom = half the hide threshold (full size at that level). - // Clamped to [trackLabelFontSizeMin, trackLabelFontSize] in pixel space. - float refZoom = Mathf.Max(s.trackLabelZoomLimit * 0.5f, 1f); - float targetPx = s.trackLabelFontSize * (refZoom / _mapCamera.orthographicSize); - float clampedPx = Mathf.Clamp(targetPx, s.trackLabelFontSizeMin, s.trackLabelFontSize); - _labelScales[count] = clampedPx / s.trackLabelFontSize; - _anchorStarts[count] = anchorOffset; - _anchorCounts[count] = anchors.Length; - - // Compute screen-space angle for parallel-label rotation. - // Project two points along the track direction through the camera; - // screen Y is flipped relative to viewport Y, so negate the dy term. - Vector3 p0v = _mapCamera.WorldToViewportPoint((centroid - trackDir * 20f).GameToWorld()); - Vector3 p1v = _mapCamera.WorldToViewportPoint((centroid + trackDir * 20f).GameToWorld()); - float adx = p1v.x - p0v.x; - float ady = -(p1v.y - p0v.y); // viewport Y up → screen Y down - // Viewport UV is normalized; text is rendered in screen pixels. - // Dividing ady by aspect converts Y from "fraction of height" to - // "fraction of width" so atan2 operates in uniform pixel units. - float aspect = _mapCamera.aspect > 0f ? _mapCamera.aspect : 1f; - float angle = Mathf.Atan2(ady / aspect, adx) * Mathf.Rad2Deg; - // Keep text readable: clamp to (-90°, 90°] so it never renders upside-down. - if (angle > 90f) angle -= 180f; - else if (angle < -90f) angle += 180f; - _labelAngles[count] = angle; - - foreach (var ap in anchors) - { - Vector3 av = _mapCamera.WorldToViewportPoint(ap.GameToWorld()); - _anchorUs[anchorOffset] = av.x; - _anchorVs[anchorOffset] = av.y; - anchorOffset++; - } - si++; - count++; - } - - Native.RRPOPOUT_SetTrackLabels(_overlayHandle, _labelNames.ToString(), - _labelUs, _labelVs, - _anchorUs, _anchorVs, - _anchorStarts, _anchorCounts, - _labelAngles, _labelScales, count); - _lastLabelCount = count; - } - private static void SeedIconCullingState(int handle) { var s = PopoutModule.Settings; @@ -1480,19 +1016,22 @@ internal sealed class UiHost : MonoBehaviour s.eotdEnabled, s.eotdOnlyWhenCulled, s.eotdSizeScale); } - private static void SeedTrackLabelStyle(int handle) + // Labels / presets / waypoints must never abort camera takeover. A throw here + // used to unwind into the MapWindow Harmony prefix, which then ran the original + // Toggle() and opened the stock map — stealing the camera from overlay + popout. + private static void SeedMapChrome(int handle) { - var s = PopoutModule.Settings; - Native.RRPOPOUT_SetTrackLabelStyle(handle, - s.trackLabelFontSize, s.trackLabelLineThickness, s.trackLabelZoomLimit, - s.trackLeaderLinesEnabled, s.trackCollisionEnabled, s.trackLabelParallel, - s.trackLabelMergeEnabled, s.trackLabelMergeZoom, - s.trackIndustryLabelZoom, - s.trackUtilityRepairEnabled, s.trackUtilityDieselEnabled, - s.trackUtilityLoaderEnabled, s.trackUtilityInterchangeEnabled, - s.trackUtilityZoomLimit, - s.trackAllLabelsZoomLimit, - s.trackLabelAvoidTrack, - s.trackLabelFontSizeMin); + try + { + TrackLabelService.SeedEnabled(handle); + TrackLabelService.SeedStyle(handle); + TrackLabelService.Reset(); + MapViewPresets.PushList(handle); + MapWaypointSystem.SeedState(handle); + } + catch (System.Exception ex) + { + Log.Error($"[ui] map chrome seed failed (overlay still active): {ex}"); + } } } diff --git a/src/Main.cs b/src/Main.cs index 798003e..2244dcd 100644 --- a/src/Main.cs +++ b/src/Main.cs @@ -42,6 +42,7 @@ public static class Main // 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(); + Modules.Popout.WqDumpCommand.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 97a5307..f1c879b 100644 --- a/src/Modules/Popout/DetachedPanel.cs +++ b/src/Modules/Popout/DetachedPanel.cs @@ -1,8 +1,10 @@ using System; using System.Runtime.InteropServices; +using HarmonyLib; using S3.Core; // Log using UI.Map; using UnityEngine; +using UnityEngine.InputSystem; namespace S3.Modules.Popout { @@ -80,6 +82,18 @@ namespace S3.Modules.Popout { Native.RRPOPOUT_SetPanDisablesFollow(_windowHandle, PopoutModule.Settings.panDisablesFollow); Native.RRPOPOUT_SetRightClickRecenter(_windowHandle, PopoutModule.Settings.rightClickRecenter); SeedIconCullingState(_windowHandle); + try + { + TrackLabelService.SeedEnabled(_windowHandle); + TrackLabelService.SeedStyle(_windowHandle); + TrackLabelService.Reset(); + MapViewPresets.PushList(_windowHandle); + MapWaypointSystem.SeedState(_windowHandle); + } + catch (Exception ex) + { + Log.Error($"[popout] map chrome seed failed (popout still active): {ex}"); + } Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha); var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme); bgc.a *= PopoutModule.Settings.overlayMapBgAlpha; @@ -105,6 +119,7 @@ namespace S3.Modules.Popout { _mapCamera.orthographicSize = Mathf.Clamp(mapZoom, 100f, 10000f); if (mapRotation != 0f) ApplyMapRotation(mapRotation); + MapCameraMemory.TryRestore(_mapCamera, ApplyMapRotation); if (syncRotation != 0) { _mapSyncPlayer = true; Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, true); } if (followPlayer != 0) { _followPlayer = true; Native.RRPOPOUT_SetFollowPlayer(_windowHandle, true); } @@ -133,6 +148,7 @@ namespace S3.Modules.Popout { // --- Status bar --- UpdateStatusText(); + TrackLabelService.Push(_windowHandle, _mapCamera); // --- Menu list refresh --- _listTimer += Time.deltaTime; @@ -176,11 +192,16 @@ namespace S3.Modules.Popout { if (hotkeyNow && !_prevHotkeyDown) CloseRequested = true; _prevHotkeyDown = hotkeyNow; - // T key ("Jump to Mouse"): teleport the player to the last-known cursor position - // on the map. We use GetAsyncKeyState so this fires even when the game window - // doesn't have focus. Unity's Input System won't fire Teleport from the popout - // window, so we poll here instead. VK_T = 0x54 (matches the game's default binding). - bool teleportNow = IsDown(0x54); + // "Jump to Mouse": teleport the player to the last-known cursor position on the + // map. We use GetAsyncKeyState so this fires even when the game window doesn't + // have focus. Unity's Input System won't fire Teleport from the popout window, + // so we poll here instead, using the key + modifier actually bound to Game/Teleport + // (resolved once via ResolveTeleportBinding) rather than a hardcoded key — a plain + // VK_T poll ignored whatever modifier the binding requires (default Shift+T, or + // any rebind), so the popout would fire on the bare key alone. + ResolveTeleportBinding(); + bool teleportNow = IsDown(_teleportVk) && + (_teleportModifierVk == 0 || IsDown(_teleportModifierVk)); if (teleportNow && !_prevTeleportDown && _lastMouseX >= 0f) PanelFinder.GetMapDrag()?.OnTeleport?.Invoke(new Vector2(_lastMouseX, 1f - _lastMouseY)); _prevTeleportDown = teleportNow; @@ -204,6 +225,19 @@ namespace S3.Modules.Popout { Native.RRPOPOUT_SetMapRotation(_windowHandle, _mapRotationDeg); } + private void DisableFollowForPreset() { + if (_followPlayer) { + _followPlayer = false; + Native.RRPOPOUT_SetFollowPlayer(_windowHandle, false); + } + if (_mapSyncPlayer) { + _mapSyncPlayer = false; + Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, false); + } + if (MapEnhancerBridge.IsInstalled && MapEnhancerBridge.FollowMode) + MapEnhancerBridge.ToggleFollowMode(); + } + // --------------------------------------------------------------------------- private void UpdateStatusText() { string status = PanelFinder.BuildStatusText(_mapCamera!); @@ -243,6 +277,59 @@ namespace S3.Modules.Popout { private static bool IsDown(int vk) => (GetAsyncKeyState(vk) & 0x8000) != 0; + // Cache of the actual Game/Teleport keybind (resolved once, lazily, from the game's + // live InputAction so a rebind is picked up on the next popout open). Falls back to + // Shift+T — the game's own default — if resolution fails for any reason. + private static bool _teleportBindingResolved; + private static int _teleportVk = 0x54; // VK_T + private static int _teleportModifierVk = 0x10; // VK_SHIFT + + private static void ResolveTeleportBinding() { + if (_teleportBindingResolved) return; + _teleportBindingResolved = true; + try { + var action = Traverse.Create(UI.GameInput.shared).Field("_teleportAction").GetValue(); + if (action == null) return; + + int mainVk = 0, modVk = 0; + foreach (var binding in action.bindings) { + int vk = KeyPathToVirtualKey(binding.effectivePath); + if (vk == 0) continue; + if (binding.isPartOfComposite && string.Equals(binding.name, "modifier", StringComparison.OrdinalIgnoreCase)) + modVk = vk; + else if (!binding.isComposite) + mainVk = vk; + } + if (mainVk != 0) { + _teleportVk = mainVk; + _teleportModifierVk = modVk; + } + } catch (Exception ex) { + Log.Error($"[popout] failed to resolve Teleport keybind, falling back to Shift+T: {ex}"); + } + } + + // Maps an Input System key path (e.g. "/t", "/leftShift", + // "/f5") to its Win32 virtual-key code. Returns 0 if unrecognised. + private static int KeyPathToVirtualKey(string? effectivePath) { + if (string.IsNullOrEmpty(effectivePath)) return 0; + int slash = effectivePath!.LastIndexOf('/'); + string key = (slash >= 0 ? effectivePath.Substring(slash + 1) : effectivePath).ToLowerInvariant(); + switch (key) { + case "leftshift": case "rightshift": case "shift": return 0x10; + case "leftctrl": case "rightctrl": case "ctrl": return 0x11; + case "leftalt": case "rightalt": case "alt": return 0x12; + } + if (key.Length == 1) { + char c = key[0]; + if (c >= 'a' && c <= 'z') return char.ToUpperInvariant(c); + if (c >= '0' && c <= '9') return c; + } + if (key.Length >= 2 && key[0] == 'f' && int.TryParse(key.Substring(1), out int fn) && fn is >= 1 and <= 24) + return 0x70 + (fn - 1); + return 0; + } + private static int ToVirtualKey(KeyCode k) { int c = (int)k; if (c >= 97 && c <= 122) return c - 32; @@ -251,6 +338,8 @@ namespace S3.Modules.Popout { } public void Destroy() { + if (_mapCamera != null) + MapCameraMemory.Capture(_mapCamera, _mapRotationDeg); if (_mapCamera != null) { _mapCamera.targetTexture = _savedTargetTexture; _mapCamera.rect = _savedRect; @@ -330,7 +419,10 @@ namespace S3.Modules.Popout { case InputEventType.LButtonUp: if (!_didDrag) - PanelFinder.GetMapDrag()?.OnClick?.Invoke(viewportPos); + { + var vp = new Vector2(e.x, 1f - e.y); + PanelFinder.GetMapDrag()?.OnClick?.Invoke(vp); + } _isDragging = false; _didDrag = false; break; @@ -494,6 +586,17 @@ namespace S3.Modules.Popout { PopoutModule.Settings.eotdSizeScale = Mathf.Clamp(e.y, 1.0f, 10.0f); PopoutModule.Persist(); break; + default: + { + var cmd = (UICmd)(int)e.x; + if (TrackLabelService.TryHandleCommand(cmd, e.y, _windowHandle)) + break; + if (MapViewPresets.TryHandleCommand(cmd, e.y, _windowHandle, _mapCamera, + ApplyMapRotation, DisableFollowForPreset)) + break; + MapWaypointSystem.TryHandleCommand(cmd, _windowHandle); + break; + } } break; } diff --git a/src/Modules/Popout/MapEnhancerBridge.cs b/src/Modules/Popout/MapEnhancerBridge.cs index a4d8d4f..88bec73 100644 --- a/src/Modules/Popout/MapEnhancerBridge.cs +++ b/src/Modules/Popout/MapEnhancerBridge.cs @@ -268,6 +268,8 @@ namespace S3.Modules.Popout { .OrderBy(sp => sp.name) .ToArray(); + public static void JumpToCar(Car car) => JumpToCarPosition(car); + private static void JumpToCarPosition(Car car) { try { var mapCam = MapBuilder.Shared?.mapCamera?.transform; diff --git a/src/Modules/Popout/MapViewPresets.cs b/src/Modules/Popout/MapViewPresets.cs new file mode 100644 index 0000000..f018b16 --- /dev/null +++ b/src/Modules/Popout/MapViewPresets.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Helpers; +using UI.Map; +using UnityEngine; + +namespace S3.Modules.Popout; + +internal struct MapViewPreset +{ + public string name; + public float x, z, zoom, rotationY; +} + +internal static class MapViewPresets +{ + private static bool _stashValid; + private static float _stashX, _stashZ, _stashZoom, _stashRot; + + public static void PushList(int handle) + { + try + { + var presets = List(); + if (presets.Count == 0) + { + Native.RRPOPOUT_SetPresetList(handle, ""); + return; + } + var sb = new StringBuilder(); + for (int i = 0; i < presets.Count; i++) + { + if (i > 0) sb.Append('\n'); + sb.Append(string.IsNullOrEmpty(presets[i].name) ? $"View {i + 1}" : presets[i].name); + } + Native.RRPOPOUT_SetPresetList(handle, sb.ToString()); + } + catch (Exception ex) + { + S3.Core.Log.Error($"[popout] preset list push failed: {ex.Message}"); + } + } + + private static string ReadRename(int handle) + { + try + { + var buf = new StringBuilder(128); + Native.RRPOPOUT_GetPresetRenameName(handle, buf, 128); + return buf.ToString().Trim(); + } + catch (Exception ex) + { + S3.Core.Log.Error($"[popout] preset rename read failed: {ex.Message}"); + return ""; + } + } + + public static bool TryHandleCommand(UICmd cmd, float y, int handle, Camera? cam, + Action applyRotation, Action disableFollow) + { + int index = (int)y; + switch (cmd) + { + case UICmd.PresetAdd: + if (cam == null) return true; + AddCurrent(cam); + PushList(handle); + return true; + case UICmd.PresetApply: + if (cam == null) return true; + disableFollow(); + Apply(index, cam, applyRotation); + return true; + case UICmd.PresetDelete: + Delete(index); + CancelStash(cam, applyRotation); + PushList(handle); + return true; + case UICmd.PresetRename: + Rename(index, ReadRename(handle)); + PushList(handle); + return true; + case UICmd.PresetPreview: + if (cam == null) return true; + disableFollow(); + Preview(index, cam, applyRotation); + return true; + case UICmd.PresetCommitEdit: + if (cam == null) return true; + Rename(index, ReadRename(handle)); + CommitEdit(index, cam, applyRotation); + PushList(handle); + return true; + case UICmd.PresetCancelEdit: + CancelStash(cam, applyRotation); + return true; + default: + return false; + } + } + + private static List List() + { + var s = PopoutModule.Settings; + var names = s.presetNames ?? Array.Empty(); + var xs = s.presetX ?? Array.Empty(); + var zs = s.presetZ ?? Array.Empty(); + var zooms = s.presetZoom ?? Array.Empty(); + var rots = s.presetRot ?? Array.Empty(); + int n = Math.Min(names.Length, Math.Min(xs.Length, Math.Min(zs.Length, Math.Min(zooms.Length, rots.Length)))); + var list = new List(n); + for (int i = 0; i < n; i++) + list.Add(new MapViewPreset + { + name = names[i] ?? $"View {i + 1}", + x = xs[i], z = zs[i], zoom = zooms[i], rotationY = rots[i] + }); + return list; + } + + private static void Save(List list) + { + int n = list.Count; + var names = new string[n]; + var xs = new float[n]; + var zs = new float[n]; + var zooms = new float[n]; + var rots = new float[n]; + for (int i = 0; i < n; i++) + { + names[i] = list[i].name ?? ""; + xs[i] = list[i].x; + zs[i] = list[i].z; + zooms[i] = list[i].zoom; + rots[i] = list[i].rotationY; + } + var s = PopoutModule.Settings; + s.presetNames = names; + s.presetX = xs; + s.presetZ = zs; + s.presetZoom = zooms; + s.presetRot = rots; + PopoutModule.Persist(); + } + + private static MapViewPreset Capture(Camera cam) + { + MigrateLegacyIfNeeded(cam); + ToGameXZ(cam.transform.position, out float x, out float z); + return new MapViewPreset + { + name = "", + x = x, + z = z, + zoom = cam.orthographicSize, + rotationY = cam.transform.eulerAngles.y, + }; + } + + private static void Apply(int index, Camera cam, Action applyRotation) + { + MigrateLegacyIfNeeded(cam); + var presets = List(); + if (index < 0 || index >= presets.Count) return; + ApplyPreset(presets[index], cam, applyRotation); + } + + private static void ApplyPreset(MapViewPreset p, Camera cam, Action applyRotation) + { + SetCameraXZ(cam, p.x, p.z, PopoutModule.Settings.presetUseGameCoords); + cam.orthographicSize = Mathf.Clamp(p.zoom, 25f, 10000f); + applyRotation(p.rotationY); + PanelFinder.UpdateMapForZoom(); + } + + // Old presets stored Unity world XZ. After a floating-origin rebase those + // numbers no longer map to the same place on the railroad. Convert the + // whole list the first time we save in game space. + private static void MigrateLegacyIfNeeded(Camera cam) + { + var s = PopoutModule.Settings; + if (s.presetUseGameCoords) return; + var list = List(); + for (int i = 0; i < list.Count; i++) + { + var p = list[i]; + var world = new Vector3(p.x, cam.transform.position.y, p.z); + ToGameXZ(world, out p.x, out p.z); + list[i] = p; + } + s.presetUseGameCoords = true; + if (list.Count > 0) Save(list); + else PopoutModule.Persist(); + } + + internal static void ToGameXZ(Vector3 world, out float x, out float z) + { + try + { + var g = WorldTransformer.WorldToGame(world); + x = g.x; z = g.z; + } + catch + { + x = world.x; z = world.z; + } + } + + internal static void SetCameraXZ(Camera cam, float x, float z, bool gameSpace) + { + var t = cam.transform; + if (!gameSpace) + { + t.position = new Vector3(x, t.position.y, z); + return; + } + try + { + var world = WorldTransformer.GameToWorld(new Vector3(x, 0f, z)); + world.y = t.position.y; + t.position = world; + } + catch + { + t.position = new Vector3(x, t.position.y, z); + } + } + + private static void AddCurrent(Camera cam) + { + var list = List(); + var p = Capture(cam); + p.name = NextName(list); + list.Add(p); + Save(list); + } + + private static string NextName(List list) + { + int n = list.Count + 1; + string name = $"View {n}"; + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var p in list) + if (!string.IsNullOrEmpty(p.name)) used.Add(p.name); + while (used.Contains(name)) + { + n++; + name = $"View {n}"; + } + return name; + } + + private static void Delete(int index) + { + var list = List(); + if (index < 0 || index >= list.Count) return; + list.RemoveAt(index); + Save(list); + } + + private static void Rename(int index, string name) + { + if (string.IsNullOrWhiteSpace(name)) return; + var list = List(); + if (index < 0 || index >= list.Count) return; + var p = list[index]; + p.name = name.Trim(); + list[index] = p; + Save(list); + } + + private static void Preview(int index, Camera cam, Action applyRotation) + { + if (!_stashValid) + { + var cur = Capture(cam); + _stashX = cur.x; _stashZ = cur.z; _stashZoom = cur.zoom; _stashRot = cur.rotationY; + _stashValid = true; + } + Apply(index, cam, applyRotation); + } + + private static void CommitEdit(int index, Camera cam, Action applyRotation) + { + var list = List(); + if (index < 0 || index >= list.Count) + { + CancelStash(cam, applyRotation); + return; + } + var p = Capture(cam); + p.name = list[index].name; + list[index] = p; + Save(list); + RestoreStash(cam, applyRotation); + } + + private static void CancelStash(Camera? cam, Action applyRotation) + { + if (cam != null) RestoreStash(cam, applyRotation); + else _stashValid = false; + } + + private static void RestoreStash(Camera cam, Action applyRotation) + { + if (!_stashValid) return; + ApplyPreset(new MapViewPreset + { + x = _stashX, z = _stashZ, zoom = _stashZoom, rotationY = _stashRot + }, cam, applyRotation); + _stashValid = false; + } +} + +/// +/// Remembers the map camera between overlay/popout close and the next open. +/// MapEnhancer patches OnWindowShown to snap the camera to the player; we +/// capture before teardown and write the view back after Show() returns. +/// +internal static class MapCameraMemory +{ + public static void Capture(Camera? cam, float rotationY) + { + if (cam == null) return; + var s = PopoutModule.Settings; + MapViewPresets.ToGameXZ(cam.transform.position, out s.lastViewX, out s.lastViewZ); + s.lastViewZoom = cam.orthographicSize; + s.lastViewRot = rotationY; + s.lastViewValid = true; + s.lastViewIsGame = true; + PopoutModule.Persist(); + } + + public static bool TryRestore(Camera? cam, Action applyRotation) + { + if (cam == null) return false; + var s = PopoutModule.Settings; + if (!s.lastViewValid) return false; + MapViewPresets.SetCameraXZ(cam, s.lastViewX, s.lastViewZ, s.lastViewIsGame); + cam.orthographicSize = Mathf.Clamp(s.lastViewZoom, 25f, 10000f); + applyRotation(s.lastViewRot); + PanelFinder.UpdateMapForZoom(); + return true; + } +} diff --git a/src/Modules/Popout/MapWaypointSystem.cs b/src/Modules/Popout/MapWaypointSystem.cs new file mode 100644 index 0000000..b89b9dc --- /dev/null +++ b/src/Modules/Popout/MapWaypointSystem.cs @@ -0,0 +1,560 @@ +using System.Collections.Generic; +using Game.Messages; +using HarmonyLib; +using Helpers; +using Model; +using Model.AI; +using Track; +using UI.Map; +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace S3.Modules.Popout; + +/// +/// Destination pins on the map camera RT for Auto Engineer waypoints. +/// Vanilla: one pin per loco in Waypoint mode. WaypointQueue: numbered queue. +/// +internal static class MapWaypointSystem +{ + private static GameObject? _holder; + private static readonly Dictionary _markers = new(); + private static readonly Dictionary _locoColors = new(); + private static readonly Dictionary _iconOrig = new(); + private static int _nextColor; + private static float _rebuildTimer; + private const float kRebuildInterval = 1.5f; + private const float kYOffset = 3600f; + private static Sprite? _circle; + private static TMP_FontAsset? _tmpFont; + + public static void Install() + { + if (_holder != null) return; + _holder = new GameObject("S3.Waypoint.Holder"); + Object.DontDestroyOnLoad(_holder); + _rebuildTimer = 0f; + } + + public static void Uninstall() + { + ClearAll(); + if (_holder != null) { Object.Destroy(_holder); _holder = null; } + _circle = null; + _tmpFont = null; + _locoColors.Clear(); + _nextColor = 0; + RestoreLocoColors(); + } + + public static void Tick(float dt) + { + if (_holder == null) return; + if (!PopoutModule.Settings.waypointsEnabled) + { + ClearAll(); + RestoreLocoColors(); + return; + } + // Only while the ImGui overlay or OS popout owns the map camera. Cloning + // MapIcon templates with the stock map closed (or worse, while it is + // opening) can poke MapWindow.Show and steal the camera. + if (!S3.Core.Ui.UiService.IsOverlayVisible && !PopoutModule.IsDetached) + { + ClearAll(); + RestoreLocoColors(); + return; + } + TintLocoIcons(); + _rebuildTimer -= dt; + if (_rebuildTimer <= 0f) { _rebuildTimer = kRebuildInterval; Rebuild(); } + } + + public static void SeedState(int handle) + { + try + { + var s = PopoutModule.Settings; + Native.RRPOPOUT_SetWaypointState(handle, s.waypointsEnabled, s.waypointsSelectedOnly); + } + catch (System.Exception ex) + { + S3.Core.Log.Error($"[popout] waypoint state seed failed: {ex.Message}"); + } + } + + public static bool TryHandleCommand(UICmd cmd, int handle) + { + var s = PopoutModule.Settings; + switch (cmd) + { + case UICmd.ToggleWaypoints: + s.waypointsEnabled = !s.waypointsEnabled; + PopoutModule.Persist(); + SeedState(handle); + return true; + case UICmd.ToggleWaypointsSelectedOnly: + s.waypointsSelectedOnly = !s.waypointsSelectedOnly; + PopoutModule.Persist(); + SeedState(handle); + return true; + default: + return false; + } + } + + private static void Rebuild() + { + try + { + RebuildInner(); + } + catch (System.Exception ex) + { + S3.Core.Log.Error($"[popout] waypoint rebuild: {ex.Message}"); + } + } + + private static void RebuildInner() + { + if (_holder == null) return; + var tc = TrainController.Shared; + if (tc == null) { ClearAll(); return; } + + var wanted = new HashSet(); + string? selectedId = SelectedLocoId(tc); + + foreach (Car car in tc.Cars) + { + if (car is not BaseLocomotive loco) continue; + if (PopoutModule.Settings.waypointsSelectedOnly && + (selectedId == null || loco.id != selectedId)) + continue; + + if (!CollectPoints(loco, out var points) || points.Count == 0) continue; + + Color color = ColorForLoco(loco.id); + int mapLayer = GetMapLayer(tc); + for (int i = 0; i < points.Count; i++) + { + string key = $"{loco.id}:{i}"; + wanted.Add(key); + int number = i + 1; + if (!_markers.TryGetValue(key, out var marker) || marker == null) + { + marker = CreateMarker(tc, mapLayer, number, points[i].active, color); + if (marker == null) continue; + _markers[key] = marker; + } + marker.Configure(points[i].gamePos, number, points[i].active, color); + } + } + + var stale = new List(); + foreach (var kv in _markers) + { + if (!wanted.Contains(kv.Key)) + { + if (kv.Value != null) Object.Destroy(kv.Value.gameObject); + stale.Add(kv.Key); + } + } + foreach (var k in stale) _markers.Remove(k); + } + + private static bool CollectPoints(BaseLocomotive loco, out List<(Vector3 gamePos, bool active)> points) + { + if (WaypointQueueBridge.TryGetQueue(loco.id, out points)) + return true; + + points = new List<(Vector3, bool)>(); + try + { + var planner = loco.AutoEngineerPlanner; + if (planner == null) return false; + object? raw = Traverse.Create(planner).Field("_orders").GetValue(); + if (raw is not Orders orders) return false; + if (orders.Mode != AutoEngineerMode.Waypoint || !orders.Waypoint.HasValue) + return false; + var loc = Graph.Shared.ResolveLocationString(orders.Waypoint.Value.LocationString); + Vector3 pos = Graph.Shared.GetPosition(loc); + points.Add((pos, true)); + return true; + } + catch + { + return false; + } + } + + private static string? SelectedLocoId(TrainController tc) + { + var sel = tc.SelectedCar; + if (sel is BaseLocomotive l) return l.id; + if (sel == null) return null; + try + { + foreach (Car c in sel.EnumerateCoupled()) + if (c is BaseLocomotive loc) return loc.id; + } + catch { } + return null; + } + + private static readonly Color[] kLocoPalette = + { + new Color(0.95f, 0.26f, 0.21f), // red + new Color(0.20f, 0.60f, 0.98f), // blue + new Color(0.18f, 0.80f, 0.44f), // green + new Color(0.98f, 0.82f, 0.14f), // yellow + new Color(0.68f, 0.35f, 0.92f), // purple + new Color(0.10f, 0.85f, 0.85f), // cyan + new Color(0.98f, 0.52f, 0.12f), // orange + new Color(0.95f, 0.40f, 0.70f), // pink + new Color(0.55f, 0.90f, 0.20f), // lime + new Color(0.30f, 0.45f, 0.95f), // indigo + new Color(0.90f, 0.30f, 0.45f), // rose + new Color(0.20f, 0.72f, 0.72f), // teal + }; + + internal static Color ColorForLoco(string id) + { + if (string.IsNullOrEmpty(id)) id = "?"; + if (_locoColors.TryGetValue(id, out var c)) return c; + c = kLocoPalette[_nextColor % kLocoPalette.Length]; + if (_nextColor >= kLocoPalette.Length) + { + float hue = ((_nextColor * 0.6180339887f) % 1f); + c = Color.HSVToRGB(hue, 0.82f, 1f); + } + _nextColor++; + _locoColors[id] = c; + return c; + } + + internal static bool IsAutoEngineerActive(BaseLocomotive loco) + { + try + { + var planner = loco.AutoEngineerPlanner; + if (planner == null) return false; + object? raw = Traverse.Create(planner).Field("_orders").GetValue(); + return raw is Orders orders && orders.Mode != AutoEngineerMode.Off; + } + catch + { + return false; + } + } + + private static void TintLocoIcons() + { + var tc = TrainController.Shared; + if (tc == null) { RestoreLocoColors(); return; } + + var seen = new HashSet(); + foreach (Car car in tc.Cars) + { + if (car is not BaseLocomotive loco) continue; + var icon = Traverse.Create(loco).Field("MapIcon").Value; + if (icon == null) continue; + bool ae = IsAutoEngineerActive(loco); + Color tint = ae ? ColorForLoco(loco.id) : default; + foreach (var img in icon.GetComponentsInChildren(true)) + { + if (img == null) continue; + seen.Add(img); + if (ae) + { + if (!_iconOrig.ContainsKey(img)) + _iconOrig[img] = Color.white; + img.color = tint; + } + else + { + if (!_iconOrig.ContainsKey(img)) + _iconOrig[img] = img.color; + else + img.color = _iconOrig[img]; + } + } + } + + if (_iconOrig.Count == seen.Count) return; + var dropped = new List(); + foreach (var kv in _iconOrig) + { + if (kv.Key == null || !seen.Contains(kv.Key)) + { + if (kv.Key != null) kv.Key.color = kv.Value; + dropped.Add(kv.Key); + } + } + foreach (var img in dropped) _iconOrig.Remove(img); + } + + private static void RestoreLocoColors() + { + foreach (var kv in _iconOrig) + { + if (kv.Key != null) + kv.Key.color = kv.Value; + } + _iconOrig.Clear(); + } + + private static WaypointMarker? CreateMarker(TrainController tc, int mapLayer, int number, bool active, Color color) + { + var template = GetAnyCarIcon(tc); + if (template == null || _holder == null) return null; + + var go = Object.Instantiate(template.gameObject, _holder.transform); + go.name = "S3_Waypoint_Marker"; + go.SetActive(false); + SetLayerRecursive(go, mapLayer); + + var children = new List(); + foreach (Transform child in go.transform) children.Add(child); + foreach (var child in children) Object.Destroy(child.gameObject); + + var mapIcon = go.GetComponent(); + if (mapIcon != null) + { + mapIcon.enabled = false; + Object.Destroy(mapIcon); + } + + var pinGo = new GameObject("pin"); + pinGo.layer = mapLayer; + pinGo.transform.SetParent(go.transform, false); + var img = pinGo.AddComponent(); + img.sprite = CircleSprite(); + img.type = Image.Type.Simple; + var pinRt = pinGo.GetComponent(); + pinRt.sizeDelta = new Vector2(1f, 1f); + pinRt.anchoredPosition = Vector2.zero; + + TryTmpFont(out var font); + var numHold = new GameObject("num"); + numHold.layer = mapLayer; + numHold.transform.SetParent(go.transform, false); + var holdRt = numHold.AddComponent(); + holdRt.sizeDelta = new Vector2(1.2f, 1.2f); + holdRt.anchoredPosition = Vector2.zero; + holdRt.localRotation = Quaternion.identity; + holdRt.localScale = Vector3.one; + + // 8-direction stroke so the digit stays readable on a car of the same hue. + const float kStroke = 0.07f; + var outlines = new TextMeshProUGUI[8]; + int oi = 0; + for (int dy = -1; dy <= 1; dy++) + for (int dx = -1; dx <= 1; dx++) + { + if (dx == 0 && dy == 0) continue; + outlines[oi++] = MakeDigit(numHold.transform, mapLayer, font, + OutlineColor(color), new Vector2(dx * kStroke, dy * kStroke)); + } + + var label = MakeDigit(numHold.transform, mapLayer, font, color, Vector2.zero); + + var canvas = go.GetComponent(); + if (canvas != null) + { + // Cloned MapIcons are often Screen Space-Camera, which keeps text + // upright on screen. World Space glues the digits to the map so they + // yaw with the camera. UI faces -local Z, so look down the world -Y + // axis to show the front of the canvas (avoids mirrored letters). + canvas.renderMode = RenderMode.WorldSpace; + canvas.additionalShaderChannels |= AdditionalCanvasShaderChannels.TexCoord1 + | AdditionalCanvasShaderChannels.TexCoord2 + | AdditionalCanvasShaderChannels.Normal + | AdditionalCanvasShaderChannels.Tangent; + } + + var marker = go.AddComponent(); + marker.Init(img, label, outlines); + marker.Configure(Vector3.zero, number, active, color); + go.SetActive(true); + return marker; + } + + private static TextMeshProUGUI MakeDigit(Transform parent, int layer, TMP_FontAsset? font, + Color color, Vector2 offset) + { + var go = new GameObject(offset == Vector2.zero ? "fill" : "ol"); + go.layer = layer; + go.transform.SetParent(parent, false); + var tmp = go.AddComponent(); + tmp.alignment = TextAlignmentOptions.Center; + tmp.fontSize = 6f; + tmp.fontStyle = FontStyles.Bold; + tmp.color = color; + tmp.raycastTarget = false; + if (font != null) tmp.font = font; + var rt = go.GetComponent(); + rt.sizeDelta = new Vector2(1.2f, 1.2f); + rt.anchoredPosition = offset; + rt.localRotation = Quaternion.identity; + rt.localScale = Vector3.one; + return tmp; + } + + // Light digits get a black stroke; dark digits get a white one. + internal static Color OutlineColor(Color fill) + { + float lum = fill.r * 0.299f + fill.g * 0.587f + fill.b * 0.114f; + return lum > 0.55f ? new Color(0.05f, 0.05f, 0.06f, 1f) + : new Color(1f, 1f, 1f, 1f); + } + + private static bool TryTmpFont(out TMP_FontAsset font) + { + font = _tmpFont!; + if (_tmpFont != null) { font = _tmpFont; return true; } + try + { + _tmpFont = TMP_Settings.defaultFontAsset; + font = _tmpFont; + return font != null; + } + catch + { + return false; + } + } + + private static void ClearAll() + { + foreach (var kv in _markers) + if (kv.Value != null) Object.Destroy(kv.Value.gameObject); + _markers.Clear(); + } + + private static MapIcon? GetAnyCarIcon(TrainController tc) + { + foreach (Car car in tc.Cars) + { + if (car.IsLocomotive) continue; + var icon = Traverse.Create(car).Field("MapIcon").Value; + if (icon != null) return icon; + } + foreach (Car car in tc.Cars) + { + var icon = Traverse.Create(car).Field("MapIcon").Value; + if (icon != null) return icon; + } + return null; + } + + private static int GetMapLayer(TrainController tc) + { + foreach (Car car in tc.Cars) + { + var icon = Traverse.Create(car).Field("MapIcon").Value; + if (icon != null) return icon.gameObject.layer; + } + return LayerMask.NameToLayer("Map"); + } + + private static void SetLayerRecursive(GameObject go, int layer) + { + go.layer = layer; + foreach (Transform child in go.transform) + SetLayerRecursive(child.gameObject, layer); + } + + private static Sprite CircleSprite() + { + if (_circle != null) return _circle; + int radius = 32; + int size = radius * 2; + var tex = new Texture2D(size, size, TextureFormat.RGBA32, mipChain: false); + tex.filterMode = FilterMode.Bilinear; + var pixels = new Color32[size * size]; + float c = radius - 0.5f; + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + { + float dist = Mathf.Sqrt((x - c) * (x - c) + (y - c) * (y - c)); + byte a = (byte)(Mathf.Clamp01(radius - dist) * 255f); + pixels[y * size + x] = new Color32(255, 255, 255, a); + } + tex.SetPixels32(pixels); + tex.Apply(); + _circle = Sprite.Create(tex, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f)); + return _circle; + } +} + +internal class WaypointMarker : MonoBehaviour +{ + private Image? _img; + private TextMeshProUGUI? _label; + private TextMeshProUGUI[] _outlines = System.Array.Empty(); + private Canvas? _canvas; + private Vector3 _gamePos; + private bool _active; + + public void Init(Image pin, TextMeshProUGUI? label, TextMeshProUGUI[] outlines) + { + _img = pin; + _label = label; + _outlines = outlines ?? System.Array.Empty(); + _canvas = GetComponent(); + } + + public void Configure(Vector3 gamePos, int number, bool active, Color color) + { + _gamePos = gamePos; + _active = active; + string text = number > 0 ? number.ToString() : ""; + bool show = number > 0; + if (_label != null) + { + _label.text = text; + _label.enabled = show; + _label.color = color; + } + var stroke = MapWaypointSystem.OutlineColor(color); + foreach (var ol in _outlines) + { + if (ol == null) continue; + ol.text = text; + ol.enabled = show; + ol.color = stroke; + } + if (_img != null) + { + // Dark disc so the numbered color stays readable; brighter when this + // is the active (next) waypoint. + float v = active ? 0.38f : 0.22f; + Color.RGBToHSV(color, out float h, out float s, out _); + _img.color = Color.HSVToRGB(h, Mathf.Min(s, 0.85f), v); + } + } + + private void Update() + { + try + { + var worldPos = WorldTransformer.GameToWorld(_gamePos); + worldPos.y += 3600f; + // Look down -Y so the UI (which faces -local Z) points at the overhead + // map camera. World-fixed up keeps the digits glued to the map as it yaws. + transform.SetPositionAndRotation( + worldPos, Quaternion.LookRotation(Vector3.down, Vector3.forward)); + + if (_canvas == null) _canvas = GetComponent(); + if (_canvas != null && _canvas.renderMode != RenderMode.WorldSpace) + _canvas.renderMode = RenderMode.WorldSpace; + + var mb = MapBuilder.Shared; + float scaleMul = _active ? 0.0024f : 0.0018f; + if (mb?.mapCamera != null) + transform.localScale = Vector3.one * (mb.mapCamera.orthographicSize * 8f * scaleMul); + } + catch { } + } +} diff --git a/src/Modules/Popout/NativeInterop.cs b/src/Modules/Popout/NativeInterop.cs index c35733e..e66c72c 100644 --- a/src/Modules/Popout/NativeInterop.cs +++ b/src/Modules/Popout/NativeInterop.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; +using System.Text; namespace S3.Modules.Popout { @@ -72,6 +73,29 @@ namespace S3.Modules.Popout { TrackLabelSetAllZoom = 45, // y = orthographicSize beyond which ALL labels hide ToggleAvoidTrackLabels = 46, // push labels off their own track line TrackLabelSetFontSizeMin = 47, // y = minimum font size px [4, max]; labels auto-scale with zoom + PresetAdd = 48, // save current camera as a new view preset + PresetApply = 49, // jump to preset; y = 0-based index + PresetDelete = 50, // delete preset; y = 0-based index + PresetRename = 51, // rename preset; y = index, name via GetPresetRenameName + PresetPreview = 52, // stash current view and jump to preset; y = index + PresetCommitEdit = 53, // write current camera into preset and restore stash; y = index + PresetCancelEdit = 54, // restore stash without saving camera + ToggleWaypoints = 55, // toggle AE waypoint pins on the map + ToggleWaypointsSelectedOnly = 56, // filter waypoint pins to the selected loco + ToggleRadio = 57, // radio-control map mode + RadioPin = 58, // pin the currently selected consist loco + RadioSelect = 59, // select pinned loco; y = index + RadioUnpin = 60, // unpin; y = index + RadioRename = 61, // rename pin; y = index, name via GetRadioRenameName + RadioSetTool = 62, // y = 0 idle, 1 waypoint-place mode + RadioSetForward = 63, // y = 0 reverse, 1 forward + RadioSetSpeed = 64, // y = mph + RadioStop = 65, // AE Off on selected pin + RadioFollow = 66, // follow selected pin on the map + RadioJump = 67, // jump map to pin; y = index + RadioWpChoose = 68, // y = 0 Go, 1 Couple, 2 Pickup, 3 Dropoff, 4 Cut + RadioWpCount = 69, // y = car count (>= 1) + RadioWpCancel = 70, // close the waypoint order popup } // Must match MapThemeData in native/include/shared_types.h exactly (36 floats = 144 bytes). @@ -102,6 +126,10 @@ namespace S3.Modules.Popout { [DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)] public static extern int RRPOPOUT_CreateWindow(string title, int width, int height); + // Skip map ImGui chrome (toolbar, radio, presets). Blit the frame texture only. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetPlainContent(int windowHandle, bool plain); + // Set the source texture and the UV sub-rect to blit next frame. // Call this on the main thread immediately before IssuePluginEvent. // u0,v0 = top-left UV in D3D convention (V=0 at top); u1,v1 = bottom-right. @@ -212,6 +240,16 @@ namespace S3.Modules.Popout { [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] public static extern int RRPOPOUT_OverlayWantsMouse(); + // 1 when an ImGui text field has focus (preset rename). C# swallows game keys. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern int RRPOPOUT_OverlayWantsKeyboard(); + + // Per-frame overlay keyboard: UTF-16 characters plus a key-down bit mask. + // Bits: 0 Backspace, 1 Delete, 2 Enter, 3 Escape, 4 Left, 5 Right, 6 Home, + // 7 End, 8 Tab, 9 A, 10 C, 11 V, 12 X. mods: 1 Ctrl, 2 Shift, 4 Alt. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)] + public static extern void RRPOPOUT_SetOverlayKeyboard(string chars, uint keyDown, uint mods); + // 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)] @@ -244,6 +282,10 @@ namespace S3.Modules.Popout { [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] public static extern void RRPOPOUT_SetOverlayAlpha(float alpha); + // Dim overlay + ignore ImGui mouse while the map-opened pie is up. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetOverlayPieBlock(bool blocked); + // Set the map image alpha [0.0, 1.0]. Independent of chrome alpha. [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] public static extern void RRPOPOUT_SetOverlayMapAlpha(float alpha); @@ -311,5 +353,38 @@ namespace S3.Modules.Popout { float allLabelsZoom, bool avoidTrack, float fontSizeMin); + + // Named camera-view presets (newline-separated names). + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)] + public static extern void RRPOPOUT_SetPresetList(int windowHandle, string names); + + // UTF-16 name currently in the preset rename field (native InputText buffer). + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)] + public static extern void RRPOPOUT_GetPresetRenameName(int windowHandle, StringBuilder outBuf, int maxChars); + + // Seed waypoint-pin toggles for the gear menu. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetWaypointState(int windowHandle, bool enabled, bool selectedOnly); + + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)] + public static extern void RRPOPOUT_SetRadioList(int windowHandle, string names, uint[] colors, int colorCount); + + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetRadioState(int windowHandle, + bool radioOn, int selected, int tool, bool wqInstalled, + ulong aeBits, bool forward, float speed); + + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)] + public static extern void RRPOPOUT_GetRadioRenameName(int windowHandle, StringBuilder outBuf, int maxChars); + + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetRadioGhost(int windowHandle, + bool visible, float u, float v, float angleDeg, uint color); + + // stage: 0 off, 1 choose WQ order, 2 enter car count. u/v = Unity viewport. + // flags bit0 = snapped to a free coupler (Couple/Pickup enabled). + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetRadioWpPopup(int windowHandle, + int stage, float u, float v, int flags, int count); } } diff --git a/src/Modules/Popout/NativeLoader.cs b/src/Modules/Popout/NativeLoader.cs index c5dc30d..3893214 100644 --- a/src/Modules/Popout/NativeLoader.cs +++ b/src/Modules/Popout/NativeLoader.cs @@ -19,7 +19,28 @@ internal static class NativeLoader [DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode)] private static extern IntPtr LoadLibrary(string lpFileName); + [DllImport("kernel32")] + private static extern uint SetErrorMode(uint uMode); + + [DllImport("user32", CharSet = CharSet.Unicode)] + private static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type); + + // ERROR_MOD_NOT_FOUND: the loader resolved S3Native.dll itself but couldn't find one + // of its dependencies. In practice that's always the MSVC runtime DLLs + // (msvcp140/vcruntime140[_1].dll), which only ship via the VC++ Redistributable — + // Windows doesn't include them by default. + private const int ErrorModNotFound = 126; + private const uint MbIconWarning = 0x30; + + // Suppresses the OS's own blocking "X.dll was not found" dialog for the + // LoadLibrary call below, so a missing dependency comes back as a plain + // LoadLibrary failure instead of a hidden system dialog that stalls the + // whole game window (what looked like "the game just doesn't launch"). + private const uint SemFailCriticalErrors = 0x0001; + private const uint SemNoOpenFileErrorBox = 0x8000; + private static bool _loaded; + private static bool _warnedMissingRuntime; public static bool EnsureLoaded() { @@ -32,10 +53,14 @@ internal static class NativeLoader return false; } + uint prevErrorMode = SetErrorMode(SemFailCriticalErrors | SemNoOpenFileErrorBox); IntPtr handle = LoadLibrary(dll); + SetErrorMode(prevErrorMode); if (handle == IntPtr.Zero) { - Log.Error($"[popout] LoadLibrary failed (Win32 error {Marshal.GetLastWin32Error()}): {dll}"); + int error = Marshal.GetLastWin32Error(); + Log.Error($"[popout] LoadLibrary failed (Win32 error {error}): {dll}"); + if (error == ErrorModNotFound) WarnMissingRuntime(); return false; } @@ -43,4 +68,20 @@ internal static class NativeLoader Log.Info($"[popout] native S3Native.dll loaded from {dll}"); return true; } + + // Shown via a plain Win32 MessageBox rather than our ImGui overlay, since the + // overlay's renderer lives in the very DLL that just failed to load. + private static void WarnMissingRuntime() + { + if (_warnedMissingRuntime) return; + _warnedMissingRuntime = true; + MessageBox(IntPtr.Zero, + "Seton's Special Sauce needs the Microsoft Visual C++ Redistributable " + + "(x64) to render the map, and it isn't installed.\n\n" + + "Download and install it from:\n" + + "https://aka.ms/vs/17/release/vc_redist.x64.exe\n\n" + + "The rest of the mod will keep working; only the map window is affected.", + "Seton's Special Sauce - Missing VC++ Runtime", + MbIconWarning); + } } diff --git a/src/Modules/Popout/PopoutModule.cs b/src/Modules/Popout/PopoutModule.cs index 2902fc3..2fc358c 100644 --- a/src/Modules/Popout/PopoutModule.cs +++ b/src/Modules/Popout/PopoutModule.cs @@ -45,13 +45,6 @@ public sealed class PopoutModule : IModule private static bool _pendingOverlayOpen; private static float _pendingOverlayTimer; - // Deferred window scale restore: a 1-frame counter so any Toggle() close animation - // plays at scale=0 (invisible) before we put the panel back to its normal size. - // Static class has no MonoBehaviour, so we tick this down in Tick() instead of a coroutine. - private static Window? _pendingRestoreWindow; - private static Vector3 _pendingRestoreScale; - private static int _pendingRestoreFrames; - public static bool IsDetached => _activePanel != null; public PopoutModule() @@ -83,6 +76,7 @@ public sealed class PopoutModule : IModule MapIconCuller.Install(); EotdSystem.Install(); + MapWaypointSystem.Install(); _host = new GameObject("S3.Popout.Host"); Object.DontDestroyOnLoad(_host); @@ -97,6 +91,7 @@ public sealed class PopoutModule : IModule _pendingOverlayOpen = false; MapIconCuller.Uninstall(); EotdSystem.Uninstall(); + MapWaypointSystem.Uninstall(); if (_host != null) { Object.Destroy(_host); _host = null; } } @@ -162,23 +157,13 @@ public sealed class PopoutModule : IModule // While the popout is live, keep the in-game window zeroed every frame. // MapWindow.Show() starts a Unity animation that can re-set localScale to // non-zero across subsequent frames — this override wins each tick. - if (_activePanel != null && _hiddenWindow != null && - _hiddenWindow.transform is RectTransform suppRect && suppRect.localScale != Vector3.zero) - { - suppRect.localScale = Vector3.zero; - } - - // Deferred window scale restore (see RestoreInGameWindow). - if (_pendingRestoreFrames > 0 && --_pendingRestoreFrames == 0) - { - if (_pendingRestoreWindow != null && _pendingRestoreWindow.transform is RectTransform r) - r.localScale = _pendingRestoreScale; - _pendingRestoreWindow = null; - } + StockMapGuard.Tick(); float dt = UnityEngine.Time.deltaTime; MapIconCuller.TickFade(dt); EotdSystem.Tick(dt); + try { MapWaypointSystem.Tick(dt); } + catch (System.Exception ex) { Log.Error($"[popout] waypoint tick: {ex.Message}"); } if (_hotkey.Down()) Toggle(); @@ -250,8 +235,8 @@ public sealed class PopoutModule : IModule } UiService.MapBypass = true; - MapWindow.Show(); - UiService.MapBypass = false; + try { MapWindow.Show(); } + finally { UiService.MapBypass = false; } if (!PanelFinder.IsMapReady()) { @@ -298,15 +283,16 @@ public sealed class PopoutModule : IModule // 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; + try + { + UiService.MapBypass = true; + MapWindow.Toggle(); + } + finally { UiService.MapBypass = false; } } - // Defer scale restore by 1 frame so any Toggle() close animation plays at - // scale=0 before the panel snaps back to its normal size. - _pendingRestoreWindow = _hiddenWindow; - _pendingRestoreScale = _savedWindowScale; - _pendingRestoreFrames = 1; + // RestoreInGameWindow still closes the stock window; StockMapGuard keeps + // it at scale zero while the module is enabled, so skip the deferred + // scale snap-back that used to flash the vanilla map. _hiddenWindow = null; } } diff --git a/src/Modules/Popout/PopoutSettings.cs b/src/Modules/Popout/PopoutSettings.cs index 1dac1f8..3efdd96 100644 --- a/src/Modules/Popout/PopoutSettings.cs +++ b/src/Modules/Popout/PopoutSettings.cs @@ -76,6 +76,33 @@ public class PopoutSettings public bool trackLabelAvoidTrack = false; // push labels perpendicular so they don't cover track lines public float trackLabelFontSizeMin = 10f; // smallest pixel size (at zoom limit); auto-scales between this and trackLabelFontSize + // Named camera bookmarks — parallel primitive arrays so JsonUtility on this + // Mono runtime cannot drop (or fail to parse) a nested struct array and reset + // the whole settings file (which would disable the Map Module and reopen the + // stock map). Lengths are kept in lockstep by MapViewPresets. + public string[] presetNames = new string[0]; + public float[] presetX = new float[0]; + public float[] presetZ = new float[0]; + public float[] presetZoom = new float[0]; + public float[] presetRot = new float[0]; + // True once presets / last-view are stored as game-space XZ (WorldTransformer). + // False = legacy Unity world XZ, which breaks after origin rebase / teleport. + public bool presetUseGameCoords; + public bool lastViewIsGame; + + // Last map camera view (overlay + popout). Primitive fields so JsonUtility + // cannot drop them. Restored on reopen because MapWindow.Show + MapEnhancer + // recenters on the player every time the stock window is shown. + public bool lastViewValid; + public float lastViewX; + public float lastViewZ; + public float lastViewZoom = 500f; + public float lastViewRot; + + // Auto Engineer waypoint pins on the map. + public bool waypointsEnabled = true; + public bool waypointsSelectedOnly = false; + // Custom theme colors — edited live in the Settings color picker. // Initialized to S3 Dark so first-launch looks reasonable before the user tunes it. public MapThemeData customTheme = new MapThemeData { diff --git a/src/Modules/Popout/TrackLabelService.cs b/src/Modules/Popout/TrackLabelService.cs new file mode 100644 index 0000000..5506850 --- /dev/null +++ b/src/Modules/Popout/TrackLabelService.cs @@ -0,0 +1,643 @@ +using System.Collections.Generic; +using System.Text; +using Helpers; +using Model.Ops; +using Track; +using UnityEngine; + +namespace S3.Modules.Popout; + +/// +/// Industry / track-name labels for the map overlay and OS popout. +/// Rebuilds span clusters every ~5 s, projects them through the map camera each +/// frame, and pushes UV + names to native for ImGui drawing. +/// +internal static class TrackLabelService +{ + private static IndustryComponent[]? _industryComponents; + private static bool _effectiveMergeEnabled = true; + + private static readonly List<(Vector3 centroid, Vector3 trackDir, Vector3[] anchors, string name, int utilityType)> + _trackSpanLabels = new(); + private static readonly List<(Vector3 centroid, string name, int utilityType)> _industryLabels = new(); + + private static float _spawnPointTimer = 99f; + private static int _lastLabelCount = -1; + private static readonly StringBuilder _labelNames = new(); + + private static Vector3[] _labelWorldOffsets = System.Array.Empty(); + private static float[] _labelUs = System.Array.Empty(); + private static float[] _labelVs = System.Array.Empty(); + private static float[] _labelAngles = System.Array.Empty(); + private static float[] _labelScales = System.Array.Empty(); + private static float[] _anchorUs = System.Array.Empty(); + private static float[] _anchorVs = System.Array.Empty(); + private static int[] _anchorStarts = System.Array.Empty(); + private static int[] _anchorCounts = System.Array.Empty(); + private static int _totalAnchorCount; + + private const float kMergeDistGame = 250f; + private static readonly float[] s_emptyFloat = System.Array.Empty(); + private static readonly int[] s_emptyInt = System.Array.Empty(); + + public static void Reset() + { + _spawnPointTimer = 99f; + _trackSpanLabels.Clear(); + _industryLabels.Clear(); + _lastLabelCount = -1; + _industryComponents = null; + } + + public static void SeedStyle(int handle) + { + var s = PopoutModule.Settings; + Native.RRPOPOUT_SetTrackLabelStyle(handle, + s.trackLabelFontSize, s.trackLabelLineThickness, s.trackLabelZoomLimit, + s.trackLeaderLinesEnabled, s.trackCollisionEnabled, s.trackLabelParallel, + s.trackLabelMergeEnabled, s.trackLabelMergeZoom, + s.trackIndustryLabelZoom, + s.trackUtilityRepairEnabled, s.trackUtilityDieselEnabled, + s.trackUtilityLoaderEnabled, s.trackUtilityInterchangeEnabled, + s.trackUtilityZoomLimit, + s.trackAllLabelsZoomLimit, + s.trackLabelAvoidTrack, + s.trackLabelFontSizeMin); + } + + public static void SeedEnabled(int handle) + { + Native.RRPOPOUT_SetTrackLabelsEnabled(handle, PopoutModule.Settings.trackLabelsEnabled); + } + + public static bool TryHandleCommand(UICmd cmd, float y, int handle) + { + var s = PopoutModule.Settings; + switch (cmd) + { + case UICmd.ToggleTrackLabels: + s.trackLabelsEnabled = !s.trackLabelsEnabled; + PopoutModule.Persist(); + Native.RRPOPOUT_SetTrackLabelsEnabled(handle, s.trackLabelsEnabled); + if (!s.trackLabelsEnabled) + Clear(handle); + return true; + case UICmd.TrackLabelSetFontSize: + s.trackLabelFontSize = Mathf.Clamp(y, 8f, 24f); + PopoutModule.Persist(); + SeedStyle(handle); + return true; + case UICmd.TrackLabelSetLineThick: + s.trackLabelLineThickness = Mathf.Clamp(y, 1f, 4f); + PopoutModule.Persist(); + SeedStyle(handle); + return true; + case UICmd.TrackLabelSetZoomLimit: + s.trackLabelZoomLimit = Mathf.Clamp(y, 200f, 8000f); + PopoutModule.Persist(); + SeedStyle(handle); + return true; + case UICmd.ToggleLeaderLines: + s.trackLeaderLinesEnabled = !s.trackLeaderLinesEnabled; + PopoutModule.Persist(); + SeedStyle(handle); + return true; + case UICmd.ToggleCollision: + s.trackCollisionEnabled = !s.trackCollisionEnabled; + PopoutModule.Persist(); + SeedStyle(handle); + return true; + case UICmd.ToggleParallelLabels: + s.trackLabelParallel = !s.trackLabelParallel; + PopoutModule.Persist(); + SeedStyle(handle); + return true; + case UICmd.ToggleMergeLabels: + s.trackLabelMergeEnabled = !s.trackLabelMergeEnabled; + PopoutModule.Persist(); + SeedStyle(handle); + ForceRebuild(); + return true; + case UICmd.TrackLabelSetMergeZoom: + s.trackLabelMergeZoom = Mathf.Clamp(y, 50f, 8000f); + PopoutModule.Persist(); + return true; + case UICmd.TrackLabelSetIndustryZoom: + s.trackIndustryLabelZoom = Mathf.Clamp(y, 200f, 8000f); + PopoutModule.Persist(); + return true; + case UICmd.ToggleUtilityRepairLabels: + s.trackUtilityRepairEnabled = !s.trackUtilityRepairEnabled; + PopoutModule.Persist(); + ForceRebuild(); + return true; + case UICmd.ToggleUtilityDieselLabels: + s.trackUtilityDieselEnabled = !s.trackUtilityDieselEnabled; + PopoutModule.Persist(); + ForceRebuild(); + return true; + case UICmd.ToggleUtilityLoaderLabels: + s.trackUtilityLoaderEnabled = !s.trackUtilityLoaderEnabled; + PopoutModule.Persist(); + ForceRebuild(); + return true; + case UICmd.ToggleUtilityInterchangeLabels: + s.trackUtilityInterchangeEnabled = !s.trackUtilityInterchangeEnabled; + PopoutModule.Persist(); + ForceRebuild(); + return true; + case UICmd.TrackLabelSetUtilityZoom: + s.trackUtilityZoomLimit = Mathf.Clamp(y, 50f, 8000f); + PopoutModule.Persist(); + return true; + case UICmd.TrackLabelSetAllZoom: + s.trackAllLabelsZoomLimit = Mathf.Clamp(y, 200f, 8000f); + PopoutModule.Persist(); + return true; + case UICmd.ToggleAvoidTrackLabels: + s.trackLabelAvoidTrack = !s.trackLabelAvoidTrack; + PopoutModule.Persist(); + return true; + case UICmd.TrackLabelSetFontSizeMin: + s.trackLabelFontSizeMin = Mathf.Clamp(y, 4f, s.trackLabelFontSize); + PopoutModule.Persist(); + return true; + default: + return false; + } + } + + public static void Push(int handle, Camera? mapCamera) + { + try + { + PushInner(handle, mapCamera); + } + catch (System.Exception ex) + { + S3.Core.Log.Error($"[popout] track labels push: {ex.Message}"); + } + } + + private static void PushInner(int handle, Camera? mapCamera) + { + if (mapCamera == null) return; + if (!PopoutModule.Settings.trackLabelsEnabled) return; + + var s = PopoutModule.Settings; + bool shouldMerge = s.trackLabelMergeEnabled || + (mapCamera.orthographicSize > s.trackLabelMergeZoom); + if (shouldMerge != _effectiveMergeEnabled) + { + _effectiveMergeEnabled = shouldMerge; + ForceRebuild(); + } + + _spawnPointTimer += Time.deltaTime; + if (_spawnPointTimer >= 5f || _industryComponents == null) + { + RebuildTrackSpanLabels(mapCamera); + _spawnPointTimer = 0f; + } + + int maxLabels = System.Math.Max(_trackSpanLabels.Count, _industryLabels.Count); + if (_labelUs.Length < maxLabels) + { + _labelUs = new float[maxLabels]; + _labelVs = new float[maxLabels]; + _labelAngles = new float[maxLabels]; + _labelScales = new float[maxLabels]; + _anchorStarts = new int [maxLabels]; + _anchorCounts = new int [maxLabels]; + } + if (_anchorUs.Length < _totalAnchorCount) + { + _anchorUs = new float[_totalAnchorCount]; + _anchorVs = new float[_totalAnchorCount]; + } + + if (mapCamera.orthographicSize > s.trackAllLabelsZoomLimit) + { + if (_lastLabelCount != 0) Clear(handle); + return; + } + + if (mapCamera.orthographicSize > s.trackIndustryLabelZoom) + { + _labelNames.Clear(); + int icCount = 0; + foreach (var (centroid, name, _) in _industryLabels) + { + Vector3 vp = mapCamera.WorldToViewportPoint(centroid.GameToWorld()); + if (vp.z < 0f || vp.x < -0.05f || vp.x > 1.05f || + vp.y < -0.05f || vp.y > 1.05f) continue; + + if (icCount > 0) _labelNames.Append('\n'); + _labelNames.Append(name); + _labelUs[icCount] = vp.x; + _labelVs[icCount] = vp.y; + _labelAngles[icCount] = 0f; + _labelScales[icCount] = 1.5f; + _anchorStarts[icCount] = 0; + _anchorCounts[icCount] = 0; + icCount++; + } + Native.RRPOPOUT_SetTrackLabels(handle, _labelNames.ToString(), + _labelUs, _labelVs, + s_emptyFloat, s_emptyFloat, + _anchorStarts, _anchorCounts, + _labelAngles, _labelScales, icCount); + _lastLabelCount = icCount; + return; + } + + if (mapCamera.orthographicSize > s.trackLabelZoomLimit) + { + if (_lastLabelCount != 0) Clear(handle); + return; + } + + _labelNames.Clear(); + int si = 0; + int count = 0; + int anchorOffset = 0; + foreach (var (centroid, trackDir, anchors, name, utilityType) in _trackSpanLabels) + { + if (utilityType != 0 && mapCamera.orthographicSize > s.trackUtilityZoomLimit) { si++; continue; } + + Vector3 labelCenter = si < _labelWorldOffsets.Length + ? centroid + _labelWorldOffsets[si] + : centroid; + + Vector3 vp = mapCamera.WorldToViewportPoint(labelCenter.GameToWorld()); + if (vp.z < 0f || vp.x < -0.05f || vp.x > 1.05f || + vp.y < -0.05f || vp.y > 1.05f) { si++; continue; } + + if (count > 0) _labelNames.Append('\n'); + _labelNames.Append(name); + _labelUs[count] = vp.x; + _labelVs[count] = vp.y; + float refZoom = Mathf.Max(s.trackLabelZoomLimit * 0.5f, 1f); + float targetPx = s.trackLabelFontSize * (refZoom / mapCamera.orthographicSize); + float clampedPx = Mathf.Clamp(targetPx, s.trackLabelFontSizeMin, s.trackLabelFontSize); + _labelScales[count] = clampedPx / s.trackLabelFontSize; + _anchorStarts[count] = anchorOffset; + _anchorCounts[count] = anchors.Length; + + Vector3 p0v = mapCamera.WorldToViewportPoint((centroid - trackDir * 20f).GameToWorld()); + Vector3 p1v = mapCamera.WorldToViewportPoint((centroid + trackDir * 20f).GameToWorld()); + float adx = p1v.x - p0v.x; + float ady = -(p1v.y - p0v.y); + float aspect = mapCamera.aspect > 0f ? mapCamera.aspect : 1f; + float angle = Mathf.Atan2(ady / aspect, adx) * Mathf.Rad2Deg; + if (angle > 90f) angle -= 180f; + else if (angle < -90f) angle += 180f; + _labelAngles[count] = angle; + + foreach (var ap in anchors) + { + Vector3 av = mapCamera.WorldToViewportPoint(ap.GameToWorld()); + _anchorUs[anchorOffset] = av.x; + _anchorVs[anchorOffset] = av.y; + anchorOffset++; + } + si++; + count++; + } + + Native.RRPOPOUT_SetTrackLabels(handle, _labelNames.ToString(), + _labelUs, _labelVs, + _anchorUs, _anchorVs, + _anchorStarts, _anchorCounts, + _labelAngles, _labelScales, count); + _lastLabelCount = count; + } + + private static void ForceRebuild() => _spawnPointTimer = 99f; + + private static void Clear(int handle) + { + Native.RRPOPOUT_SetTrackLabels(handle, "", + s_emptyFloat, s_emptyFloat, s_emptyFloat, s_emptyFloat, + s_emptyInt, s_emptyInt, s_emptyFloat, s_emptyFloat, 0); + _lastLabelCount = 0; + } + + private static (Vector3 pos, Vector3 dir) FindStraightestNearMiddle(IList pts) + { + int n = pts.Count; + Vector3 overallDir = (pts[n - 1] - pts[0]).normalized; + if (n == 2) return ((pts[0] + pts[1]) * 0.5f, overallDir); + + float totalLen = 0f; + for (int k = 1; k < n; k++) totalLen += Vector3.Distance(pts[k], pts[k - 1]); + if (totalLen < 0.01f) return (pts[n / 2], overallDir); + + float midLen = totalLen * 0.5f; + float bestScore = -1f; + Vector3 bestPos = pts[n / 2]; + Vector3 bestDir = overallDir; + float cumLen = 0f; + + for (int k = 1; k < n; k++) + { + float segLen = Vector3.Distance(pts[k], pts[k - 1]); + if (segLen < 0.01f) { cumLen += segLen; continue; } + + float segMidLen = cumLen + segLen * 0.5f; + cumLen += segLen; + Vector3 segDir = (pts[k] - pts[k - 1]) / segLen; + float straight = Mathf.Abs(Vector3.Dot(segDir, overallDir)); + float distFromMid = Mathf.Abs(segMidLen - midLen) / midLen; + float score = straight * straight * (1f - distFromMid * 0.5f); + + if (score > bestScore) + { + bestScore = score; + bestPos = (pts[k] + pts[k - 1]) * 0.5f; + bestDir = segDir; + } + } + return (bestPos, bestDir); + } + + private static void RebuildTrackSpanLabels(Camera mapCamera) + { + _industryComponents = Object.FindObjectsOfType(); + + var raw = new List<(Vector3 pos, Vector3 dir, string name, int utilityType)>(); + var seenSpans = new HashSet(); + var settings = PopoutModule.Settings; + foreach (var ic in _industryComponents) + { + if (ic == null || !ic.IsVisible || ic.trackSpans.Length == 0) continue; + string[] names = ExpandSpanNames(ic); + for (int si = 0; si < ic.trackSpans.Length; si++) + { + var span = ic.trackSpans[si]; + if (!seenSpans.Add(span)) continue; + var pts = span.GetPoints() as IList; + Vector3 pos, dir; + if (pts != null && pts.Count >= 2) + (pos, dir) = FindStraightestNearMiddle(pts); + else + { + pos = span.GetCenterPoint(); + dir = Vector3.right; + } + string spanName = NormalizeSpanName(si < names.Length ? names[si] : ic.DisplayName); + int utType = GetUtilityType(spanName); + if (utType == 1 && !settings.trackUtilityRepairEnabled) continue; + if (utType == 2 && !settings.trackUtilityDieselEnabled) continue; + if (utType == 3 && !settings.trackUtilityLoaderEnabled) continue; + if (utType == 4 && !settings.trackUtilityInterchangeEnabled) continue; + raw.Add((pos, dir, spanName, utType)); + } + } + + _trackSpanLabels.Clear(); + if (!_effectiveMergeEnabled) + { + foreach (var (pos, dir, name, utType) in raw) + _trackSpanLabels.Add((pos, dir, new[] { pos }, name, utType)); + _totalAnchorCount = _trackSpanLabels.Count; + RebuildIndustryLabels(); + ComputeWorldSpaceOffsets(mapCamera); + return; + } + + var grouped = new Dictionary>(); + foreach (var (pos, dir, name, utType) in raw) + { + if (!grouped.TryGetValue(name, out var list)) grouped[name] = list = new(); + list.Add((pos, dir, utType)); + } + + foreach (var (name, entries) in grouped) + { + var assigned = new bool[entries.Count]; + for (int i = 0; i < entries.Count; i++) + { + if (assigned[i]) continue; + var cluster = new List<(Vector3 pos, Vector3 dir, int utilityType)> { entries[i] }; + assigned[i] = true; + bool added; + do { + added = false; + for (int j = i + 1; j < entries.Count; j++) + { + if (assigned[j]) continue; + foreach (var (cp, _, _) in cluster) + if (Vector3.Distance(entries[j].pos, cp) < kMergeDistGame) + { cluster.Add(entries[j]); assigned[j] = true; added = true; break; } + } + } while (added); + + int clusterUtType = entries[0].utilityType; + Vector3 centroid = Vector3.zero; + Vector3 refDir = cluster[0].dir; + Vector3 avgDir = Vector3.zero; + var anchorPositions = new Vector3[cluster.Count]; + for (int k = 0; k < cluster.Count; k++) + { + centroid += cluster[k].pos; + anchorPositions[k] = cluster[k].pos; + var d = cluster[k].dir; + avgDir += Vector3.Dot(d, refDir) >= 0f ? d : -d; + } + centroid /= cluster.Count; + _trackSpanLabels.Add((centroid, avgDir.normalized, anchorPositions, name, clusterUtType)); + } + } + + _totalAnchorCount = 0; + foreach (var (_, _, anchors, _, _) in _trackSpanLabels) + _totalAnchorCount += anchors.Length; + + RebuildIndustryLabels(); + ComputeWorldSpaceOffsets(mapCamera); + } + + private static void RebuildIndustryLabels() + { + _industryLabels.Clear(); + var byName = new Dictionary(); + var settings = PopoutModule.Settings; + + foreach (var ic in _industryComponents) + { + if (ic == null || !ic.IsVisible || ic.trackSpans.Length == 0) continue; + + string industryName = GetIndustryName(ic); + int utType = GetUtilityType(industryName); + if (utType == 1 && !settings.trackUtilityRepairEnabled) continue; + if (utType == 2 && !settings.trackUtilityDieselEnabled) continue; + if (utType == 3 && !settings.trackUtilityLoaderEnabled) continue; + if (utType == 4 && !settings.trackUtilityInterchangeEnabled) continue; + + Vector3 centroid = Vector3.zero; + int n = 0; + + foreach (var span in ic.trackSpans) + { + var pts = span.GetPoints() as IList; + centroid += pts != null && pts.Count >= 2 + ? FindStraightestNearMiddle(pts).pos + : span.GetCenterPoint(); + n++; + } + if (n == 0) continue; + centroid /= n; + + if (byName.TryGetValue(industryName, out var entry)) + byName[industryName] = (entry.sum + centroid, entry.count + 1, utType); + else + byName[industryName] = (centroid, 1, utType); + } + + foreach (var (name, (sum, count, utType)) in byName) + _industryLabels.Add((sum / count, name, utType)); + } + + private static void ComputeWorldSpaceOffsets(Camera mapCamera) + { + int n = _trackSpanLabels.Count; + if (n == 0) { _labelWorldOffsets = System.Array.Empty(); return; } + + float screenH = mapCamera.pixelHeight > 0 ? mapCamera.pixelHeight : 1080f; + float worldH = mapCamera.orthographicSize * 2f; + float wpp = worldH / screenH; + + var s = PopoutModule.Settings; + float fH = s.trackLabelFontSize * wpp; + float cW = fH * 0.55f; + float pad = 3f * wpp; + float gap = 4f * wpp; + + var cx = new float[n]; + var cz = new float[n]; + var hw = new float[n]; + var hh = new float[n]; + + for (int i = 0; i < n; i++) + { + var (centroid, trackDir, _, name, _) = _trackSpanLabels[i]; + + hw[i] = name.Length * cW * 0.5f + pad; + hh[i] = fH * 0.5f + pad; + + float lx = trackDir.x, lz = trackDir.z; + float len = Mathf.Sqrt(lx * lx + lz * lz); + if (len > 0.001f) { lx /= len; lz /= len; } + float px = -lz, pz = lx; + + float initOff = hh[i] + gap; + cx[i] = centroid.x + px * initOff; + cz[i] = centroid.z + pz * initOff; + } + + const int kMaxIter = 40; + for (int iter = 0; iter < kMaxIter; iter++) + { + bool anyOverlap = false; + for (int i = 0; i < n; i++) + { + for (int j = i + 1; j < n; j++) + { + float ox = (hw[i] + hw[j]) - Mathf.Abs(cx[i] - cx[j]); + float oz = (hh[i] + hh[j]) - Mathf.Abs(cz[i] - cz[j]); + if (ox <= 0f || oz <= 0f) continue; + anyOverlap = true; + float pushX = 0f, pushZ = 0f; + if (ox < oz) + pushX = ox * 0.55f * (cx[i] < cx[j] ? -1f : 1f); + else + pushZ = oz * 0.55f * (cz[i] < cz[j] ? -1f : 1f); + cx[i] += pushX; cz[i] += pushZ; + cx[j] -= pushX; cz[j] -= pushZ; + } + } + if (!anyOverlap) break; + } + + if (_labelWorldOffsets.Length < n) + _labelWorldOffsets = new Vector3[n]; + for (int i = 0; i < n; i++) + { + var (centroid, _, _, _, _) = _trackSpanLabels[i]; + _labelWorldOffsets[i] = new Vector3(cx[i] - centroid.x, 0f, cz[i] - centroid.z); + } + } + + private static string GetIndustryName(IndustryComponent ic) + { + string displayName = ic.DisplayName; + + if (ic.trackSpans.Length > 1 && displayName.Contains('/')) + { + string first = displayName.Split('/')[0].Trim(); + int sp = first.LastIndexOf(' '); + string baseName = sp >= 0 ? first.Substring(0, sp) : first; + return NormalizeSpanName(baseName); + } + + int slash = displayName.IndexOf('/'); + string name = slash >= 0 ? displayName.Substring(0, slash).Trim() : displayName; + name = NormalizeSpanName(name); + int lastSp = name.LastIndexOf(' '); + if (lastSp >= 0 && IsTrackCode(name.Substring(lastSp + 1))) + return name.Substring(0, lastSp); + return name; + } + + private static string NormalizeSpanName(string name) + { + int idx = name.IndexOf(" Interchange to ", System.StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + return name.Substring(0, idx + " Interchange".Length); + return name; + } + + private static int GetUtilityType(string name) + { + if (name.IndexOf(" Interchange", System.StringComparison.OrdinalIgnoreCase) >= 0 || + name.StartsWith("Interchange", System.StringComparison.OrdinalIgnoreCase)) + return 4; + if (name.EndsWith(" Repair Track", System.StringComparison.OrdinalIgnoreCase) || + name.EndsWith(" Repair", System.StringComparison.OrdinalIgnoreCase)) + return 1; + if (name.EndsWith(" Diesel Stand", System.StringComparison.OrdinalIgnoreCase) || + name.EndsWith(" Diesel", System.StringComparison.OrdinalIgnoreCase)) + return 2; + if (name.EndsWith(" Coal Loader", System.StringComparison.OrdinalIgnoreCase) || + name.EndsWith(" Loader", System.StringComparison.OrdinalIgnoreCase) || + name.EndsWith(" Coaling Tower", System.StringComparison.OrdinalIgnoreCase)) + return 3; + return 0; + } + + private static bool IsTrackCode(string s) + { + if (s.Length == 0 || s.Length > 4) return false; + int i = 0; + while (i < s.Length && char.IsLetter(s[i])) i++; + if (i == 0 || i > 3) return false; + while (i < s.Length && char.IsDigit(s[i])) i++; + return i == s.Length; + } + + private static string[] ExpandSpanNames(IndustryComponent ic) + { + if (ic.trackSpans.Length <= 1) return new[] { ic.DisplayName }; + string[] parts = ic.DisplayName.Split('/'); + if (parts.Length != ic.trackSpans.Length) return new[] { ic.DisplayName }; + string first = parts[0].Trim(); + int sp = first.LastIndexOf(' '); + string prefix = sp >= 0 ? first.Substring(0, sp + 1) : ""; + var result = new string[parts.Length]; + result[0] = first; + for (int i = 1; i < parts.Length; i++) + result[i] = prefix + parts[i].Trim(); + return result; + } +} diff --git a/src/Modules/Popout/WaypointQueueBridge.cs b/src/Modules/Popout/WaypointQueueBridge.cs new file mode 100644 index 0000000..b39bcd0 --- /dev/null +++ b/src/Modules/Popout/WaypointQueueBridge.cs @@ -0,0 +1,420 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using HarmonyLib; +using Model; +using Track; +using UnityEngine; +using UnityModManagerNet; + +namespace S3.Modules.Popout; + +/// +/// Optional WaypointQueue integration via reflection — no compile-time reference. +/// Silent no-op when the mod is not installed. +/// +internal static class WaypointQueueBridge +{ + private static bool? _installed; + + public static bool IsInstalled + { + get + { + if (_installed.HasValue) return _installed.Value; + _installed = Type.GetType("WaypointQueue.State.ModStateManager, WaypointQueue") != null + || FindMod("WaypointQueue") != null; + return _installed.Value; + } + } + + public static bool TryGetQueue(string locoId, out List<(Vector3 gamePos, bool active)> points) + { + points = new List<(Vector3, bool)>(); + if (!IsInstalled || string.IsNullOrEmpty(locoId)) return false; + try + { + var mgrType = Type.GetType("WaypointQueue.State.ModStateManager, WaypointQueue"); + if (mgrType == null) return false; + var shared = mgrType.GetProperty("Shared")?.GetValue(null); + if (shared == null) return false; + + object? state = Traverse.Create(shared).Method("GetLocoWaypointState", locoId).GetValue(); + if (state == null) return false; + + var waypoints = Traverse.Create(state).Property("Waypoints").GetValue() as IEnumerable; + if (waypoints == null) return false; + + object? unresolved = Traverse.Create(state).Property("UnresolvedWaypoint").GetValue(); + string? activeId = unresolved != null + ? Traverse.Create(unresolved).Property("Id").Value + : null; + + foreach (object wp in waypoints) + { + if (wp == null) continue; + if (!TryWaypointPosition(wp, out Vector3 pos)) continue; + string? id = null; + try { id = Traverse.Create(wp).Property("Id").Value; } catch { } + bool active = !string.IsNullOrEmpty(activeId) && activeId == id; + points.Add((pos, active)); + } + + if (points.Count > 0 && !points.Exists(p => p.active)) + { + var first = points[0]; + points[0] = (first.gamePos, true); + } + return points.Count > 0; + } + catch + { + return false; + } + } + + internal sealed class WqWaypointSnap + { + public int Number; + public string Id = ""; + public bool Active; + public Vector3 Position; + public Quaternion Rotation = Quaternion.identity; + public bool HasPosition; + public string CoupleToCarId = ""; + public string CouplingSearchMode = ""; + public string UncouplingMode = ""; + public int NumberOfCarsToCut; + public string PostCouplingCutMode = ""; + public string UncouplingSearchResultCarId = ""; + public string CouplingSearchResultCarId = ""; + public bool CountFromNearest = true; + public bool Pickup; + public bool Dropoff; + public string Name = ""; + public string Notes = ""; + public string AreaName = ""; + public bool WillWait; + public int WaitMinutes; + public bool WillRefuel; + public string RefuelLoad = ""; + } + + /// + /// Typed queue snapshot for car-card cut preview. Empty list if the loco has no queue. + /// + public static bool TryGetSnapshot(string locoId, out List snaps) + { + snaps = new List(); + if (!TryGetQueueObjects(locoId, out _, out List waypoints, out string? unresolvedId)) + return false; + for (int i = 0; i < waypoints.Count; i++) + { + object wp = waypoints[i]; + if (wp == null) continue; + var s = new WqWaypointSnap { Number = i + 1 }; + s.Id = ReadStr(wp, "Id") ?? ""; + s.Active = !string.IsNullOrEmpty(unresolvedId) && unresolvedId == s.Id; + s.HasPosition = TryWaypointPose(wp, out s.Position, out s.Rotation); + s.CoupleToCarId = ReadStr(wp, "CoupleToCarId") ?? ""; + s.CouplingSearchMode = ReadEnum(wp, "CouplingSearchMode"); + s.UncouplingMode = ReadEnum(wp, "UncouplingMode"); + s.PostCouplingCutMode = ReadEnum(wp, "PostCouplingCutMode"); + s.UncouplingSearchResultCarId = ReadStr(wp, "UncouplingSearchResultCarId") ?? ""; + s.CouplingSearchResultCarId = ReadStr(wp, "CouplingSearchResultCarId") ?? ""; + s.NumberOfCarsToCut = ReadInt(wp, "NumberOfCarsToCut"); + s.CountFromNearest = ReadBool(wp, "CountUncoupledFromNearestToWaypoint", true); + s.Pickup = ReadBool(wp, "WillPostCoupleCutPickup", false); + s.Dropoff = ReadBool(wp, "WillPostCoupleCutDropoff", false); + s.Name = ReadStr(wp, "Name") ?? ""; + s.Notes = ReadStr(wp, "Notes") ?? ""; + s.AreaName = ReadStr(wp, "AreaName") ?? ""; + s.WillWait = ReadBool(wp, "WillWait", false); + s.WaitMinutes = ReadInt(wp, "WaitForDurationMinutes"); + s.WillRefuel = ReadBool(wp, "WillRefuel", false); + s.RefuelLoad = ReadStr(wp, "RefuelLoadName") ?? ""; + if (!s.Pickup && string.Equals(s.PostCouplingCutMode, "Pickup", StringComparison.OrdinalIgnoreCase)) + s.Pickup = Coupling(s); + if (!s.Dropoff && string.Equals(s.PostCouplingCutMode, "Dropoff", StringComparison.OrdinalIgnoreCase)) + s.Dropoff = Coupling(s); + snaps.Add(s); + } + return true; + } + + internal static bool Coupling(WqWaypointSnap s) => + !string.IsNullOrEmpty(s.CoupleToCarId) + || string.Equals(s.CouplingSearchMode, "Nearest", StringComparison.OrdinalIgnoreCase) + || string.Equals(s.CouplingSearchMode, "SpecificCar", StringComparison.OrdinalIgnoreCase); + + static string? ReadStr(object wp, string name) + { + try + { + object? v = Traverse.Create(wp).Property(name).GetValue(); + return v as string ?? v?.ToString(); + } + catch { return null; } + } + + static string ReadEnum(object wp, string name) + { + try + { + object? v = Traverse.Create(wp).Property(name).GetValue(); + return v?.ToString() ?? ""; + } + catch { return ""; } + } + + static int ReadInt(object wp, string name) + { + try + { + object? v = Traverse.Create(wp).Property(name).GetValue(); + if (v is int i) return i; + if (v is long l) return (int)l; + if (v != null) return Convert.ToInt32(v); + } + catch { } + return 0; + } + + static bool ReadBool(object wp, string name, bool fallback) + { + try + { + object? v = Traverse.Create(wp).Property(name).GetValue(); + if (v is bool b) return b; + } + catch { } + return fallback; + } + + /// + /// Raw WQ state for debug dumps. waypoints is empty if the loco has no queue. + /// + public static bool TryGetQueueObjects( + string locoId, + out object? state, + out List waypoints, + out string? unresolvedId) + { + state = null; + waypoints = new List(); + unresolvedId = null; + if (!IsInstalled || string.IsNullOrEmpty(locoId)) return false; + try + { + var mgrType = Type.GetType("WaypointQueue.State.ModStateManager, WaypointQueue"); + if (mgrType == null) return false; + var shared = mgrType.GetProperty("Shared")?.GetValue(null); + if (shared == null) return false; + + state = Traverse.Create(shared).Method("GetLocoWaypointState", locoId).GetValue(); + if (state == null) return true; + + object? unresolved = Traverse.Create(state).Property("UnresolvedWaypoint").GetValue(); + if (unresolved != null) + { + try { unresolvedId = Traverse.Create(unresolved).Property("Id").Value; } + catch { } + } + + if (Traverse.Create(state).Property("Waypoints").GetValue() is IEnumerable list) + { + foreach (object wp in list) + if (wp != null) waypoints.Add(wp); + } + return true; + } + catch + { + return false; + } + } + + private static bool TryWaypointPosition(object wp, out Vector3 pos) => + TryWaypointPose(wp, out pos, out _); + + private static bool TryWaypointPose(object wp, out Vector3 pos, out Quaternion rot) + { + pos = default; + rot = Quaternion.identity; + try + { + object? locObj = Traverse.Create(wp).Property("Location").GetValue(); + if (locObj is Location loc && loc.IsValid) + { + pos = loc.GetPosition(); + rot = loc.GetRotation(); + return true; + } + } + catch { } + + try + { + string? locStr = Traverse.Create(wp).Property("LocationString").Value; + if (string.IsNullOrEmpty(locStr) || Graph.Shared == null) return false; + Location loc2 = Graph.Shared.ResolveLocationString(locStr); + if (!loc2.IsValid) return false; + pos = loc2.GetPosition(); + rot = loc2.GetRotation(); + return true; + } + catch { } + + return false; + } + + /// + /// Append a waypoint to the loco's WQ list. Does not call vanilla SetWaypoint + /// (that Harmony prefix can wipe the queue). Returns the last waypoint object + /// so callers can set couple/cut/pickup fields. + /// + public static bool TryAppend(BaseLocomotive loco, Location location, string? coupleToCarId, out object? waypoint) + { + waypoint = null; + if (!IsInstalled || loco == null) return false; + try + { + var ctrlType = Type.GetType("WaypointQueue.WaypointQueueController, WaypointQueue"); + if (ctrlType == null) return false; + object? shared = ctrlType.GetProperty("Shared")?.GetValue(null); + if (shared == null) return false; + + string couple = coupleToCarId ?? ""; + Traverse.Create(shared).Method("AddWaypoint", loco, location, couple, false, false).GetValue(); + waypoint = LastWaypoint(loco.id); + return waypoint != null; + } + catch (Exception ex) + { + S3.Core.Log.Warn($"[radio] WQ append failed: {ex.Message}"); + return false; + } + } + + public static void ApplyCut(object waypoint, string? specificCarId) + { + try + { + var t = Traverse.Create(waypoint); + if (!string.IsNullOrEmpty(specificCarId) && + TryEnum(waypoint, "UncoupleMode", "BySpecificCar", out object byCar)) + { + t.Property("UncouplingMode").SetValue(byCar); + t.Property("UncouplingSearchResultCarId").SetValue(specificCarId); + } + else + { + ApplyUncoupleByCount(waypoint, 1); + return; + } + Persist(waypoint); + } + catch (Exception ex) + { + S3.Core.Log.Warn($"[radio] WQ cut fields failed: {ex.Message}"); + } + } + + /// + /// Couple, then pick up N cars. WQ clears Pickup if NumberOfCarsToCut is 0. + /// + public static void ApplyPickup(object waypoint, int count) + => ApplyPostCoupleCut(waypoint, "Pickup", count); + + /// + /// Couple, then drop off N cars. WQ clears Dropoff if NumberOfCarsToCut is 0. + /// + public static void ApplyDropoff(object waypoint, int count) + => ApplyPostCoupleCut(waypoint, "Dropoff", count); + + /// + /// Uncouple N cars at this waypoint with no coupling order (spot / drop on track). + /// + public static void ApplyUncoupleByCount(object waypoint, int count) + { + try + { + int n = Math.Max(1, count); + var t = Traverse.Create(waypoint); + t.Property("NumberOfCarsToCut").SetValue(n); + if (TryEnum(waypoint, "UncoupleMode", "ByCount", out object byCount)) + t.Property("UncouplingMode").SetValue(byCount); + Persist(waypoint); + } + catch (Exception ex) + { + S3.Core.Log.Warn($"[radio] WQ uncouple-by-count failed: {ex.Message}"); + } + } + + private static void ApplyPostCoupleCut(object waypoint, string pickupOrDropoff, int count) + { + try + { + int n = Math.Max(1, count); + var t = Traverse.Create(waypoint); + // Count first — WQ zeros the post-couple mode when the count is still 0. + t.Property("NumberOfCarsToCut").SetValue(n); + if (TryEnum(waypoint, "PostCoupleCutType", pickupOrDropoff, out object cut)) + t.Property("PostCouplingCutMode").SetValue(cut); + if (TryEnum(waypoint, "UncoupleMode", "ByCount", out object byCount)) + t.Property("UncouplingMode").SetValue(byCount); + Persist(waypoint); + } + catch (Exception ex) + { + S3.Core.Log.Warn($"[radio] WQ {pickupOrDropoff} fields failed: {ex.Message}"); + } + } + + private static bool TryEnum(object waypoint, string nested, string name, out object value) + { + value = null!; + Type wpType = waypoint.GetType(); + Type? t = wpType.GetNestedType(nested) + ?? Type.GetType($"WaypointQueue.ManagedWaypoint+{nested}, WaypointQueue"); + if (t == null || !Enum.IsDefined(t, name)) return false; + value = Enum.Parse(t, name); + return true; + } + + private static object? LastWaypoint(string locoId) + { + var mgrType = Type.GetType("WaypointQueue.State.ModStateManager, WaypointQueue"); + object? shared = mgrType?.GetProperty("Shared")?.GetValue(null); + if (shared == null) return null; + object? state = Traverse.Create(shared).Method("GetLocoWaypointState", locoId).GetValue(); + if (state == null) return null; + if (Traverse.Create(state).Property("Waypoints").GetValue() is not IList list || list.Count == 0) + return null; + return list[list.Count - 1]; + } + + private static void Persist(object waypoint) + { + try + { + var ctrlType = Type.GetType("WaypointQueue.WaypointQueueController, WaypointQueue"); + object? shared = ctrlType?.GetProperty("Shared")?.GetValue(null); + if (shared == null) return; + Traverse.Create(shared).Method("UpdateWaypoint", waypoint).GetValue(); + } + catch { } + } + + private static UnityModManager.ModEntry? FindMod(string id) + { + try + { + foreach (var m in UnityModManager.modEntries) + if (m.Info.Id == id) return m; + } + catch { } + return null; + } +} diff --git a/src/Modules/Popout/WqDumpCommand.cs b/src/Modules/Popout/WqDumpCommand.cs new file mode 100644 index 0000000..588cfa7 --- /dev/null +++ b/src/Modules/Popout/WqDumpCommand.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using HarmonyLib; +using Model; +using Model.AI; +using S3.Core; +using UI.Console; +using UnityEngine; + +namespace S3.Modules.Popout; + +[HarmonyPatch(typeof(ConsoleCommandHandler))] +[HarmonyPatch("_HandleSlashCommand")] +static class WqDumpCommandPatch +{ + static bool Prefix(string[] comps, ref string __result) + { + if (comps.Length == 0 || !string.Equals(comps[0], "/s3wq", StringComparison.OrdinalIgnoreCase)) + return true; + __result = WqDumpCommand.Handle(comps); + return false; + } +} + +static class WqDumpCommand +{ + public static void Install() + { + try + { + var h = new Harmony("S3.wqdump"); + h.CreateClassProcessor(typeof(WqDumpCommandPatch)).Patch(); + } + catch (Exception e) + { + Log.Error($"[wqdump] patch failed: {e.Message}"); + } + } + + internal static string Handle(string[] comps) + { + if (comps.Length >= 2) + { + string sub = comps[1].ToLowerInvariant(); + if (sub == "help") return Usage(); + if (sub != "dump") return $"Unknown subcommand '{comps[1]}'. {Usage()}"; + } + + return Dump(); + } + + static string Usage() => + "Usage: /s3wq dump (selected consist loco; writes Mods/S3/wq-dump.txt)"; + + static string Dump() + { + if (!WaypointQueueBridge.IsInstalled) + return "WaypointQueue is not installed."; + + Car? seed = null; + try { seed = TrainController.Shared?.SelectedCar; } + catch { } + if (seed == null) + return "No car selected."; + + BaseLocomotive? loco = FindLoco(seed); + if (loco == null) + return $"No locomotive on consist of {seed.DisplayName} ({seed.id})."; + + var sb = new StringBuilder(); + sb.AppendLine($"=== S3 WQ dump {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==="); + sb.AppendLine($"Selected: {seed.DisplayName} id={seed.id}"); + sb.AppendLine($"Loco: {loco.DisplayName} id={loco.id}"); + sb.AppendLine(); + sb.AppendLine("-- consist (EnumerateCoupled) --"); + int i = 0; + try + { + foreach (Car c in seed.EnumerateCoupled()) + { + if (c == null) continue; + string kind = c is BaseLocomotive ? "loco" : "car"; + sb.AppendLine($" [{i++}] {kind} {c.DisplayName} id={c.id}"); + } + } + catch (Exception e) + { + sb.AppendLine($" EnumerateCoupled failed: {e.Message}"); + } + + sb.AppendLine(); + DumpVanillaWaypoint(sb, loco); + + if (!WaypointQueueBridge.TryGetQueueObjects(loco.id, out object? state, out List waypoints, out string? unresolvedId)) + { + sb.AppendLine("Failed to read WaypointQueue state (reflection)."); + return Finish(sb); + } + + sb.AppendLine($"-- WaypointQueue unresolvedId={unresolvedId ?? "(none)"} count={waypoints.Count} --"); + if (state != null) + sb.AppendLine($" state type: {state.GetType().FullName}"); + if (waypoints.Count == 0) + sb.AppendLine(" (empty queue)"); + + for (int n = 0; n < waypoints.Count; n++) + { + object wp = waypoints[n]; + string? id = PropStr(wp, "Id"); + bool active = !string.IsNullOrEmpty(unresolvedId) && unresolvedId == id; + sb.AppendLine(); + sb.AppendLine($" waypoint {n + 1}{(active ? " [ACTIVE]" : "")} type={wp.GetType().FullName}"); + DumpObject(sb, wp, " "); + } + + return Finish(sb); + } + + static void DumpVanillaWaypoint(StringBuilder sb, BaseLocomotive loco) + { + sb.AppendLine("-- vanilla AE Orders.Waypoint --"); + try + { + var planner = loco.AutoEngineerPlanner; + if (planner == null) + { + sb.AppendLine(" (no AutoEngineerPlanner)"); + return; + } + object? raw = Traverse.Create(planner).Field("_orders").GetValue(); + if (raw is not Orders orders) + { + sb.AppendLine(" (no Orders)"); + return; + } + sb.AppendLine($" Mode={orders.Mode}"); + OrderWaypoint? maybe = orders.Waypoint; + if (!maybe.HasValue) + { + sb.AppendLine(" Waypoint=null"); + return; + } + OrderWaypoint vwp = maybe.Value; + sb.AppendLine($" LocationString={vwp.LocationString}"); + sb.AppendLine($" CoupleToCarId={vwp.CoupleToCarId}"); + } + catch (Exception e) + { + sb.AppendLine($" {e.Message}"); + } + sb.AppendLine(); + } + + static void DumpObject(StringBuilder sb, object obj, string pad) + { + PropertyInfo[] props; + try { props = obj.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance); } + catch (Exception e) + { + sb.AppendLine($"{pad}(properties failed: {e.Message})"); + return; + } + + Array.Sort(props, (a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)); + foreach (PropertyInfo p in props) + { + if (p.GetIndexParameters().Length > 0) continue; + try + { + object? val = p.GetValue(obj, null); + sb.AppendLine($"{pad}{p.Name} = {Fmt(val)}"); + } + catch (Exception e) + { + sb.AppendLine($"{pad}{p.Name} = <{e.GetType().Name}: {e.Message}>"); + } + } + } + + static string Fmt(object? val) + { + if (val == null) return "null"; + if (val is string s) return s.Length == 0 ? "\"\"" : s; + if (val is Car car) return $"Car({car.DisplayName} id={car.id})"; + if (val is BaseLocomotive loco) return $"Loco({loco.DisplayName} id={loco.id})"; + Type t = val.GetType(); + if (t.IsEnum) return val.ToString() ?? ""; + if (val is Vector3 v) return $"({v.x:F1},{v.y:F1},{v.z:F1})"; + string text = val.ToString() ?? ""; + if (text.Length > 240) return text.Substring(0, 240) + "..."; + return text; + } + + static string? PropStr(object obj, string name) + { + try { return Traverse.Create(obj).Property(name).Value; } + catch { return null; } + } + + static BaseLocomotive? FindLoco(Car seed) + { + if (seed is BaseLocomotive self) return self; + BaseLocomotive? first = null; + try + { + foreach (Car c in seed.EnumerateCoupled()) + { + if (c is not BaseLocomotive loco) continue; + first ??= loco; + bool mu = false; + try { mu = Traverse.Create(loco).Property("IsMuEnabled").Value; } + catch { } + if (!mu) return loco; + } + } + catch { } + return first; + } + + static string Finish(StringBuilder sb) + { + string text = sb.ToString(); + try { Log.Info("[wqdump]\n" + text); } + catch { } + + string path = "(not written)"; + try + { + string dir = Main.ModEntry.Path; + path = Path.Combine(dir, "wq-dump.txt"); + File.WriteAllText(path, text); + } + catch (Exception e) + { + return text + $"\nFailed to write file: {e.Message}"; + } + + return $"Wrote {path}\n(also in the S3 log)\n\n" + text; + } +}