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:
commit
a1fcf01125
47 changed files with 4944 additions and 0 deletions
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal 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
13
Directory.Build.props
Normal 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
11
Info.json
Normal 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
54
README.md
Normal 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
67
dist/build-common.ps1
vendored
Normal 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
20
dist/build-local.ps1
vendored
Normal 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
31
dist/build-release.ps1
vendored
Normal 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
72
native/CMakeLists.txt
Normal 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
31
native/external/IUnityGraphics.h
vendored
Normal 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
15
native/external/IUnityGraphicsD3D11.h
vendored
Normal 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
35
native/external/IUnityInterface.h
vendored
Normal 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));
|
||||
}
|
||||
};
|
||||
42
native/include/shared_types.h
Normal file
42
native/include/shared_types.h
Normal 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");
|
||||
594
native/src/d3d11_renderer.cpp
Normal file
594
native/src/d3d11_renderer.cpp
Normal 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);
|
||||
}
|
||||
15
native/src/d3d11_renderer.h
Normal file
15
native/src/d3d11_renderer.h
Normal 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
19
native/src/dllmain.cpp
Normal 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
214
native/src/exports.cpp
Normal 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
32
native/src/input_queue.h
Normal 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;
|
||||
};
|
||||
89
native/src/popout_window.h
Normal file
89
native/src/popout_window.h
Normal 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
|
||||
}
|
||||
};
|
||||
490
native/src/popout_windows.cpp
Normal file
490
native/src/popout_windows.cpp
Normal 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);
|
||||
}
|
||||
11
native/src/popout_windows.h
Normal file
11
native/src/popout_windows.h
Normal 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
38
src/Core/IModule.cs
Normal 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
18
src/Core/Log.cs
Normal 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
83
src/Core/ModConflicts.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
50
src/Core/ModuleRegistry.cs
Normal file
50
src/Core/ModuleRegistry.cs
Normal 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
77
src/Core/SettingsPanel.cs
Normal 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
52
src/Core/SettingsStore.cs
Normal 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
42
src/Main.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
124
src/Modules/PhysicsOptimizer/CarDebugVisualizer.cs
Normal file
124
src/Modules/PhysicsOptimizer/CarDebugVisualizer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
129
src/Modules/PhysicsOptimizer/ConsistFreezer.cs
Normal file
129
src/Modules/PhysicsOptimizer/ConsistFreezer.cs
Normal 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
|
||||
}
|
||||
}
|
||||
204
src/Modules/PhysicsOptimizer/ConsistLOD.cs
Normal file
204
src/Modules/PhysicsOptimizer/ConsistLOD.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
141
src/Modules/PhysicsOptimizer/ConsoleCommands.cs
Normal file
141
src/Modules/PhysicsOptimizer/ConsoleCommands.cs
Normal 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>";
|
||||
}
|
||||
99
src/Modules/PhysicsOptimizer/PhysicsOptimizerModule.cs
Normal file
99
src/Modules/PhysicsOptimizer/PhysicsOptimizerModule.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
286
src/Modules/PhysicsOptimizer/PhysicsOverlayGUI.cs
Normal file
286
src/Modules/PhysicsOptimizer/PhysicsOverlayGUI.cs
Normal 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 0–100%
|
||||
|
||||
// Graph rendered as a texture — avoids all GL coordinate-space issues.
|
||||
// Unity textures are Y-up (row 0 = bottom pixel), IMGUI is Y-down,
|
||||
// but GUI.DrawTexture just stretches the texture into the target rect,
|
||||
// so we flip Y in our write formula and the result renders correctly.
|
||||
Texture2D _graphTex;
|
||||
Color32[] _graphPixels;
|
||||
const int TexW = 300; // matches RingSize exactly — 1 pixel column per sample
|
||||
const int TexH = 90;
|
||||
|
||||
static readonly Color32 ColBg = new(16, 16, 16, 220);
|
||||
static readonly Color32 Col60 = new(210, 210, 210, 255); // 60fps reference line
|
||||
static readonly Color32 Col30 = new(210, 210, 210, 255); // 30fps reference line
|
||||
static readonly Color32 ColRender = new(50, 140, 215, 255); // render frame time (blue)
|
||||
static readonly Color32 ColFrame = new(70, 200, 70, 255); // FixedUpdate total (green)
|
||||
static readonly Color32 ColTick = new(210, 185, 50, 255); // Tick() only (yellow)
|
||||
static readonly Color32 ColPosCars = new(220, 110, 30, 255); // PositionCars (orange)
|
||||
|
||||
static readonly int WindowId = "S3PhysicsOverlay".GetHashCode();
|
||||
|
||||
const float RefFps60Ms = 16.7f;
|
||||
const float RefFps30Ms = 33.3f;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
_graphTex = new Texture2D(TexW, TexH, TextureFormat.RGBA32, false)
|
||||
{ filterMode = FilterMode.Point, hideFlags = HideFlags.HideAndDontSave };
|
||||
_graphPixels = new Color32[TexW * TexH];
|
||||
|
||||
_solidTex = new Texture2D(1, 1, TextureFormat.RGBA32, false)
|
||||
{ hideFlags = HideFlags.HideAndDontSave };
|
||||
_solidTex.SetPixel(0, 0, Color.white);
|
||||
_solidTex.Apply();
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
// Record render frame time each rendered frame (not each physics tick).
|
||||
// Time.unscaledDeltaTime = wall-clock ms since last render frame.
|
||||
PhysicsTimer.RecordRenderFrame(Time.unscaledDeltaTime * 1000f);
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (_graphTex != null) Destroy(_graphTex);
|
||||
if (_solidTex != null) Destroy(_solidTex);
|
||||
Instance = null;
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
if (!Visible) return;
|
||||
EnsureStyles();
|
||||
// Tint the solid-white window background to dark gray at the chosen opacity.
|
||||
// Using a custom style (solid texture) means Opacity=1 → fully opaque, not
|
||||
// capped by whatever alpha the default IMGUI skin has baked in.
|
||||
Color prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = new Color(0.063f, 0.063f, 0.063f, Opacity);
|
||||
_windowRect = GUILayout.Window(WindowId, _windowRect, DrawWindow,
|
||||
"Physics Profiler", _windowStyle, GUILayout.MinWidth(420f));
|
||||
GUI.backgroundColor = prevBg;
|
||||
}
|
||||
|
||||
void DrawWindow(int _)
|
||||
{
|
||||
// The window background was already drawn with the opacity tint — restore
|
||||
// backgroundColor so buttons and labels inside use their normal skin colors.
|
||||
GUI.backgroundColor = Color.white;
|
||||
GUILayout.Label(PhysicsTimer.GetReport());
|
||||
|
||||
// Reserve space for graph in window-local coords.
|
||||
Rect localRect = GUILayoutUtility.GetRect(
|
||||
GUIContent.none, GUIStyle.none,
|
||||
GUILayout.Height(TexH), GUILayout.ExpandWidth(true));
|
||||
|
||||
if (Event.current.type == EventType.Repaint)
|
||||
{
|
||||
UpdateGraphTexture();
|
||||
// GUI.DrawTexture uses window-local coords — no coordinate conversion needed.
|
||||
GUI.DrawTexture(localRect, _graphTex, ScaleMode.StretchToFill);
|
||||
|
||||
// 10% headroom above the 30fps line so it's never squashed against the top pixel row.
|
||||
float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f);
|
||||
|
||||
// Reference-line labels: centered vertically on the line, anchored to right edge.
|
||||
float y60Gui = LocalGaugeY(localRect, maxMs, RefFps60Ms);
|
||||
GUI.Label(new Rect(localRect.xMax - 54f, y60Gui - 9f, 52f, 18f), "60 fps", _fps60Label);
|
||||
float y30Gui = LocalGaugeY(localRect, maxMs, RefFps30Ms);
|
||||
GUI.Label(new Rect(localRect.xMax - 54f, y30Gui - 9f, 52f, 18f), "30 fps", _fps30Label);
|
||||
|
||||
// Current sample readout (top-left of graph).
|
||||
int prev = (PhysicsTimer.RingWrite - 1 + PhysicsTimer.RingSize) % PhysicsTimer.RingSize;
|
||||
GUI.Label(new Rect(localRect.x + 4f, localRect.y + 2f, 340f, 20f),
|
||||
$"render:{PhysicsTimer.RingRender[prev]:F2}ms phys:{PhysicsTimer.RingFrame[prev]:F2}ms tick:{PhysicsTimer.RingTick[prev]:F2}ms",
|
||||
_dimLabel);
|
||||
}
|
||||
|
||||
// Legend row
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label(" ■ Render", _blueLabel);
|
||||
GUILayout.Label(" ■ FixedUpdate", _greenLabel);
|
||||
GUILayout.Label(" ■ Tick()", _yellowLabel);
|
||||
if (PhysicsTimer.HasPosCarsData)
|
||||
GUILayout.Label(" ■ PosCars", _orangeLabel);
|
||||
GUILayout.Label($" [{PhysicsTimer.RingSize} frames]", _dimLabel);
|
||||
GUILayout.EndHorizontal();
|
||||
|
||||
// Physics Optimizer row: plain-text toggle (no button background), then slow-updating stats
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label("Physics Optimizer", _dimLabel, GUILayout.Width(130f));
|
||||
if (GUILayout.Button(ConsistLOD.Enabled ? "ON" : "OFF",
|
||||
ConsistLOD.Enabled ? _onStyle : _offStyle, GUILayout.Width(34f)))
|
||||
{
|
||||
ConsistLOD.Enabled = !ConsistLOD.Enabled;
|
||||
// Persist toggle state so it survives a game restart.
|
||||
PhysicsOptimizerModule.Settings.OptimizerEnabled = ConsistLOD.Enabled;
|
||||
PhysicsOptimizerModule.Persist();
|
||||
}
|
||||
|
||||
// Stats refresh once per second — readable, not flickering
|
||||
if (Time.frameCount != _lodStatsFrame && Time.frameCount % 60 == 0)
|
||||
{
|
||||
_lodStatsFrame = Time.frameCount;
|
||||
if (ConsistLOD.Enabled)
|
||||
{
|
||||
int fast = ConsistLOD.LastFastPathCount;
|
||||
int full = ConsistLOD.LastFullPathCount;
|
||||
int total = fast + full;
|
||||
_lodStatsStr = $"fast:{fast}/{total} full:{full}/{total} dist:{ConsistLOD.DistanceThreshold:F0}m resync:1/{ConsistLOD.ResyncInterval}";
|
||||
}
|
||||
else
|
||||
{
|
||||
_lodStatsStr = "";
|
||||
}
|
||||
|
||||
int atRest = ConsistFreezer.LastAtRestCars;
|
||||
int byDist = ConsistFreezer.LastDistanceCars;
|
||||
_freezeStatsStr = $"stopped:{atRest} dist/spd:{byDist}";
|
||||
|
||||
bool anyDbg = PhysicsOptimizerModule.Settings.DebugHighlightFrozen ||
|
||||
PhysicsOptimizerModule.Settings.DebugHighlightFastPath ||
|
||||
PhysicsOptimizerModule.Settings.DebugHighlightFullPath;
|
||||
_debugStatsStr = anyDbg
|
||||
? $"dbg — frozen:{ConsistFreezer.FrozenCars.Count} fast:{ConsistLOD.FastPathCars.Count} full:{ConsistLOD.FullPathCars.Count} tinted:{CarDebugVisualizer.Instance?.TintedCount ?? 0}"
|
||||
: "";
|
||||
}
|
||||
GUILayout.Label(_lodStatsStr, _dimLabel);
|
||||
GUILayout.EndHorizontal();
|
||||
|
||||
if (_debugStatsStr.Length > 0)
|
||||
GUILayout.Label(_debugStatsStr, _dimLabel);
|
||||
|
||||
// Auto-freeze row — shows cars skipping the Verlet tick each physics step
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label("Auto Freeze", _dimLabel, GUILayout.Width(130f));
|
||||
if (GUILayout.Button(ConsistFreezer.AutoFreezeEnabled ? "ON" : "OFF",
|
||||
ConsistFreezer.AutoFreezeEnabled ? _onStyle : _offStyle, GUILayout.Width(34f)))
|
||||
{
|
||||
ConsistFreezer.AutoFreezeEnabled = !ConsistFreezer.AutoFreezeEnabled;
|
||||
PhysicsOptimizerModule.Settings.AutoFreezeEnabled = ConsistFreezer.AutoFreezeEnabled;
|
||||
PhysicsOptimizerModule.Persist();
|
||||
}
|
||||
GUILayout.Label(_freezeStatsStr, _dimLabel);
|
||||
GUILayout.EndHorizontal();
|
||||
|
||||
GUI.DragWindow(new Rect(0f, 0f, _windowRect.width, 20f));
|
||||
}
|
||||
|
||||
// Y in IMGUI window-local space: top of rect = maxMs, bottom = 0ms.
|
||||
static float LocalGaugeY(Rect r, float maxMs, float ms) =>
|
||||
r.yMax - Mathf.Clamp01(ms / maxMs) * r.height;
|
||||
|
||||
void UpdateGraphTexture()
|
||||
{
|
||||
float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f);
|
||||
int write = PhysicsTimer.RingWrite;
|
||||
int n = PhysicsTimer.RingSize;
|
||||
float[] render = PhysicsTimer.RingRender;
|
||||
float[] frame = PhysicsTimer.RingFrame;
|
||||
float[] tick = PhysicsTimer.RingTick;
|
||||
float[] posCars = PhysicsTimer.RingPosCars;
|
||||
|
||||
// Background fill.
|
||||
for (int i = 0; i < _graphPixels.Length; i++)
|
||||
_graphPixels[i] = ColBg;
|
||||
|
||||
// Stacked filled areas — drawn back-to-front so later layers paint over earlier ones.
|
||||
//
|
||||
// Layer 1 (bottom): render frame time.
|
||||
DrawFilledArea(render, null, n, write, maxMs, ColRender);
|
||||
// Layer 2: FixedUpdate total, stacked on top of render.
|
||||
DrawFilledArea(frame, render, n, write, maxMs, ColFrame);
|
||||
// Layer 3: Tick(), painted over the lower part of the FixedUpdate band (same baseline).
|
||||
DrawFilledArea(tick, render, n, write, maxMs, ColTick);
|
||||
// Layer 4: PosCars, painted over the lower part of the Tick band (same baseline).
|
||||
if (PhysicsTimer.HasPosCarsData)
|
||||
DrawFilledArea(posCars, render, n, write, maxMs, ColPosCars);
|
||||
|
||||
// Reference lines on top of all data.
|
||||
DrawHLine(maxMs, RefFps60Ms, Col60);
|
||||
DrawHLine(maxMs, RefFps30Ms, Col30);
|
||||
|
||||
_graphTex.SetPixels32(_graphPixels);
|
||||
_graphTex.Apply(false);
|
||||
}
|
||||
|
||||
// Pixel row for a given ms value: row 0 = bottom (0ms), row TexH-1 = top (maxMs).
|
||||
static int MsToRow(float ms, float maxMs) =>
|
||||
Mathf.Clamp((int)(ms / maxMs * TexH), 0, TexH - 1);
|
||||
|
||||
void DrawHLine(float maxMs, float ms, Color32 color)
|
||||
{
|
||||
int row = MsToRow(ms, maxMs);
|
||||
int offset = row * TexW;
|
||||
for (int x = 0; x < TexW; x++)
|
||||
_graphPixels[offset + x] = color;
|
||||
}
|
||||
|
||||
// Fills a solid area from [baselines[i]] to [baselines[i] + values[i]] for each sample column.
|
||||
// baselines == null means 0 (fill from the bottom of the chart).
|
||||
void DrawFilledArea(float[] values, float[] baselines, int n, int write, float maxMs, Color32 color)
|
||||
{
|
||||
for (int x = 0; x < TexW; x++)
|
||||
{
|
||||
int si = (write + (int)((float)x / TexW * n)) % n;
|
||||
float bot = baselines != null ? baselines[si] : 0f;
|
||||
int rowBot = MsToRow(bot, maxMs);
|
||||
int rowTop = MsToRow(bot + values[si], maxMs);
|
||||
int lo = Mathf.Min(rowBot, rowTop);
|
||||
int hi = Mathf.Max(rowBot, rowTop);
|
||||
for (int r = lo; r <= hi; r++)
|
||||
_graphPixels[r * TexW + x] = color;
|
||||
}
|
||||
}
|
||||
|
||||
void EnsureStyles()
|
||||
{
|
||||
if (_blueLabel != null) return;
|
||||
_blueLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.2f, 0.55f, 0.85f) } };
|
||||
_greenLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.4f, 1f, 0.4f) } };
|
||||
_yellowLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(1f, 0.85f, 0.2f) } };
|
||||
_orangeLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.9f, 0.45f, 0.1f) } };
|
||||
_dimLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.55f, 0.55f, 0.55f) } };
|
||||
_fps60Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } };
|
||||
_fps30Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } };
|
||||
_onStyle = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.4f, 1f, 0.4f) }, hover = { textColor = new Color(0.7f, 1f, 0.7f) } };
|
||||
_offStyle = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(1f, 0.35f, 0.3f) }, hover = { textColor = new Color(1f, 0.6f, 0.55f) } };
|
||||
|
||||
// Custom window style: solid white background texture so GUI.backgroundColor.a
|
||||
// gives true 0–100% opacity rather than being capped by the skin's baked-in alpha.
|
||||
_windowStyle = new GUIStyle(GUI.skin.window);
|
||||
_windowStyle.normal.background = _solidTex;
|
||||
_windowStyle.onNormal.background = _solidTex;
|
||||
_windowStyle.focused.background = _solidTex;
|
||||
_windowStyle.onFocused.background = _solidTex;
|
||||
}
|
||||
}
|
||||
40
src/Modules/PhysicsOptimizer/PhysicsSettings.cs
Normal file
40
src/Modules/PhysicsOptimizer/PhysicsSettings.cs
Normal 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>();
|
||||
}
|
||||
290
src/Modules/PhysicsOptimizer/PhysicsSettingsUI.cs
Normal file
290
src/Modules/PhysicsOptimizer/PhysicsSettingsUI.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
227
src/Modules/PhysicsOptimizer/PhysicsTimer.cs
Normal file
227
src/Modules/PhysicsOptimizer/PhysicsTimer.cs
Normal 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);
|
||||
}
|
||||
393
src/Modules/Popout/DetachedPanel.cs
Normal file
393
src/Modules/Popout/DetachedPanel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
166
src/Modules/Popout/MapEnhancerBridge.cs
Normal file
166
src/Modules/Popout/MapEnhancerBridge.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/Modules/Popout/MapWindowButton.cs
Normal file
54
src/Modules/Popout/MapWindowButton.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
119
src/Modules/Popout/NativeInterop.cs
Normal file
119
src/Modules/Popout/NativeInterop.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
46
src/Modules/Popout/NativeLoader.cs
Normal file
46
src/Modules/Popout/NativeLoader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
59
src/Modules/Popout/PanelFinder.cs
Normal file
59
src/Modules/Popout/PanelFinder.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/Modules/Popout/PopoutHost.cs
Normal file
11
src/Modules/Popout/PopoutHost.cs
Normal 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();
|
||||
}
|
||||
160
src/Modules/Popout/PopoutModule.cs
Normal file
160
src/Modules/Popout/PopoutModule.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
17
src/Modules/Popout/PopoutSettings.cs
Normal file
17
src/Modules/Popout/PopoutSettings.cs
Normal 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;
|
||||
}
|
||||
58
src/Modules/Popout/PopoutSettingsUI.cs
Normal file
58
src/Modules/Popout/PopoutSettingsUI.cs
Normal 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
90
src/S3.csproj
Normal 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>
|
||||
Loading…
Reference in a new issue