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.
This commit is contained in:
Seton Carmichael 2026-06-17 13:57:39 -04:00
commit a1fcf01125
47 changed files with 4944 additions and 0 deletions

16
.gitignore vendored Normal file
View file

@ -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

13
Directory.Build.props Normal file
View file

@ -0,0 +1,13 @@
<Project>
<!-- Path to the Railroader install. Every managed HintPath and the native
install step resolve from this. Override on the command line or via an
environment variable of the same name if your install lives elsewhere:
dotnet build /p:GameDir="X:\path\to\Railroader" -->
<PropertyGroup>
<GameDir Condition="'$(GameDir)' == ''">D:\Seton\SteamApps\steamapps\common\Railroader</GameDir>
<GameManaged>$(GameDir)\Railroader_Data\Managed</GameManaged>
<UmmDir>$(GameManaged)\UnityModManager</UmmDir>
</PropertyGroup>
</Project>

11
Info.json Normal file
View file

@ -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": []
}

54
README.md Normal file
View file

@ -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-<version>.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
```

67
dist/build-common.ps1 vendored Normal file
View file

@ -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
}
}

20
dist/build-local.ps1 vendored Normal file
View file

@ -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

31
dist/build-release.ps1 vendored Normal file
View file

@ -0,0 +1,31 @@
# Build S³ and package a drag-install zip for Forgejo downloads.
# .\dist\build-release.ps1
# Produces dist\SetonsSpecialSauce-<version>.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

72
native/CMakeLists.txt Normal file
View file

@ -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 $<$<CONFIG:Release>:/Zi>)
target_link_options(RRPopout PRIVATE $<$<CONFIG:Release>:/DEBUG /OPT:REF /OPT:ICF>)
endif()

31
native/external/IUnityGraphics.h vendored Normal file
View file

@ -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 };

15
native/external/IUnityGraphicsD3D11.h vendored Normal file
View file

@ -0,0 +1,15 @@
#pragma once
#include "IUnityInterface.h"
#include <d3d11.h>
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 };

35
native/external/IUnityInterface.h vendored Normal file
View file

@ -0,0 +1,35 @@
#pragma once
#include <stdint.h>
// 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<typename T>
T* Get() {
return static_cast<T*>(GetInterfaceByGUID(T::GUID));
}
};

View file

@ -0,0 +1,42 @@
#pragma once
#include <cstdint>
// 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");

View file

@ -0,0 +1,594 @@
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <d3d11.h>
#include <dxgi.h>
#include <d3dcompiler.h>
#include <wrl/client.h>
#include <cstdio>
#include <cstring>
#include <mutex>
#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<ID3D11VertexShader> g_vs;
static ComPtr<ID3D11PixelShader> g_ps;
static ComPtr<ID3D11Buffer> g_uvRectCB;
static ComPtr<ID3D11SamplerState> g_sampler;
static ComPtr<ID3D11BlendState> g_blendOpaque;
static ComPtr<ID3D11RasterizerState> g_rasterState;
static ComPtr<ID3D11DepthStencilState> 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<ID3D11RenderTargetView> rtv[8];
ComPtr<ID3D11DepthStencilView> dsv;
UINT numViewports = D3D11_VIEWPORT_AND_SCISSORRECT_OBJECT_COUNT_PER_PIPELINE;
D3D11_VIEWPORT viewports[D3D11_VIEWPORT_AND_SCISSORRECT_OBJECT_COUNT_PER_PIPELINE];
ComPtr<ID3D11VertexShader> vs;
ComPtr<ID3D11PixelShader> ps;
ComPtr<ID3D11Buffer> vsCB;
ComPtr<ID3D11InputLayout> inputLayout;
D3D11_PRIMITIVE_TOPOLOGY topology = D3D11_PRIMITIVE_TOPOLOGY_UNDEFINED;
ComPtr<ID3D11ShaderResourceView> psSRV[1];
ComPtr<ID3D11SamplerState> psSampler[1];
ComPtr<ID3D11BlendState> blendState;
FLOAT blendFactor[4];
UINT sampleMask;
ComPtr<ID3D11RasterizerState> rasterizerState;
ComPtr<ID3D11DepthStencilState> 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<ID3DBlob> 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<IDXGIDevice> dxgiDevice; ComPtr<IDXGIAdapter> adapter; ComPtr<IDXGIFactory> 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<UINT>(w);
scd.BufferDesc.Height = static_cast<UINT>(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<ID3D11Texture2D*>(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<ID3D11Texture2D*>(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<ID3D11ShaderResourceView> 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<UVRectCB*>(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<float>(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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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);
}

View file

@ -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);

19
native/src/dllmain.cpp Normal file
View file

@ -0,0 +1,19 @@
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
// 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;
}

214
native/src/exports.cpp Normal file
View file

@ -0,0 +1,214 @@
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <d3d11.h>
#include <cstring>
#include <mutex>
#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 <cstdint>
// ---------------------------------------------------------------------------
// 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<IUnityGraphicsD3D11>();
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<IUnityGraphics>();
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<int32_t>(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<PopoutWindow::ImMenuItem>& list, std::mutex& mtx) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (!win || !names) return;
std::vector<PopoutWindow::ImMenuItem> 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<std::mutex> 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<std::mutex> 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(); }

32
native/src/input_queue.h Normal file
View file

@ -0,0 +1,32 @@
#pragma once
#include <queue>
#include <mutex>
#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<std::mutex> 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<std::mutex> 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<InputEvent> m_queue;
std::mutex m_mutex;
};

View file

@ -0,0 +1,89 @@
#pragma once
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <d3d11.h>
#include <atomic>
#include <mutex>
#include <cstring>
#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<int> width {800};
std::atomic<int> height {600};
// Texture + UV rect written by RRPOPOUT_SetFrameTexture on the main thread,
// read by Renderer_Present on the render thread.
std::atomic<void*> pendingTexture {nullptr};
std::atomic<float> srcU0 {0.f}, srcV0 {0.f};
std::atomic<float> srcU1 {1.f}, srcV1 {1.f};
std::atomic<bool> resizing {false};
std::atomic<bool> destroyRequested {false};
std::atomic<bool> alive {true};
InputQueue inputQueue;
// -----------------------------------------------------------------------
// ImGui input state
// Written by the Win32 message pump thread; consumed by the render thread.
// -----------------------------------------------------------------------
std::atomic<float> imMouseX {-1.f};
std::atomic<float> imMouseY {-1.f};
std::atomic<bool> imLButton {false};
std::atomic<bool> imRButton {false};
// Wheel accumulator in raw WHEEL_DELTA units (120 per notch).
// fetch_add on message pump; exchange(0) on render thread.
std::atomic<int> imWheelRaw {0};
// Written by render thread after each ImGui frame.
// When true, PollInputEvents suppresses mouse events so they don't reach Unity.
std::atomic<bool> 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<ImMenuItem> imLocoList;
std::mutex imLocoMutex;
std::vector<ImMenuItem> imLocationList;
std::mutex imLocationMutex;
// -----------------------------------------------------------------------
// Map rotation + ME flag (C# → render thread via exports)
// -----------------------------------------------------------------------
std::atomic<float> imMapRotationDeg {0.f};
std::atomic<float> imMapZoom {500.f};
std::atomic<bool> imMapSyncPlayer {false};
std::atomic<bool> imMapEnhancerInstalled{false};
std::atomic<bool> imFollowPlayer {false};
std::atomic<bool> imAlwaysOnTop {false};
// Loaded geometry from the save file — consumed by MessagePumpThread before CreateWindowExW.
std::atomic<int> lastWinX {-1}, lastWinY {-1};
std::atomic<int> lastWinW {900}, lastWinH {700};
PopoutWindow() {
strncpy_s(imStatusText, sizeof(imStatusText),
"Railroader \xe2\x80\x94 Map", _TRUNCATE); // UTF-8 em-dash
}
};

View file

@ -0,0 +1,490 @@
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <dwmapi.h> // DwmSetWindowAttribute
#include <shellapi.h> // ExtractIconExW
#include <windowsx.h> // GET_X_LPARAM, GET_Y_LPARAM
#include "../include/shared_types.h"
#include <unordered_map>
#include <mutex>
#include <atomic>
#include <cstdio> // FILE*, fopen_s, fprintf, fgets
#include <cstring> // strcmp, strchr
#include <string> // std::wstring
#include "popout_window.h"
#include "popout_windows.h" // WM_RRPOPOUT_SET_TOPMOST constant
#include "d3d11_renderer.h"
// ---------------------------------------------------------------------------
// Persistent state — saved to <GameRoot>/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<int, PopoutWindow*> g_windows;
static std::mutex g_windowsMutex;
static std::atomic<int> 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<CreateParams*>(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<LPVOID>(static_cast<intptr_t>(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<int(WINAPI*)(int)>(GetProcAddress(ux, MAKEINTRESOURCEA(135))))
fn(1 /* AllowDark */);
});
if (HMODULE ux = GetModuleHandleW(L"uxtheme.dll"))
if (auto fn = reinterpret_cast<bool(WINAPI*)(HWND,bool)>(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<std::mutex> 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<PopoutWindow*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
switch (msg) {
case WM_CREATE: {
auto* cs = reinterpret_cast<CREATESTRUCTW*>(lParam);
int handle = static_cast<int>(reinterpret_cast<intptr_t>(cs->lpCreateParams));
std::lock_guard<std::mutex> lock(g_windowsMutex);
auto it = g_windows.find(handle);
if (it != g_windows.end()) {
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(it->second));
win = it->second;
win->hwnd = hwnd;
}
return 0;
}
case WM_SIZE: {
int cw = static_cast<int>(LOWORD(lParam));
int ch = static_cast<int>(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<float>(GET_X_LPARAM(lParam));
float py = static_cast<float>(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<float>(GET_X_LPARAM(lParam)) / cw;
e.y = static_cast<float>(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<float>(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<float>(pt.x) / cw; e.y = static_cast<float>(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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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);
}

View file

@ -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);

38
src/Core/IModule.cs Normal file
View file

@ -0,0 +1,38 @@
namespace S3.Core;
/// <summary>
/// One optional feature of S³. Modules are registered in <see cref="Main.Load"/>,
/// disabled by default, and toggled from the in-game settings panel.
///
/// Toggle semantics (current): a module's enabled state is read once at load.
/// <see cref="OnEnable"/> is called only for modules enabled at startup; flipping
/// the toggle persists the choice and applies on the next game launch. The
/// <see cref="OnDisable"/> hook exists so live toggling can be wired in later
/// without changing the module contract.
/// </summary>
public interface IModule
{
/// <summary>Stable key used for settings and the Harmony patch owner id.</summary>
string Id { get; }
string DisplayName { get; }
string Description { get; }
/// <summary>
/// Backed by the module's own settings block so the value persists. The
/// registry reads this to decide whether to call <see cref="OnEnable"/>.
/// </summary>
bool Enabled { get; set; }
/// <summary>Apply patches, spawn GameObjects, load native deps, etc.</summary>
void OnEnable();
/// <summary>Reverse of <see cref="OnEnable"/>. Reserved for live toggling.</summary>
void OnDisable();
/// <summary>IMGUI body shown inside this module's foldout in the settings panel.</summary>
void DrawSettings();
/// <summary>Persist this module's own settings file. Called on toggle and on window close.</summary>
void SaveSettings();
}

18
src/Core/Log.cs Normal file
View file

@ -0,0 +1,18 @@
using UnityModManagerNet;
namespace S3.Core;
/// <summary>
/// Thin static wrapper over the UMM mod logger so every module can log without
/// holding a ModEntry reference. Initialized once from <see cref="Main.Load"/>.
/// </summary>
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);
}

83
src/Core/ModConflicts.cs Normal file
View file

@ -0,0 +1,83 @@
using System.Collections.Generic;
using UnityEngine;
using UnityModManagerNet;
namespace S3.Core;
/// <summary>
/// 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 <c>Toggleable</c> (no OnToggle), so
/// it can't be torn down live. Setting <c>ModEntry.Enabled = false</c> +
/// <c>SaveSettingsAndParams()</c> persists the choice; it takes effect on the
/// next launch — identical to unchecking it in the UMM panel by hand.
/// </summary>
public static class ModConflicts
{
/// <summary>Legacy mod id → friendly name. These are the mods S³ supersedes.</summary>
private static readonly Dictionary<string, string> 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<UnityModManager.ModEntry> EnabledConflicts()
{
var found = new List<UnityModManager.ModEntry>();
foreach (string id in Legacy.Keys)
{
UnityModManager.ModEntry? m = UnityModManager.FindMod(id);
if (m != null && m.Enabled)
found.Add(m);
}
return found;
}
/// <summary>Draws the warning banner + disable button at the top of the settings page.</summary>
public static void DrawBanner()
{
List<UnityModManager.ModEntry> 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("<b>⚠ Conflicting standalone mods detected</b>");
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);
}
}

View file

@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
namespace S3.Core;
/// <summary>
/// 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.
/// </summary>
public sealed class ModuleRegistry
{
private readonly List<IModule> _modules = new();
public IReadOnlyList<IModule> Modules => _modules;
public void Register(IModule module) => _modules.Add(module);
/// <summary>Call <see cref="IModule.OnEnable"/> on every module currently enabled.</summary>
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}");
}
}
}
/// <summary>Persist every module's settings (called from UMM's OnSaveGUI).</summary>
public void SaveAll()
{
foreach (IModule m in _modules)
{
try { m.SaveSettings(); }
catch (Exception e) { Log.Error($"[{m.Id}] failed to save settings: {e}"); }
}
}
}

77
src/Core/SettingsPanel.cs Normal file
View file

@ -0,0 +1,77 @@
using System.Collections.Generic;
using UnityEngine;
namespace S3.Core;
/// <summary>
/// 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.
/// </summary>
public static class SettingsPanel
{
private static readonly HashSet<string> _expanded = new();
private static readonly Dictionary<string, bool> _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);
}
}

52
src/Core/SettingsStore.cs Normal file
View file

@ -0,0 +1,52 @@
using System;
using System.IO;
using UnityEngine;
namespace S3.Core;
/// <summary>
/// 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.
/// </summary>
public static class SettingsStore
{
private static string _dir = "";
public static void Init(string modPath) => _dir = modPath;
public static T Load<T>(string fileName) where T : class, new()
{
try
{
string path = Path.Combine(_dir, fileName);
if (File.Exists(path))
{
T? obj = JsonUtility.FromJson<T>(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<T>(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}");
}
}
}

42
src/Main.cs Normal file
View file

@ -0,0 +1,42 @@
using S3.Core;
using UnityModManagerNet;
namespace S3;
/// <summary>
/// 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 <see cref="SettingsPanel"/>. Each module owns its own flat
/// settings file (see <see cref="SettingsStore"/>).
/// </summary>
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;
}
}

View file

@ -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<Car, Renderer[]> _rendCache = new();
readonly Dictionary<Car, Color> _curr = new();
readonly Dictionary<Car, Color> _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<Car> 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<Renderer>()
: System.Array.Empty<Renderer>();
// 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<Car>();
foreach (var car in _rendCache.Keys)
if (car == null) dead.Add(car);
foreach (var car in dead) _rendCache.Remove(car);
}
}

View file

@ -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<uint> _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<Car> 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
}
}

View file

@ -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<string> 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<Car> FastPathCars = new();
public static readonly HashSet<Car> 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<IntegrationSet, bool> _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();
}
}

View file

@ -0,0 +1,141 @@
using System.Linq;
using System.Text;
using HarmonyLib;
using Model;
using Model.Physics;
using UI.Console;
namespace S3.Modules.PhysicsOptimizer;
/// <summary>
/// Hooks into ConsoleCommandHandler._HandleSlashCommand to add /rpf commands
/// before the game's switch block sees them.
/// </summary>
[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 <meters|off>";
}
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 <meters> 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 <freeze|unfreeze|dump|timing|overlay|forceactive|lod|help>";
}

View file

@ -0,0 +1,99 @@
using System;
using HarmonyLib;
using S3.Core;
using UnityEngine;
namespace S3.Modules.PhysicsOptimizer;
/// <summary>
/// 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. <see cref="Settings"/> is
/// a static instance the overlay/visualizer read directly.
/// </summary>
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<PhysicsSettings>(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<PhysicsOverlayGUI>();
overlay.Visible = Settings.ShowOverlay;
overlay.Opacity = Settings.OverlayOpacity;
go.AddComponent<CarDebugVisualizer>();
}
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;
}
}

View file

@ -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 0100%
// 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 0100% 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;
}
}

View file

@ -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<string>();
}

View file

@ -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("<b>LOD Fast-Path</b>", 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("<b>Auto Freeze</b> (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("<b>Locomotive Exclusions</b>", 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("<b>Road Number Filter</b>", 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("<b>Profiler Overlay</b>", 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("<b>Debug Visualization</b>", 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();
}
}

View file

@ -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);
}

View file

@ -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<MapWindow>()?.ClickLocateMe();
break;
case InputEventType.UICommand:
switch ((UICmd)(int)e.x) {
case UICmd.Recenter:
UnityEngine.Object.FindObjectOfType<MapWindow>()?.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;
}
}
}
}

View file

@ -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<bool>("mapFollowMode").Value;
}
}
public static void ToggleFollowMode() {
var me = GetInstance();
if (me == null) return;
bool current = Traverse.Create(me).Field<bool>("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<string>(); }
}
// 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<string>(); }
}
// ---------------------------------------------------------------------------
private static Car[] GetLocosSorted() =>
TrainController.Shared?.Cars
.Where(c => c.IsLocomotive)
.OrderBy(c => c.SortName)
.ToArray() ?? Array.Empty<Car>();
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;
}
}
}

View file

@ -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<Button>();
return;
}
var pwc = UnityEngine.Object.FindObjectOfType<ProgrammaticWindowCreator>();
if (pwc == null) return;
// Container rect in the title bar — same y-level as the close button.
var container = new GameObject("RRPopout_Btn").AddComponent<RectTransform>();
container.SetParent(win.transform, false);
container.SetInsetAndSizeFromParentEdge(RectTransform.Edge.Top, 3f, 22f);
container.SetInsetAndSizeFromParentEdge(RectTransform.Edge.Right, 40f, 70f);
// Let the game's UIPanel system create a native-styled button.
UIPanel.Create(container, pwc.builderAssets, builder => {
var el = builder.AddButtonCompact("Pop Out", PopoutModule.Toggle);
s_button = el?.RectTransform?.GetComponentInChildren<Button>();
});
}
internal static void UpdateLabel(bool isPopped) {
if (s_button == null) return;
// UIPanelBuilder puts the label in a TMP_Text child of the button.
var label = s_button.GetComponentInChildren<TMPro.TMP_Text>();
if (label != null)
label.text = isPopped ? "Re-attach" : "Pop Out";
}
}
}

View file

@ -0,0 +1,119 @@
using System;
using System.Runtime.InteropServices;
namespace S3.Modules.Popout {
// Must match InputEvent in native/include/shared_types.h exactly.
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct InputEvent {
public int type; // InputEventType (0=Move,1=LDown,2=LUp,3=RDown,4=RUp,5=Wheel)
public float x; // normalized [0,1] left=0
public float y; // normalized [0,1] top=0
public float delta; // wheel delta; positive = scroll up
}
public enum InputEventType {
MouseMove = 0,
LButtonDown = 1,
LButtonUp = 2,
RButtonDown = 3,
RButtonUp = 4,
MouseWheel = 5,
UICommand = 6, // toolbar button; x = UICmd value cast to float
}
public enum UICmd {
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; InputEvent.y = 0-based index
JumpToLocation = 6, // jump to named location; InputEvent.y = 0-based index
SetRotation = 7, // set map rotation; InputEvent.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
}
internal static class Native {
// RRPopout.dll lives in the mod folder (Mods/S3) and is loaded by full path
// via NativeLoader before any of these are called; once loaded, [DllImport]
// binds to it by name. (Pure-UMM install — no game-root copy, no winhttp proxy.)
private const string Dll = "RRPopout";
// Create a floating window. Returns a handle (> 0) or 0 on failure.
// Native loads saved position/size from its state file; width/height are the first-run defaults.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
public static extern int RRPOPOUT_CreateWindow(string title, int width, int height);
// Set the source texture and the UV sub-rect to blit next frame.
// Call this on the main thread immediately before IssuePluginEvent.
// u0,v0 = top-left UV in D3D convention (V=0 at top); u1,v1 = bottom-right.
// Pass (0, 0, 1, 1) for full texture.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetFrameTexture(
int windowHandle, IntPtr texturePtr,
float u0, float v0, float u1, float v1);
// Returns the UnityRenderingEvent callback pointer for GL.IssuePluginEvent.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr RRPOPOUT_GetRenderEventFunc();
// Fills outWidth / outHeight with the current client area size.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_GetWindowSize(int windowHandle, out int outWidth, out int outHeight);
// Drains queued input events from the OS window into outEvents.
// Returns the number of events written (≤ maxEvents).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern int RRPOPOUT_PollInputEvents(
int windowHandle,
[Out] InputEvent[] outEvents,
int maxEvents);
// Close and free the window.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_DestroyWindow(int windowHandle);
// Update the status bar text shown at the bottom of the popout.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
public static extern void RRPOPOUT_SetStatusText(int windowHandle, string text);
// Set the locomotive list shown in the Follow submenu.
// names: loco display names joined by '\n'.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
public static extern void RRPOPOUT_SetLocoList(int windowHandle, string names);
// Set the location list shown in the Jump to location submenu.
// names: location names joined by '\n'.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
public static extern void RRPOPOUT_SetLocationList(int windowHandle, string names);
// Tell the native UI whether MapEnhancer is installed (controls menu layout).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetMapEnhancerInstalled(int windowHandle, bool installed);
// Update the compass display to reflect the current map rotation (degrees).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetMapRotation(int windowHandle, float degrees);
// Highlight (or un-highlight) the sync button in the toolbar.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetMapSyncPlayer(int windowHandle, bool active);
// Highlight (or un-highlight) the follow-player toolbar button.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetFollowPlayer(int windowHandle, bool active);
// Push current map zoom so native can persist it on window close.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetMapZoom(int windowHandle, float zoom);
// Read back the C#-side state that native loaded from its save file.
// followPlayer / syncRotation return 1 (true) or 0 (false).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_GetPersistedState(int windowHandle,
out int followPlayer, out int syncRotation,
out float mapRotation, out float mapZoom);
}
}

View file

@ -0,0 +1,46 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using S3.Core;
namespace S3.Modules.Popout;
/// <summary>
/// Preloads the native RRPopout.dll from the mod folder before any P/Invoke.
///
/// S³ is a pure-UMM install: there is no winhttp proxy and Unity never calls
/// UnityPluginLoad for our plugin. By loading the DLL ourselves via its full path,
/// every [DllImport("RRPopout")] in <see cref="Native"/> then binds to it by name.
/// The native renderer lazily initializes its D3D device from the supplied texture,
/// so the missing UnityPluginLoad is fine — that lazy path becomes the normal one.
/// </summary>
internal static class NativeLoader
{
[DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr LoadLibrary(string lpFileName);
private static bool _loaded;
public static bool EnsureLoaded()
{
if (_loaded) return true;
string dll = Path.Combine(Main.ModEntry.Path, "RRPopout.dll");
if (!File.Exists(dll))
{
Log.Error($"[popout] native DLL not found: {dll}");
return false;
}
IntPtr handle = LoadLibrary(dll);
if (handle == IntPtr.Zero)
{
Log.Error($"[popout] LoadLibrary failed (Win32 error {Marshal.GetLastWin32Error()}): {dll}");
return false;
}
_loaded = true;
Log.Info($"[popout] native RRPopout.dll loaded from {dll}");
return true;
}
}

View file

@ -0,0 +1,59 @@
using HarmonyLib;
using UI.Common;
using UI.Map;
using UnityEngine;
namespace S3.Modules.Popout {
// Locates the map panel at runtime.
//
// MapWindow.instance is private in the current game build, so we use
// FindObjectOfType<MapWindow>() instead. All the useful fields (_renderTexture,
// _window, mapDrag) are also private — accessed via Harmony Traverse.
//
// MapBuilder.Shared and MapBuilder.mapCamera are public.
internal static class PanelFinder {
// Returns the live MapWindow MonoBehaviour, or null if the map isn't loaded.
public static MapWindow? GetMapWindow() =>
Object.FindObjectOfType<MapWindow>();
// Returns the RenderTexture the map camera renders to each frame.
public static RenderTexture? GetMapRenderTexture() {
var mw = GetMapWindow();
return mw == null ? null
: Traverse.Create(mw).Field<RenderTexture>("_renderTexture").Value;
}
// Returns the Window UI component (exposes public IsShown / ShowWindow).
public static Window? GetMapWindowUI() {
var mw = GetMapWindow();
return mw == null ? null
: Traverse.Create(mw).Field<Window>("_window").Value;
}
// Returns the MapDrag component (exposes public OnZoom / OnDragChange actions).
public static MapDrag? GetMapDrag() {
var mw = GetMapWindow();
return mw == null ? null
: Traverse.Create(mw).Field<MapDrag>("mapDrag").Value;
}
public static bool IsMapReady() {
var mw = GetMapWindow();
if (mw == null) return false;
return GetMapRenderTexture() != null && MapBuilder.Shared?.mapCamera != null;
}
// Recomputes map icon scale for the current orthographic size.
// MapBuilder.UpdateForZoom() is private in the current game build (it used to
// be public), so we invoke it via Traverse. Called after we set
// orthographicSize directly (zoom / RT setup) so icons rescale to match.
public static void UpdateMapForZoom() {
var mb = MapBuilder.Shared;
if (mb != null)
Traverse.Create(mb).Method("UpdateForZoom").GetValue();
}
}
}

View file

@ -0,0 +1,11 @@
using UnityEngine;
namespace S3.Modules.Popout;
// Per-frame driver for the popout module. Spawned by PopoutModule.OnEnable on a
// DontDestroyOnLoad GameObject so it survives scene transitions. Mirrors what the
// standalone mod did from UMM's modEntry.OnUpdate.
internal sealed class PopoutHost : MonoBehaviour
{
private void Update() => PopoutModule.Tick();
}

View file

@ -0,0 +1,160 @@
using S3.Core;
using UI.Common;
using UI.Map;
using UnityEngine;
using UnityModManagerNet;
namespace S3.Modules.Popout;
/// <summary>
/// S³ module that detaches the in-game map into a resizable native OS window
/// (rendered by the shared Win32 + D3D11 + Dear ImGui native engine).
///
/// Loads the native RRPopout.dll on enable, then spawns a <see cref="PopoutHost"/>
/// MonoBehaviour that drives the per-frame map button, hotkey, and panel lifecycle.
/// </summary>
public sealed class PopoutModule : IModule
{
private const string SettingsFile = "S3.popout.json";
public static PopoutSettings Settings { get; private set; } = new();
// Persistent KeyBinding instance (not rebuilt per frame) so .Down() edge detection works.
private static KeyBinding _hotkey = new();
public static KeyBinding Hotkey => _hotkey;
private static DetachedPanel? _activePanel;
private static Window? _hiddenWindow;
private static Vector2 _savedWindowSize;
private static bool _mapWasOpen;
private static GameObject? _host;
public static bool IsDetached => _activePanel != null;
public PopoutModule()
{
Settings = SettingsStore.Load<PopoutSettings>(SettingsFile);
RebuildHotkey();
}
public string Id => "popout";
public string DisplayName => "Map Popout";
public string Description =>
"Detaches the in-game map into a separate resizable window you can put on a " +
"second monitor. Open a save, then press the hotkey or use the Pop Out button " +
"on the map window. Optional MapEnhancer integration for follow modes.";
public bool Enabled
{
get => Settings.enabled;
set => Settings.enabled = value;
}
public void OnEnable()
{
if (!NativeLoader.EnsureLoaded())
{
Log.Error("[popout] native load failed — module will not run this session.");
return;
}
_host = new GameObject("S3.Popout.Host");
Object.DontDestroyOnLoad(_host);
_host.AddComponent<PopoutHost>();
}
public void OnDisable()
{
// Reserved for live toggling. When wired, destroy the active panel + host here.
if (_activePanel != null) { _activePanel.Destroy(); _activePanel = null; RestoreInGameWindow(); }
if (_host != null) { Object.Destroy(_host); _host = null; }
}
public void SaveSettings() => Persist();
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
public void DrawSettings() => PopoutSettingsUI.Draw();
internal static void RebuildHotkey() =>
_hotkey = new KeyBinding { modifiers = (byte)Settings.hotkeyModifiers, keyCode = (KeyCode)Settings.hotkeyKeyCode };
// Called each frame by PopoutHost.
internal static void Tick()
{
MapWindowButton.TryInstall();
MapWindowButton.UpdateLabel(_activePanel != null);
if (_hotkey.Down())
Toggle();
_activePanel?.Update();
if (_activePanel != null && (!_activePanel.IsAlive || _activePanel.CloseRequested))
{
_activePanel.Destroy();
_activePanel = null;
RestoreInGameWindow();
Log.Info("[popout] Map popout closed.");
}
}
internal static void Toggle()
{
if (_activePanel != null)
{
_activePanel.Destroy();
_activePanel = null;
RestoreInGameWindow();
Log.Info("[popout] Map re-attached.");
return;
}
// Remember whether the map was already open — if we opened it ourselves, we
// close it again when the popout closes.
Window? win = PanelFinder.GetMapWindowUI();
_mapWasOpen = win != null && win.IsShown;
MapWindow.Show();
if (!PanelFinder.IsMapReady())
{
Log.Warn("[popout] Map not ready — ensure a save is loaded.");
return;
}
// Collapse the in-game panel to zero size (invisible, all components active).
_hiddenWindow = PanelFinder.GetMapWindowUI();
if (_hiddenWindow != null && _hiddenWindow.transform is RectTransform rect)
{
_savedWindowSize = rect.sizeDelta;
rect.sizeDelta = Vector2.zero;
}
_activePanel = new DetachedPanel("Railroader — Map");
if (!_activePanel.IsAlive)
{
Log.Error("[popout] RRPOPOUT_CreateWindow returned 0.");
_activePanel = null;
RestoreInGameWindow();
return;
}
Log.Info("[popout] Map detached.");
}
private static void RestoreInGameWindow()
{
if (_hiddenWindow == null) return;
if (_hiddenWindow.transform is RectTransform rect)
rect.sizeDelta = _savedWindowSize;
// If the map wasn't open before the popout, close it logically. Toggle() is
// used instead of SetActive(false) — SetActive can make MapWindow unfindable
// by FindObjectOfType on the next open.
if (!_mapWasOpen)
MapWindow.Toggle();
_hiddenWindow = null;
}
}

View file

@ -0,0 +1,17 @@
using System;
using UnityEngine;
namespace S3.Modules.Popout;
// Flat settings block for the Map Popout module (own file: S3.popout.json).
// Hotkey is stored as flat primitives rather than a UMM KeyBinding so JsonUtility
// round-trips it reliably (no nested custom-class serialization).
[Serializable]
public class PopoutSettings
{
public bool enabled = false;
// UMM modifier bitmask: 1=Shift, 2=Ctrl, 4=Alt. Default: F9, no modifier.
public int hotkeyModifiers = 0;
public int hotkeyKeyCode = (int)KeyCode.F9;
}

View file

@ -0,0 +1,58 @@
using UnityEngine;
namespace S3.Modules.Popout;
// Renders the Map Popout settings as the body of its foldout: hotkey rebinding,
// detach/re-attach button, and current status.
static class PopoutSettingsUI
{
private static bool _capturing;
public static void Draw()
{
PopoutSettings s = PopoutModule.Settings;
// ── Hotkey ──────────────────────────────────────────────────────────────
GUILayout.BeginHorizontal();
GUILayout.Label("Pop-out hotkey:", GUILayout.Width(110f));
if (_capturing)
{
GUILayout.Label("Press a key… (Esc to cancel)");
Event e = Event.current;
if (e.type == EventType.KeyDown)
{
if (e.keyCode != KeyCode.Escape && e.keyCode != KeyCode.None)
{
s.hotkeyKeyCode = (int)e.keyCode;
s.hotkeyModifiers = (e.shift ? 1 : 0) | (e.control ? 2 : 0) | (e.alt ? 4 : 0);
PopoutModule.RebuildHotkey();
PopoutModule.Persist();
}
_capturing = false;
e.Use();
}
}
else
{
if (GUILayout.Button($"{HotkeyLabel(s)} (click to change)", GUILayout.Width(220f)))
_capturing = true;
}
GUILayout.EndHorizontal();
GUILayout.Space(8f);
// ── Status + detach button ──────────────────────────────────────────────
GUILayout.Label(PopoutModule.IsDetached ? "Status: Detached" : "Status: Docked");
if (GUILayout.Button(PopoutModule.IsDetached ? "Re-attach map" : "Detach map", GUILayout.Width(160f)))
PopoutModule.Toggle();
}
private static string HotkeyLabel(PopoutSettings s)
{
string prefix = "";
if ((s.hotkeyModifiers & 2) != 0) prefix += "Ctrl+";
if ((s.hotkeyModifiers & 1) != 0) prefix += "Shift+";
if ((s.hotkeyModifiers & 4) != 0) prefix += "Alt+";
return prefix + (KeyCode)s.hotkeyKeyCode;
}
}

90
src/S3.csproj Normal file
View file

@ -0,0 +1,90 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>latest</LangVersion>
<!-- annotations: allow `?` / `null!` syntax from the forklifted Popout code
without emitting nullable warnings for the un-annotated Physics code. -->
<Nullable>annotations</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<AssemblyName>S3</AssemblyName>
<RootNamespace>S3</RootNamespace>
<ModVersion>0.2.0</ModVersion>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<!-- Game/Unity assemblies live in the game folder; never copy them to output. -->
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<EnableDefaultNoneItems>false</EnableDefaultNoneItems>
</PropertyGroup>
<!-- Game code -->
<ItemGroup>
<Reference Include="Assembly-CSharp">
<HintPath>$(GameManaged)\Assembly-CSharp.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Map.Runtime">
<HintPath>$(GameManaged)\Map.Runtime.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<!-- Unity -->
<ItemGroup>
<Reference Include="UnityEngine">
<HintPath>$(GameManaged)\UnityEngine.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>$(GameManaged)\UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.IMGUIModule">
<HintPath>$(GameManaged)\UnityEngine.IMGUIModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.PhysicsModule">
<HintPath>$(GameManaged)\UnityEngine.PhysicsModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.JSONSerializeModule">
<HintPath>$(GameManaged)\UnityEngine.JSONSerializeModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.UI">
<HintPath>$(GameManaged)\UnityEngine.UI.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.UIModule">
<HintPath>$(GameManaged)\UnityEngine.UIModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.InputLegacyModule">
<HintPath>$(GameManaged)\UnityEngine.InputLegacyModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Unity.TextMeshPro">
<HintPath>$(GameManaged)\Unity.TextMeshPro.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Unity.RenderPipelines.Universal.Runtime">
<HintPath>$(GameManaged)\Unity.RenderPipelines.Universal.Runtime.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<!-- UMM + Harmony -->
<ItemGroup>
<Reference Include="UnityModManager">
<HintPath>$(UmmDir)\UnityModManager.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(UmmDir)\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>