commit a1fcf0112509cb92c4c203560a7440889832f1ca Author: seton Date: Wed Jun 17 13:57:39 2026 -0400 Initial commit: S3 - Seton's Special Sauce v0.2.0 Consolidates two standalone Railroader mods into one UMM "everything mod" with an optional-module framework. Modules are disabled by default and toggled per-module from the S3 settings page. Core framework: - IModule contract plus ModuleRegistry, with each module owning a Harmony instance scoped by its id so only enabled modules patch the game - Per-module flat JSON settings (SettingsStore). On this Mono runtime JsonUtility silently drops nested custom-class fields, so settings stay flat - Foldout-per-module settings panel plus a detector that offers to disable the old standalone mods if they are still installed Modules (moved over to parity, verified in-game): - Physics Optimizer (was RailroaderPhysicsOverhaul): LOD fast-path and auto-freeze, profiler overlay, debug car tinting, /rpf console commands - Map Popout (was RRPopout): native map detach window. Pure-UMM install that drops the winhttp proxy and LoadLibrary's RRPopout.dll from the mod folder. Native Win32 + D3D11 + Dear ImGui engine included. Fixes a latent break where the now-private MapBuilder.UpdateForZoom() is reached via Traverse. Build: dotnet for the managed assembly (netstandard2.1) and CMake for the native DLL. build-local.ps1 installs into the game, build-release.ps1 packages the UMM drag-install zip. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44847a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Build output +[Bb]in/ +[Oo]bj/ +dist/stage/ +dist/*.zip + +# Native build +native/build/ +native/out/ + +# IDE +.vs/ +*.user + +# Agent briefing — shared out-of-band, not published +AGENTS.md diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..b62f704 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,13 @@ + + + + + D:\Seton\SteamApps\steamapps\common\Railroader + $(GameDir)\Railroader_Data\Managed + $(GameManaged)\UnityModManager + + + diff --git a/Info.json b/Info.json new file mode 100644 index 0000000..134b362 --- /dev/null +++ b/Info.json @@ -0,0 +1,11 @@ +{ + "Id": "S3", + "DisplayName": "S³ - Seton's Special Sauce", + "Author": "seton", + "Version": "0.2.0", + "ManagerVersion": "0.27.0", + "GameVersionPoint": "0", + "AssemblyName": "S3.dll", + "EntryMethod": "S3.Main.Load", + "Requirements": [] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d7778f --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# S³ - Seton's Special Sauce + +An umbrella "everything mod" for [Railroader](https://store.steampowered.com/app/1638770/Railroader/), +built on [Unity Mod Manager](https://www.nexusmods.com/site/mods/21). + +S³ is a thin core that hosts independent, optional **modules**, each disabled by +default. Open the S³ settings page in the UMM in-game menu and expand a module's +foldout to enable and configure it. Module enable/disable applies on the next +game launch. + +I originally planned on releasing individual mods, but considering my workflow of just making what I want when I want it, I figured a unified platform for all my tweaks and additions would be more convenient for myself (one repo to manage) and the community. + +## Modules + +| Module | What it does | +|---|---| +| Physics Optimizer | Cuts CPU spent on train physics (LOD fast-path + auto-freeze), with a profiler overlay and debug car tinting. Console: `/rpf` | +| Map Popout | Detaches the in-game map into a separate resizable window for a second monitor. Optional MapEnhancer integration. | + +Both are disabled by default - You must enable them per-module from the S³ settings page, a game restart is required for this to take effect. +More modules will follow - S³ is designed to grow. + +## Migrating from the standalone mods + +S³ replaces the separate **Physics Optimizer** (`RailroaderPhysicsOverhaul`) and +**PopOut Windows** (`RRPopout`) mods. Uninstall those before installing S³. +Settings do not carry over, so re-configure each module from the S³ settings page. + +If these mods are detected you will be prompted to disable them in the S³ settings page. + +## Building + +Requires the .NET SDK (`dotnet`). The native module additionally needs CMake + +the MSVC toolchain. + +- **Local test install:** `.\dist\build-local.ps1` builds and copies into the + game's `Mods\S3\` folder. +- **Release zip:** `.\dist\build-release.ps1` produces + `dist\SetonsSpecialSauce-.zip` for upload. + +The game install path is set once in [`Directory.Build.props`](Directory.Build.props) +(`GameDir`); override per-build with `/p:GameDir=...` or the build script's +`-GameDir` parameter. + +## Layout + +``` +src/ single managed assembly (S3.dll, netstandard2.1) + Main.cs UMM entry point + Core/ module framework: IModule, registry, settings, settings panel + Modules/ one folder per module +native/ shared Win32 + D3D11 + Dear ImGui external-window UI engine +dist/ build + packaging scripts +``` diff --git a/dist/build-common.ps1 b/dist/build-common.ps1 new file mode 100644 index 0000000..094ff0e --- /dev/null +++ b/dist/build-common.ps1 @@ -0,0 +1,67 @@ +# Shared build helpers for S³ (dot-sourced by build-local.ps1 and build-release.ps1). +# Not run directly. + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$script:RepoRoot = Split-Path $PSScriptRoot -Parent +$script:ModId = "S3" + +function Get-ModVersion { + # Single source of truth: the Version field in Info.json. + $info = Get-Content (Join-Path $RepoRoot "Info.json") -Raw | ConvertFrom-Json + return $info.Version +} + +function Invoke-ManagedBuild { + param([string]$Configuration = "Release", [string]$GameDir = "") + + Write-Host "=== Building managed (S3.dll, $Configuration) ===" -ForegroundColor Cyan + $csproj = Join-Path $RepoRoot "src\S3.csproj" + $args = @($csproj, "-c", $Configuration, "--nologo", "-v", "minimal") + if ($GameDir) { $args += "/p:GameDir=$GameDir" } + dotnet build @args | Out-Host + if ($LASTEXITCODE -ne 0) { throw "Managed build failed." } + + $dll = Join-Path $RepoRoot "src\bin\$Configuration\S3.dll" + if (-not (Test-Path $dll)) { throw "S3.dll not found at $dll" } + return $dll +} + +function Invoke-NativeBuild { + param([string]$Configuration = "Release") + + $cmakeLists = Join-Path $RepoRoot "native\CMakeLists.txt" + if (-not (Test-Path $cmakeLists)) { + Write-Host "=== No native/CMakeLists.txt — skipping native build ===" -ForegroundColor DarkYellow + return $null + } + + Write-Host "=== Building native (RRPopout.dll, $Configuration) ===" -ForegroundColor Cyan + $buildDir = Join-Path $RepoRoot "native\build" + if (-not (Test-Path $buildDir)) { cmake -B $buildDir -A x64 (Join-Path $RepoRoot "native") | Out-Host } + cmake --build $buildDir --config $Configuration | Out-Host + if ($LASTEXITCODE -ne 0) { throw "Native build failed." } + + # MSVC multi-config generators place the DLL in a per-config subfolder. + $dll = Join-Path $buildDir "bin\$Configuration\RRPopout.dll" + if (-not (Test-Path $dll)) { $dll = Join-Path $buildDir "bin\RRPopout.dll" } # single-config fallback + if (-not (Test-Path $dll)) { throw "RRPopout.dll not found under $buildDir\bin" } + return $dll +} + +# Assembles the Mods\S3 layout into $DestModDir (the S3 folder itself). +function New-ModLayout { + param([Parameter(Mandatory)][string]$DestModDir, + [Parameter(Mandatory)][string]$ManagedDll, + [string]$NativeDll = $null) + + if (Test-Path $DestModDir) { Remove-Item $DestModDir -Recurse -Force } + New-Item -ItemType Directory -Force -Path $DestModDir | Out-Null + + Copy-Item $ManagedDll (Join-Path $DestModDir "S3.dll") -Force + Copy-Item (Join-Path $RepoRoot "Info.json") (Join-Path $DestModDir "Info.json") -Force + if ($NativeDll) { + Copy-Item $NativeDll (Join-Path $DestModDir "RRPopout.dll") -Force + } +} diff --git a/dist/build-local.ps1 b/dist/build-local.ps1 new file mode 100644 index 0000000..8aef0bf --- /dev/null +++ b/dist/build-local.ps1 @@ -0,0 +1,20 @@ +# Build S³ and install it straight into the local game copy for testing. +# .\dist\build-local.ps1 +# .\dist\build-local.ps1 -GameDir "X:\path\to\Railroader" -Configuration Debug + +param( + [string]$Configuration = "Release", + [string]$GameDir = "D:\Seton\SteamApps\steamapps\common\Railroader" +) + +. (Join-Path $PSScriptRoot "build-common.ps1") + +if (-not (Test-Path $GameDir)) { throw "GameDir not found: $GameDir" } + +$managed = Invoke-ManagedBuild -Configuration $Configuration -GameDir $GameDir +$native = Invoke-NativeBuild -Configuration $Configuration + +$modDir = Join-Path $GameDir "Mods\$ModId" +New-ModLayout -DestModDir $modDir -ManagedDll $managed -NativeDll $native + +Write-Host "=== Installed to $modDir ===" -ForegroundColor Green diff --git a/dist/build-release.ps1 b/dist/build-release.ps1 new file mode 100644 index 0000000..43b31e2 --- /dev/null +++ b/dist/build-release.ps1 @@ -0,0 +1,31 @@ +# Build S³ and package a drag-install zip for Forgejo downloads. +# .\dist\build-release.ps1 +# Produces dist\SetonsSpecialSauce-.zip with the layout: +# S3/Info.json +# S3/S3.dll +# S3/RRPopout.dll (when the native module is present) + +param( + [string]$Configuration = "Release" +) + +. (Join-Path $PSScriptRoot "build-common.ps1") + +$managed = Invoke-ManagedBuild -Configuration $Configuration +$native = Invoke-NativeBuild -Configuration $Configuration + +$stageRoot = Join-Path $PSScriptRoot "stage" +$modDir = Join-Path $stageRoot $ModId +if (Test-Path $stageRoot) { Remove-Item $stageRoot -Recurse -Force } +New-Item -ItemType Directory -Force -Path $stageRoot | Out-Null + +New-ModLayout -DestModDir $modDir -ManagedDll $managed -NativeDll $native + +$version = Get-ModVersion +$zip = Join-Path $PSScriptRoot "SetonsSpecialSauce-$version.zip" +if (Test-Path $zip) { Remove-Item $zip -Force } + +# Zip the S3 folder itself so the archive root contains S3\ — what UMM expects. +Compress-Archive -Path $modDir -DestinationPath $zip -Force + +Write-Host "=== Release zip ready: $zip ===" -ForegroundColor Green diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt new file mode 100644 index 0000000..33f0928 --- /dev/null +++ b/native/CMakeLists.txt @@ -0,0 +1,72 @@ +cmake_minimum_required(VERSION 3.21) +project(RRPopout CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# --------------------------------------------------------------------------- +# Dear ImGui — fetched once at configure time, built as a static lib. +# Only the D3D11 backend is compiled; Win32 input is fed manually via atomics. +# --------------------------------------------------------------------------- +include(FetchContent) +FetchContent_Declare( + imgui + GIT_REPOSITORY https://github.com/ocornut/imgui.git + GIT_TAG v1.90.4 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(imgui) + +add_library(imgui STATIC + ${imgui_SOURCE_DIR}/imgui.cpp + ${imgui_SOURCE_DIR}/imgui_draw.cpp + ${imgui_SOURCE_DIR}/imgui_tables.cpp + ${imgui_SOURCE_DIR}/imgui_widgets.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_dx11.cpp +) +target_include_directories(imgui PUBLIC + ${imgui_SOURCE_DIR} + ${imgui_SOURCE_DIR}/backends +) +# imgui_impl_dx11.cpp calls D3DCompile at runtime to build its own shaders. +target_link_libraries(imgui PUBLIC d3d11 d3dcompiler) + +# Suppress MSVC warnings inside third-party ImGui source. +if(MSVC) + target_compile_options(imgui PRIVATE /W0) +endif() + +# --------------------------------------------------------------------------- +# RRPopout native plugin DLL +# --------------------------------------------------------------------------- +add_library(RRPopout SHARED + src/dllmain.cpp + src/exports.cpp + src/popout_windows.cpp + src/d3d11_renderer.cpp +) + +target_include_directories(RRPopout PRIVATE + include + external +) + +target_link_libraries(RRPopout PRIVATE + imgui + d3d11 + dxgi + d3dcompiler + dwmapi # DwmSetWindowAttribute (dark title bar, rounded corners) + shell32 # ExtractIconExW (EXE icon loading) +) + +set_target_properties(RRPopout PROPERTIES + OUTPUT_NAME "RRPopout" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" +) + +if(MSVC) + target_compile_options(RRPopout PRIVATE /W3 /WX- /GR- /EHsc) + target_compile_options(RRPopout PRIVATE $<$:/Zi>) + target_link_options(RRPopout PRIVATE $<$:/DEBUG /OPT:REF /OPT:ICF>) +endif() diff --git a/native/external/IUnityGraphics.h b/native/external/IUnityGraphics.h new file mode 100644 index 0000000..54eeb37 --- /dev/null +++ b/native/external/IUnityGraphics.h @@ -0,0 +1,31 @@ +#pragma once +#include "IUnityInterface.h" + +enum UnityGfxDeviceEventType { + kUnityGfxDeviceEventInitialize = 0, + kUnityGfxDeviceEventShutdown = 1, + kUnityGfxDeviceEventBeforeReset = 2, + kUnityGfxDeviceEventAfterReset = 3, +}; + +enum UnityGfxRenderer { + kUnityGfxRendererD3D11 = 2, + kUnityGfxRendererD3D12 = 18, + kUnityGfxRendererVulkan = 21, + kUnityGfxRendererOpenGLCore = 17, +}; + +typedef void (UNITY_INTERFACE_API * IUnityGraphicsDeviceEventCallback)(UnityGfxDeviceEventType eventType); +typedef void (UNITY_INTERFACE_API * UnityRenderingEvent)(int eventId); + +struct IUnityGraphics : IUnityInterface { + static const UnityInterfaceGUID GUID; + + virtual UnityGfxRenderer UNITY_INTERFACE_API GetRenderer() = 0; + virtual void UNITY_INTERFACE_API RegisterDeviceEventCallback(IUnityGraphicsDeviceEventCallback callback) = 0; + virtual void UNITY_INTERFACE_API UnregisterDeviceEventCallback(IUnityGraphicsDeviceEventCallback callback) = 0; + virtual int UNITY_INTERFACE_API ReserveEventIDRange(int count) = 0; +}; + +// {7CBA0A9C-0EFB-4B97-A51E-03B7B9994DC1} +const UnityInterfaceGUID IUnityGraphics::GUID = { 0x7CBA0A9C0EFB4B97ULL, 0xA51E03B7B9994DC1ULL }; diff --git a/native/external/IUnityGraphicsD3D11.h b/native/external/IUnityGraphicsD3D11.h new file mode 100644 index 0000000..ebdd4a1 --- /dev/null +++ b/native/external/IUnityGraphicsD3D11.h @@ -0,0 +1,15 @@ +#pragma once +#include "IUnityInterface.h" +#include + +struct IUnityGraphicsD3D11 : IUnityInterface { + static const UnityInterfaceGUID GUID; + + virtual ID3D11Device* UNITY_INTERFACE_API GetDevice() = 0; + virtual ID3D11DeviceContext* UNITY_INTERFACE_API GetImmediateContext() = 0; + virtual HRESULT UNITY_INTERFACE_API CreateBuffer(const D3D11_BUFFER_DESC* desc, const D3D11_SUBRESOURCE_DATA* pInitialData, ID3D11Buffer** ppBuffer) = 0; + virtual HRESULT UNITY_INTERFACE_API CreateTexture2D(const D3D11_TEXTURE2D_DESC* desc, const D3D11_SUBRESOURCE_DATA* pInitialData, ID3D11Texture2D** ppTexture2D) = 0; +}; + +// {AAB04B50-A9A8-4DAA-89F7-B3ADB602D28E} +const UnityInterfaceGUID IUnityGraphicsD3D11::GUID = { 0xAAB04B50A9A84DAAULL, 0x89F7B3ADB602D28EULL }; diff --git a/native/external/IUnityInterface.h b/native/external/IUnityInterface.h new file mode 100644 index 0000000..371d51c --- /dev/null +++ b/native/external/IUnityInterface.h @@ -0,0 +1,35 @@ +#pragma once +#include + +// Minimal subset of Unity's plugin SDK headers (from Unity's NativeRenderingPlugin sample). +// Full headers available at: https://github.com/Unity-Technologies/NativeRenderingPlugin + +#ifndef UNITY_INTERFACE_API +# if defined(_WIN32) +# define UNITY_INTERFACE_API __stdcall +# define UNITY_INTERFACE_EXPORT __declspec(dllexport) +# else +# define UNITY_INTERFACE_API +# define UNITY_INTERFACE_EXPORT __attribute__((visibility("default"))) +# endif +#endif + +struct IUnityInterface {}; + +// Must be declared before IUnityInterfaces uses it. +struct UnityInterfaceGUID { + uint64_t high; + uint64_t low; +}; + +struct IUnityInterfaces { + // x64 Windows ABI: a 16-byte struct is passed by pointer (caller allocates on stack, + // passes rdx = &struct). Our signature must match Unity's vtable exactly — one + // struct-by-value arg — so the compiler generates the correct pointer-passing call. + virtual IUnityInterface* UNITY_INTERFACE_API GetInterfaceByGUID(UnityInterfaceGUID guid) = 0; + + template + T* Get() { + return static_cast(GetInterfaceByGUID(T::GUID)); + } +}; diff --git a/native/include/shared_types.h b/native/include/shared_types.h new file mode 100644 index 0000000..8e570b3 --- /dev/null +++ b/native/include/shared_types.h @@ -0,0 +1,42 @@ +#pragma once +#include + +// Shared between RRPopout.dll (C++) and RRPopout.Managed.dll (C#). +// The C# struct in NativeInterop.cs MUST match this layout exactly. + +enum InputEventType : int32_t { + MouseMove = 0, + LButtonDown = 1, + LButtonUp = 2, + RButtonDown = 3, + RButtonUp = 4, + MouseWheel = 5, + // Toolbar button clicked. x field carries the UICmd_ command ID (cast to int). + UICommand = 6, +}; + +// Command IDs for UICommand events. +// For FollowLoco / JumpToLocation the list index is carried in InputEvent::y. +enum UICmd : int32_t { + Recenter = 1, + FollowMode = 2, // toggle MapEnhancer follow on/off + FollowPlayerCam = 3, // follow Camera.main continuously + FollowSelectedLoco = 4, // follow TrainController.Shared.SelectedCar + FollowLoco = 5, // follow specific loco; y = 0-based index in loco list + JumpToLocation = 6, // jump map camera to named location; y = 0-based index + SetRotation = 7, // set map rotation; y = degrees [0,360) + RotateReset = 8, // reset map rotation to 0 + RotateSyncPlayer = 9, // toggle sync-rotation-to-player-camera mode + ToggleFollowPlayer = 10, // toggle continuous follow of player world position +}; + +#pragma pack(push, 4) +struct InputEvent { + int32_t type; // InputEventType + float x; // normalized [0,1] within client area, left = 0 + float y; // normalized [0,1] within client area, top = 0 + float delta; // wheel delta (positive = scroll up); unused for non-wheel events +}; +#pragma pack(pop) + +static_assert(sizeof(InputEvent) == 16, "InputEvent size mismatch — update NativeInterop.cs"); diff --git a/native/src/d3d11_renderer.cpp b/native/src/d3d11_renderer.cpp new file mode 100644 index 0000000..25e3ba8 --- /dev/null +++ b/native/src/d3d11_renderer.cpp @@ -0,0 +1,594 @@ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include +#include +#include +#include "d3d11_renderer.h" +#include "popout_window.h" +#include "../include/shared_types.h" + +// Dear ImGui + D3D11 backend +#include "imgui.h" +#include "imgui_impl_dx11.h" + +using Microsoft::WRL::ComPtr; + +// --------------------------------------------------------------------------- +// Shared device/context captured from Unity via UnityPluginLoad +// --------------------------------------------------------------------------- +static ID3D11Device* g_device = nullptr; +static ID3D11DeviceContext* g_context = nullptr; + +// --------------------------------------------------------------------------- +// Shared blit resources (created once in Renderer_OnDeviceInit) +// --------------------------------------------------------------------------- +static ComPtr g_vs; +static ComPtr g_ps; +static ComPtr g_uvRectCB; +static ComPtr g_sampler; +static ComPtr g_blendOpaque; +static ComPtr g_rasterState; +static ComPtr g_depthState; + +static bool g_imguiInited = false; + +// cbuffer layout — must be 16-byte aligned +struct alignas(16) UVRectCB { float u0, v0, u1, v1; }; + +// --------------------------------------------------------------------------- +// HLSL: fullscreen triangle blit +// --------------------------------------------------------------------------- +static const char* kBlitVS = R"hlsl( +cbuffer UVRect : register(b0) { float4 _uvRect; }; +struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; }; +VSOut main(uint id : SV_VertexID) { + VSOut o; + float2 base = float2((id & 2u) ? 2.f : 0.f, (id & 1u) ? 2.f : 0.f); + o.pos = float4(base.x * 2.f - 1.f, 1.f - base.y * 2.f, 0.f, 1.f); + o.uv = lerp(_uvRect.xy, _uvRect.zw, base); + return o; +} +)hlsl"; + +static const char* kBlitPS = R"hlsl( +Texture2D gTex : register(t0); +SamplerState gSamp : register(s0); +struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; }; +float4 main(VSOut i) : SV_Target { return gTex.Sample(gSamp, i.uv); } +)hlsl"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +static DXGI_FORMAT TypedFormat(DXGI_FORMAT f) { + switch (f) { + case DXGI_FORMAT_R8G8B8A8_TYPELESS: return DXGI_FORMAT_R8G8B8A8_UNORM; + case DXGI_FORMAT_B8G8R8A8_TYPELESS: return DXGI_FORMAT_B8G8R8A8_UNORM; + case DXGI_FORMAT_R16G16B16A16_TYPELESS: return DXGI_FORMAT_R16G16B16A16_FLOAT; + case DXGI_FORMAT_R32G32B32A32_TYPELESS: return DXGI_FORMAT_R32G32B32A32_FLOAT; + case DXGI_FORMAT_R10G10B10A2_TYPELESS: return DXGI_FORMAT_R10G10B10A2_UNORM; + case DXGI_FORMAT_R32G32_TYPELESS: return DXGI_FORMAT_R32G32_FLOAT; + default: return f; + } +} + +// --------------------------------------------------------------------------- +// D3D11 state save/restore — prevents corrupting Unity's render state +// --------------------------------------------------------------------------- +struct D3D11StateBlock { + ComPtr rtv[8]; + ComPtr dsv; + UINT numViewports = D3D11_VIEWPORT_AND_SCISSORRECT_OBJECT_COUNT_PER_PIPELINE; + D3D11_VIEWPORT viewports[D3D11_VIEWPORT_AND_SCISSORRECT_OBJECT_COUNT_PER_PIPELINE]; + ComPtr vs; + ComPtr ps; + ComPtr vsCB; + ComPtr inputLayout; + D3D11_PRIMITIVE_TOPOLOGY topology = D3D11_PRIMITIVE_TOPOLOGY_UNDEFINED; + ComPtr psSRV[1]; + ComPtr psSampler[1]; + ComPtr blendState; + FLOAT blendFactor[4]; + UINT sampleMask; + ComPtr rasterizerState; + ComPtr depthStencilState; + UINT stencilRef; + + void Capture(ID3D11DeviceContext* ctx) { + ctx->OMGetRenderTargets(8, &rtv[0], &dsv); + ctx->RSGetViewports(&numViewports, viewports); + ctx->VSGetShader(&vs, nullptr, nullptr); + ctx->VSGetConstantBuffers(0, 1, &vsCB); + ctx->PSGetShader(&ps, nullptr, nullptr); + ctx->IAGetInputLayout(&inputLayout); + ctx->IAGetPrimitiveTopology(&topology); + ctx->PSGetShaderResources(0, 1, &psSRV[0]); + ctx->PSGetSamplers(0, 1, &psSampler[0]); + ctx->OMGetBlendState(&blendState, blendFactor, &sampleMask); + ctx->RSGetState(&rasterizerState); + ctx->OMGetDepthStencilState(&depthStencilState, &stencilRef); + } + void Restore(ID3D11DeviceContext* ctx) { + ID3D11RenderTargetView* rtvPtrs[8]; + for (int i = 0; i < 8; i++) rtvPtrs[i] = rtv[i].Get(); + ctx->OMSetRenderTargets(8, rtvPtrs, dsv.Get()); + ctx->RSSetViewports(numViewports, viewports); + ctx->VSSetShader(vs.Get(), nullptr, 0); + ID3D11Buffer* cb = vsCB.Get(); + ctx->VSSetConstantBuffers(0, 1, &cb); + ctx->PSSetShader(ps.Get(), nullptr, 0); + ctx->IASetInputLayout(inputLayout.Get()); + ctx->IASetPrimitiveTopology(topology); + ctx->PSSetShaderResources(0, 1, &psSRV[0]); + ctx->PSSetSamplers(0, 1, &psSampler[0]); + ctx->OMSetBlendState(blendState.Get(), blendFactor, sampleMask); + ctx->RSSetState(rasterizerState.Get()); + ctx->OMSetDepthStencilState(depthStencilState.Get(), stencilRef); + } +}; + +// --------------------------------------------------------------------------- +// ImGui initialisation (call once per D3D11 device) +// --------------------------------------------------------------------------- +static void InitImGui(ID3D11Device* device, ID3D11DeviceContext* context) { + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + + ImGuiIO& io = ImGui::GetIO(); + io.IniFilename = nullptr; // no imgui.ini — we're a mod, not a standalone app + io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange; // Win32 owns the cursor + + // Dark theme base + ImGui::StyleColorsDark(); + ImGuiStyle& style = ImGui::GetStyle(); + style.WindowRounding = 0.f; + style.WindowBorderSize = 0.f; + style.FrameRounding = 3.f; + style.GrabRounding = 3.f; + style.ScrollbarRounding = 3.f; + // Slightly warmer toolbar background to distinguish from the map + style.Colors[ImGuiCol_WindowBg] = ImVec4(0.12f, 0.12f, 0.12f, 1.f); + + // Fonts: default (ASCII) + merge the ⚙ gear glyph from Segoe UI Symbol. + // If seguisym.ttf is absent the button renders a box — fully functional fallback. + io.Fonts->AddFontDefault(); + { + ImFontConfig fc; + fc.MergeMode = true; + fc.GlyphMinAdvanceX = 13.f; + static const ImWchar kGlyphRanges[] = { + 0x2699, 0x2699, // ⚙ gear (settings button) + 0x25CE, 0x25CE, // ◎ bullseye (follow-player button) + 0 }; + io.Fonts->AddFontFromFileTTF("C:\\Windows\\Fonts\\seguisym.ttf", + 14.f, &fc, kGlyphRanges); + // seguisym.ttf absent → AddFontFromFileTTF returns nullptr, merge is skipped + } + io.Fonts->Build(); + + ImGui_ImplDX11_Init(device, context); + g_imguiInited = true; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- +void Renderer_OnDeviceInit(ID3D11Device* device) { + if (!device) return; + g_device = device; + device->GetImmediateContext(&g_context); + + // Compile blit shaders + ComPtr vsBlob, psBlob, errBlob; + if (FAILED(D3DCompile(kBlitVS, strlen(kBlitVS), "BlitVS", nullptr, nullptr, + "main", "vs_5_0", 0, 0, &vsBlob, &errBlob))) { + if (errBlob) OutputDebugStringA((char*)errBlob->GetBufferPointer()); + return; + } + if (FAILED(D3DCompile(kBlitPS, strlen(kBlitPS), "BlitPS", nullptr, nullptr, + "main", "ps_5_0", 0, 0, &psBlob, &errBlob))) { + if (errBlob) OutputDebugStringA((char*)errBlob->GetBufferPointer()); + return; + } + device->CreateVertexShader(vsBlob->GetBufferPointer(), vsBlob->GetBufferSize(), nullptr, &g_vs); + device->CreatePixelShader (psBlob->GetBufferPointer(), psBlob->GetBufferSize(), nullptr, &g_ps); + + D3D11_SAMPLER_DESC sd = {}; + sd.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; + sd.AddressU = sd.AddressV = sd.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP; + sd.MaxLOD = D3D11_FLOAT32_MAX; + device->CreateSamplerState(&sd, &g_sampler); + + D3D11_BLEND_DESC bd = {}; + bd.RenderTarget[0].BlendEnable = FALSE; + bd.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL; + device->CreateBlendState(&bd, &g_blendOpaque); + + D3D11_RASTERIZER_DESC rd = {}; + rd.FillMode = D3D11_FILL_SOLID; + rd.CullMode = D3D11_CULL_NONE; + device->CreateRasterizerState(&rd, &g_rasterState); + + D3D11_DEPTH_STENCIL_DESC dd = {}; + dd.DepthEnable = FALSE; + device->CreateDepthStencilState(&dd, &g_depthState); + + D3D11_BUFFER_DESC cbd = {}; + cbd.ByteWidth = sizeof(UVRectCB); + cbd.Usage = D3D11_USAGE_DYNAMIC; + cbd.BindFlags = D3D11_BIND_CONSTANT_BUFFER; + cbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; + device->CreateBuffer(&cbd, nullptr, &g_uvRectCB); + + InitImGui(device, g_context); +} + +void Renderer_OnDeviceShutdown() { + if (g_imguiInited) { + ImGui_ImplDX11_Shutdown(); + ImGui::DestroyContext(); + g_imguiInited = false; + } + g_vs.Reset(); g_ps.Reset(); g_uvRectCB.Reset(); + g_sampler.Reset(); g_blendOpaque.Reset(); + g_rasterState.Reset(); g_depthState.Reset(); + if (g_context) { g_context->Release(); g_context = nullptr; } + g_device = nullptr; +} + +bool Renderer_EnsureSwapchain(PopoutWindow* win) { + if (!g_device || !win || !win->contentHwnd) return false; + int w = win->width.load(), h = win->height.load(); + if (w <= 0 || h <= 0) return false; + + if (win->swapChain) { + DXGI_SWAP_CHAIN_DESC desc; + win->swapChain->GetDesc(&desc); + if ((int)desc.BufferDesc.Width == w && (int)desc.BufferDesc.Height == h) return true; + g_context->OMSetRenderTargets(0, nullptr, nullptr); + g_context->Flush(); + if (win->rtv) { win->rtv->Release(); win->rtv = nullptr; } + if (win->swapChain) { win->swapChain->Release(); win->swapChain = nullptr; } + } + + ComPtr dxgiDevice; ComPtr adapter; ComPtr factory; + if (FAILED(g_device->QueryInterface(__uuidof(IDXGIDevice), (void**)&dxgiDevice))) return false; + if (FAILED(dxgiDevice->GetAdapter(&adapter))) return false; + if (FAILED(adapter->GetParent(__uuidof(IDXGIFactory), (void**)&factory))) return false; + + DXGI_SWAP_CHAIN_DESC scd = {}; + scd.BufferCount = 2; + scd.BufferDesc.Width = static_cast(w); + scd.BufferDesc.Height = static_cast(h); + scd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; + scd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; + scd.OutputWindow = win->contentHwnd; + scd.SampleDesc.Count = 1; + scd.Windowed = TRUE; + scd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD; + scd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH; + if (FAILED(factory->CreateSwapChain(g_device, &scd, &win->swapChain))) return false; + factory->MakeWindowAssociation(win->contentHwnd, DXGI_MWA_NO_ALT_ENTER); + + ID3D11Texture2D* bb = nullptr; + win->swapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&bb); + if (bb) { g_device->CreateRenderTargetView(bb, nullptr, &win->rtv); bb->Release(); } + return win->rtv != nullptr; +} + +void Renderer_ReleaseSwapchain(PopoutWindow* win) { + if (!win) return; + if (win->rtv) { win->rtv->Release(); win->rtv = nullptr; } + if (win->swapChain) { win->swapChain->Release(); win->swapChain = nullptr; } +} + +void Renderer_Present(PopoutWindow* win) { + if (!win || win->resizing.load()) return; + void* rawTex = win->pendingTexture.load(); + if (!rawTex) return; + + // Lazy device init from the first texture if UnityPluginLoad didn't fire + if (!g_device) { + auto* tex = static_cast(rawTex); + ID3D11Device* dev = nullptr; + tex->GetDevice(&dev); + if (!dev) return; + Renderer_OnDeviceInit(dev); + dev->Release(); + } + if (!g_device || !g_context) return; + if (!Renderer_EnsureSwapchain(win)) return; + + auto* srcTex = static_cast(rawTex); + D3D11_TEXTURE2D_DESC texDesc; + srcTex->GetDesc(&texDesc); + + D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc = {}; + srvDesc.Format = TypedFormat(texDesc.Format); + srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D; + srvDesc.Texture2D.MostDetailedMip = 0; + srvDesc.Texture2D.MipLevels = 1; + ComPtr srv; + if (FAILED(g_device->CreateShaderResourceView(srcTex, &srvDesc, &srv))) return; + + // Save Unity's render state + D3D11StateBlock saved; + saved.Capture(g_context); + + int w = win->width.load(), h = win->height.load(); + D3D11_VIEWPORT vp { 0.f, 0.f, (float)w, (float)h, 0.f, 1.f }; + + // Update UV rect constant buffer + if (g_uvRectCB) { + D3D11_MAPPED_SUBRESOURCE mapped = {}; + if (SUCCEEDED(g_context->Map(g_uvRectCB.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped))) { + auto* cb = static_cast(mapped.pData); + cb->u0 = win->srcU0.load(); cb->v0 = win->srcV0.load(); + cb->u1 = win->srcU1.load(); cb->v1 = win->srcV1.load(); + g_context->Unmap(g_uvRectCB.Get(), 0); + } + } + + // Blit map texture into swapchain back buffer + float bf[4] = {}; + g_context->OMSetRenderTargets(1, &win->rtv, nullptr); + g_context->RSSetViewports(1, &vp); + g_context->VSSetShader(g_vs.Get(), nullptr, 0); + ID3D11Buffer* cbPtr = g_uvRectCB.Get(); + g_context->VSSetConstantBuffers(0, 1, &cbPtr); + g_context->PSSetShader(g_ps.Get(), nullptr, 0); + g_context->IASetInputLayout(nullptr); + g_context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + ID3D11ShaderResourceView* srvPtr = srv.Get(); + g_context->PSSetShaderResources(0, 1, &srvPtr); + ID3D11SamplerState* sampPtr = g_sampler.Get(); + g_context->PSSetSamplers(0, 1, &sampPtr); + g_context->OMSetBlendState(g_blendOpaque.Get(), bf, 0xFFFFFFFF); + g_context->RSSetState(g_rasterState.Get()); + g_context->OMSetDepthStencilState(g_depthState.Get(), 0); + g_context->Draw(3, 0); + + // ----------------------------------------------------------------------- + // ImGui overlay — compass rose on map + toolbar strip at bottom + // ----------------------------------------------------------------------- + if (g_imguiInited) { + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize = ImVec2((float)w, (float)h); + io.MousePos = ImVec2(win->imMouseX.load(), win->imMouseY.load()); + io.MouseDown[0] = win->imLButton.load(); + io.MouseDown[1] = win->imRButton.load(); + int rawWheel = win->imWheelRaw.exchange(0); + io.MouseWheel = (float)rawWheel / WHEEL_DELTA; + + ImGui_ImplDX11_NewFrame(); + ImGui::NewFrame(); + + // Shared command helper — usable from any window in this frame + auto pushCmd = [&](UICmd cmd, float idx = 0.f) { + InputEvent ev{}; ev.type = UICommand; + ev.x = static_cast(cmd); ev.y = idx; + win->inputQueue.push(ev); + }; + + const float kBarH = 22.f; + + // ── Compass overlay (floating in the bottom-right of the map area) ── + const float kCR = 36.f; // compass circle radius + const float kCWin = kCR * 2.f + 20.f; // window size (leaves room for N label) + const float kCPad = 10.f; // margin from window edge + + if (h > kBarH + kCWin + kCPad * 2.f) { // skip if window too small + ImGui::SetNextWindowPos( + ImVec2(w - kCWin - kCPad, h - kBarH - kCWin - kCPad), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(kCWin, kCWin)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); + ImGui::Begin("##compass_overlay", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoBackground); + + ImGui::SetCursorPos(ImVec2(0.f, 0.f)); + ImGui::InvisibleButton("##compassHit", ImVec2(kCWin, kCWin)); + bool cHov = ImGui::IsItemHovered(); + bool cAct = ImGui::IsItemActive(); + + // Drag to rotate: angle from compass centre → new bearing + if (cAct) { + ImVec2 wp = ImGui::GetWindowPos(); + ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f }; + float ddx = io.MousePos.x - cc.x, ddy = io.MousePos.y - cc.y; + if (ddx*ddx + ddy*ddy > 9.f) { + float ang = atan2f(ddx, -ddy) * (180.f / 3.14159265f); + if (ang < 0.f) ang += 360.f; + win->imMapRotationDeg.store(ang); + win->imMapSyncPlayer.store(false); + pushCmd(UICmd::SetRotation, ang); + } + } + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + win->imMapRotationDeg.store(0.f); + win->imMapSyncPlayer.store(false); + pushCmd(UICmd::RotateReset); + } + + // Draw compass rose + { + float rotDeg = win->imMapRotationDeg.load(); + float rotRad = rotDeg * (3.14159265f / 180.f); + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec2 wp = ImGui::GetWindowPos(); + ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f }; + + // Background disk + dl->AddCircleFilled(cc, kCR + 2.f, + cAct ? IM_COL32(40,40,40,230) + : cHov ? IM_COL32(32,32,32,230) + : IM_COL32(22,22,22,200), 48); + dl->AddCircle(cc, kCR + 2.f, IM_COL32(100,100,100,200), 48, 1.5f); + + // Tick marks: 8 directions — cardinals longer, inter-cardinals shorter + for (int i = 0; i < 8; ++i) { + float a = (i * 45.f - rotDeg) * (3.14159265f / 180.f); + float ox = sinf(a), oy = -cosf(a); + bool card = (i % 2 == 0); + float inner = kCR - (card ? 9.f : 5.f); + dl->AddLine({ cc.x + ox*inner, cc.y + oy*inner }, + { cc.x + ox*(kCR - 1.f), cc.y + oy*(kCR - 1.f) }, + IM_COL32(170,170,170, card ? 220 : 130), + card ? 1.5f : 1.f); + } + + // Needle: diamond split red (N) / white (S) + float nx = sinf(rotRad), ny = -cosf(rotRad); + float bx = -ny * 7.f, by = nx * 7.f; // perpendicular half-width + ImVec2 nTip = { cc.x + nx*(kCR-5.f), cc.y + ny*(kCR-5.f) }; + ImVec2 sTail = { cc.x - nx*(kCR-5.f)*0.55f, cc.y - ny*(kCR-5.f)*0.55f }; + ImVec2 bleft = { cc.x + bx, cc.y + by }; + ImVec2 brght = { cc.x - bx, cc.y - by }; + + dl->AddTriangleFilled(nTip, bleft, brght, IM_COL32(205,50,50,255)); // N red + dl->AddTriangleFilled(sTail, bleft, brght, IM_COL32(205,205,205,220));// S white + dl->AddTriangle(nTip, bleft, brght, IM_COL32(0,0,0,100), 0.5f); + dl->AddTriangle(sTail, bleft, brght, IM_COL32(0,0,0, 80), 0.5f); + + // Centre cap + dl->AddCircleFilled(cc, 5.5f, IM_COL32(25,25,25,255)); + dl->AddCircleFilled(cc, 3.5f, IM_COL32(210,210,210,255)); + + // "N" label at needle tip + ImVec2 nPos = { cc.x + nx*(kCR + 4.f) - 4.f, cc.y + ny*(kCR + 4.f) - 7.f }; + dl->AddText(ImGui::GetFont(), 14.f, nPos, IM_COL32(220,55,55,255), "N"); + } + + if (cHov) + ImGui::SetTooltip("Rotation: %.0f\xc2\xb0\nDrag to rotate | Right-click to reset", + win->imMapRotationDeg.load()); + + ImGui::End(); + ImGui::PopStyleVar(2); + } + + // ── Toolbar strip ──────────────────────────────────────────────────── + ImGui::SetNextWindowPos(ImVec2(0.f, (float)h - kBarH)); + ImGui::SetNextWindowSize(ImVec2((float)w, kBarH)); + ImGui::SetNextWindowBgAlpha(1.f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.f, 2.f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 2.f)); + ImGui::Begin("##toolbar", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoFocusOnAppearing); + + // Status label (left) + { + char buf[256]; + { std::lock_guard lk(win->imStatusMutex); strncpy_s(buf, win->imStatusText, _TRUNCATE); } + ImGui::TextUnformatted(buf); + } + + const float kBtnH = kBarH - 4.f; + const float kBtnW = 22.f; + const float kGap = 4.f; + + // ◎ Follow-player position toggle + ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW - kGap - kBtnW); + { + bool fOn = win->imFollowPlayer.load(); + if (fOn) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.18f, 0.52f, 0.18f, 1.f)); + if (ImGui::Button("\xe2\x97\x8e##follow", ImVec2(kBtnW, kBtnH))) + pushCmd(UICmd::ToggleFollowPlayer); + if (fOn) ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(fOn ? "Following player position\nClick to stop" + : "Follow player position"); + } + + // ⚙ Settings gear + ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW); + if (ImGui::Button("\xe2\x9a\x99##cfg", ImVec2(kBtnW, kBtnH))) + ImGui::OpenPopup("##settings"); + + if (ImGui::BeginPopup("##settings")) { + if (ImGui::MenuItem("Center on player")) + pushCmd(UICmd::Recenter); + + // Sync rotation — checkmark shows live state + if (ImGui::MenuItem("Sync rotation to player camera", nullptr, + win->imMapSyncPlayer.load())) + pushCmd(UICmd::RotateSyncPlayer); + + ImGui::Separator(); + + bool meOn = win->imMapEnhancerInstalled.load(); + if (meOn) { + if (ImGui::BeginMenu("Map Enhancer")) { + if (ImGui::MenuItem("Toggle follow mode")) + pushCmd(UICmd::FollowMode); + ImGui::Separator(); + if (ImGui::BeginMenu("Follow")) { + if (ImGui::MenuItem("Player camera")) + pushCmd(UICmd::FollowPlayerCam); + if (ImGui::MenuItem("Selected loco")) + pushCmd(UICmd::FollowSelectedLoco); + if (ImGui::MenuItem("Stop following")) + pushCmd(UICmd::FollowMode); + { + std::lock_guard lk(win->imLocoMutex); + if (!win->imLocoList.empty()) { + ImGui::Separator(); + for (int i = 0; i < (int)win->imLocoList.size(); ++i) + if (ImGui::MenuItem(win->imLocoList[i].label)) + pushCmd(UICmd::FollowLoco, (float)i); + } + } + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Jump to location")) { + std::lock_guard lk(win->imLocationMutex); + if (win->imLocationList.empty()) + ImGui::TextDisabled("(loading...)"); + else + for (int i = 0; i < (int)win->imLocationList.size(); ++i) + if (ImGui::MenuItem(win->imLocationList[i].label)) + pushCmd(UICmd::JumpToLocation, (float)i); + ImGui::EndMenu(); + } + ImGui::EndMenu(); + } + } else { + if (ImGui::BeginMenu("Jump to location")) { + std::lock_guard lk(win->imLocationMutex); + if (win->imLocationList.empty()) + ImGui::TextDisabled("(loading...)"); + else + for (int i = 0; i < (int)win->imLocationList.size(); ++i) + if (ImGui::MenuItem(win->imLocationList[i].label)) + pushCmd(UICmd::JumpToLocation, (float)i); + ImGui::EndMenu(); + } + } + + ImGui::EndPopup(); + } + + ImGui::PopStyleVar(3); + ImGui::End(); + + ImGui::Render(); + ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData()); + win->imWantMouse.store(ImGui::GetIO().WantCaptureMouse); + } + + win->swapChain->Present(0, 0); + + // Restore Unity's render state + saved.Restore(g_context); +} diff --git a/native/src/d3d11_renderer.h b/native/src/d3d11_renderer.h new file mode 100644 index 0000000..63e94bc --- /dev/null +++ b/native/src/d3d11_renderer.h @@ -0,0 +1,15 @@ +#pragma once +#include "popout_window.h" + +// Called from exports.cpp (render thread only) +void Renderer_OnDeviceInit(ID3D11Device* device); +void Renderer_OnDeviceShutdown(); + +// Creates/recreates the swapchain for a window. Safe to call from render thread. +bool Renderer_EnsureSwapchain(PopoutWindow* win); + +// Releases swapchain resources. Called from WM_DESTROY on the pump thread. +void Renderer_ReleaseSwapchain(PopoutWindow* win); + +// Blits pendingTexture into the swapchain and calls Present. Render thread only. +void Renderer_Present(PopoutWindow* win); diff --git a/native/src/dllmain.cpp b/native/src/dllmain.cpp new file mode 100644 index 0000000..6935379 --- /dev/null +++ b/native/src/dllmain.cpp @@ -0,0 +1,19 @@ +#define WIN32_LEAN_AND_MEAN +#include + +// Forward declarations from other translation units +void Plugin_Initialize(); +void Plugin_Shutdown(); + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID) { + switch (reason) { + case DLL_PROCESS_ATTACH: + DisableThreadLibraryCalls(hModule); + Plugin_Initialize(); + break; + case DLL_PROCESS_DETACH: + Plugin_Shutdown(); + break; + } + return TRUE; +} diff --git a/native/src/exports.cpp b/native/src/exports.cpp new file mode 100644 index 0000000..d62eca6 --- /dev/null +++ b/native/src/exports.cpp @@ -0,0 +1,214 @@ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include "../external/IUnityInterface.h" +#include "../external/IUnityGraphics.h" +#include "../external/IUnityGraphicsD3D11.h" +#include "../include/shared_types.h" +#include "d3d11_renderer.h" +#include "popout_windows.h" +#include + +// --------------------------------------------------------------------------- +// Unity plugin lifecycle +// --------------------------------------------------------------------------- +static IUnityInterfaces* g_unityInterfaces = nullptr; +static IUnityGraphics* g_unityGraphics = nullptr; + +static void UNITY_INTERFACE_API OnGraphicsDeviceEvent(UnityGfxDeviceEventType eventType) { + if (eventType == kUnityGfxDeviceEventInitialize) { + auto* d3d11 = g_unityInterfaces->Get(); + if (d3d11) Renderer_OnDeviceInit(d3d11->GetDevice()); + } else if (eventType == kUnityGfxDeviceEventShutdown) { + Renderer_OnDeviceShutdown(); + } +} + +extern "C" UNITY_INTERFACE_EXPORT void UNITY_INTERFACE_API +UnityPluginLoad(IUnityInterfaces* interfaces) { + g_unityInterfaces = interfaces; + g_unityGraphics = interfaces->Get(); + g_unityGraphics->RegisterDeviceEventCallback(OnGraphicsDeviceEvent); + OnGraphicsDeviceEvent(kUnityGfxDeviceEventInitialize); +} + +extern "C" UNITY_INTERFACE_EXPORT void UNITY_INTERFACE_API +UnityPluginUnload() { + if (g_unityGraphics) + g_unityGraphics->UnregisterDeviceEventCallback(OnGraphicsDeviceEvent); +} + +// --------------------------------------------------------------------------- +// Render event callback — called by Unity on the render thread +// --------------------------------------------------------------------------- +static void UNITY_INTERFACE_API OnRenderEvent(int eventId) { + PopoutWindow* win = GetPopoutWindow(eventId); + if (win) Renderer_Present(win); +} + +// --------------------------------------------------------------------------- +// Exported functions (called from C# via [DllImport]) +// --------------------------------------------------------------------------- +extern "C" __declspec(dllexport) +int RRPOPOUT_CreateWindow(const wchar_t* title, int width, int height) { + return CreatePopoutWindow(title, width, height); +} + +extern "C" __declspec(dllexport) +void RRPOPOUT_SetFrameTexture(int windowHandle, void* texturePtr, + float u0, float v0, float u1, float v1) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win) return; + win->srcU0.store(u0); win->srcV0.store(v0); + win->srcU1.store(u1); win->srcV1.store(v1); + win->pendingTexture.store(texturePtr); +} + +extern "C" __declspec(dllexport) +UnityRenderingEvent RRPOPOUT_GetRenderEventFunc() { + return OnRenderEvent; +} + +extern "C" __declspec(dllexport) +void RRPOPOUT_GetWindowSize(int windowHandle, int* outWidth, int* outHeight) { + GetPopoutWindowSize(windowHandle, outWidth, outHeight); +} + +// Poll input events — mouse-type events are suppressed while ImGui has capture +// (e.g. cursor is over the toolbar) to prevent accidental map pan/zoom. +extern "C" __declspec(dllexport) +int RRPOPOUT_PollInputEvents(int windowHandle, InputEvent* outEvents, int maxEvents) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win) return 0; + + int n = win->inputQueue.drain(outEvents, maxEvents); + + if (win->imWantMouse.load()) { + // Keep only non-mouse events (UICommand etc.); drop pointer motion. + int kept = 0; + for (int i = 0; i < n; ++i) { + if (outEvents[i].type == static_cast(UICommand)) + outEvents[kept++] = outEvents[i]; + } + return kept; + } + return n; +} + +extern "C" __declspec(dllexport) +void RRPOPOUT_DestroyWindow(int windowHandle) { + DestroyPopoutWindow(windowHandle); +} + +// Helper: parse a newline-separated UTF-16 string into the window's ImMenuItem list. +static void SetNamedList(int windowHandle, const wchar_t* names, + std::vector& list, std::mutex& mtx) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win || !names) return; + + std::vector tmp; + const wchar_t* p = names; + while (*p) { + const wchar_t* end = p; + while (*end && *end != L'\n') ++end; + PopoutWindow::ImMenuItem item{}; + WideCharToMultiByte(CP_UTF8, 0, p, (int)(end - p), + item.label, (int)sizeof(item.label) - 1, nullptr, nullptr); + tmp.push_back(item); + p = (*end == L'\n') ? end + 1 : end; + } + std::lock_guard lk(mtx); + list = std::move(tmp); +} + +// Set the locomotive list shown in the Follow submenu. +// names: UTF-16 loco display names joined by '\n'. +extern "C" __declspec(dllexport) +void RRPOPOUT_SetLocoList(int windowHandle, const wchar_t* names) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (win) SetNamedList(windowHandle, names, win->imLocoList, win->imLocoMutex); +} + +// Set the location list shown in the Jump to location submenu. +// names: UTF-16 location names joined by '\n'. +extern "C" __declspec(dllexport) +void RRPOPOUT_SetLocationList(int windowHandle, const wchar_t* names) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (win) SetNamedList(windowHandle, names, win->imLocationList, win->imLocationMutex); +} + +// Notify native side whether MapEnhancer is installed, so the ImGui menu can +// show or hide ME-specific items without polling C# each frame. +extern "C" __declspec(dllexport) +void RRPOPOUT_SetMapEnhancerInstalled(int windowHandle, bool installed) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (win) win->imMapEnhancerInstalled.store(installed); +} + +// Sync the compass display to the current map rotation (degrees, [0,360)). +// Called by C# after it applies a rotation or while in sync-to-player mode. +extern "C" __declspec(dllexport) +void RRPOPOUT_SetMapRotation(int windowHandle, float degrees) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (win) win->imMapRotationDeg.store(degrees); +} + +// Tell the sync button whether sync-to-player mode is active (highlights it). +extern "C" __declspec(dllexport) +void RRPOPOUT_SetMapSyncPlayer(int windowHandle, bool active) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (win) win->imMapSyncPlayer.store(active); +} + +// Highlight (or un-highlight) the follow-player toolbar button. +extern "C" __declspec(dllexport) +void RRPOPOUT_SetFollowPlayer(int windowHandle, bool active) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (win) win->imFollowPlayer.store(active); +} + +// Push current map zoom level so native can persist it on close. +extern "C" __declspec(dllexport) +void RRPOPOUT_SetMapZoom(int windowHandle, float zoom) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (win) win->imMapZoom.store(zoom); +} + +// Read back the C#-side persisted state that native loaded from the state file. +// Call this once after RRPOPOUT_CreateWindow to restore the previous session. +extern "C" __declspec(dllexport) +void RRPOPOUT_GetPersistedState(int windowHandle, + int32_t* followPlayer, int32_t* syncRotation, + float* mapRotation, float* mapZoom) +{ + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win) { + *followPlayer = 0; *syncRotation = 0; + *mapRotation = 0.f; *mapZoom = 500.f; + return; + } + *followPlayer = win->imFollowPlayer.load() ? 1 : 0; + *syncRotation = win->imMapSyncPlayer.load() ? 1 : 0; + *mapRotation = win->imMapRotationDeg.load(); + *mapZoom = win->imMapZoom.load(); +} + +// Update the ImGui toolbar status label. +// text is UTF-16; converted to UTF-8 for ImGui here on the calling thread. +extern "C" __declspec(dllexport) +void RRPOPOUT_SetStatusText(int windowHandle, const wchar_t* text) { + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win || !text) return; + char buf[256] = {}; + WideCharToMultiByte(CP_UTF8, 0, text, -1, buf, (int)sizeof(buf) - 1, nullptr, nullptr); + std::lock_guard lk(win->imStatusMutex); + strncpy_s(win->imStatusText, buf, _TRUNCATE); +} + +// --------------------------------------------------------------------------- +// Plugin_Initialize / Plugin_Shutdown (called from dllmain.cpp) +// --------------------------------------------------------------------------- +void Plugin_Initialize() { } +void Plugin_Shutdown() { Renderer_OnDeviceShutdown(); } diff --git a/native/src/input_queue.h b/native/src/input_queue.h new file mode 100644 index 0000000..2d8305e --- /dev/null +++ b/native/src/input_queue.h @@ -0,0 +1,32 @@ +#pragma once +#include +#include +#include "../include/shared_types.h" + +// Thread-safe queue of InputEvents. Written by the Win32 message pump thread, +// read by the Unity main thread via RRPOPOUT_PollInputEvents. +class InputQueue { +public: + void push(const InputEvent& e) { + std::lock_guard lock(m_mutex); + if (m_queue.size() < kMaxBacklog) { + m_queue.push(e); + } + } + + // Drains up to maxEvents into outEvents. Returns number written. + int drain(InputEvent* outEvents, int maxEvents) { + std::lock_guard lock(m_mutex); + int n = 0; + while (n < maxEvents && !m_queue.empty()) { + outEvents[n++] = m_queue.front(); + m_queue.pop(); + } + return n; + } + +private: + static constexpr size_t kMaxBacklog = 256; + std::queue m_queue; + std::mutex m_mutex; +}; diff --git a/native/src/popout_window.h b/native/src/popout_window.h new file mode 100644 index 0000000..1149553 --- /dev/null +++ b/native/src/popout_window.h @@ -0,0 +1,89 @@ +#pragma once +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include "input_queue.h" + +struct PopoutWindow { + HWND hwnd = nullptr; + HWND contentHwnd = nullptr; // D3D11 swapchain target + IDXGISwapChain* swapChain = nullptr; + ID3D11RenderTargetView* rtv = nullptr; + + // Message-pump thread that owns hwnd. Kept open for the window's lifetime so + // DestroyPopoutWindow can join it before freeing this struct (the pump thread + // touches `this` while processing WM_DESTROY). + HANDLE pumpThread = nullptr; + + // Swapchain dimensions — content area only (no status bar gap any more; + // the ImGui toolbar is an overlay rendered into the same swapchain). + std::atomic width {800}; + std::atomic height {600}; + + // Texture + UV rect written by RRPOPOUT_SetFrameTexture on the main thread, + // read by Renderer_Present on the render thread. + std::atomic pendingTexture {nullptr}; + std::atomic srcU0 {0.f}, srcV0 {0.f}; + std::atomic srcU1 {1.f}, srcV1 {1.f}; + + std::atomic resizing {false}; + std::atomic destroyRequested {false}; + std::atomic alive {true}; + + InputQueue inputQueue; + + // ----------------------------------------------------------------------- + // ImGui input state + // Written by the Win32 message pump thread; consumed by the render thread. + // ----------------------------------------------------------------------- + std::atomic imMouseX {-1.f}; + std::atomic imMouseY {-1.f}; + std::atomic imLButton {false}; + std::atomic imRButton {false}; + // Wheel accumulator in raw WHEEL_DELTA units (120 per notch). + // fetch_add on message pump; exchange(0) on render thread. + std::atomic imWheelRaw {0}; + // Written by render thread after each ImGui frame. + // When true, PollInputEvents suppresses mouse events so they don't reach Unity. + std::atomic imWantMouse {false}; + + // ----------------------------------------------------------------------- + // Status bar label (UTF-8), shown in the ImGui toolbar. + // Written by RRPOPOUT_SetStatusText (main thread), read by render thread. + // ----------------------------------------------------------------------- + char imStatusText[256]; + std::mutex imStatusMutex; + + // ----------------------------------------------------------------------- + // Dynamic menu lists (UTF-8 labels), set by RRPOPOUT_SetLocoList / + // RRPOPOUT_SetLocationList from the C# side every few seconds. + // Read by the render thread inside the ImGui settings menu. + // ----------------------------------------------------------------------- + struct ImMenuItem { char label[128]; }; + std::vector imLocoList; + std::mutex imLocoMutex; + std::vector imLocationList; + std::mutex imLocationMutex; + + // ----------------------------------------------------------------------- + // Map rotation + ME flag (C# → render thread via exports) + // ----------------------------------------------------------------------- + std::atomic imMapRotationDeg {0.f}; + std::atomic imMapZoom {500.f}; + std::atomic imMapSyncPlayer {false}; + std::atomic imMapEnhancerInstalled{false}; + std::atomic imFollowPlayer {false}; + std::atomic imAlwaysOnTop {false}; + + // Loaded geometry from the save file — consumed by MessagePumpThread before CreateWindowExW. + std::atomic lastWinX {-1}, lastWinY {-1}; + std::atomic lastWinW {900}, lastWinH {700}; + + PopoutWindow() { + strncpy_s(imStatusText, sizeof(imStatusText), + "Railroader \xe2\x80\x94 Map", _TRUNCATE); // UTF-8 em-dash + } +}; diff --git a/native/src/popout_windows.cpp b/native/src/popout_windows.cpp new file mode 100644 index 0000000..64b4351 --- /dev/null +++ b/native/src/popout_windows.cpp @@ -0,0 +1,490 @@ +#define WIN32_LEAN_AND_MEAN +#include +#include // DwmSetWindowAttribute +#include // ExtractIconExW +#include // GET_X_LPARAM, GET_Y_LPARAM +#include "../include/shared_types.h" +#include +#include +#include +#include // FILE*, fopen_s, fprintf, fgets +#include // strcmp, strchr +#include // std::wstring +#include "popout_window.h" +#include "popout_windows.h" // WM_RRPOPOUT_SET_TOPMOST constant +#include "d3d11_renderer.h" + +// --------------------------------------------------------------------------- +// Persistent state — saved to /RRPopout/window_state.ini on close, +// loaded and applied in MessagePumpThread before ShowWindow. +// --------------------------------------------------------------------------- +static std::wstring GetStatePath() { + wchar_t buf[MAX_PATH] = {}; + GetModuleFileNameW(nullptr, buf, MAX_PATH); // Railroader.exe full path + wchar_t* sep = wcsrchr(buf, L'\\'); + if (sep) sep[1] = L'\0'; // trim to directory (keep slash) + return std::wstring(buf) + L"RRPopout\\config.ini"; +} + +static void SaveWindowState(HWND hwnd, PopoutWindow* win) { + if (!win) return; + FILE* f = nullptr; + if (_wfopen_s(&f, GetStatePath().c_str(), L"w") != 0 || !f) return; + RECT r = {}; + GetWindowRect(hwnd, &r); + fprintf(f, + "windowX=%d\nwindowY=%d\nwindowWidth=%d\nwindowHeight=%d\n" + "alwaysOnTop=%d\nfollowPlayer=%d\nsyncRotation=%d\n" + "mapRotation=%.2f\nmapZoom=%.1f\n", + r.left, r.top, r.right - r.left, r.bottom - r.top, + (int)win->imAlwaysOnTop.load(), + (int)win->imFollowPlayer.load(), + (int)win->imMapSyncPlayer.load(), + win->imMapRotationDeg.load(), + win->imMapZoom.load()); + fclose(f); +} + +// Fills in saved values; untouched fields keep their PopoutWindow defaults. +static void LoadWindowState(PopoutWindow* win) { + if (!win) return; + FILE* f = nullptr; + if (_wfopen_s(&f, GetStatePath().c_str(), L"r") != 0 || !f) return; + char line[128]; + while (fgets(line, sizeof(line), f)) { + char* eq = strchr(line, '='); + if (!eq) continue; + *eq = '\0'; + float val = (float)atof(eq + 1); + if (!strcmp(line, "windowX")) win->lastWinX.store((int)val); + else if (!strcmp(line, "windowY")) win->lastWinY.store((int)val); + else if (!strcmp(line, "windowWidth")) win->lastWinW.store((int)val); + else if (!strcmp(line, "windowHeight")) win->lastWinH.store((int)val); + else if (!strcmp(line, "alwaysOnTop")) win->imAlwaysOnTop.store(val != 0.f); + else if (!strcmp(line, "followPlayer")) win->imFollowPlayer.store(val != 0.f); + else if (!strcmp(line, "syncRotation")) win->imMapSyncPlayer.store(val != 0.f); + else if (!strcmp(line, "mapRotation")) win->imMapRotationDeg.store(val); + else if (!strcmp(line, "mapZoom")) win->imMapZoom.store(val); + } + fclose(f); +} + +// --------------------------------------------------------------------------- +// Global window registry +// --------------------------------------------------------------------------- +static std::unordered_map g_windows; +static std::mutex g_windowsMutex; +static std::atomic g_nextHandle {1}; + +static const wchar_t* kWindowClass = L"RRPopout_FloatWindow"; +static const wchar_t* kContentClass = L"RRPopout_Content"; +static bool g_classRegistered = false; +static bool g_contentClassRegistered = false; + +// --------------------------------------------------------------------------- +// Content child window — D3D11 renders here. +// Forwards all mouse/wheel messages to the parent so the WndProc can update +// ImGui input state and the Unity input queue. +// --------------------------------------------------------------------------- +static LRESULT CALLBACK ContentWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + case WM_LBUTTONDOWN: case WM_LBUTTONUP: + case WM_RBUTTONDOWN: case WM_RBUTTONUP: + case WM_MOUSEMOVE: case WM_MOUSEWHEEL: + if (HWND parent = GetParent(hwnd)) + return SendMessageW(parent, msg, wParam, lParam); + break; + } + return DefWindowProcW(hwnd, msg, wParam, lParam); +} + +static void EnsureContentClassRegistered() { + if (g_contentClassRegistered) return; + WNDCLASSEXW wc = {}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = ContentWndProc; + wc.hInstance = GetModuleHandleW(nullptr); + wc.hbrBackground = nullptr; // D3D11 fills it, no GDI paint + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); // reset cursor when leaving resize border + wc.lpszClassName = kContentClass; + RegisterClassExW(&wc); + g_contentClassRegistered = true; +} + +// --------------------------------------------------------------------------- +// Forward declarations +// --------------------------------------------------------------------------- +static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); +static DWORD WINAPI MessagePumpThread(LPVOID param); +PopoutWindow* GetPopoutWindow(int handle); + +// --------------------------------------------------------------------------- +// Window class registration +// --------------------------------------------------------------------------- +static bool EnsureWindowClassRegistered() { + if (g_classRegistered) return true; + WNDCLASSEXW wc = {}; + wc.cbSize = sizeof(wc); + wc.style = CS_HREDRAW | CS_VREDRAW; + wc.lpfnWndProc = WndProc; + wc.hInstance = GetModuleHandleW(nullptr); + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + // Extract the first icon from Railroader.exe by file index (more reliable than + // MAKEINTRESOURCEW(1) — Unity games don't always place their icon at resource ID 1). + { + wchar_t exePath[MAX_PATH] = {}; + GetModuleFileNameW(nullptr, exePath, MAX_PATH); // path to the host EXE + HICON iconLg = nullptr, iconSm = nullptr; + if (ExtractIconExW(exePath, 0, &iconLg, &iconSm, 1) > 0 && iconLg) { + wc.hIcon = iconLg; + wc.hIconSm = iconSm ? iconSm : iconLg; + } + } + wc.lpszClassName = kWindowClass; + if (!RegisterClassExW(&wc)) { + if (GetLastError() != ERROR_CLASS_ALREADY_EXISTS) return false; + } + g_classRegistered = true; + return true; +} + +// --------------------------------------------------------------------------- +// Creation params passed to the pump thread +// --------------------------------------------------------------------------- +struct CreateParams { + const wchar_t* title; + int width, height, handle; + HANDLE doneEvent; + bool success; +}; + +// --------------------------------------------------------------------------- +// Message pump thread — owns the HWND for its lifetime +// --------------------------------------------------------------------------- +static DWORD WINAPI MessagePumpThread(LPVOID param) { + CreateParams* cp = reinterpret_cast(param); + int handle = cp->handle; + const wchar_t* title = cp->title; + + // Load saved geometry — apply before CreateWindowExW so the window appears + // in the right place without any visible jump. + PopoutWindow* w0 = GetPopoutWindow(handle); + if (w0) { + w0->lastWinW.store(cp->width); // seed with defaults in case no file exists + w0->lastWinH.store(cp->height); + LoadWindowState(w0); + + // Validate loaded position against the virtual screen (all monitors combined). + // If the title bar would be unreachable, fall back to CW_USEDEFAULT so the + // window re-centres instead of appearing on a missing monitor. + if (w0->lastWinX.load() >= 0) { + int vsX = GetSystemMetrics(SM_XVIRTUALSCREEN); + int vsY = GetSystemMetrics(SM_YVIRTUALSCREEN); + int vsW = GetSystemMetrics(SM_CXVIRTUALSCREEN); + int vsH = GetSystemMetrics(SM_CYVIRTUALSCREEN); + int wx = w0->lastWinX.load(); + int wy = w0->lastWinY.load(); + // Title bar must have at least 200px visible and not be above the top edge + if (wx > vsX + vsW - 200 || wx < vsX - w0->lastWinW.load() + 200 || + wy < vsY || wy > vsY + vsH - 50) { + w0->lastWinX.store(-1); + w0->lastWinY.store(-1); + } + } + } + int startX = (w0 && w0->lastWinX.load() >= 0) ? w0->lastWinX.load() : CW_USEDEFAULT; + int startY = (w0 && w0->lastWinY.load() >= 0) ? w0->lastWinY.load() : CW_USEDEFAULT; + int startW = (w0 && w0->lastWinW.load() > 0) ? w0->lastWinW.load() : cp->width; + int startH = (w0 && w0->lastWinH.load() > 0) ? w0->lastWinH.load() : cp->height; + + HWND hwnd = CreateWindowExW( + WS_EX_APPWINDOW, kWindowClass, title, WS_OVERLAPPEDWINDOW, + startX, startY, startW, startH, + nullptr, nullptr, GetModuleHandleW(nullptr), + reinterpret_cast(static_cast(handle)) + ); + if (!hwnd) { cp->success = false; SetEvent(cp->doneEvent); return 1; } + + cp->success = true; + SetEvent(cp->doneEvent); // unblock caller — HWND is valid from here + + // All remaining setup runs while the window is HIDDEN so the message pump + // starts immediately after ShowWindow with no unresponsive-window spinner. + + // Dark title bar + rounded corners (Windows 10 20H1+ / Windows 11) + BOOL dark = TRUE; + DwmSetWindowAttribute(hwnd, 20 /* DWMWA_USE_IMMERSIVE_DARK_MODE */, &dark, sizeof(dark)); + int corners = 2; /* DWMWCP_ROUND */ + DwmSetWindowAttribute(hwnd, 33 /* DWMWA_WINDOW_CORNER_PREFERENCE */, &corners, sizeof(corners)); + + // Dark context/popup menus — one-time process-wide opt-in. + // RefreshImmersiveColorPolicyState (104) and FlushMenuThemes (136) are omitted: + // they broadcast SendMessage to all windows in the process and block this thread + // until busy Unity windows process it, causing 10-15 s stalls at map open time. + static std::once_flag s_uxInit; + std::call_once(s_uxInit, [] { + if (HMODULE ux = GetModuleHandleW(L"uxtheme.dll")) + if (auto fn = reinterpret_cast(GetProcAddress(ux, MAKEINTRESOURCEA(135)))) + fn(1 /* AllowDark */); + }); + if (HMODULE ux = GetModuleHandleW(L"uxtheme.dll")) + if (auto fn = reinterpret_cast(GetProcAddress(ux, MAKEINTRESOURCEA(133)))) + fn(hwnd, true); + + // "Always on Top" entry in the title-bar system menu + if (HMENU sys = GetSystemMenu(hwnd, FALSE)) { + AppendMenuW(sys, MF_SEPARATOR, 0, nullptr); + AppendMenuW(sys, MF_STRING, 9001, L"Always on Top"); + // Restore saved topmost state + if (w0 && w0->imAlwaysOnTop.load()) { + SetWindowPos(hwnd, HWND_TOPMOST, 0,0,0,0, SWP_NOMOVE|SWP_NOSIZE); + CheckMenuItem(sys, 9001, MF_CHECKED); + } + } + + // Content child window — D3D11 renders here, ImGui overlays on top. + // Fills the entire client area; the toolbar is an ImGui overlay. + { + PopoutWindow* w = GetPopoutWindow(handle); + if (w) { + EnsureContentClassRegistered(); + RECT cr{}; GetClientRect(hwnd, &cr); + int cw = cr.right, ch = cr.bottom; + w->contentHwnd = CreateWindowExW(0, kContentClass, nullptr, + WS_CHILD | WS_VISIBLE, 0, 0, cw, ch, + hwnd, nullptr, GetModuleHandleW(nullptr), nullptr); + w->width.store(cw); + w->height.store(ch); + } + } + + ShowWindow(hwnd, SW_SHOWDEFAULT); + UpdateWindow(hwnd); + + MSG msg; + while (GetMessage(&msg, nullptr, 0, 0)) { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + + { + std::lock_guard lock(g_windowsMutex); + auto it = g_windows.find(handle); + if (it != g_windows.end()) it->second->alive.store(false); + } + return 0; +} + +// --------------------------------------------------------------------------- +// WndProc +// --------------------------------------------------------------------------- +static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + PopoutWindow* win = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + + switch (msg) { + + case WM_CREATE: { + auto* cs = reinterpret_cast(lParam); + int handle = static_cast(reinterpret_cast(cs->lpCreateParams)); + std::lock_guard lock(g_windowsMutex); + auto it = g_windows.find(handle); + if (it != g_windows.end()) { + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(it->second)); + win = it->second; + win->hwnd = hwnd; + } + return 0; + } + + case WM_SIZE: { + int cw = static_cast(LOWORD(lParam)); + int ch = static_cast(HIWORD(lParam)); + if (win && win->contentHwnd) { + SetWindowPos(win->contentHwnd, nullptr, 0, 0, cw, ch, + SWP_NOZORDER | SWP_NOACTIVATE); + win->width.store(cw); + win->height.store(ch); + } + return 0; + } + + case WM_MOUSEMOVE: { + if (!win) break; + float px = static_cast(GET_X_LPARAM(lParam)); + float py = static_cast(GET_Y_LPARAM(lParam)); + win->imMouseX.store(px); + win->imMouseY.store(py); + RECT r; GetClientRect(hwnd, &r); + int cw = r.right, ch = r.bottom; + if (cw > 0 && ch > 0) { + InputEvent e{}; e.type = MouseMove; e.x = px / cw; e.y = py / ch; + win->inputQueue.push(e); + } + return 0; + } + + case WM_LBUTTONDOWN: case WM_LBUTTONUP: + case WM_RBUTTONDOWN: case WM_RBUTTONUP: { + if (!win) break; + bool down = (msg == WM_LBUTTONDOWN || msg == WM_RBUTTONDOWN); + if (msg == WM_LBUTTONDOWN) win->imLButton.store(true); + else if (msg == WM_LBUTTONUP) win->imLButton.store(false); + else if (msg == WM_RBUTTONDOWN) win->imRButton.store(true); + else win->imRButton.store(false); + + RECT r; GetClientRect(hwnd, &r); + int cw = r.right, ch = r.bottom; + if (cw > 0 && ch > 0) { + InputEvent e{}; + e.type = (msg == WM_LBUTTONDOWN) ? LButtonDown + : (msg == WM_LBUTTONUP) ? LButtonUp + : (msg == WM_RBUTTONDOWN) ? RButtonDown : RButtonUp; + e.x = static_cast(GET_X_LPARAM(lParam)) / cw; + e.y = static_cast(GET_Y_LPARAM(lParam)) / ch; + if (down) SetCapture(hwnd); else ReleaseCapture(); + win->inputQueue.push(e); + } + return 0; + } + + case WM_MOUSEWHEEL: { + if (!win) break; + int rawDelta = GET_WHEEL_DELTA_WPARAM(wParam); + win->imWheelRaw.fetch_add(rawDelta); + InputEvent e{}; + e.type = MouseWheel; + e.delta = static_cast(rawDelta) / WHEEL_DELTA; + POINT pt { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; + ScreenToClient(hwnd, &pt); + RECT r; GetClientRect(hwnd, &r); + int cw = r.right, ch = r.bottom; + if (cw > 0 && ch > 0) { e.x = static_cast(pt.x) / cw; e.y = static_cast(pt.y) / ch; } + win->inputQueue.push(e); + return 0; + } + + case WM_ENTERSIZEMOVE: + if (win) win->resizing.store(true); + return 0; + + case WM_EXITSIZEMOVE: + if (win) win->resizing.store(false); + return 0; + + case WM_SYSCOMMAND: + if (wParam == 9001) { // "Always on Top" toggle + BOOL isTop = (GetWindowLongW(hwnd, GWL_EXSTYLE) & WS_EX_TOPMOST) != 0; + SetWindowPos(hwnd, isTop ? HWND_NOTOPMOST : HWND_TOPMOST, + 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); + if (HMENU sys = GetSystemMenu(hwnd, FALSE)) + CheckMenuItem(sys, 9001, isTop ? MF_UNCHECKED : MF_CHECKED); + if (win) win->imAlwaysOnTop.store(!isTop); + return 0; + } + break; + + // Posted by RRPOPOUT_SetAlwaysOnTop — runs on the pump thread to avoid + // cross-thread SetWindowPos issues. + case WM_RRPOPOUT_SET_TOPMOST: { + bool on = (wParam != 0); + SetWindowPos(hwnd, on ? HWND_TOPMOST : HWND_NOTOPMOST, + 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); + if (HMENU sys = GetSystemMenu(hwnd, FALSE)) + CheckMenuItem(sys, 9001, on ? MF_CHECKED : MF_UNCHECKED); + if (win) win->imAlwaysOnTop.store(on); + return 0; + } + + case WM_CLOSE: + if (win) { + SaveWindowState(hwnd, win); // persist before HWND is destroyed + win->alive.store(false); + } + DestroyWindow(hwnd); + return 0; + + case WM_DESTROY: + Renderer_ReleaseSwapchain(win); + if (win) win->hwnd = nullptr; + PostQuitMessage(0); + return 0; + } + + return DefWindowProcW(hwnd, msg, wParam, lParam); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- +int CreatePopoutWindow(const wchar_t* title, int width, int height) { + if (!EnsureWindowClassRegistered()) return 0; + + int handle = g_nextHandle.fetch_add(1); + auto* win = new PopoutWindow(); + win->width.store(width); + win->height.store(height); + { std::lock_guard lock(g_windowsMutex); g_windows[handle] = win; } + + HANDLE doneEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + CreateParams cp { title, width, height, handle, doneEvent, false }; + HANDLE thread = CreateThread(nullptr, 0, MessagePumpThread, &cp, 0, nullptr); + if (!thread) { + CloseHandle(doneEvent); + std::lock_guard lock(g_windowsMutex); + g_windows.erase(handle); delete win; return 0; + } + WaitForSingleObject(doneEvent, 5000); + CloseHandle(doneEvent); + + if (!cp.success) { + WaitForSingleObject(thread, 2000); // let the pump thread unwind first + CloseHandle(thread); + std::lock_guard lock(g_windowsMutex); + g_windows.erase(handle); delete win; return 0; + } + // Keep the thread handle so DestroyPopoutWindow can join before freeing `win`. + win->pumpThread = thread; + return handle; +} + +void DestroyPopoutWindow(int handle) { + PopoutWindow* win = nullptr; + { + std::lock_guard lock(g_windowsMutex); + auto it = g_windows.find(handle); + if (it == g_windows.end()) return; + win = it->second; g_windows.erase(it); + } + if (!win) return; + + // Ask the pump thread to close the window (no-op if the user already closed it). + if (win->hwnd) PostMessage(win->hwnd, WM_CLOSE, 0, 0); + + // Join the pump thread before freeing: it dereferences `win` while handling + // WM_DESTROY (Renderer_ReleaseSwapchain, etc.). Without this join the delete + // below races that teardown and can use-after-free. + if (win->pumpThread) { + WaitForSingleObject(win->pumpThread, 2000); + CloseHandle(win->pumpThread); + win->pumpThread = nullptr; + } + delete win; +} + +PopoutWindow* GetPopoutWindow(int handle) { + std::lock_guard lock(g_windowsMutex); + auto it = g_windows.find(handle); + return (it != g_windows.end()) ? it->second : nullptr; +} + +void GetPopoutWindowSize(int handle, int* w, int* h) { + PopoutWindow* win = GetPopoutWindow(handle); + if (win && win->alive.load()) { *w = win->width.load(); *h = win->height.load(); } + else { *w = 0; *h = 0; } +} + +int PollPopoutInputEvents(int handle, InputEvent* outEvents, int maxEvents) { + PopoutWindow* win = GetPopoutWindow(handle); + if (!win) return 0; + return win->inputQueue.drain(outEvents, maxEvents); +} diff --git a/native/src/popout_windows.h b/native/src/popout_windows.h new file mode 100644 index 0000000..dae789d --- /dev/null +++ b/native/src/popout_windows.h @@ -0,0 +1,11 @@ +#pragma once +#include "popout_window.h" + +// Custom WM_APP message for thread-safe topmost toggling (pump thread handles it). +static constexpr UINT WM_RRPOPOUT_SET_TOPMOST = WM_APP + 1; + +int CreatePopoutWindow(const wchar_t* title, int width, int height); +void DestroyPopoutWindow(int handle); +PopoutWindow* GetPopoutWindow(int handle); +void GetPopoutWindowSize(int handle, int* w, int* h); +int PollPopoutInputEvents(int handle, InputEvent* outEvents, int maxEvents); diff --git a/src/Core/IModule.cs b/src/Core/IModule.cs new file mode 100644 index 0000000..6769d11 --- /dev/null +++ b/src/Core/IModule.cs @@ -0,0 +1,38 @@ +namespace S3.Core; + +/// +/// One optional feature of S³. Modules are registered in , +/// disabled by default, and toggled from the in-game settings panel. +/// +/// Toggle semantics (current): a module's enabled state is read once at load. +/// is called only for modules enabled at startup; flipping +/// the toggle persists the choice and applies on the next game launch. The +/// hook exists so live toggling can be wired in later +/// without changing the module contract. +/// +public interface IModule +{ + /// Stable key used for settings and the Harmony patch owner id. + string Id { get; } + + string DisplayName { get; } + string Description { get; } + + /// + /// Backed by the module's own settings block so the value persists. The + /// registry reads this to decide whether to call . + /// + bool Enabled { get; set; } + + /// Apply patches, spawn GameObjects, load native deps, etc. + void OnEnable(); + + /// Reverse of . Reserved for live toggling. + void OnDisable(); + + /// IMGUI body shown inside this module's foldout in the settings panel. + void DrawSettings(); + + /// Persist this module's own settings file. Called on toggle and on window close. + void SaveSettings(); +} diff --git a/src/Core/Log.cs b/src/Core/Log.cs new file mode 100644 index 0000000..310e23f --- /dev/null +++ b/src/Core/Log.cs @@ -0,0 +1,18 @@ +using UnityModManagerNet; + +namespace S3.Core; + +/// +/// Thin static wrapper over the UMM mod logger so every module can log without +/// holding a ModEntry reference. Initialized once from . +/// +public static class Log +{ + private static UnityModManager.ModEntry.ModLogger? _logger; + + public static void Init(UnityModManager.ModEntry modEntry) => _logger = modEntry.Logger; + + public static void Info(string msg) => _logger?.Log(msg); + public static void Warn(string msg) => _logger?.Warning(msg); + public static void Error(string msg) => _logger?.Error(msg); +} diff --git a/src/Core/ModConflicts.cs b/src/Core/ModConflicts.cs new file mode 100644 index 0000000..0b70b0f --- /dev/null +++ b/src/Core/ModConflicts.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityModManagerNet; + +namespace S3.Core; + +/// +/// Detects the standalone mods that S³ replaces and helps the user disable them. +/// +/// Running a standalone mod alongside its S³ module equivalent means the same +/// game methods get Harmony-patched twice (and, for the popout, two native +/// windows fight over the map texture). That produces errors, so we surface a +/// warning and a one-click disable — but never disable silently. +/// +/// Disable mechanics: neither legacy mod is Toggleable (no OnToggle), so +/// it can't be torn down live. Setting ModEntry.Enabled = false + +/// SaveSettingsAndParams() persists the choice; it takes effect on the +/// next launch — identical to unchecking it in the UMM panel by hand. +/// +public static class ModConflicts +{ + /// Legacy mod id → friendly name. These are the mods S³ supersedes. + private static readonly Dictionary Legacy = new() + { + { "RailroaderPhysicsOverhaul", "Physics Optimizer (standalone)" }, + { "RRPopout", "PopOut Windows (standalone)" }, + }; + + public static void CheckAtLoad() + { + foreach (UnityModManager.ModEntry m in EnabledConflicts()) + Log.Warn($"Conflicting standalone mod '{m.Info.Id}' is enabled — S³ replaces it. " + + "Disable it (S³ settings page or UMM) to avoid double-patching."); + } + + public static bool HasConflicts() => EnabledConflicts().Count > 0; + + private static List EnabledConflicts() + { + var found = new List(); + foreach (string id in Legacy.Keys) + { + UnityModManager.ModEntry? m = UnityModManager.FindMod(id); + if (m != null && m.Enabled) + found.Add(m); + } + return found; + } + + /// Draws the warning banner + disable button at the top of the settings page. + public static void DrawBanner() + { + List conflicts = EnabledConflicts(); + if (conflicts.Count == 0) + return; + + Color prev = GUI.color; + GUI.color = new Color(1f, 0.82f, 0.35f); + GUILayout.BeginVertical("box"); + GUI.color = prev; + + GUILayout.Label("⚠ Conflicting standalone mods detected"); + foreach (UnityModManager.ModEntry m in conflicts) + GUILayout.Label($" • {m.Info.DisplayName} ({m.Info.Id})"); + GUILayout.Label("S³ replaces these. Running both double-patches the game and causes errors."); + + if (GUILayout.Button("Disable standalone mods (applies after restart)")) + { + foreach (UnityModManager.ModEntry m in conflicts) + { + m.Enabled = false; + if (m.Toggleable) + m.Active = false; + Log.Info($"Disabled standalone mod '{m.Info.Id}'."); + } + UnityModManager.SaveSettingsAndParams(); + Log.Info("Standalone mods disabled — restart the game to apply."); + } + + GUILayout.EndVertical(); + GUILayout.Space(6); + } +} diff --git a/src/Core/ModuleRegistry.cs b/src/Core/ModuleRegistry.cs new file mode 100644 index 0000000..9133aca --- /dev/null +++ b/src/Core/ModuleRegistry.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; + +namespace S3.Core; + +/// +/// Holds every registered module and enables the ones flagged on at load. +/// One failing module must never take down the rest, so each enable is guarded. +/// +public sealed class ModuleRegistry +{ + private readonly List _modules = new(); + + public IReadOnlyList Modules => _modules; + + public void Register(IModule module) => _modules.Add(module); + + /// Call on every module currently enabled. + public void EnableConfigured() + { + foreach (IModule m in _modules) + { + if (!m.Enabled) + { + Log.Info($"[{m.Id}] disabled — skipping."); + continue; + } + + try + { + m.OnEnable(); + Log.Info($"[{m.Id}] enabled."); + } + catch (Exception e) + { + Log.Error($"[{m.Id}] failed to enable: {e}"); + } + } + } + + /// Persist every module's settings (called from UMM's OnSaveGUI). + public void SaveAll() + { + foreach (IModule m in _modules) + { + try { m.SaveSettings(); } + catch (Exception e) { Log.Error($"[{m.Id}] failed to save settings: {e}"); } + } + } +} diff --git a/src/Core/SettingsPanel.cs b/src/Core/SettingsPanel.cs new file mode 100644 index 0000000..50ce5b2 --- /dev/null +++ b/src/Core/SettingsPanel.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace S3.Core; + +/// +/// Draws the S³ settings page inside UMM: a conflict banner, then one +/// collapsible foldout per module, each with an enable toggle and (when +/// expanded) the module's own settings body. +/// +/// Because toggling applies on next launch, the panel snapshots each module's +/// loaded-enabled state and shows a "restart to apply" hint when the user has +/// changed it this session. +/// +public static class SettingsPanel +{ + private static readonly HashSet _expanded = new(); + private static readonly Dictionary _loadedState = new(); + private static bool _snapshotted; + + public static void Draw(ModuleRegistry registry) + { + if (!_snapshotted) + { + foreach (IModule m in registry.Modules) + _loadedState[m.Id] = m.Enabled; + _snapshotted = true; + } + + ModConflicts.DrawBanner(); + + if (registry.Modules.Count == 0) + { + GUILayout.Label("No modules registered yet."); + return; + } + + foreach (IModule m in registry.Modules) + DrawModule(m); + } + + private static void DrawModule(IModule m) + { + GUILayout.BeginVertical("box"); + + GUILayout.BeginHorizontal(); + bool expanded = _expanded.Contains(m.Id); + if (GUILayout.Button(expanded ? "▼" : "▶", GUILayout.Width(28))) + { + if (expanded) _expanded.Remove(m.Id); + else _expanded.Add(m.Id); + } + + bool enabled = GUILayout.Toggle(m.Enabled, " " + m.DisplayName); + if (enabled != m.Enabled) + { + m.Enabled = enabled; + m.SaveSettings(); + } + + GUILayout.FlexibleSpace(); + if (_loadedState.TryGetValue(m.Id, out bool wasEnabled) && wasEnabled != m.Enabled) + GUILayout.Label("(restart to apply)"); + GUILayout.EndHorizontal(); + + if (_expanded.Contains(m.Id)) + { + GUILayout.Space(4); + GUILayout.Label(m.Description); + GUILayout.Space(6); + m.DrawSettings(); + } + + GUILayout.EndVertical(); + GUILayout.Space(4); + } +} diff --git a/src/Core/SettingsStore.cs b/src/Core/SettingsStore.cs new file mode 100644 index 0000000..13b3bc4 --- /dev/null +++ b/src/Core/SettingsStore.cs @@ -0,0 +1,52 @@ +using System; +using System.IO; +using UnityEngine; + +namespace S3.Core; + +/// +/// Loads/saves a module's settings object to its own JSON file in the mod folder. +/// +/// IMPORTANT: each settings type must be a FLAT [Serializable] class (only +/// primitives, strings, enums, and arrays of those) serialized as the file's +/// root object. Railroader's Mono runtime silently drops nested custom-class +/// fields from JsonUtility output (the same way it breaks UMM's XML serializer), +/// so do not nest [Serializable] classes inside a settings object. +/// +public static class SettingsStore +{ + private static string _dir = ""; + + public static void Init(string modPath) => _dir = modPath; + + public static T Load(string fileName) where T : class, new() + { + try + { + string path = Path.Combine(_dir, fileName); + if (File.Exists(path)) + { + T? obj = JsonUtility.FromJson(File.ReadAllText(path)); + if (obj != null) + return obj; + } + } + catch (Exception e) + { + Log.Error($"Failed to read {fileName}, using defaults: {e}"); + } + return new T(); + } + + public static void Save(string fileName, T obj) + { + try + { + File.WriteAllText(Path.Combine(_dir, fileName), JsonUtility.ToJson(obj, prettyPrint: true)); + } + catch (Exception e) + { + Log.Error($"Failed to save {fileName}: {e}"); + } + } +} diff --git a/src/Main.cs b/src/Main.cs new file mode 100644 index 0000000..63a415f --- /dev/null +++ b/src/Main.cs @@ -0,0 +1,42 @@ +using S3.Core; +using UnityModManagerNet; + +namespace S3; + +/// +/// UMM entry point for Seton's Special Sauce. +/// +/// S³ is an umbrella "everything mod": a thin core that hosts independent, +/// optional modules (each disabled by default). The core initializes settings +/// storage, registers the modules, enables the ones turned on, and hands the +/// settings UI to . Each module owns its own flat +/// settings file (see ). +/// +public static class Main +{ + internal static UnityModManager.ModEntry ModEntry { get; private set; } = null!; + + private static ModuleRegistry _registry = null!; + + public static bool Load(UnityModManager.ModEntry modEntry) + { + ModEntry = modEntry; + Log.Init(modEntry); + SettingsStore.Init(modEntry.Path); + + _registry = new ModuleRegistry(); + // Modules are registered here. Each module loads its own settings in its + // constructor. Order here is display order in the settings panel. + _registry.Register(new Modules.PhysicsOptimizer.PhysicsOptimizerModule()); + _registry.Register(new Modules.Popout.PopoutModule()); + + _registry.EnableConfigured(); + ModConflicts.CheckAtLoad(); + + modEntry.OnGUI = _ => SettingsPanel.Draw(_registry); + modEntry.OnSaveGUI = _ => _registry.SaveAll(); + + Log.Info("Seton's Special Sauce loaded."); + return true; + } +} diff --git a/src/Modules/PhysicsOptimizer/CarDebugVisualizer.cs b/src/Modules/PhysicsOptimizer/CarDebugVisualizer.cs new file mode 100644 index 0000000..db733ee --- /dev/null +++ b/src/Modules/PhysicsOptimizer/CarDebugVisualizer.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using Model; +using UnityEngine; + +namespace S3.Modules.PhysicsOptimizer; + +[UnityEngine.DefaultExecutionOrder(10000)] // run after all game LateUpdates so our MPB is last to write +public class CarDebugVisualizer : MonoBehaviour +{ + public static CarDebugVisualizer Instance { get; private set; } + public int TintedCount => _curr.Count; + + // A 1x1 white texture fed via MPB overrides _MainTex so the shader renders + // texture*color = white*color = solid color, regardless of the car's original texture. + Texture2D _whiteTex; + readonly MaterialPropertyBlock _mpb = new(); + + readonly Dictionary _rendCache = new(); + readonly Dictionary _curr = new(); + readonly Dictionary _prev = new(); + + static readonly Color ColFrozen = new(1.00f, 0.90f, 0.00f); // yellow + static readonly Color ColFastPath = new(0.00f, 1.00f, 1.00f); // cyan + static readonly Color ColFullPath = new(1.00f, 0.00f, 1.00f); // magenta + + void Awake() + { + Instance = this; + _whiteTex = new Texture2D(1, 1, TextureFormat.RGBA32, false) + { hideFlags = HideFlags.HideAndDontSave }; + _whiteTex.SetPixel(0, 0, Color.white); + _whiteTex.Apply(); + } + + void OnDestroy() + { + ClearAll(); + if (_whiteTex) Destroy(_whiteTex); + Instance = null; + } + + void LateUpdate() + { + _prev.Clear(); + foreach (var kv in _curr) _prev[kv.Key] = kv.Value; + _curr.Clear(); + + if (PhysicsOptimizerModule.Settings.DebugHighlightFrozen) + Collect(ConsistFreezer.FrozenCars, ColFrozen); + if (PhysicsOptimizerModule.Settings.DebugHighlightFastPath) + Collect(ConsistLOD.FastPathCars, ColFastPath); + if (PhysicsOptimizerModule.Settings.DebugHighlightFullPath) + Collect(ConsistLOD.FullPathCars, ColFullPath); + + foreach (var (car, color) in _curr) + ApplyTint(car, color); + + foreach (var car in _prev.Keys) + if (!_curr.ContainsKey(car)) + ClearTint(car); + + if (Time.frameCount % 300 == 0) + PurgeCache(); + } + + void Collect(HashSet source, Color color) + { + foreach (var car in source) + if (car != null && !_curr.ContainsKey(car)) + _curr[car] = color; + } + + void ApplyTint(Car car, Color color) + { + foreach (var r in GetRenderers(car)) + { + if (r == null) continue; + _mpb.Clear(); + _mpb.SetColor("_Color", color); + _mpb.SetColor("_BaseColor", color); + _mpb.SetTexture("_MainTex", _whiteTex); // built-in RP + _mpb.SetTexture("_BaseMap", _whiteTex); // URP + _mpb.SetTexture("_BaseColorMap", _whiteTex); // HDRP + r.SetPropertyBlock(_mpb); + } + } + + void ClearTint(Car car) + { + foreach (var r in GetRenderers(car)) + if (r != null) r.SetPropertyBlock(null); + } + + void ClearAll() + { + foreach (var car in _curr.Keys) ClearTint(car); + foreach (var car in _prev.Keys) ClearTint(car); + _curr.Clear(); + _prev.Clear(); + _rendCache.Clear(); + } + + Renderer[] GetRenderers(Car car) + { + if (!_rendCache.TryGetValue(car, out var renderers) || renderers.Length == 0) + { + renderers = car.BodyTransform != null + ? car.BodyTransform.GetComponentsInChildren() + : System.Array.Empty(); + // Don't cache empty results — retry next frame until the mesh loads + if (renderers.Length > 0) + _rendCache[car] = renderers; + } + return renderers; + } + + void PurgeCache() + { + var dead = new List(); + foreach (var car in _rendCache.Keys) + if (car == null) dead.Add(car); + foreach (var car in dead) _rendCache.Remove(car); + } +} diff --git a/src/Modules/PhysicsOptimizer/ConsistFreezer.cs b/src/Modules/PhysicsOptimizer/ConsistFreezer.cs new file mode 100644 index 0000000..ccd3c58 --- /dev/null +++ b/src/Modules/PhysicsOptimizer/ConsistFreezer.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using HarmonyLib; +using Model; +using Model.Physics; +using UnityEngine; + +namespace S3.Modules.PhysicsOptimizer; + +public static class ConsistFreezer +{ + // Manual freeze — explicit /rpf freeze command + static readonly HashSet _frozenSetIds = new(); + public static bool IsFrozen(uint setId) => _frozenSetIds.Contains(setId); + public static bool Freeze(uint setId) => _frozenSetIds.Add(setId); + public static bool Unfreeze(uint setId) => _frozenSetIds.Remove(setId); + public static void ClearAll() => _frozenSetIds.Clear(); + + // Auto-freeze: skip the Verlet tick for consists that are far from the camera + // and moving slowly enough that physics quality there doesn't matter. + // This is our integrated replacement for the stock optimizer (AllCarsAtRest only). + public static bool ExcludeLocomotives = true; + public static bool AutoFreezeEnabled = true; + public static float AutoFreezeDistance = 200f; // meters + public static float AutoFreezeSpeedThreshold = 0.3f; // m/s (~0.7 mph) + + static float AutoFreezeDistanceSq => AutoFreezeDistance * AutoFreezeDistance; + + // Per-tick frozen-car counters — flushed to Last* by FlushFrameCounts(). + // internal so ShouldSkipTickPatch (same file, different class) can increment them. + internal static int _frameAtRestCars; + internal static int _frameDistanceCars; + public static int LastAtRestCars { get; private set; } + public static int LastDistanceCars { get; private set; } + + // Per-tick set of cars whose Verlet tick is being skipped — for debug visualization. + public static readonly HashSet FrozenCars = new(); + + internal static void FlushFrameCounts() + { + LastAtRestCars = _frameAtRestCars; + LastDistanceCars = _frameDistanceCars; + _frameAtRestCars = 0; + _frameDistanceCars = 0; + FrozenCars.Clear(); + } + + // Called from ShouldSkipTickPatch. Internal so the patch class (same file) can reach it. + internal static bool ShouldAutoFreeze(IntegrationSet set) + { + if (!AutoFreezeEnabled) return false; + // ShouldSkipTick is per-IntegrationSet — we can't freeze freight cars while keeping + // a coupled loco ticking. If ExcludeLocomotives is on, the whole consist stays live + // so AI waypoint queues on the loco don't get interrupted by a physics freeze. + if (ExcludeLocomotives) + { + foreach (Car car in set.Cars) + if (car is BaseLocomotive) return false; + } + Camera cam = Camera.main; + if (cam == null) return false; + Vector3 camPos = cam.transform.position; + float distSqThresh = AutoFreezeDistanceSq; + float speedThresh = AutoFreezeSpeedThreshold; + + foreach (Car car in set.Cars) + { + Transform body = car.BodyTransform; + if (body == null) continue; + + // If ANY car is within distance → don't freeze the consist. + if ((body.position - camPos).sqrMagnitude < distSqThresh) return false; + + // If ANY car is moving above threshold → don't freeze. + if (Mathf.Abs(car.velocity) >= speedThresh) return false; + } + + // Every car is both far and slow — safe to skip the Verlet tick. + return true; + } +} + +// Completely replaces the stock ShouldSkipTick getter. +// Priority order: +// 1. Manual freeze → always skip +// 2. ForceActive → never skip (profiling bypass) +// 3. AllCarsAtRest → stock optimizer behaviour, replicated explicitly +// 4. Auto-freeze → our distance + speed tier +[HarmonyPatch(typeof(IntegrationSet), "get_ShouldSkipTick")] +static class ShouldSkipTickPatch +{ + static bool Prefix(IntegrationSet __instance, ref bool __result) + { + if (ConsistFreezer.IsFrozen(__instance.Id)) + { + foreach (Car c in __instance.Cars) ConsistFreezer.FrozenCars.Add(c); + __result = true; + return false; + } + + if (PhysicsTimer.ForceActive) + { + __result = false; + return false; + } + + // Replicate stock optimizer behaviour explicitly so it works with or without + // the stock optimizer mod installed. + if (__instance.AllCarsAtRest()) + { + if (ConsistFreezer.ExcludeLocomotives) + { + foreach (Car c in __instance.Cars) + if (c is BaseLocomotive) { __result = false; return false; } + } + foreach (Car c in __instance.Cars) ConsistFreezer.FrozenCars.Add(c); + ConsistFreezer._frameAtRestCars += __instance.NumberOfCars; + __result = true; + return false; + } + + __result = ConsistFreezer.ShouldAutoFreeze(__instance); + if (__result) + { + foreach (Car c in __instance.Cars) ConsistFreezer.FrozenCars.Add(c); + ConsistFreezer._frameDistanceCars += __instance.NumberOfCars; + } + return false; // always override — stock getter never runs + } +} diff --git a/src/Modules/PhysicsOptimizer/ConsistLOD.cs b/src/Modules/PhysicsOptimizer/ConsistLOD.cs new file mode 100644 index 0000000..7103547 --- /dev/null +++ b/src/Modules/PhysicsOptimizer/ConsistLOD.cs @@ -0,0 +1,204 @@ +using System.Collections.Generic; +using System.Reflection; +using Helpers; +using HarmonyLib; +using Model; +using Model.Physics; +using Track; +using UnityEngine; + +namespace S3.Modules.PhysicsOptimizer; + +public static class ConsistLOD +{ + public static bool Enabled = true; + public static float DistanceThreshold = 30f; + // Must be a power of 2 (1, 2, 4, 8, 16). 1 = full path every tick (no dead-reckoning). + public static int ResyncInterval = 4; + + // Blacklist/whitelist — when enabled, filters which consists get LOD treatment. + public static bool ExcludeLocomotives = true; + public static bool BlacklistEnabled = false; + public static bool IsBlacklist = true; // true = listed consists skip LOD; false = only listed use LOD + public static readonly HashSet RoadNumberList = new(System.StringComparer.OrdinalIgnoreCase); + + public static int _globalTick = 0; + public static int LastFastPathCount; + public static int LastFullPathCount; + + // Per-tick sets for debug visualization — cleared at FixedUpdate start, populated by the patch. + public static readonly HashSet FastPathCars = new(); + public static readonly HashSet FullPathCars = new(); + + static float DistanceSq => DistanceThreshold * DistanceThreshold; + + // Per-consist LOD eligibility cache — rebuilt lazily, flushed periodically and on settings change. + // IntegrationSet is not a UnityEngine.Object, so we key by reference identity. + static readonly Dictionary _consistCache = new(); + public static void InvalidateConsistCache() => _consistCache.Clear(); + + internal static bool ShouldUseFastPath(Car car) + { + if (!Enabled) return false; + if (ExcludeLocomotives && car is Model.BaseLocomotive) return false; + if (!car.EndGearA.IsCoupled || !car.EndGearB.IsCoupled) return false; + if (car.BodyTransform == null) return false; + Camera cam = Camera.main; + if (cam == null) return false; + if ((car.BodyTransform.position - cam.transform.position).sqrMagnitude <= DistanceSq) + return false; + + // Blacklist/whitelist — cached per IntegrationSet instance ID to avoid per-car scanning. + if (BlacklistEnabled && car.set != null) + { + if (!_consistCache.TryGetValue(car.set, out bool allowed)) + { + allowed = IsConsistAllowed(car.set); + _consistCache[car.set] = allowed; + } + if (!allowed) return false; + } + + return true; + } + + static bool IsConsistAllowed(IntegrationSet set) + { + if (RoadNumberList.Count == 0) return true; // empty list = no filtering + bool hasMatch = false; + foreach (Car c in set.Cars) + { + // Accept a match against the full display name ("UP 1492"), the road number + // alone ("1492"), or the reporting mark alone ("UP") — users naturally type + // whichever they remember, so don't require the exact concatenated string. + if (RoadNumberList.Contains(c.DisplayName) || + RoadNumberList.Contains(c.Ident.RoadNumber) || + RoadNumberList.Contains(c.Ident.ReportingMark)) + { + hasMatch = true; + break; + } + } + // Blacklist: a match means this consist is NOT allowed to use LOD. + // Whitelist: no match means this consist is NOT allowed to use LOD. + return IsBlacklist ? !hasMatch : hasMatch; + } +} + +// Cached accessor for Car._mover (internal field — reflection needed from our assembly). +// CarMover.Move() is public, so once we have the instance we call it directly. +static class CarMoverAccess +{ + static readonly FieldInfo Field = + typeof(Car).GetField("_mover", BindingFlags.Instance | BindingFlags.NonPublic); + + public static CarMover Get(Car car) => (CarMover)Field.GetValue(car); +} + +[HarmonyPatch(typeof(Car), "PositionWheelBoundsFront")] +static class PositionWheelBoundsFrontLODPatch +{ + static int _fastCount; + static int _fullCount; + + public static void ResetFrameCounts() + { + ConsistLOD.LastFastPathCount = _fastCount; + ConsistLOD.LastFullPathCount = _fullCount; + _fastCount = 0; + _fullCount = 0; + } + + static bool Prefix(Car __instance, Location wheelBoundsF, Graph graph, bool update, + ref Location __result) + { + if (!update || !ConsistLOD.ShouldUseFastPath(__instance)) + { + _fullCount++; + ConsistLOD.FullPathCars.Add(__instance); + return true; // run original + } + + // Fibonacci hash: 4-bit result (0-15) distributes Unity InstanceIDs evenly + // and supports ResyncInterval 1/2/4/8 via modular comparison. + int id = __instance.GetInstanceID(); + int bucket = (int)(((uint)id * 2654435761u) >> 28) & 0xF; + bool isResync = ConsistLOD.ResyncInterval <= 1 || + ConsistLOD._globalTick % ConsistLOD.ResyncInterval == bucket % ConsistLOD.ResyncInterval; + + if (isResync) + { + _fullCount++; + ConsistLOD.FastPathCars.Add(__instance); // logically fast-path, just doing a scheduled sync + return true; // let original run this tick + } + + _fastCount++; + ConsistLOD.FastPathCars.Add(__instance); + + // 1. Update track physics bounds — constraint solver and couplers need these. + float boundsSpan = __instance.carLength - __instance.wheelInsetF - __instance.wheelInsetR; + Location wbR = graph.LocationByMoving(wheelBoundsF, -boundsSpan); + __instance.WheelBoundsF = wheelBoundsF; + __instance.WheelBoundsR = wbR; + __result = wbR; + + // Keep LogicalEnd locations in sync — used by CarDidPosition's segment-cache + // and coupler placement when the culler transitions distance bands. + __instance.LocationF = graph.LocationByMoving(wheelBoundsF, __instance.wheelInsetF, false, Graph.EndOfTrackHandling.Unclamped); + __instance.LocationR = graph.LocationByMoving(wbR, -__instance.wheelInsetR, false, Graph.EndOfTrackHandling.Unclamped); + + // 2. Replicate SetBodyPosition's chord-based rotation: LookRotation between + // the front and rear TRUCK positions, matching the game's exact formula. + // Use the same graph instance and the same PositionAccuracy as PositionWheelBoundsFront + // would choose (IsVisible ? High : Standard) so the position is byte-for-byte identical + // to the full path — preventing a positional jump when the camera closes in. + float halfTruckSpan = (__instance.carLength - __instance.truckSeparation) / 2f; + Location truckFLoc = graph.LocationByMoving(wheelBoundsF, -(halfTruckSpan - __instance.wheelInsetF)); + Location truckRLoc = graph.LocationByMoving(truckFLoc, -__instance.truckSeparation); + PositionAccuracy accuracy = __instance.IsVisible ? PositionAccuracy.High : PositionAccuracy.Standard; + var prF = graph.GetPositionRotation(truckFLoc, accuracy); + var prR = graph.GetPositionRotation(truckRLoc, accuracy); + + Vector3 avgUp = Vector3.Lerp(prF.Rotation * Vector3.up, prR.Rotation * Vector3.up, 0.5f); + Quaternion bodyRot = Quaternion.LookRotation(prF.Position - prR.Position, avgUp); + Vector3 gameCenter = Vector3.Lerp(prF.Position, prR.Position, 0.5f); + Vector3 worldCenter = WorldTransformer.GameToWorld(gameCenter); + + // 3. Drive _mover.Move() exactly as SetBodyPosition does. + // This keeps _moverPosition current every fast-path tick so: + // a) Camera-follow code (which reads _moverPosition) sees correct position. + // b) On the next resync tick, _velocity = Δpos/dt ≈ actual v, not N×v, + // so the rigidbody isn't slammed backward causing a snap. + CarMoverAccess.Get(__instance).Move(worldCenter, bodyRot, immediate: false); + + // 4. TagCallout is only updated inside SetBodyPosition; keep it in sync here. + __instance.TagCallout?.SetPosition(worldCenter); + + // 5. Fire OnPosition so TrainController.CarDidPosition runs: this updates the + // spatial-hash, car-culler sphere (distance-band gating), and segment cache. + // Without this the culler's bounding sphere stays frozen at the last resync + // position, causing stale IsVisible / IsNearby states on fast-path ticks. + __instance.OnPosition?.Invoke(gameCenter, bodyRot); + + return false; // skip original PositionWheelBoundsFront + SetBodyPosition + } +} + +[HarmonyPatch(typeof(TrainController), "FixedUpdate")] +static class TrainControllerFixedUpdateCounterPatch +{ + static void Prefix() + { + ConsistLOD._globalTick++; + ConsistLOD.FastPathCars.Clear(); + ConsistLOD.FullPathCars.Clear(); + PositionWheelBoundsFrontLODPatch.ResetFrameCounts(); + ConsistFreezer.FlushFrameCounts(); + + // Periodic consist cache flush — silently handles couple/decouple events + // without needing to patch the coupler system. ~12 s at 50 Hz. + if (ConsistLOD._globalTick % 600 == 0) + ConsistLOD.InvalidateConsistCache(); + } +} diff --git a/src/Modules/PhysicsOptimizer/ConsoleCommands.cs b/src/Modules/PhysicsOptimizer/ConsoleCommands.cs new file mode 100644 index 0000000..c4e544f --- /dev/null +++ b/src/Modules/PhysicsOptimizer/ConsoleCommands.cs @@ -0,0 +1,141 @@ +using System.Linq; +using System.Text; +using HarmonyLib; +using Model; +using Model.Physics; +using UI.Console; + +namespace S3.Modules.PhysicsOptimizer; + +/// +/// Hooks into ConsoleCommandHandler._HandleSlashCommand to add /rpf commands +/// before the game's switch block sees them. +/// +[HarmonyPatch(typeof(ConsoleCommandHandler))] +[HarmonyPatch("_HandleSlashCommand")] +static class ConsoleCommandPatch +{ + static bool Prefix(string[] comps, ref string __result) + { + if (comps.Length == 0 || comps[0].ToLower() != "/rpf") + return true; // not our command, let original handle it + + __result = RpfCommands.Handle(comps); + return false; // swallow — don't pass to original + } +} + +static class RpfCommands +{ + internal static string Handle(string[] comps) + { + if (comps.Length < 2) + return Usage(); + + return comps[1].ToLower() switch + { + "freeze" => Freeze(), + "unfreeze" => Unfreeze(), + "dump" => Dump(), + "timing" => PhysicsTimer.GetReport(), + "overlay" => ToggleOverlay(), + "forceactive" => ToggleForceActive(), + "lod" => SetLod(comps), + "help" => Usage(), + _ => $"Unknown subcommand '{comps[1]}'. {Usage()}", + }; + } + + static string Freeze() + { + var (car, set) = GetSelection(); + if (car == null) return "No car selected."; + if (set == null) return $"{car.id} has no IntegrationSet."; + + // Zero velocities so cars stop cleanly before we freeze the solver. + set.SetVelocity(0f, set.Cars.ToList()); + bool added = ConsistFreezer.Freeze(set.Id); + return added + ? $"Froze set #{set.Id} ({set.NumberOfCars} cars). Use /rpf unfreeze to release." + : $"Set #{set.Id} was already frozen."; + } + + static string Unfreeze() + { + var (car, set) = GetSelection(); + if (car == null) return "No car selected."; + if (set == null) return $"{car.id} has no IntegrationSet."; + + bool removed = ConsistFreezer.Unfreeze(set.Id); + return removed + ? $"Unfroze set #{set.Id} — solver resumed." + : $"Set #{set.Id} was not frozen."; + } + + static string Dump() + { + var (car, set) = GetSelection(); + if (car == null) return "No car selected."; + if (set == null) return $"{car.id} has no IntegrationSet."; + + var sb = new StringBuilder(); + sb.AppendLine($"=== IntegrationSet #{set.Id} | {set.NumberOfCars} cars | frozen={ConsistFreezer.IsFrozen(set.Id)} ==="); + + float totalWeightLbs = 0f; + int idx = 0; + foreach (Car c in set.Cars) + { + totalWeightLbs += c.Weight; + float mph = c.velocity * 2.23694f; + string coupled = c.EndGearA.IsCoupled ? "A" : ""; + coupled += c.EndGearB.IsCoupled ? "B" : ""; + if (coupled == "") coupled = "solo"; + sb.AppendLine($" [{idx++}] {c.id} {c.DisplayName} | {mph:F1}mph | {c.Weight:F0}lb | coupled={coupled} | loc={c.WheelBoundsF.segment?.id}"); + } + + sb.AppendLine($"Total: {totalWeightLbs / 2000f:F0} short tons"); + return sb.ToString(); + } + + static (Car car, IntegrationSet set) GetSelection() + { + Car car = TrainController.Shared?.SelectedCar; + return (car, car?.set); + } + + static string ToggleOverlay() + { + var gui = PhysicsOverlayGUI.Instance; + if (gui == null) return "Overlay not initialized."; + gui.Visible = !gui.Visible; + return $"Overlay {(gui.Visible ? "shown" : "hidden")}."; + } + + static string SetLod(string[] comps) + { + if (comps.Length < 3) + { + ConsistLOD.Enabled = !ConsistLOD.Enabled; + return $"LOD {(ConsistLOD.Enabled ? "enabled" : "disabled")}. Threshold: {ConsistLOD.DistanceThreshold:F0}m. Usage: /rpf lod "; + } + if (comps[2].ToLower() == "off") { ConsistLOD.Enabled = false; return "LOD disabled."; } + if (float.TryParse(comps[2], out float dist) && dist > 0f) + { + ConsistLOD.DistanceThreshold = dist; + ConsistLOD.Enabled = true; + return $"LOD enabled, fast path for interior cars beyond {dist:F0}m."; + } + return "Usage: /rpf lod or /rpf lod off"; + } + + static string ToggleForceActive() + { + PhysicsTimer.ForceActive = !PhysicsTimer.ForceActive; + return PhysicsTimer.ForceActive + ? "ForceActive ON — stock optimizer bypassed, solver always runs." + : "ForceActive OFF — stock optimizer active."; + } + + static string Usage() => + "Usage: /rpf "; +} diff --git a/src/Modules/PhysicsOptimizer/PhysicsOptimizerModule.cs b/src/Modules/PhysicsOptimizer/PhysicsOptimizerModule.cs new file mode 100644 index 0000000..8acda39 --- /dev/null +++ b/src/Modules/PhysicsOptimizer/PhysicsOptimizerModule.cs @@ -0,0 +1,99 @@ +using System; +using HarmonyLib; +using S3.Core; +using UnityEngine; + +namespace S3.Modules.PhysicsOptimizer; + +/// +/// S³ module wrapping the physics-CPU optimizations (LOD fast-path + auto-freeze) +/// plus the profiler overlay and debug visualizer. +/// +/// Owns a Harmony instance keyed "S3.physics" and patches only its own types, so +/// the patches exist only while the module is enabled. is +/// a static instance the overlay/visualizer read directly. +/// +public sealed class PhysicsOptimizerModule : IModule +{ + private const string SettingsFile = "S3.physics.json"; + + public static PhysicsSettings Settings { get; private set; } = new(); + + private static Harmony? _harmony; + + // Attribute-annotated patch classes the module owns. PosCarsTimerPatch is NOT + // here — it has no [HarmonyPatch] attribute and is applied dynamically by + // PhysicsTimer.TryPatchPositionCars (method name varies by game version). + private static readonly Type[] PatchTypes = + { + typeof(PositionWheelBoundsFrontLODPatch), + typeof(TrainControllerFixedUpdateCounterPatch), + typeof(ShouldSkipTickPatch), + typeof(TickTimerPatch), + typeof(FixedUpdateTimerPatch), + typeof(ConsoleCommandPatch), + }; + + public PhysicsOptimizerModule() => Settings = SettingsStore.Load(SettingsFile); + + public string Id => "physics"; + public string DisplayName => "Physics Optimizer"; + public string Description => + "Cuts CPU time spent on train physics (LOD fast-path + auto-freeze for far/slow consists). " + + "Includes a profiler overlay and debug car tinting. Console: /rpf"; + + public bool Enabled + { + get => Settings.enabled; + set => Settings.enabled = value; + } + + public void OnEnable() + { + ApplySettingsToStatics(); + + _harmony = new Harmony("S3.physics"); + foreach (Type t in PatchTypes) + _harmony.CreateClassProcessor(t).Patch(); + PhysicsTimer.TryPatchPositionCars(_harmony, Main.ModEntry.Logger); + + var go = new GameObject("S3.PhysicsOptimizer.Host"); + UnityEngine.Object.DontDestroyOnLoad(go); + PhysicsOverlayGUI overlay = go.AddComponent(); + overlay.Visible = Settings.ShowOverlay; + overlay.Opacity = Settings.OverlayOpacity; + go.AddComponent(); + } + + public void OnDisable() + { + // Reserved for live toggling. Today modules apply on next launch, so this + // is not called; when it is, unpatch via _harmony.UnpatchAll("S3.physics") + // and destroy the host GameObject. + _harmony?.UnpatchAll("S3.physics"); + _harmony = null; + } + + public void SaveSettings() => Persist(); + internal static void Persist() => SettingsStore.Save(SettingsFile, Settings); + + public void DrawSettings() => PhysicsSettingsUI.Draw(); + + private static void ApplySettingsToStatics() + { + ConsistLOD.Enabled = Settings.OptimizerEnabled; + ConsistLOD.DistanceThreshold = Settings.DistanceThreshold; + ConsistLOD.ResyncInterval = Settings.ResyncInterval; + ConsistLOD.BlacklistEnabled = Settings.BlacklistEnabled; + ConsistLOD.IsBlacklist = Settings.IsBlacklist; + ConsistLOD.RoadNumberList.Clear(); + foreach (string name in Settings.RoadNumberList) + ConsistLOD.RoadNumberList.Add(name); + ConsistLOD.ExcludeLocomotives = Settings.ExcludeLocosFromLOD; + + ConsistFreezer.ExcludeLocomotives = Settings.ExcludeLocosFromFreeze; + ConsistFreezer.AutoFreezeEnabled = Settings.AutoFreezeEnabled; + ConsistFreezer.AutoFreezeDistance = Settings.AutoFreezeDistance; + ConsistFreezer.AutoFreezeSpeedThreshold = Settings.AutoFreezeSpeedThreshold; + } +} diff --git a/src/Modules/PhysicsOptimizer/PhysicsOverlayGUI.cs b/src/Modules/PhysicsOptimizer/PhysicsOverlayGUI.cs new file mode 100644 index 0000000..115736c --- /dev/null +++ b/src/Modules/PhysicsOptimizer/PhysicsOverlayGUI.cs @@ -0,0 +1,286 @@ +using UnityEngine; + +namespace S3.Modules.PhysicsOptimizer; + +public class PhysicsOverlayGUI : MonoBehaviour +{ + public static PhysicsOverlayGUI Instance { get; private set; } + public bool Visible = true; + public float Opacity = 1.0f; + + Rect _windowRect = new(10f, 10f, 420f, 10f); + GUIStyle _blueLabel; + GUIStyle _greenLabel; + GUIStyle _yellowLabel; + GUIStyle _orangeLabel; + GUIStyle _dimLabel; + GUIStyle _fps60Label; + GUIStyle _fps30Label; + GUIStyle _onStyle; // bold green, no button background + GUIStyle _offStyle; // bold red, no button background + + string _lodStatsStr = ""; + string _freezeStatsStr = ""; + string _debugStatsStr = ""; + int _lodStatsFrame; + + GUIStyle _windowStyle; + Texture2D _solidTex; // 1×1 white — lets GUI.backgroundColor.a control opacity 0–100% + + // Graph rendered as a texture — avoids all GL coordinate-space issues. + // Unity textures are Y-up (row 0 = bottom pixel), IMGUI is Y-down, + // but GUI.DrawTexture just stretches the texture into the target rect, + // so we flip Y in our write formula and the result renders correctly. + Texture2D _graphTex; + Color32[] _graphPixels; + const int TexW = 300; // matches RingSize exactly — 1 pixel column per sample + const int TexH = 90; + + static readonly Color32 ColBg = new(16, 16, 16, 220); + static readonly Color32 Col60 = new(210, 210, 210, 255); // 60fps reference line + static readonly Color32 Col30 = new(210, 210, 210, 255); // 30fps reference line + static readonly Color32 ColRender = new(50, 140, 215, 255); // render frame time (blue) + static readonly Color32 ColFrame = new(70, 200, 70, 255); // FixedUpdate total (green) + static readonly Color32 ColTick = new(210, 185, 50, 255); // Tick() only (yellow) + static readonly Color32 ColPosCars = new(220, 110, 30, 255); // PositionCars (orange) + + static readonly int WindowId = "S3PhysicsOverlay".GetHashCode(); + + const float RefFps60Ms = 16.7f; + const float RefFps30Ms = 33.3f; + + void Awake() + { + Instance = this; + _graphTex = new Texture2D(TexW, TexH, TextureFormat.RGBA32, false) + { filterMode = FilterMode.Point, hideFlags = HideFlags.HideAndDontSave }; + _graphPixels = new Color32[TexW * TexH]; + + _solidTex = new Texture2D(1, 1, TextureFormat.RGBA32, false) + { hideFlags = HideFlags.HideAndDontSave }; + _solidTex.SetPixel(0, 0, Color.white); + _solidTex.Apply(); + } + + void LateUpdate() + { + // Record render frame time each rendered frame (not each physics tick). + // Time.unscaledDeltaTime = wall-clock ms since last render frame. + PhysicsTimer.RecordRenderFrame(Time.unscaledDeltaTime * 1000f); + } + + void OnDestroy() + { + if (_graphTex != null) Destroy(_graphTex); + if (_solidTex != null) Destroy(_solidTex); + Instance = null; + } + + void OnGUI() + { + if (!Visible) return; + EnsureStyles(); + // Tint the solid-white window background to dark gray at the chosen opacity. + // Using a custom style (solid texture) means Opacity=1 → fully opaque, not + // capped by whatever alpha the default IMGUI skin has baked in. + Color prevBg = GUI.backgroundColor; + GUI.backgroundColor = new Color(0.063f, 0.063f, 0.063f, Opacity); + _windowRect = GUILayout.Window(WindowId, _windowRect, DrawWindow, + "Physics Profiler", _windowStyle, GUILayout.MinWidth(420f)); + GUI.backgroundColor = prevBg; + } + + void DrawWindow(int _) + { + // The window background was already drawn with the opacity tint — restore + // backgroundColor so buttons and labels inside use their normal skin colors. + GUI.backgroundColor = Color.white; + GUILayout.Label(PhysicsTimer.GetReport()); + + // Reserve space for graph in window-local coords. + Rect localRect = GUILayoutUtility.GetRect( + GUIContent.none, GUIStyle.none, + GUILayout.Height(TexH), GUILayout.ExpandWidth(true)); + + if (Event.current.type == EventType.Repaint) + { + UpdateGraphTexture(); + // GUI.DrawTexture uses window-local coords — no coordinate conversion needed. + GUI.DrawTexture(localRect, _graphTex, ScaleMode.StretchToFill); + + // 10% headroom above the 30fps line so it's never squashed against the top pixel row. + float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f); + + // Reference-line labels: centered vertically on the line, anchored to right edge. + float y60Gui = LocalGaugeY(localRect, maxMs, RefFps60Ms); + GUI.Label(new Rect(localRect.xMax - 54f, y60Gui - 9f, 52f, 18f), "60 fps", _fps60Label); + float y30Gui = LocalGaugeY(localRect, maxMs, RefFps30Ms); + GUI.Label(new Rect(localRect.xMax - 54f, y30Gui - 9f, 52f, 18f), "30 fps", _fps30Label); + + // Current sample readout (top-left of graph). + int prev = (PhysicsTimer.RingWrite - 1 + PhysicsTimer.RingSize) % PhysicsTimer.RingSize; + GUI.Label(new Rect(localRect.x + 4f, localRect.y + 2f, 340f, 20f), + $"render:{PhysicsTimer.RingRender[prev]:F2}ms phys:{PhysicsTimer.RingFrame[prev]:F2}ms tick:{PhysicsTimer.RingTick[prev]:F2}ms", + _dimLabel); + } + + // Legend row + GUILayout.BeginHorizontal(); + GUILayout.Label(" ■ Render", _blueLabel); + GUILayout.Label(" ■ FixedUpdate", _greenLabel); + GUILayout.Label(" ■ Tick()", _yellowLabel); + if (PhysicsTimer.HasPosCarsData) + GUILayout.Label(" ■ PosCars", _orangeLabel); + GUILayout.Label($" [{PhysicsTimer.RingSize} frames]", _dimLabel); + GUILayout.EndHorizontal(); + + // Physics Optimizer row: plain-text toggle (no button background), then slow-updating stats + GUILayout.BeginHorizontal(); + GUILayout.Label("Physics Optimizer", _dimLabel, GUILayout.Width(130f)); + if (GUILayout.Button(ConsistLOD.Enabled ? "ON" : "OFF", + ConsistLOD.Enabled ? _onStyle : _offStyle, GUILayout.Width(34f))) + { + ConsistLOD.Enabled = !ConsistLOD.Enabled; + // Persist toggle state so it survives a game restart. + PhysicsOptimizerModule.Settings.OptimizerEnabled = ConsistLOD.Enabled; + PhysicsOptimizerModule.Persist(); + } + + // Stats refresh once per second — readable, not flickering + if (Time.frameCount != _lodStatsFrame && Time.frameCount % 60 == 0) + { + _lodStatsFrame = Time.frameCount; + if (ConsistLOD.Enabled) + { + int fast = ConsistLOD.LastFastPathCount; + int full = ConsistLOD.LastFullPathCount; + int total = fast + full; + _lodStatsStr = $"fast:{fast}/{total} full:{full}/{total} dist:{ConsistLOD.DistanceThreshold:F0}m resync:1/{ConsistLOD.ResyncInterval}"; + } + else + { + _lodStatsStr = ""; + } + + int atRest = ConsistFreezer.LastAtRestCars; + int byDist = ConsistFreezer.LastDistanceCars; + _freezeStatsStr = $"stopped:{atRest} dist/spd:{byDist}"; + + bool anyDbg = PhysicsOptimizerModule.Settings.DebugHighlightFrozen || + PhysicsOptimizerModule.Settings.DebugHighlightFastPath || + PhysicsOptimizerModule.Settings.DebugHighlightFullPath; + _debugStatsStr = anyDbg + ? $"dbg — frozen:{ConsistFreezer.FrozenCars.Count} fast:{ConsistLOD.FastPathCars.Count} full:{ConsistLOD.FullPathCars.Count} tinted:{CarDebugVisualizer.Instance?.TintedCount ?? 0}" + : ""; + } + GUILayout.Label(_lodStatsStr, _dimLabel); + GUILayout.EndHorizontal(); + + if (_debugStatsStr.Length > 0) + GUILayout.Label(_debugStatsStr, _dimLabel); + + // Auto-freeze row — shows cars skipping the Verlet tick each physics step + GUILayout.BeginHorizontal(); + GUILayout.Label("Auto Freeze", _dimLabel, GUILayout.Width(130f)); + if (GUILayout.Button(ConsistFreezer.AutoFreezeEnabled ? "ON" : "OFF", + ConsistFreezer.AutoFreezeEnabled ? _onStyle : _offStyle, GUILayout.Width(34f))) + { + ConsistFreezer.AutoFreezeEnabled = !ConsistFreezer.AutoFreezeEnabled; + PhysicsOptimizerModule.Settings.AutoFreezeEnabled = ConsistFreezer.AutoFreezeEnabled; + PhysicsOptimizerModule.Persist(); + } + GUILayout.Label(_freezeStatsStr, _dimLabel); + GUILayout.EndHorizontal(); + + GUI.DragWindow(new Rect(0f, 0f, _windowRect.width, 20f)); + } + + // Y in IMGUI window-local space: top of rect = maxMs, bottom = 0ms. + static float LocalGaugeY(Rect r, float maxMs, float ms) => + r.yMax - Mathf.Clamp01(ms / maxMs) * r.height; + + void UpdateGraphTexture() + { + float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f); + int write = PhysicsTimer.RingWrite; + int n = PhysicsTimer.RingSize; + float[] render = PhysicsTimer.RingRender; + float[] frame = PhysicsTimer.RingFrame; + float[] tick = PhysicsTimer.RingTick; + float[] posCars = PhysicsTimer.RingPosCars; + + // Background fill. + for (int i = 0; i < _graphPixels.Length; i++) + _graphPixels[i] = ColBg; + + // Stacked filled areas — drawn back-to-front so later layers paint over earlier ones. + // + // Layer 1 (bottom): render frame time. + DrawFilledArea(render, null, n, write, maxMs, ColRender); + // Layer 2: FixedUpdate total, stacked on top of render. + DrawFilledArea(frame, render, n, write, maxMs, ColFrame); + // Layer 3: Tick(), painted over the lower part of the FixedUpdate band (same baseline). + DrawFilledArea(tick, render, n, write, maxMs, ColTick); + // Layer 4: PosCars, painted over the lower part of the Tick band (same baseline). + if (PhysicsTimer.HasPosCarsData) + DrawFilledArea(posCars, render, n, write, maxMs, ColPosCars); + + // Reference lines on top of all data. + DrawHLine(maxMs, RefFps60Ms, Col60); + DrawHLine(maxMs, RefFps30Ms, Col30); + + _graphTex.SetPixels32(_graphPixels); + _graphTex.Apply(false); + } + + // Pixel row for a given ms value: row 0 = bottom (0ms), row TexH-1 = top (maxMs). + static int MsToRow(float ms, float maxMs) => + Mathf.Clamp((int)(ms / maxMs * TexH), 0, TexH - 1); + + void DrawHLine(float maxMs, float ms, Color32 color) + { + int row = MsToRow(ms, maxMs); + int offset = row * TexW; + for (int x = 0; x < TexW; x++) + _graphPixels[offset + x] = color; + } + + // Fills a solid area from [baselines[i]] to [baselines[i] + values[i]] for each sample column. + // baselines == null means 0 (fill from the bottom of the chart). + void DrawFilledArea(float[] values, float[] baselines, int n, int write, float maxMs, Color32 color) + { + for (int x = 0; x < TexW; x++) + { + int si = (write + (int)((float)x / TexW * n)) % n; + float bot = baselines != null ? baselines[si] : 0f; + int rowBot = MsToRow(bot, maxMs); + int rowTop = MsToRow(bot + values[si], maxMs); + int lo = Mathf.Min(rowBot, rowTop); + int hi = Mathf.Max(rowBot, rowTop); + for (int r = lo; r <= hi; r++) + _graphPixels[r * TexW + x] = color; + } + } + + void EnsureStyles() + { + if (_blueLabel != null) return; + _blueLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.2f, 0.55f, 0.85f) } }; + _greenLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.4f, 1f, 0.4f) } }; + _yellowLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(1f, 0.85f, 0.2f) } }; + _orangeLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.9f, 0.45f, 0.1f) } }; + _dimLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.55f, 0.55f, 0.55f) } }; + _fps60Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } }; + _fps30Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } }; + _onStyle = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.4f, 1f, 0.4f) }, hover = { textColor = new Color(0.7f, 1f, 0.7f) } }; + _offStyle = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(1f, 0.35f, 0.3f) }, hover = { textColor = new Color(1f, 0.6f, 0.55f) } }; + + // Custom window style: solid white background texture so GUI.backgroundColor.a + // gives true 0–100% opacity rather than being capped by the skin's baked-in alpha. + _windowStyle = new GUIStyle(GUI.skin.window); + _windowStyle.normal.background = _solidTex; + _windowStyle.onNormal.background = _solidTex; + _windowStyle.focused.background = _solidTex; + _windowStyle.onFocused.background = _solidTex; + } +} diff --git a/src/Modules/PhysicsOptimizer/PhysicsSettings.cs b/src/Modules/PhysicsOptimizer/PhysicsSettings.cs new file mode 100644 index 0000000..ebbe903 --- /dev/null +++ b/src/Modules/PhysicsOptimizer/PhysicsSettings.cs @@ -0,0 +1,40 @@ +using System; + +namespace S3.Modules.PhysicsOptimizer; + +// Flat settings block for the Physics Optimizer module (own file: S3.physics.json). +// Flat by design — JsonUtility on this runtime drops nested custom-class fields. +[Serializable] +public class PhysicsSettings +{ + // S³ module enable flag (default off, per S³ convention). + public bool enabled = false; + + // LOD fast-path tier + public bool OptimizerEnabled = true; + public float DistanceThreshold = 30f; + public int ResyncInterval = 8; + + // Auto-freeze tier + public bool AutoFreezeEnabled = true; + public float AutoFreezeDistance = 200f; // meters + public float AutoFreezeSpeedThreshold = 0.09f; // m/s (~0.2 mph) + + // Profiler overlay + public bool ShowOverlay = true; + public float OverlayOpacity = 0.75f; + + // Locomotive exclusions + public bool ExcludeLocosFromLOD = true; + public bool ExcludeLocosFromFreeze = true; + + // Debug visualization + public bool DebugHighlightFrozen = false; + public bool DebugHighlightFastPath = false; + public bool DebugHighlightFullPath = false; + + // Road number filter + public bool BlacklistEnabled = false; + public bool IsBlacklist = true; // true = blacklist, false = whitelist + public string[] RoadNumberList = Array.Empty(); +} diff --git a/src/Modules/PhysicsOptimizer/PhysicsSettingsUI.cs b/src/Modules/PhysicsOptimizer/PhysicsSettingsUI.cs new file mode 100644 index 0000000..68ff59c --- /dev/null +++ b/src/Modules/PhysicsOptimizer/PhysicsSettingsUI.cs @@ -0,0 +1,290 @@ +using System.Linq; +using UnityEngine; + +namespace S3.Modules.PhysicsOptimizer; + +// Renders the Physics Optimizer settings as the body of its foldout in the S³ +// settings panel. Writes both the persisted settings and the runtime statics on +// every change, then persists via PhysicsOptimizerModule.Persist(). +static class PhysicsSettingsUI +{ + static readonly int[] ResyncOptions = { 1, 2, 4, 8, 16 }; + static readonly string[] ResyncLabels = { "1/1 (max quality)", "1/2", "1/4", "1/8 (default)", "1/16" }; + + static string _addInput = ""; + static string _toRemove = null; // deferred to avoid mutating list during enumeration + + public static void Draw() + { + PhysicsSettings s = PhysicsOptimizerModule.Settings; + bool changed = false; + GUILayout.BeginVertical(); + + // ── LOD Fast-Path ───────────────────────────────────────────────────────── + GUILayout.Label("LOD Fast-Path", GUILayout.ExpandWidth(false)); + GUILayout.Space(4f); + + bool newEnabled = GUILayout.Toggle(s.OptimizerEnabled, + " Enabled (skips expensive 3D updates for cars far from camera)"); + if (newEnabled != s.OptimizerEnabled) + { + s.OptimizerEnabled = newEnabled; + ConsistLOD.Enabled = newEnabled; + changed = true; + } + + GUILayout.Space(6f); + + // Distance threshold slider + GUILayout.BeginHorizontal(); + GUILayout.Label($"Distance threshold: {s.DistanceThreshold:F0} m", GUILayout.Width(220f)); + float newDist = GUILayout.HorizontalSlider(s.DistanceThreshold, 5f, 200f, GUILayout.Width(200f)); + GUILayout.EndHorizontal(); + GUILayout.Label(" Cars farther than this from the camera use dead-reckoning.", GUI.skin.label); + + if (Mathf.Abs(newDist - s.DistanceThreshold) > 0.5f) + { + s.DistanceThreshold = Mathf.Round(newDist); + ConsistLOD.DistanceThreshold = s.DistanceThreshold; + changed = true; + } + + GUILayout.Space(6f); + + // Resync quality radio buttons + GUILayout.Label("Resync quality (fraction of far cars fully updated per tick):"); + GUILayout.BeginHorizontal(); + for (int i = 0; i < ResyncOptions.Length; i++) + { + bool selected = s.ResyncInterval == ResyncOptions[i]; + if (GUILayout.Toggle(selected, ResyncLabels[i], GUI.skin.button, GUILayout.Width(130f)) && !selected) + { + s.ResyncInterval = ResyncOptions[i]; + ConsistLOD.ResyncInterval = s.ResyncInterval; + changed = true; + } + } + GUILayout.EndHorizontal(); + GUILayout.Label(" Lower = smoother visuals; higher = cheaper. Cost spread evenly via stagger.", GUI.skin.label); + + GUILayout.Space(12f); + + // ── Auto Freeze ─────────────────────────────────────────────────────────── + GUILayout.Label("Auto Freeze (replaces stock optimizer)", GUILayout.ExpandWidth(false)); + GUILayout.Space(4f); + + bool newAF = GUILayout.Toggle(s.AutoFreezeEnabled, + " Skip Verlet tick for consists that are far away and nearly stopped"); + if (newAF != s.AutoFreezeEnabled) + { + s.AutoFreezeEnabled = newAF; + ConsistFreezer.AutoFreezeEnabled = newAF; + changed = true; + } + + if (s.AutoFreezeEnabled) + { + GUILayout.Space(4f); + + GUILayout.BeginHorizontal(); + GUILayout.Label($"Freeze distance: {s.AutoFreezeDistance:F0} m", GUILayout.Width(200f)); + float newFDist = GUILayout.HorizontalSlider(s.AutoFreezeDistance, 50f, 1000f, GUILayout.Width(200f)); + GUILayout.EndHorizontal(); + GUILayout.Label(" Consists with no car closer than this are eligible to freeze.", GUI.skin.label); + if (Mathf.Abs(newFDist - s.AutoFreezeDistance) > 1f) + { + s.AutoFreezeDistance = Mathf.Round(newFDist); + ConsistFreezer.AutoFreezeDistance = s.AutoFreezeDistance; + changed = true; + } + + GUILayout.Space(4f); + + GUILayout.BeginHorizontal(); + GUILayout.Label($"Speed threshold: {s.AutoFreezeSpeedThreshold * 2.23694f:F1} mph", GUILayout.Width(200f)); + float newSpd = GUILayout.HorizontalSlider(s.AutoFreezeSpeedThreshold * 2.23694f, 0f, 10f, GUILayout.Width(200f)); + GUILayout.EndHorizontal(); + GUILayout.Label(" Consist must be slower than this to be eligible.", GUI.skin.label); + float newSpdMs = newSpd / 2.23694f; + if (Mathf.Abs(newSpdMs - s.AutoFreezeSpeedThreshold) > 0.01f) + { + s.AutoFreezeSpeedThreshold = newSpdMs; + ConsistFreezer.AutoFreezeSpeedThreshold = s.AutoFreezeSpeedThreshold; + changed = true; + } + } + + GUILayout.Space(12f); + + // ── Locomotive Exclusions ───────────────────────────────────────────────── + GUILayout.Label("Locomotive Exclusions", GUILayout.ExpandWidth(false)); + GUILayout.Space(4f); + + bool newExLOD = GUILayout.Toggle(s.ExcludeLocosFromLOD, " Exclude locomotives from LOD fast-path"); + if (newExLOD != s.ExcludeLocosFromLOD) + { + s.ExcludeLocosFromLOD = newExLOD; + ConsistLOD.ExcludeLocomotives = newExLOD; + changed = true; + } + + bool newExFreeze = GUILayout.Toggle(s.ExcludeLocosFromFreeze, " Exclude locomotives from Auto Freeze"); + if (newExFreeze != s.ExcludeLocosFromFreeze) + { + s.ExcludeLocosFromFreeze = newExFreeze; + ConsistFreezer.ExcludeLocomotives = newExFreeze; + changed = true; + } + + GUILayout.Space(12f); + + // ── Road Number Filter ──────────────────────────────────────────────────── + GUILayout.Label("Road Number Filter", GUILayout.ExpandWidth(false)); + GUILayout.Space(4f); + + bool newBlEnabled = GUILayout.Toggle(s.BlacklistEnabled, " Enable road number filter"); + if (newBlEnabled != s.BlacklistEnabled) + { + s.BlacklistEnabled = newBlEnabled; + ConsistLOD.BlacklistEnabled = newBlEnabled; + ConsistLOD.InvalidateConsistCache(); + changed = true; + } + + if (s.BlacklistEnabled) + { + GUILayout.Space(4f); + + // Mode toggle + GUILayout.BeginHorizontal(); + GUILayout.Label("Mode:", GUILayout.Width(45f)); + if (GUILayout.Toggle(s.IsBlacklist, " Blacklist (listed consists skip optimizer)", + GUILayout.ExpandWidth(false)) && !s.IsBlacklist) + { + s.IsBlacklist = true; + ConsistLOD.IsBlacklist = true; + ConsistLOD.InvalidateConsistCache(); + changed = true; + } + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + GUILayout.Space(45f); + if (GUILayout.Toggle(!s.IsBlacklist, " Whitelist (only listed consists use optimizer)", + GUILayout.ExpandWidth(false)) && s.IsBlacklist) + { + s.IsBlacklist = false; + ConsistLOD.IsBlacklist = false; + ConsistLOD.InvalidateConsistCache(); + changed = true; + } + GUILayout.EndHorizontal(); + + GUILayout.Space(6f); + + // Add entry + GUILayout.BeginHorizontal(); + GUILayout.Label("Road number:", GUILayout.Width(110f)); + _addInput = GUILayout.TextField(_addInput, GUILayout.Width(160f)); + if (GUILayout.Button("Add", GUILayout.Width(50f))) + { + string trimmed = _addInput.Trim(); + if (trimmed.Length > 0 && !s.RoadNumberList.Contains(trimmed)) + { + s.RoadNumberList = s.RoadNumberList.Append(trimmed).ToArray(); + SyncList(s); + changed = true; + } + _addInput = ""; + } + GUILayout.EndHorizontal(); + GUILayout.Label(" Enter the full display name as shown in-game (e.g. \"UP 1234\").", GUI.skin.label); + + GUILayout.Space(4f); + + // Current entries + if (s.RoadNumberList.Length == 0) + { + GUILayout.Label(" (no entries)", GUI.skin.label); + } + else + { + foreach (string name in s.RoadNumberList) + { + GUILayout.BeginHorizontal(); + GUILayout.Label(name, GUILayout.Width(180f)); + if (GUILayout.Button("Remove", GUILayout.Width(70f))) + _toRemove = name; + GUILayout.EndHorizontal(); + } + + // Apply deferred removal (can't mutate inside foreach) + if (_toRemove != null) + { + s.RoadNumberList = s.RoadNumberList.Where(n => n != _toRemove).ToArray(); + SyncList(s); + _toRemove = null; + changed = true; + } + } + } + + GUILayout.Space(12f); + + // ── Profiler Overlay ────────────────────────────────────────────────────── + GUILayout.Label("Profiler Overlay", GUILayout.ExpandWidth(false)); + GUILayout.Space(4f); + + bool newOverlay = GUILayout.Toggle(s.ShowOverlay, " Show in-game profiler overlay"); + if (newOverlay != s.ShowOverlay) + { + s.ShowOverlay = newOverlay; + if (PhysicsOverlayGUI.Instance != null) + PhysicsOverlayGUI.Instance.Visible = newOverlay; + changed = true; + } + + GUILayout.Space(4f); + + GUILayout.BeginHorizontal(); + GUILayout.Label($"Opacity: {s.OverlayOpacity * 100f:F0}%", GUILayout.Width(110f)); + float newOpacity = GUILayout.HorizontalSlider(s.OverlayOpacity, 0.1f, 1.0f, GUILayout.Width(200f)); + GUILayout.EndHorizontal(); + if (Mathf.Abs(newOpacity - s.OverlayOpacity) > 0.01f) + { + s.OverlayOpacity = newOpacity; + if (PhysicsOverlayGUI.Instance != null) + PhysicsOverlayGUI.Instance.Opacity = s.OverlayOpacity; + changed = true; + } + + GUILayout.Space(12f); + + // ── Debug Visualization ─────────────────────────────────────────────────── + GUILayout.Label("Debug Visualization", GUILayout.ExpandWidth(false)); + GUILayout.Space(4f); + GUILayout.Label(" Tints car models by physics state. Useful for verifying LOD and freeze behaviour.", GUI.skin.label); + GUILayout.Space(4f); + + bool newHF = GUILayout.Toggle(s.DebugHighlightFrozen, " Highlight frozen cars (yellow)"); + if (newHF != s.DebugHighlightFrozen) { s.DebugHighlightFrozen = newHF; changed = true; } + + bool newHFP = GUILayout.Toggle(s.DebugHighlightFastPath, " Highlight fast-path cars (cyan)"); + if (newHFP != s.DebugHighlightFastPath) { s.DebugHighlightFastPath = newHFP; changed = true; } + + bool newHFU = GUILayout.Toggle(s.DebugHighlightFullPath, " Highlight full-accuracy cars (magenta)"); + if (newHFU != s.DebugHighlightFullPath) { s.DebugHighlightFullPath = newHFU; changed = true; } + + GUILayout.EndVertical(); + + if (changed) + PhysicsOptimizerModule.Persist(); + } + + static void SyncList(PhysicsSettings s) + { + ConsistLOD.RoadNumberList.Clear(); + foreach (string n in s.RoadNumberList) + ConsistLOD.RoadNumberList.Add(n); + ConsistLOD.InvalidateConsistCache(); + } +} diff --git a/src/Modules/PhysicsOptimizer/PhysicsTimer.cs b/src/Modules/PhysicsOptimizer/PhysicsTimer.cs new file mode 100644 index 0000000..e66f8e3 --- /dev/null +++ b/src/Modules/PhysicsOptimizer/PhysicsTimer.cs @@ -0,0 +1,227 @@ +using System.Diagnostics; +using System.Reflection; +using System.Text; +using HarmonyLib; +using Model.Physics; +using UnityModManagerNet; + +namespace S3.Modules.PhysicsOptimizer; + +public static class PhysicsTimer +{ + // --- Config --- + public const int RingSize = 300; // ~5 seconds at 60fps + const int SampleFrames = 60; + + // --- ForceActive: bypass AllCarsAtRest() so we always get solver data --- + public static bool ForceActive = false; + + // --- PositionCars sub-timing (dynamically patched — may not be present) --- + public static bool HasPosCarsData { get; private set; } = false; + + // --- Rolling average accumulators --- + static long _tickElapsed; + static long _frameElapsed; + static long _posCarsElapsed; + static int _tickCarSum; + static int _frames; + + static long _lastTickElapsed; + static long _lastFrameElapsed; + static long _lastPosCarsElapsed; + static int _lastTickCarSum; + static int _lastFrames; + + // --- Render frame rolling average (recorded from LateUpdate, not FixedUpdate) --- + static float _renderMsAccum; + static int _renderFrameCount; + static float _lastAvgRenderMs; + public static float LastAvgRenderMs => _lastAvgRenderMs; + + // --- Per-physics-frame ring buffers (graph) --- + public static readonly float[] RingFrame = new float[RingSize]; // total FixedUpdate ms + public static readonly float[] RingTick = new float[RingSize]; // Tick() only ms + public static readonly float[] RingPosCars = new float[RingSize]; // PositionCars ms (0 if not patched) + public static readonly float[] RingRender = new float[RingSize]; // render frame ms at time of physics tick + public static int RingWrite { get; private set; } + + // Most recent render-frame time — updated each LateUpdate, sampled into RingRender each FixedUpdate. + static float _lastInstantRenderMs; + + // Intra-frame accumulators — reset each FixedUpdate + static long _frameTickElapsed; + static long _framePosCarsElapsed; + static int _frameCarCount; + + static readonly double TicksPerMs = 1000.0 / Stopwatch.Frequency; + + // --- Recording --- + + public static void RecordTick(long ticks, int cars) + { + _tickElapsed += ticks; + _frameTickElapsed += ticks; + _tickCarSum += cars; + _frameCarCount += cars; + } + + public static void RecordPosCars(long ticks) + { + _posCarsElapsed += ticks; + _framePosCarsElapsed += ticks; + } + + public static void RecordFrame(long ticks) + { + // Write ring entry + RingFrame[RingWrite] = (float)(ticks * TicksPerMs); + RingTick[RingWrite] = (float)(_frameTickElapsed * TicksPerMs); + RingPosCars[RingWrite] = (float)(_framePosCarsElapsed * TicksPerMs); + RingRender[RingWrite] = _lastInstantRenderMs; + RingWrite = (RingWrite + 1) % RingSize; + + _frameTickElapsed = 0; + _framePosCarsElapsed = 0; + _frameCarCount = 0; + + // Rolling average + _frameElapsed += ticks; + _frames++; + if (_frames < SampleFrames) return; + + _lastTickElapsed = _tickElapsed; + _lastFrameElapsed = _frameElapsed; + _lastPosCarsElapsed = _posCarsElapsed; + _lastTickCarSum = _tickCarSum; + _lastFrames = _frames; + + _tickElapsed = 0; + _frameElapsed = 0; + _posCarsElapsed = 0; + _tickCarSum = 0; + _frames = 0; + } + + // Called from PhysicsOverlayGUI.LateUpdate() to capture render frame time. + public static void RecordRenderFrame(float deltaMs) + { + _lastInstantRenderMs = deltaMs; // sampled into RingRender on the next RecordFrame call + _renderMsAccum += deltaMs; + _renderFrameCount++; + if (_renderFrameCount < SampleFrames) return; + _lastAvgRenderMs = _renderMsAccum / _renderFrameCount; + _renderMsAccum = 0; + _renderFrameCount = 0; + } + + // --- Graph scaling --- + // Returns the max stacked total (render + FixedUpdate) so the chart scales to show all layers. + public static float GetRingMax() + { + float max = 0f; + for (int i = 0; i < RingSize; i++) + { + float total = RingRender[i] + RingFrame[i]; + if (total > max) max = total; + } + return max * 1.15f; + } + + // --- Text report --- + public static string GetReport() + { + if (_lastFrames == 0) + return "Collecting samples..."; + + double frameMs = _lastFrameElapsed * TicksPerMs / _lastFrames; + double tickMs = _lastTickElapsed * TicksPerMs / _lastFrames; + double posCarsMs = _lastPosCarsElapsed * TicksPerMs / _lastFrames; + double otherMs = frameMs - tickMs; + double pct = frameMs > 0 ? tickMs / frameMs * 100.0 : 0; + + var sb = new StringBuilder(); + sb.AppendLine($"FixedUpdate: {frameMs:F3}ms/frame"); + sb.AppendLine($" Tick(): {tickMs:F3}ms/frame ({pct:F0}% of FixedUpdate)"); + + int avgCars = _lastFrames > 0 ? _lastTickCarSum / _lastFrames : 0; + if (avgCars > 0) + sb.AppendLine($" per car: {tickMs / avgCars * 1000.0:F1}µs × {avgCars} cars"); + + if (HasPosCarsData) + { + double integrateMs = tickMs - posCarsMs; + sb.AppendLine($" PosCars: {posCarsMs:F3}ms (3D update)"); + sb.AppendLine($" Integrate: {integrateMs:F3}ms (Verlet+constraints)"); + } + + sb.AppendLine($" Air/other: {otherMs:F3}ms/frame"); + + if (_lastAvgRenderMs > 0) + { + float fps = 1000f / _lastAvgRenderMs; + sb.Append($"Render: {_lastAvgRenderMs:F1}ms/frame ({fps:F0}fps)"); + } + + return sb.ToString(); + } + + // --- Dynamic patch for PositionCars --- + // Called from PhysicsOptimizerModule.OnEnable() after the static patches. + // Gracefully skips if the method doesn't exist under this name (just logs a + // message and leaves RingPosCars empty). + public static void TryPatchPositionCars(Harmony harmony, UnityModManager.ModEntry.ModLogger log) + { + string[] candidates = { + "PositionCars", "UpdateCarPositions", "UpdatePositions", + "PositionAllCars", "MoveAllCars", "UpdateElementPositions" + }; + foreach (string name in candidates) + { + var m = typeof(IntegrationSet).GetMethod(name, + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); + if (m == null) continue; + + harmony.Patch(m, + new HarmonyMethod(typeof(PosCarsTimerPatch), nameof(PosCarsTimerPatch.Prefix)), + new HarmonyMethod(typeof(PosCarsTimerPatch), nameof(PosCarsTimerPatch.Postfix))); + HasPosCarsData = true; + log.Log($"[S3.physics] Patched IntegrationSet.{name} for PositionCars timing."); + return; + } + + // None of the guesses matched — dump all non-property methods so we know what to use. + log.Log("[S3.physics] PositionCars not found. IntegrationSet methods:"); + foreach (var m in typeof(IntegrationSet).GetMethods( + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + { + if (m.IsSpecialName) continue; // skip property accessors + string parms = string.Join(", ", + System.Array.ConvertAll(m.GetParameters(), p => p.ParameterType.Name + " " + p.Name)); + log.Log($"[S3.physics] {m.ReturnType.Name} {m.Name}({parms})"); + } + } +} + +// Static patch class used by TryPatchPositionCars — must be visible to Harmony's IL. +static class PosCarsTimerPatch +{ + public static void Prefix(out long __state) => __state = Stopwatch.GetTimestamp(); + public static void Postfix(long __state) => + PhysicsTimer.RecordPosCars(Stopwatch.GetTimestamp() - __state); +} + +[HarmonyPatch(typeof(IntegrationSet), "Tick")] +static class TickTimerPatch +{ + static void Prefix(out long __state) => __state = Stopwatch.GetTimestamp(); + static void Postfix(IntegrationSet __instance, long __state) => + PhysicsTimer.RecordTick(Stopwatch.GetTimestamp() - __state, __instance.NumberOfCars); +} + +[HarmonyPatch(typeof(TrainController), "FixedUpdate")] +static class FixedUpdateTimerPatch +{ + static void Prefix(out long __state) => __state = Stopwatch.GetTimestamp(); + static void Postfix(long __state) => + PhysicsTimer.RecordFrame(Stopwatch.GetTimestamp() - __state); +} diff --git a/src/Modules/Popout/DetachedPanel.cs b/src/Modules/Popout/DetachedPanel.cs new file mode 100644 index 0000000..5b86374 --- /dev/null +++ b/src/Modules/Popout/DetachedPanel.cs @@ -0,0 +1,393 @@ +using System; +using System.Runtime.InteropServices; +using Helpers; // WorldTransformer (in Assembly-CSharp) +using S3.Core; // Log +using UI.Map; +using UnityEngine; +using UnityEngine.Rendering; + +namespace S3.Modules.Popout { + + // Manages one detached map popout window. + internal class DetachedPanel { + + public bool IsAlive => _windowHandle > 0; + + private static IntPtr _renderEventFunc = IntPtr.Zero; + + private int _windowHandle; + private Camera? _mapCamera; + private RenderTexture? _ownRT; + + private RenderTexture? _savedTargetTexture; + private Rect _savedRect; + private float _savedAspect; + + // Drag / click state + private bool _isDragging; + private bool _didDrag; + private float _dragStartX, _dragStartY; + private float _camStartX, _camStartZ; + + private const float kClickThreshold = 0.02f; + private const float kZoomFactor = 0.9f; + private const float kZoomMin = 100f; + private const float kZoomMax = 10000f; + + // GetAsyncKeyState works regardless of which window has focus. + [DllImport("user32.dll")] private static extern short GetAsyncKeyState(int vKey); + + private bool _prevHotkeyDown; + public bool CloseRequested { get; private set; } + + // Status bar: only push to native when the string changes. + private string _lastStatusText = ""; + + // Menu list refresh — locos every 3 s, locations once. + private float _listTimer = 3f; // trigger immediately on first Update + private bool _locationsSent = false; + + // Map rotation + private float _mapRotationDeg = 0f; + private float _mapCamEulerX = 0f; + private float _mapCamEulerZ = 0f; + private bool _mapSyncPlayer = false; + + // Follow player position (independent of MapEnhancer) + private bool _followPlayer = false; + + + private const int kMaxInputEvents = 64; + private static readonly InputEvent[] s_eventBuffer = new InputEvent[kMaxInputEvents]; + + public DetachedPanel(string title) { + if (_renderEventFunc == IntPtr.Zero) + _renderEventFunc = Native.RRPOPOUT_GetRenderEventFunc(); + + _mapCamera = MapBuilder.Shared?.mapCamera; + _windowHandle = Native.RRPOPOUT_CreateWindow(title, 900, 700); + if (_windowHandle == 0 || _mapCamera == null) return; + + _mapCamEulerX = _mapCamera.transform.eulerAngles.x; + _mapCamEulerZ = _mapCamera.transform.eulerAngles.z; + + Native.RRPOPOUT_SetMapEnhancerInstalled(_windowHandle, MapEnhancerBridge.IsInstalled); + + // Native loaded window_state.ini before showing the window (position/size/topmost + // already applied). Read back the C#-side state and apply it here. + Native.RRPOPOUT_GetPersistedState(_windowHandle, + out int followPlayer, out int syncRotation, + out float mapRotation, out float mapZoom); + + // Preserve the game's original RT dimensions exactly so Camera.pixelWidth and + // Camera.pixelHeight are identical to the in-game map. Icon scripts that scale + // based on pixelWidth/Height will then produce the same sizes as in-game. + // We still set camera.aspect = window ratio so the correct FOV fills the window; + // the RT-vs-window aspect mismatch is cancelled by the camera's horizontal FOV + // expansion and the native blit's corresponding horizontal stretch. + _savedTargetTexture = _mapCamera.targetTexture; + _savedRect = _mapCamera.rect; + _savedAspect = _mapCamera.aspect; + Log.Info($"[popout] mapCam RT={((_mapCamera.targetTexture != null) ? $"{_mapCamera.targetTexture.width}x{_mapCamera.targetTexture.height}" : "null(screen)")} screen={Screen.width}x{Screen.height} rect={_mapCamera.rect} aspect={_mapCamera.aspect:F3}"); + + _mapCamera.orthographicSize = Mathf.Clamp(mapZoom, 100f, 10000f); + if (mapRotation != 0f) ApplyMapRotation(mapRotation); + if (syncRotation != 0) { _mapSyncPlayer = true; Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, true); } + if (followPlayer != 0) { _followPlayer = true; Native.RRPOPOUT_SetFollowPlayer(_windowHandle, true); } + + CreateOwnRT(); + _prevHotkeyDown = IsHotkeyDown(); + } + + public void Update() { + if (_windowHandle == 0 || _mapCamera == null || _ownRT == null) return; + + Native.RRPOPOUT_GetWindowSize(_windowHandle, out int w, out int h); + if (w == 0 && h == 0) { _windowHandle = 0; return; } + + // RT dimensions are fixed at the game's original map RT size — never resized. + // Only aspect changes as the window is resized, so the camera renders the + // correct FOV for the current window shape. + _mapCamera.enabled = true; + _mapCamera.targetTexture = _ownRT; + _mapCamera.rect = new Rect(0f, 0f, 1f, 1f); + _mapCamera.aspect = (float)w / h; + + var inGameRT = PanelFinder.GetMapRenderTexture(); + if (inGameRT != null && inGameRT != _ownRT) + Graphics.Blit(_ownRT, inGameRT); + + Native.RRPOPOUT_SetFrameTexture(_windowHandle, + _ownRT.GetNativeTexturePtr(), 0f, 1f, 1f, 0f); + GL.IssuePluginEvent(_renderEventFunc, _windowHandle); + + // --- Status bar --- + UpdateStatusText(); + + // --- Menu list refresh --- + _listTimer += Time.deltaTime; + if (_listTimer >= 3f) { + _listTimer = 0f; + PushLocoList(); + if (!_locationsSent) { PushLocationList(); _locationsSent = true; } + } + + // --- Follow player position --- + if (_followPlayer) { + if (Camera.main != null) { + var p = _mapCamera!.transform.position; + p.x = Camera.main.transform.position.x; + p.z = Camera.main.transform.position.z; + _mapCamera.transform.position = p; + } else { + _followPlayer = false; + Native.RRPOPOUT_SetFollowPlayer(_windowHandle, false); + } + } + + // --- Map rotation sync --- + if (_mapSyncPlayer) { + if (Camera.main != null) + ApplyMapRotation(Camera.main.transform.eulerAngles.y); + else { + _mapSyncPlayer = false; + Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, false); + } + } + + // --- Input --- + int count = Native.RRPOPOUT_PollInputEvents(_windowHandle, s_eventBuffer, kMaxInputEvents); + for (int i = 0; i < count; i++) + ForwardInputEvent(s_eventBuffer[i]); + + // Hotkey mirror + bool hotkeyNow = IsHotkeyDown(); + if (hotkeyNow && !_prevHotkeyDown) CloseRequested = true; + _prevHotkeyDown = hotkeyNow; + } + + // --------------------------------------------------------------------------- + private void ApplyMapRotation(float deg) { + _mapRotationDeg = ((deg % 360f) + 360f) % 360f; + if (_mapCamera != null) + _mapCamera.transform.rotation = Quaternion.Euler(_mapCamEulerX, _mapRotationDeg, _mapCamEulerZ); + Native.RRPOPOUT_SetMapRotation(_windowHandle, _mapRotationDeg); + } + + // --------------------------------------------------------------------------- + private void UpdateStatusText() { + string follow = MapEnhancerBridge.IsInstalled + ? (MapEnhancerBridge.FollowMode ? " · Following" : "") + : ""; + + string mapCoord = ""; + string camCoord = ""; + try { + // Map camera position → game coordinates. + var mapPt = WorldTransformer.WorldToGame(_mapCamera!.transform.position); + mapCoord = $" · Map ({(int)mapPt.x}, {(int)mapPt.z})"; + + // Player/main camera position → game coordinates. + if (Camera.main != null) { + var camPt = WorldTransformer.WorldToGame(Camera.main.transform.position); + camCoord = $" · Cam ({(int)camPt.x}, {(int)camPt.z})"; + } + } catch { /* WorldTransformer not yet ready — silently omit coords */ } + + string status = $"Zoom: {(int)_mapCamera!.orthographicSize}{follow}{mapCoord}{camCoord}"; + if (status == _lastStatusText) return; + Native.RRPOPOUT_SetStatusText(_windowHandle, status); + Native.RRPOPOUT_SetMapZoom(_windowHandle, _mapCamera!.orthographicSize); + _lastStatusText = status; + } + + private void PushLocoList() { + try { + var names = MapEnhancerBridge.GetLocoNames(); + Native.RRPOPOUT_SetLocoList(_windowHandle, + names.Length > 0 ? string.Join("\n", names) : ""); + } catch { } + } + + private void PushLocationList() { + try { + var names = MapEnhancerBridge.GetLocationNames(); + Native.RRPOPOUT_SetLocationList(_windowHandle, + names.Length > 0 ? string.Join("\n", names) : ""); + } catch { } + } + + // Returns true while the configured hotkey combination is held. + private static bool IsHotkeyDown() { + var kb = PopoutModule.Hotkey; + bool wantShift = (kb.modifiers & 1) != 0; + bool wantCtrl = (kb.modifiers & 2) != 0; + bool wantAlt = (kb.modifiers & 4) != 0; + if (IsDown(0x10) != wantShift) return false; + if (IsDown(0x11) != wantCtrl ) return false; + if (IsDown(0x12) != wantAlt ) return false; + return IsDown(ToVirtualKey(kb.keyCode)); + } + + private static bool IsDown(int vk) => (GetAsyncKeyState(vk) & 0x8000) != 0; + + private static int ToVirtualKey(KeyCode k) { + int c = (int)k; + if (c >= 97 && c <= 122) return c - 32; + if (c >= 282 && c <= 296) return 0x70 + (c - 282); + return c; + } + + public void Destroy() { + if (_mapCamera != null) { + _mapCamera.targetTexture = _savedTargetTexture; + _mapCamera.rect = _savedRect; + _mapCamera.aspect = _savedAspect; + _mapCamera.transform.rotation = Quaternion.Euler(_mapCamEulerX, 0f, _mapCamEulerZ); + _mapCamera = null; + } + if (_ownRT != null) { + _ownRT.Release(); + UnityEngine.Object.Destroy(_ownRT); + _ownRT = null; + } + if (_windowHandle != 0) { + Native.RRPOPOUT_DestroyWindow(_windowHandle); + _windowHandle = 0; + } + } + + // --------------------------------------------------------------------------- + private void CreateOwnRT() { + if (_ownRT != null) { _ownRT.Release(); UnityEngine.Object.Destroy(_ownRT); } + // Use the game's original RT dimensions so Camera.pixelWidth AND pixelHeight + // are identical to the in-game map. camera.aspect is updated each frame in + // Update() to match the current window shape. + int rtW = _savedTargetTexture != null ? _savedTargetTexture.width : Mathf.Min(Screen.width, 1920); + int rtH = _savedTargetTexture != null ? _savedTargetTexture.height : Mathf.Min(Screen.height, 1080); + _ownRT = new RenderTexture(rtW, rtH, 24, RenderTextureFormat.ARGB32) { name = "RRPopout_MapRT" }; + _ownRT.Create(); + if (_mapCamera != null) { + _mapCamera.targetTexture = _ownRT; + _mapCamera.rect = new Rect(0f, 0f, 1f, 1f); + } + Canvas.ForceUpdateCanvases(); + + // Apply the correct icon scale for the current zoom now that the map renders + // into our RT. MapBuilder.UpdateForZoom() recomputes MapBuilder.IconScale from + // the camera's orthographic size and rescales every map icon, matching how the + // game refreshes the map after a zoom. + PanelFinder.UpdateMapForZoom(); + } + + private void ForwardInputEvent(in InputEvent e) { + var cam = _mapCamera; + if (cam == null) return; + + var viewportPos = new Vector2(e.x, 1f - e.y); + + switch ((InputEventType)e.type) { + + case InputEventType.MouseWheel: + cam.orthographicSize = Mathf.Clamp( + cam.orthographicSize * Mathf.Pow(kZoomFactor, e.delta), + kZoomMin, kZoomMax); + // The game rescales every map icon (locos, junctions, stations, flares) + // inside MapBuilder.UpdateForZoom(), which is normally driven by + // MapWindow.OnZoom(). Because we set orthographicSize directly, we must + // trigger it ourselves — otherwise icons keep their previous pixel size + // as you zoom in/out. This is the same call MapEnhancer makes after it + // adjusts the orthographic size. + PanelFinder.UpdateMapForZoom(); + break; + + case InputEventType.LButtonDown: + _isDragging = true; + _didDrag = false; + _dragStartX = e.x; _dragStartY = e.y; + _camStartX = cam.transform.position.x; + _camStartZ = cam.transform.position.z; + // Dragging cancels position follow (same behaviour as Google Maps grab-to-pan) + if (_followPlayer) { + _followPlayer = false; + Native.RRPOPOUT_SetFollowPlayer(_windowHandle, false); + } + break; + + case InputEventType.LButtonUp: + if (!_didDrag) + PanelFinder.GetMapDrag()?.OnClick?.Invoke(viewportPos); + _isDragging = false; + _didDrag = false; + break; + + case InputEventType.MouseMove: + if (!_isDragging) break; + if (Mathf.Abs(e.x - _dragStartX) > kClickThreshold || + Mathf.Abs(e.y - _dragStartY) > kClickThreshold) + _didDrag = true; + if (!_didDrag) break; + float orthoSize = cam.orthographicSize; + float aspect = cam.aspect > 0f ? cam.aspect : 1.7f; + // Screen-space drag scaled to world units + float dxScreen = (e.x - _dragStartX) * orthoSize * 2f * aspect; + float dzScreen = (e.y - _dragStartY) * orthoSize * 2f; + // Rotate screen drag into world space using camera orientation. + // For top-down camera: right and up are horizontal (Y≈0) so no projection needed. + // Formula preserves original sign conventions at rotDeg=0. + Vector3 worldDelta = -cam.transform.right * dxScreen + cam.transform.up * dzScreen; + cam.transform.position = new Vector3( + _camStartX + worldDelta.x, cam.transform.position.y, _camStartZ + worldDelta.z); + break; + + case InputEventType.RButtonUp: + UnityEngine.Object.FindObjectOfType()?.ClickLocateMe(); + break; + + case InputEventType.UICommand: + switch ((UICmd)(int)e.x) { + case UICmd.Recenter: + UnityEngine.Object.FindObjectOfType()?.ClickLocateMe(); + break; + case UICmd.FollowMode: + MapEnhancerBridge.ToggleFollowMode(); + break; + case UICmd.FollowPlayerCam: + MapEnhancerBridge.FollowPlayerCamera(); + break; + case UICmd.FollowSelectedLoco: + MapEnhancerBridge.FollowSelectedLoco(); + break; + case UICmd.FollowLoco: + MapEnhancerBridge.FollowLoco((int)e.y); + break; + case UICmd.JumpToLocation: + MapEnhancerBridge.JumpToLocation((int)e.y); + break; + case UICmd.SetRotation: + _mapSyncPlayer = false; + Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, false); + ApplyMapRotation(e.y); + break; + case UICmd.RotateReset: + _mapSyncPlayer = false; + Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, false); + ApplyMapRotation(0f); + break; + case UICmd.RotateSyncPlayer: + _mapSyncPlayer = !_mapSyncPlayer; + Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, _mapSyncPlayer); + break; + case UICmd.ToggleFollowPlayer: + _followPlayer = !_followPlayer; + // Cancel any ME follow mode to avoid conflicting camera updates + if (_followPlayer && MapEnhancerBridge.IsInstalled && MapEnhancerBridge.FollowMode) + MapEnhancerBridge.ToggleFollowMode(); + Native.RRPOPOUT_SetFollowPlayer(_windowHandle, _followPlayer); + break; + } + break; + } + } + } +} diff --git a/src/Modules/Popout/MapEnhancerBridge.cs b/src/Modules/Popout/MapEnhancerBridge.cs new file mode 100644 index 0000000..e3a1d14 --- /dev/null +++ b/src/Modules/Popout/MapEnhancerBridge.cs @@ -0,0 +1,166 @@ +using System; +using System.Linq; +using Character; // CameraSelector +using Game.State; // SpawnPoint +using HarmonyLib; +using Helpers; // WorldTransformer (in Assembly-CSharp) +using Model; // TrainController, Car +using Track; // Graph +using UI.Map; // MapBuilder +using UnityEngine; +using UnityModManagerNet; + +namespace S3.Modules.Popout { + + // Detects MapEnhancer at runtime (no compile-time reference needed) and + // exposes the features we want to control from the popout toolbar. + // + // All MapEnhancer access is via Harmony Traverse so we never import MapEnhancer.dll. + // If MapEnhancer is not installed every ME-specific call is a silent no-op. + // Location jumping and loco listing work regardless of MapEnhancer. + internal static class MapEnhancerBridge { + + private static bool? _installed; + private static MonoBehaviour? _instance; + + // True if MapEnhancer is installed and active this session. + public static bool IsInstalled { + get { + if (_installed.HasValue) return _installed.Value; + var mod = FindMod("MapEnhancer"); + _installed = mod != null && mod.Active; + return _installed.Value; + } + } + + // Current value of the private mapFollowMode field. + public static bool FollowMode { + get { + var me = GetInstance(); + return me != null && Traverse.Create(me).Field("mapFollowMode").Value; + } + } + + public static void ToggleFollowMode() { + var me = GetInstance(); + if (me == null) return; + bool current = Traverse.Create(me).Field("mapFollowMode").Value; + Traverse.Create(me).Field("mapFollowMode").SetValue(!current); + } + + // Continuously follow Camera.main (player perspective). + // Requires MapEnhancer for continuous tracking; without it this is a no-op + // since DetachedPanel doesn't implement its own follow loop. + public static void FollowPlayerCamera() { + var me = GetInstance(); + if (me == null) return; + Traverse.Create(me).Field("mapFollowMode").SetValue(true); + Traverse.Create(me).Field("mapCameraTarget").SetValue(Camera.main); + } + + // Continuously follow TrainController.Shared.SelectedCar. + public static void FollowSelectedLoco() { + if (TrainController.Shared?.SelectedCar == null) return; + var me = GetInstance(); + if (me == null) { + // No MapEnhancer: just jump the map camera to the loco's position. + JumpToCarPosition(TrainController.Shared.SelectedCar); + return; + } + Traverse.Create(me).Field("mapFollowMode").SetValue(true); + Traverse.Create(me).Field("mapCameraTarget").SetValue(TrainController.Shared.SelectedCar); + } + + // Follow the loco at the given index in the sorted loco list. + // With MapEnhancer: enables continuous follow. + // Without MapEnhancer: one-time camera jump to the loco. + public static void FollowLoco(int index) { + var locos = GetLocosSorted(); + if (index < 0 || index >= locos.Length) return; + var car = locos[index]; + var me = GetInstance(); + if (me != null) { + Traverse.Create(me).Field("mapFollowMode").SetValue(true); + Traverse.Create(me).Field("mapCameraTarget").SetValue(car); + } else { + JumpToCarPosition(car); + } + } + + // Return sorted loco display names for the ImGui menu. + public static string[] GetLocoNames() { + try { + return GetLocosSorted() + .Select(c => (!string.IsNullOrEmpty(c.SortName) ? c.SortName : c.Ident.ReportingMark)) + .ToArray(); + } catch { return Array.Empty(); } + } + + // Jump the map camera to the named location (does not require MapEnhancer). + public static void JumpToLocation(int index) { + try { + var locs = GetLocationsSorted(); + if (index < 0 || index >= locs.Length) return; + var sp = locs[index]; + var mapCam = MapBuilder.Shared?.mapCamera?.transform; + if (mapCam == null) return; + + // Pattern from MapEnhancer: convert game position, preserve camera altitude. + var (gamePos, _) = sp.GamePositionRotation; + var worldPos = WorldTransformer.GameToWorld(gamePos); + worldPos.y = mapCam.position.y; + mapCam.position = worldPos; + + // Disable follow so the camera stays put after the jump. + var me = GetInstance(); + if (me != null) + Traverse.Create(me).Field("mapCameraTarget").SetValue(null); + } catch { } + } + + // Return sorted location names for the ImGui menu. + public static string[] GetLocationNames() { + try { + return GetLocationsSorted().Select(sp => sp.name).ToArray(); + } catch { return Array.Empty(); } + } + + // --------------------------------------------------------------------------- + private static Car[] GetLocosSorted() => + TrainController.Shared?.Cars + .Where(c => c.IsLocomotive) + .OrderBy(c => c.SortName) + .ToArray() ?? Array.Empty(); + + private static SpawnPoint[] GetLocationsSorted() => + SpawnPoint.All + .Where(sp => !string.Equals(sp.name, "DS", StringComparison.OrdinalIgnoreCase)) + .OrderBy(sp => sp.name) + .ToArray(); + + private static void JumpToCarPosition(Car car) { + try { + var mapCam = MapBuilder.Shared?.mapCamera?.transform; + if (mapCam == null) return; + var worldPos = WorldTransformer.GameToWorld(car.GetCenterPosition(Track.Graph.Shared)); + worldPos.y = mapCam.position.y; + mapCam.position = worldPos; + } catch { } + } + + private static UnityModManager.ModEntry? FindMod(string id) { + foreach (var m in UnityModManager.modEntries) + if (m.Info.Id == id) return m; + return null; + } + + private static MonoBehaviour? GetInstance() { + if (!IsInstalled) return null; + if (_instance != null) return _instance; + var type = Type.GetType("MapEnhancer.MapEnhancer, MapEnhancer"); + if (type == null) return null; + _instance = UnityEngine.Object.FindObjectOfType(type) as MonoBehaviour; + return _instance; + } + } +} diff --git a/src/Modules/Popout/MapWindowButton.cs b/src/Modules/Popout/MapWindowButton.cs new file mode 100644 index 0000000..17b369a --- /dev/null +++ b/src/Modules/Popout/MapWindowButton.cs @@ -0,0 +1,54 @@ +using UI; +using UI.Builder; +using UI.Map; +using UnityEngine; +using UnityEngine.UI; + +namespace S3.Modules.Popout { + + // Adds a "Pop Out / Re-attach" button to the in-game map window title bar + // using the game's own UIPanel / UIPanelBuilder system so it matches the + // factory UI styling exactly. + internal static class MapWindowButton { + + private static Button? s_button; + + internal static void TryInstall() { + if (s_button != null) return; + + var win = PanelFinder.GetMapWindowUI(); + // Don't install until the map has been opened at least once. + if (win == null || !win.IsShown) return; + + // Already added — recover reference after a hot-reload + var existing = win.transform.Find("RRPopout_Btn"); + if (existing != null) { + s_button = existing.GetComponentInChildren