railroader-setons-special-s.../native/src/d3d11_renderer.cpp
seton c8918851aa v0.2.4 - MapEnhancer version detection
Probe for version-specific types at startup to determine which MapEnhancer
build is installed, then gray out features unavailable in that build.

Three tiers: official v1.5.2 (minimal), v1.6.0 (adds turntable markers),
and the community fork (full feature set). Detection uses type probes -
TurntableHelper for the v1.6.0+ tier, SwitchResetAuditState for community.

Adds HasTurntable and IsCommunity capability flags on MapEnhancerBridge,
imMEHasTurntable/imMECommunity atomics on PopoutWindow, and a two-arg
RRPOPOUT_SetMapEnhancerCaps export. The renderer reads both flags and wraps
community-only menu items in BeginDisabled blocks accordingly.

Updates README with version compatibility notes and comparison screenshots.
2026-06-21 15:12:56 -04:00

1248 lines
58 KiB
C++

#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#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 "popout_windows.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;
// ---------------------------------------------------------------------------
// Theme state — written by Renderer_SetTheme (main thread), applied on the
// render thread at frame start. Mutex protects the struct copy; g_themeChanged
// signals the render thread to call ApplyThemeToStyle before NewFrame.
// ---------------------------------------------------------------------------
static MapThemeData g_theme;
static std::mutex g_themeMutex;
static std::atomic<bool> g_themeChanged {false};
static std::atomic<int> g_currentThemePreset {0};
static std::atomic<float> g_ovAlpha {1.0f}; // chrome (window + toolbar + compass) alpha
static std::atomic<float> g_mapAlpha {1.0f}; // map Image() alpha, independent of chrome
static std::atomic<float> g_mapBgAlpha {1.0f}; // camera clear colour opacity
// 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;
}
}
// Non-sRGB (linear) render-target view format for a backbuffer, so ImGui's writes
// are NOT sRGB-encoded by the GPU (which brightens). Returns UNKNOWN for formats
// where no such fix is needed/possible (e.g. float HDR has no encode anyway).
static DXGI_FORMAT UnormRtvFormat(DXGI_FORMAT f) {
switch (f) {
case DXGI_FORMAT_R8G8B8A8_TYPELESS:
case DXGI_FORMAT_R8G8B8A8_UNORM:
case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: return DXGI_FORMAT_R8G8B8A8_UNORM;
case DXGI_FORMAT_B8G8R8A8_TYPELESS:
case DXGI_FORMAT_B8G8R8A8_UNORM:
case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: return DXGI_FORMAT_B8G8R8A8_UNORM;
case DXGI_FORMAT_R10G10B10A2_TYPELESS:
case DXGI_FORMAT_R10G10B10A2_UNORM: return DXGI_FORMAT_R10G10B10A2_UNORM;
default: return DXGI_FORMAT_UNKNOWN;
}
}
// ---------------------------------------------------------------------------
// D3D11 state save/restore — prevents corrupting Unity's render state
// ---------------------------------------------------------------------------
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);
}
};
// ---------------------------------------------------------------------------
// Theme helpers — render-thread only
// ---------------------------------------------------------------------------
// Apply g_theme to ImGui's style. Safe after ImGui::CreateContext(); does not need
// the DX11 backend. Called on the render thread before NewFrame, or from InitImGui.
static void ApplyThemeToStyle(const MapThemeData& t) {
ImGuiStyle& s = ImGui::GetStyle();
auto iv4 = [](float r, float g, float b, float a) { return ImVec4(r, g, b, a); };
auto darken = [](ImVec4 c, float f) {
return ImVec4(c.x*f, c.y*f, c.z*f, c.w);
};
auto brighten = [](ImVec4 c, float f) {
return ImVec4(std::min(1.f, c.x*f), std::min(1.f, c.y*f), std::min(1.f, c.z*f), c.w);
};
ImVec4 bg = iv4(t.wBgR, t.wBgG, t.wBgB, t.wBgA);
ImVec4 acc = iv4(t.accR, t.accG, t.accB, t.accA);
ImVec4 txt = iv4(t.txtR, t.txtG, t.txtB, t.txtA);
ImVec4 pop = iv4(t.popR, t.popG, t.popB, t.popA);
s.Colors[ImGuiCol_WindowBg] = bg;
s.Colors[ImGuiCol_ChildBg] = bg;
s.Colors[ImGuiCol_PopupBg] = pop;
s.Colors[ImGuiCol_Text] = txt;
s.Colors[ImGuiCol_TextDisabled] = darken(txt, 0.5f);
s.Colors[ImGuiCol_TitleBgActive] = acc;
s.Colors[ImGuiCol_TitleBg] = darken(acc, 0.55f);
s.Colors[ImGuiCol_TitleBgCollapsed] = darken(acc, 0.35f);
s.Colors[ImGuiCol_Button] = darken(acc, 0.55f);
s.Colors[ImGuiCol_ButtonHovered] = darken(acc, 0.80f);
s.Colors[ImGuiCol_ButtonActive] = acc;
s.Colors[ImGuiCol_Header] = darken(acc, 0.40f);
s.Colors[ImGuiCol_HeaderHovered] = darken(acc, 0.65f);
s.Colors[ImGuiCol_HeaderActive] = acc;
s.Colors[ImGuiCol_FrameBg] = iv4(t.wBgR+0.07f, t.wBgG+0.07f, t.wBgB+0.07f, t.wBgA);
s.Colors[ImGuiCol_FrameBgHovered] = darken(acc, 0.35f);
s.Colors[ImGuiCol_FrameBgActive] = darken(acc, 0.55f);
s.Colors[ImGuiCol_SliderGrab] = brighten(acc, 1.1f);
s.Colors[ImGuiCol_SliderGrabActive] = brighten(acc, 1.4f);
s.Colors[ImGuiCol_CheckMark] = brighten(acc, 1.2f);
s.Colors[ImGuiCol_ResizeGrip] = iv4(t.accR, t.accG, t.accB, 0.20f);
s.Colors[ImGuiCol_ResizeGripHovered] = iv4(t.accR, t.accG, t.accB, 0.67f);
s.Colors[ImGuiCol_ResizeGripActive] = acc;
s.Colors[ImGuiCol_Separator] = iv4(t.accR, t.accG, t.accB, 0.35f);
s.Colors[ImGuiCol_SeparatorHovered] = darken(acc, 0.7f);
s.Colors[ImGuiCol_SeparatorActive] = acc;
s.Colors[ImGuiCol_MenuBarBg] = darken(bg, 0.85f);
s.Colors[ImGuiCol_ScrollbarBg] = darken(bg, 0.70f);
s.Colors[ImGuiCol_ScrollbarGrab] = darken(acc, 0.55f);
s.Colors[ImGuiCol_ScrollbarGrabHovered] = darken(acc, 0.75f);
s.Colors[ImGuiCol_ScrollbarGrabActive] = acc;
}
// Snapshots g_theme under a single mutex lock, applying a pending style change first
// if g_themeChanged is set. Replaces the previous double-lock pattern (lock to apply,
// then lock again to copy). Call once at the top of each render entry point.
static MapThemeData SnapshotTheme() {
bool changed = g_themeChanged.exchange(false);
std::lock_guard<std::mutex> lk(g_themeMutex);
if (changed) ApplyThemeToStyle(g_theme);
return g_theme;
}
// Default S3 Dark theme — identical to what InitImGui used to hardcode.
static const MapThemeData kS3DarkTheme = {
0.12f, 0.12f, 0.12f, 1.00f, // windowBg
0.26f, 0.59f, 0.98f, 1.00f, // accent (ImGui default TitleBgActive blue)
1.00f, 1.00f, 1.00f, 1.00f, // text
0.08f, 0.08f, 0.08f, 0.94f, // popupBg
1.00f, 1.00f, 1.00f, 1.00f, // mapTint (no tint)
0.086f,0.086f,0.086f,0.784f, // compassBg (IM_COL32(22,22,22,200))
0.804f,0.196f,0.196f,1.00f, // needleN (IM_COL32(205,50,50,255))
0.804f,0.804f,0.804f,0.863f, // needleS (IM_COL32(205,205,205,220))
0.082f,0.122f,0.157f,1.00f, // mapBg camera clear (dark blue-gray)
};
// ---------------------------------------------------------------------------
// 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 — specific colours are overridden by ApplyThemeToStyle below.
ImGui::StyleColorsDark();
ImGuiStyle& style = ImGui::GetStyle();
style.WindowRounding = 0.f;
style.WindowBorderSize = 0.f;
// Allow the 22px toolbar to be exactly 22px. The default 32px minimum makes it
// overhang the bottom of the in-game map window as a stray dark strip.
style.WindowMinSize = ImVec2(4.f, 4.f);
style.FrameRounding = 3.f;
style.GrabRounding = 3.f;
style.ScrollbarRounding = 3.f;
// Apply the current theme (or the default S3 Dark if none has been pushed yet).
{ std::lock_guard<std::mutex> lk(g_themeMutex); if (g_theme.accA == 0.f) g_theme = kS3DarkTheme; }
ApplyThemeToStyle(g_theme);
// 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_SetTheme(const MapThemeData* t, int presetIndex) {
if (!t) return;
{ std::lock_guard<std::mutex> lk(g_themeMutex); g_theme = *t; }
g_currentThemePreset.store(presetIndex);
g_themeChanged.store(true);
}
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; }
}
// ---------------------------------------------------------------------------
// Shared map UI chrome (compass rose + bottom toolbar), drawn for window `win`
// at logical size w x h. Pure ImGui: reads win-> state atomics and pushes
// UICommands onto win->inputQueue. The caller owns the frame lifecycle — it must
// set up io, call ImGui_ImplDX11_NewFrame()/ImGui::NewFrame() before this, and
// ImGui::Render() after. Used by the popout (Renderer_Present) and, in time, by
// the in-game overlay so the map UI is authored once and rendered on any surface.
// ---------------------------------------------------------------------------
// origin = top-left screen position of the map region; w,h = its size. For the
// popout the region is the whole window (origin 0,0); for the in-game overlay it
// is the floating window's map image rect, so the chrome tracks that window.
// reserveResizeGrip: shorten the toolbar so the in-game ImGui window's bottom-right
// resize grip stays grabbable (the popout uses its OS window border instead).
static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
bool reserveResizeGrip, bool showPopOutBtn,
const MapThemeData& theme, float mapAlpha = 1.0f) {
ImGuiIO& io = ImGui::GetIO();
// Shared command helper — usable from any window in this frame
auto pushCmd = [&](UICmd cmd, float idx = 0.f) {
InputEvent ev{}; ev.type = UICommand;
ev.x = static_cast<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(origin.x + w - kCWin - kCPad, origin.y + h - kBarH - kCWin - kCPad),
ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(kCWin, kCWin));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f);
ImGui::Begin("##compass_overlay", nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoBackground);
ImGui::SetCursorPos(ImVec2(0.f, 0.f));
ImGui::InvisibleButton("##compassHit", ImVec2(kCWin, kCWin));
bool cHov = ImGui::IsItemHovered();
bool cAct = ImGui::IsItemActive();
// Drag to rotate: angle from compass centre → new bearing
if (cAct) {
ImVec2 wp = ImGui::GetWindowPos();
ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f };
float ddx = io.MousePos.x - cc.x, ddy = io.MousePos.y - cc.y;
if (ddx*ddx + ddy*ddy > 9.f) {
float ang = atan2f(ddx, -ddy) * (180.f / 3.14159265f);
if (ang < 0.f) ang += 360.f;
win->imMapRotationDeg.store(ang);
win->imMapSyncPlayer.store(false);
pushCmd(UICmd::SetRotation, ang);
}
}
if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
win->imMapRotationDeg.store(0.f);
win->imMapSyncPlayer.store(false);
pushCmd(UICmd::RotateReset);
}
// Draw compass rose
{
float rotDeg = win->imMapRotationDeg.load();
float rotRad = rotDeg * (3.14159265f / 180.f);
ImDrawList* dl = ImGui::GetWindowDrawList();
ImVec2 wp = ImGui::GetWindowPos();
ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f };
// Derive compass colours from theme; scale alpha by mapAlpha so the
// compass responds to the Map Elements opacity slider.
auto col32 = [&](float r, float g, float b, float a) -> ImU32 {
return IM_COL32((int)(r*255), (int)(g*255), (int)(b*255), (int)(a * mapAlpha * 255));
};
ImU32 bgCol = cAct ? col32(theme.cmpR*1.6f, theme.cmpG*1.6f, theme.cmpB*1.6f, theme.cmpA)
: cHov ? col32(theme.cmpR*1.3f, theme.cmpG*1.3f, theme.cmpB*1.3f, theme.cmpA)
: col32(theme.cmpR, theme.cmpG, theme.cmpB, theme.cmpA);
ImU32 ringCol = col32(theme.accR*0.5f, theme.accG*0.5f, theme.accB*0.5f, 0.78f);
ImU32 tickCard = col32(theme.txtR*0.67f, theme.txtG*0.67f, theme.txtB*0.67f, 0.86f);
ImU32 tickIter = col32(theme.txtR*0.50f, theme.txtG*0.50f, theme.txtB*0.50f, 0.51f);
ImU32 needleN = col32(theme.nNR, theme.nNG, theme.nNB, theme.nNA);
ImU32 needleS = col32(theme.nSR, theme.nSG, theme.nSB, theme.nSA);
ImU32 labelN = col32(std::min(1.f,theme.nNR*1.1f), std::min(1.f,theme.nNG*1.1f),
std::min(1.f,theme.nNB*1.1f), 1.0f);
ImU32 capOuter = col32(theme.cmpR*0.85f, theme.cmpG*0.85f, theme.cmpB*0.85f, 1.0f);
ImU32 capInner = col32(theme.txtR*0.82f, theme.txtG*0.82f, theme.txtB*0.82f, 1.0f);
// Background disk
dl->AddCircleFilled(cc, kCR + 2.f, bgCol, 48);
dl->AddCircle(cc, kCR + 2.f, ringCol, 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) },
card ? tickCard : tickIter,
card ? 1.5f : 1.f);
}
// Needle: diamond split N / S colours from theme
float nx = sinf(rotRad), ny = -cosf(rotRad);
float bx = -ny * 7.f, by = nx * 7.f;
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, needleN);
dl->AddTriangleFilled(sTail, bleft, brght, needleS);
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, capOuter);
dl->AddCircleFilled(cc, 3.5f, capInner);
// "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, labelN, "N");
}
if (cHov)
ImGui::SetTooltip("Rotation: %.0f\xc2\xb0\nDrag to rotate | Right-click to reset",
win->imMapRotationDeg.load());
ImGui::End();
ImGui::PopStyleVar(2);
}
// ── Toolbar strip ────────────────────────────────────────────────────
// Leave the bottom-right corner clear for the ImGui resize grip when in-game.
const float kGripReserve = reserveResizeGrip ? 18.f : 0.f;
ImGui::SetNextWindowPos(ImVec2(origin.x, origin.y + (float)h - kBarH));
ImGui::SetNextWindowSize(ImVec2((float)w - kGripReserve, kBarH));
ImGui::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;
// Toolbar buttons use the accent colour from the current theme. Active (lit)
// state is a 40% brighter version of the accent.
const ImVec4 kBtnBlue = ImGui::GetStyleColorVec4(ImGuiCol_TitleBgActive);
const ImVec4 kBtnBlueActive = ImVec4(
std::min(1.f, kBtnBlue.x * 1.4f),
std::min(1.f, kBtnBlue.y * 1.4f),
std::min(1.f, kBtnBlue.z * 1.4f), 1.f);
// Luminance-based contrast text: same formula as Railroader's ColorHelper.IsDark()
auto contrastText = [](ImVec4 bg) -> ImVec4 {
float L = 0.299f*bg.x + 0.587f*bg.y + 0.114f*bg.z;
return L > 0.45f ? ImVec4(0.10f, 0.10f, 0.10f, 1.f)
: ImVec4(0.95f, 0.95f, 0.95f, 1.f);
};
// >> Pop Out (in-game overlay only) — switches map to the OS popout window
if (showPopOutBtn) {
ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW - kGap - kBtnW - kGap - kBtnW);
ImGui::PushStyleColor(ImGuiCol_Button, kBtnBlue);
ImGui::PushStyleColor(ImGuiCol_Text, contrastText(kBtnBlue));
if (ImGui::Button(">>##popout", ImVec2(kBtnW, kBtnH)))
pushCmd(UICmd::PopOut);
ImGui::PopStyleColor(2);
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Open in popout window");
}
// ◎ Follow-player position toggle
ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW - kGap - kBtnW);
{
bool fOn = win->imFollowPlayer.load();
ImVec4 fColor = fOn ? kBtnBlueActive : kBtnBlue;
ImGui::PushStyleColor(ImGuiCol_Button, fColor);
ImGui::PushStyleColor(ImGuiCol_Text, contrastText(fColor));
if (ImGui::Button("\xe2\x97\x8e##follow", ImVec2(kBtnW, kBtnH)))
pushCmd(UICmd::ToggleFollowPlayer);
ImGui::PopStyleColor(2);
if (ImGui::IsItemHovered())
ImGui::SetTooltip(fOn ? "Following player position\nClick to stop"
: "Follow player position");
}
// ⚙ Settings gear
ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW);
ImGui::PushStyleColor(ImGuiCol_Button, kBtnBlue);
ImGui::PushStyleColor(ImGuiCol_Text, contrastText(kBtnBlue));
if (ImGui::Button("\xe2\x9a\x99##cfg", ImVec2(kBtnW, kBtnH)))
ImGui::OpenPopup("##settings");
ImGui::PopStyleColor(2);
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);
if (ImGui::MenuItem("Pan cancels follow", nullptr,
win->imPanDisablesFollow.load()))
pushCmd(UICmd::TogglePanDisablesFollow);
if (ImGui::MenuItem("Right-click recenters", nullptr,
win->imRightClickRecenter.load()))
pushCmd(UICmd::ToggleRightClickRecenter);
ImGui::Separator();
// Helper lambdas for ME setting commands (y = index, delta = value)
auto pushMEBool = [&](int idx, bool val) {
InputEvent ev{}; ev.type = UICommand;
ev.x = static_cast<float>(UICmd::SetMEBool);
ev.y = static_cast<float>(idx);
ev.delta = val ? 1.f : 0.f;
win->inputQueue.push(ev);
};
auto pushMEFloat = [&](int idx, float val) {
InputEvent ev{}; ev.type = UICommand;
ev.x = static_cast<float>(UICmd::SetMEFloat);
ev.y = static_cast<float>(idx);
ev.delta = val;
win->inputQueue.push(ev);
};
bool meOn = win->imMapEnhancerInstalled.load();
bool meTurntable = win->imMEHasTurntable.load(); // v1.6.0 or community
bool meCom = win->imMECommunity.load(); // community only
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::Separator();
if (ImGui::BeginMenu("Settings")) {
uint32_t meFlags = win->imMEFlags.load();
auto meBit = [&](int b) { return (meFlags & (1u << b)) != 0; };
// -- Markers --
// Turntable Markers: available on v1.6.0 and community (ShowTurntableMarkers field).
// Everything else in this block is community-only.
ImGui::TextDisabled("Markers");
ImGui::BeginDisabled(!meTurntable);
{
bool v = meBit(MB_TurntableMarkers);
if (ImGui::MenuItem("Turntable Markers", nullptr, v)) pushMEBool(MB_TurntableMarkers, !v);
}
ImGui::EndDisabled();
ImGui::BeginDisabled(!meCom);
{
bool v = meBit(MB_TurntableControl);
if (ImGui::MenuItem("Turntable Control", nullptr, v)) pushMEBool(MB_TurntableControl, !v);
if (v) {
bool cl = meBit(MB_CheckClearance);
if (ImGui::MenuItem(" Check Clearance", nullptr, cl)) pushMEBool(MB_CheckClearance, !cl);
}
}
{
bool v = meBit(MB_CrossingMarkers);
if (ImGui::MenuItem("Road Crossing Markers", nullptr, v)) pushMEBool(MB_CrossingMarkers, !v);
}
{
bool v = meBit(MB_PassengerStops);
if (ImGui::MenuItem("Passenger Stop Tracking", nullptr, v)) pushMEBool(MB_PassengerStops, !v);
}
{
bool v = meBit(MB_IndustryAreaColors);
if (ImGui::MenuItem("Industry Area Colors", nullptr, v)) pushMEBool(MB_IndustryAreaColors, !v);
}
ImGui::EndDisabled();
ImGui::Separator();
// -- Behavior --
ImGui::TextDisabled("Behavior");
ImGui::BeginDisabled(!meCom);
{
bool v = meBit(MB_ModdedSpawnPoints);
if (ImGui::MenuItem("Modded Spawn Points", nullptr, v)) pushMEBool(MB_ModdedSpawnPoints, !v);
}
ImGui::EndDisabled();
{
bool v = meBit(MB_DoubleClick);
if (ImGui::MenuItem("Require Double Click", nullptr, v)) pushMEBool(MB_DoubleClick, !v);
}
ImGui::Separator();
// -- Scales -- (live-update atomic during drag; push on release)
ImGui::TextDisabled("Scales");
{
float v = win->imMEFlareScale.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Flare##me", &v, 0.1f, 1.0f, "%.2f"))
win->imMEFlareScale.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushMEFloat(MF_FlareScale, win->imMEFlareScale.load());
}
{
float v = win->imMEJunctionSc.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Junction##me", &v, 0.5f, 1.0f, "%.2f"))
win->imMEJunctionSc.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushMEFloat(MF_JunctionScale, win->imMEJunctionSc.load());
}
{
float v = win->imMETrackThick.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Track Width##me", &v, 0.5f, 2.0f, "%.2f"))
win->imMETrackThick.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushMEFloat(MF_TrackThickness, win->imMETrackThick.load());
}
ImGui::BeginDisabled(!meCom);
{
float v = win->imMECrossingSc.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Crossing##me", &v, 0.1f, 1.0f, "%.2f"))
win->imMECrossingSc.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushMEFloat(MF_CrossingScale, win->imMECrossingSc.load());
}
ImGui::EndDisabled();
ImGui::Separator();
// -- Switch Reset (community build only) --
ImGui::BeginDisabled(!meCom);
ImGui::TextDisabled("Switch Reset");
if (ImGui::MenuItem("All Switches - Normal"))
pushCmd(UICmd::MEResetSwitchesNormal);
if (ImGui::MenuItem("All Switches - Thrown"))
pushCmd(UICmd::MEResetSwitchesThrown);
ImGui::EndDisabled();
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::Separator();
// Theme preset picker
if (ImGui::BeginMenu("Theme")) {
int cur = g_currentThemePreset.load();
if (ImGui::MenuItem("S3 Dark", nullptr, cur == 0)) pushCmd(UICmd::SetTheme, 0.f);
if (ImGui::MenuItem("Railroader Classic", nullptr, cur == 1)) pushCmd(UICmd::SetTheme, 1.f);
if (ImGui::MenuItem("Night Mode", nullptr, cur == 2)) pushCmd(UICmd::SetTheme, 2.f);
if (ImGui::MenuItem("High Visibility", nullptr, cur == 3)) pushCmd(UICmd::SetTheme, 3.f);
if (ImGui::MenuItem("Retro Terminal", nullptr, cur == 4)) pushCmd(UICmd::SetTheme, 4.f);
ImGui::Separator();
if (ImGui::MenuItem("Custom", nullptr, cur == 5)) pushCmd(UICmd::SetTheme, 5.f);
ImGui::EndMenu();
}
// Opacity sliders (in-game overlay only — popout is a native OS window)
if (showPopOutBtn) {
ImGui::Separator();
// Chrome alpha — window frame, toolbar, compass
float alpha = g_ovAlpha.load();
ImGui::SetNextItemWidth(100.f);
if (ImGui::SliderFloat("Window##ov", &alpha, 0.1f, 1.0f, "%.2f"))
g_ovAlpha.store(alpha);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::SetAlpha, g_ovAlpha.load());
// Map image alpha — independent of chrome
float mAlpha = g_mapAlpha.load();
ImGui::SetNextItemWidth(100.f);
if (ImGui::SliderFloat("Map Elements##ov", &mAlpha, 0.0f, 1.0f, "%.2f"))
g_mapAlpha.store(mAlpha);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::SetMapAlpha, g_mapAlpha.load());
// Map background alpha — camera clear colour opacity (0 = transparent void)
float bgAlpha = g_mapBgAlpha.load();
ImGui::SetNextItemWidth(100.f);
if (ImGui::SliderFloat("Map Background##ov", &bgAlpha, 0.0f, 1.0f, "%.2f"))
g_mapBgAlpha.store(bgAlpha);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::SetMapBgAlpha, g_mapBgAlpha.load());
}
ImGui::EndPopup();
}
ImGui::PopStyleVar(3);
ImGui::End();
}
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) {
MapThemeData theme = SnapshotTheme();
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();
BuildMapUI(win, ImVec2(0.f, 0.f), (float)w, (float)h,
/*reserveResizeGrip=*/false, /*showPopOutBtn=*/false, theme);
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);
}
// ===========================================================================
// In-game overlay (spike)
// ===========================================================================
// Renders an ImGui test window into Unity's currently bound backbuffer (no
// swapchain, no Present) to prove the on-top render path. Reuses the SAME ImGui
// context + DX11 backend the popout uses (set up once in InitImGui): the popout
// and overlay each do a complete NewFrame/Render inside their own plugin event
// on the render thread, so sequential reuse of one context is safe. A second
// context tripped the DX11 backend's per-context init in release builds.
// ---------------------------------------------------------------------------
static std::atomic<void*> g_ovDeviceTex {nullptr};
static std::atomic<float> g_ovW {0.f}, g_ovH {0.f};
static std::atomic<float> g_ovMouseX {-1.f}, g_ovMouseY {-1.f};
static std::atomic<bool> g_ovLButton {false}, g_ovRButton {false};
static std::atomic<int> g_ovWheelRaw {0};
static std::atomic<bool> g_ovWantMouse {false};
static std::atomic<bool> g_ovVisible {false};
// Map texture to show in the in-game window (set from C# each frame) + UV rect.
static std::atomic<void*> g_ovMapTex {nullptr};
static std::atomic<float> g_ovMapU0 {0.f}, g_ovMapV0 {1.f};
static std::atomic<float> g_ovMapU1 {1.f}, g_ovMapV1 {0.f};
// SRV over the map texture for ImGui::Image. Rebuilt each frame; must outlive
// RenderDrawData, so it lives at file scope rather than as a frame-local.
static ComPtr<ID3D11ShaderResourceView> g_ovMapSRV;
// Size (px) of the map image region this frame. C# reads it back to set the map
// camera's aspect to the window shape, so the map fills the window (no letterbox).
static std::atomic<float> g_ovViewW {0.f}, g_ovViewH {0.f};
void Overlay_GetMapView(float* outW, float* outH) {
if (outW) *outW = g_ovViewW.load();
if (outH) *outH = g_ovViewH.load();
}
void Overlay_SetAlpha(float alpha) { g_ovAlpha.store(std::max(0.1f, std::min(1.0f, alpha))); }
void Overlay_SetMapAlpha(float alpha) { g_mapAlpha.store(std::max(0.0f, std::min(1.0f, alpha))); }
void Overlay_SetMapBgAlpha(float alpha) { g_mapBgAlpha.store(std::max(0.0f, std::min(1.0f, alpha))); }
// Map-image interaction (drag/zoom) queued here for C# to forward to the map
// camera. Same InputEvent contract the popout uses, so C# can share the logic.
static InputQueue g_ovInput;
int Overlay_PollInput(InputEvent* out, int maxEvents) { return g_ovInput.drain(out, maxEvents); }
void Overlay_SetDeviceTexture(void* texturePtr) { g_ovDeviceTex.store(texturePtr); }
void Overlay_SetVisible(bool visible) { g_ovVisible.store(visible); }
bool Overlay_WantsMouse() { return g_ovWantMouse.load(); }
void Overlay_SetMapTexture(void* texturePtr, float u0, float v0, float u1, float v1) {
g_ovMapTex.store(texturePtr);
g_ovMapU0.store(u0); g_ovMapV0.store(v0);
g_ovMapU1.store(u1); g_ovMapV1.store(v1);
}
void Overlay_SetInput(float displayW, float displayH,
float mouseX, float mouseY,
bool lButton, bool rButton, int wheel) {
g_ovW.store(displayW); g_ovH.store(displayH);
g_ovMouseX.store(mouseX); g_ovMouseY.store(mouseY);
g_ovLButton.store(lButton); g_ovRButton.store(rButton);
if (wheel) g_ovWheelRaw.fetch_add(wheel);
}
void Renderer_PresentOverlay() {
if (!g_ovVisible.load()) return;
// Lazy device init — same path the popout uses, but from a throwaway texture
// since the overlay has no map RT of its own. Renderer_OnDeviceInit also runs
// InitImGui (shared context + DX11 backend), so g_imguiInited is set here too.
if (!g_device) {
void* t = g_ovDeviceTex.load();
if (!t) return;
auto* tex = static_cast<ID3D11Texture2D*>(t);
ID3D11Device* dev = nullptr;
tex->GetDevice(&dev);
if (!dev) return;
Renderer_OnDeviceInit(dev);
dev->Release();
}
if (!g_device || !g_context || !g_imguiInited) return;
float w = g_ovW.load(), h = g_ovH.load();
if (w <= 0.f || h <= 0.f) return;
// Save Unity's render state — we draw into its currently bound RTV and leave
// it bound (no OMSetRenderTargets), so ImGui composites onto the backbuffer.
D3D11StateBlock saved;
saved.Capture(g_context);
MapThemeData theme = SnapshotTheme();
ImGuiIO& io = ImGui::GetIO();
io.DisplaySize = ImVec2(w, h);
io.MousePos = ImVec2(g_ovMouseX.load(), g_ovMouseY.load());
io.MouseDown[0] = g_ovLButton.load();
io.MouseDown[1] = g_ovRButton.load();
io.MouseWheel = (float)g_ovWheelRaw.exchange(0) / WHEEL_DELTA;
ImGui_ImplDX11_NewFrame();
ImGui::NewFrame();
// Chrome (window bg, title bar, toolbar, compass) alpha. Pushed before Begin()
// so all decorations are affected; popped around the map Image() so the map
// image has its own independent alpha, then popped finally after BuildMapUI.
const float ovAlpha = g_ovAlpha.load();
const float mapAlpha = g_mapAlpha.load();
const float mapBgA = g_mapBgAlpha.load();
// While the mouse is over the overlay, clamp chrome alpha to 50% so the UI
// remains readable even when the user has set a very low window opacity.
// Uses last frame's WantCaptureMouse (one-frame lag; imperceptible in practice).
const bool mouseOver = g_ovWantMouse.load();
const float effectiveAlpha = mouseOver ? std::max(ovAlpha, 0.5f) : ovAlpha;
const bool hasAlpha = effectiveAlpha < 0.999f;
if (hasAlpha)
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, effectiveAlpha);
g_ovMapSRV.Reset(); // release last frame's view before building this frame's
// Captured inside the window, used to position the shared chrome (toolbar +
// compass) over the map image after the window's End().
ImVec2 mapScreenPos(0.f, 0.f), mapSize(0.f, 0.f);
bool mapShown = false;
// Shared UI-state holder (status, lists, rotation) + command queue for chrome.
PopoutWindow* stateWin = GetPopoutWindow(GetOrCreateOverlayStateHandle());
// Zero padding so the map fills the window edge-to-edge and the toolbar sits
// flush at the bottom (acting as the border) with no dark frame around it.
// WindowPadding is only read during Begin() so we pop it right after — this
// keeps it off the style stack so we can freely pop/push ovAlpha around Image().
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f));
ImGui::SetNextWindowPos(ImVec2(80.f, 80.f), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(720.f, 480.f), ImGuiCond_FirstUseEver);
// Scale window bg alpha by mapBgA so the map background slider makes the void
// behind the camera transparent (showing Unity's scene), not the window chrome.
if (mapBgA < 0.999f)
ImGui::PushStyleColor(ImGuiCol_WindowBg,
ImVec4(theme.wBgR, theme.wBgG, theme.wBgB, theme.wBgA * mapBgA));
// p_open = true: ImGui adds an [X] close button to the title bar. When the user
// clicks it ImGui sets windowOpen=false; we push UICmd::Close so C# can call
// HideIfVisible() and DeactivateMap(). NoBringToFrontOnFocus keeps this window
// behind the chrome windows (toolbar + compass) that BuildMapUI creates after it.
bool windowOpen = true;
bool windowVisible = ImGui::Begin("Railroader Map##s3ingame", &windowOpen,
ImGuiWindowFlags_NoBringToFrontOnFocus);
// WindowPadding is read; pop now so ovAlpha is the only active style var.
ImGui::PopStyleVar();
if (mapBgA < 0.999f) ImGui::PopStyleColor();
if (windowVisible) {
void* mapTex = g_ovMapTex.load();
ImVec2 avail = ImGui::GetContentRegionAvail();
if (mapTex && avail.x > 4.f && avail.y > 4.f) {
auto* tex = static_cast<ID3D11Texture2D*>(mapTex);
D3D11_TEXTURE2D_DESC td; tex->GetDesc(&td);
D3D11_SHADER_RESOURCE_VIEW_DESC sd = {};
sd.Format = TypedFormat(td.Format);
sd.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
sd.Texture2D.MostDetailedMip = 0;
sd.Texture2D.MipLevels = 1;
if (SUCCEEDED(g_device->CreateShaderResourceView(tex, &sd, &g_ovMapSRV))) {
// Fill the whole content region (no letterbox). C# reads this size
// back and sets camera.aspect to match, so the map renders the right
// slice to fill the window instead of leaving bars top/bottom.
ImVec2 sz = avail;
g_ovViewW.store(sz.x);
g_ovViewH.store(sz.y);
// Transparent hit area over the map for drag/zoom. Queue events in
// normalized [0,1] image space (top-left origin) so C# can forward
// them to the map camera with the same math the popout uses.
ImVec2 imgPos = ImGui::GetCursorScreenPos();
ImGui::InvisibleButton("##mapHit", sz, ImGuiButtonFlags_MouseButtonLeft);
bool hov = ImGui::IsItemHovered();
ImVec2 nrm = { (io.MousePos.x - imgPos.x) / sz.x,
(io.MousePos.y - imgPos.y) / sz.y };
auto pushOv = [&](int type, float d) {
InputEvent e{}; e.type = type; e.x = nrm.x; e.y = nrm.y; e.delta = d;
g_ovInput.push(e);
};
if (ImGui::IsItemActivated()) pushOv(LButtonDown, 0.f);
if (ImGui::IsItemActive() &&
(io.MouseDelta.x != 0.f || io.MouseDelta.y != 0.f))
pushOv(MouseMove, 0.f);
if (ImGui::IsItemDeactivated()) pushOv(LButtonUp, 0.f);
if (hov && io.MouseWheel != 0.f) pushOv(MouseWheel, io.MouseWheel);
if (hov && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) pushOv(RButtonUp, 0.f);
// Draw the map over the same rect the hit-button occupies.
// Map alpha is independent of chrome alpha: pop ovAlpha so
// ImGuiStyleVar_Alpha doesn't multiply into the tint, then push back.
ImGui::SetCursorScreenPos(imgPos);
ImVec2 uv0(g_ovMapU0.load(), g_ovMapV0.load());
ImVec2 uv1(g_ovMapU1.load(), g_ovMapV1.load());
ImVec4 tint(theme.mapR, theme.mapG, theme.mapB, theme.mapA * mapAlpha);
if (hasAlpha) ImGui::PopStyleVar(); // lift chrome alpha
ImGui::Image((ImTextureID)g_ovMapSRV.Get(), sz, uv0, uv1, tint);
if (hasAlpha) ImGui::PushStyleVar(ImGuiStyleVar_Alpha, effectiveAlpha);
// Visible resize-grip indicator. ImGui draws its own grip during
// Begin() — behind our opaque map image, so invisible. We draw one
// on top in the corner only while the window is focused (the dark
// unfocused version was more distracting than no grip at all).
if (ImGui::IsWindowFocused()) {
ImDrawList* dl = ImGui::GetWindowDrawList();
ImVec2 br(imgPos.x + sz.x, imgPos.y + sz.y);
const float gripSz = 16.f;
ImU32 col = ImGui::GetColorU32(ImGuiCol_TitleBgActive, 1.0f);
dl->AddTriangleFilled(ImVec2(br.x, br.y),
ImVec2(br.x - gripSz, br.y),
ImVec2(br.x, br.y - gripSz), col);
}
mapScreenPos = imgPos; mapSize = sz; mapShown = true;
}
} else {
ImGui::TextWrapped("Load a save and open the in-game map, then it will "
"appear here. (F10 toggles this window.)");
}
}
ImGui::End();
// WindowPadding was already popped right after Begin(); nothing extra to pop here.
// If the user clicked [X], push a Close command for C# to process.
if (!windowOpen && stateWin) {
InputEvent ev{}; ev.type = UICommand;
ev.x = static_cast<float>(UICmd::Close);
stateWin->inputQueue.push(ev);
}
// Shared map chrome (toolbar + compass), positioned over the map image so the
// in-game map and the popout present the same UI. Reads the shared state holder.
if (mapShown && stateWin)
BuildMapUI(stateWin, mapScreenPos, mapSize.x, mapSize.y,
/*reserveResizeGrip=*/true, /*showPopOutBtn=*/true, theme, mapAlpha);
// Pop the global alpha AFTER all chrome windows, before Render().
if (hasAlpha)
ImGui::PopStyleVar();
ImGui::Render();
// Bind a linear (non-sRGB) view of Unity's backbuffer so the GPU does not
// sRGB-encode our writes (which washes the map/UI out). This makes the overlay
// match the popout, which renders into its own UNORM swapchain. Falls back to
// Unity's bound RTV if a UNORM view can't be created (no regression).
ComPtr<ID3D11RenderTargetView> linearRtv;
if (saved.rtv[0]) {
ComPtr<ID3D11Resource> res;
saved.rtv[0]->GetResource(&res);
ComPtr<ID3D11Texture2D> bb;
if (res && SUCCEEDED(res.As(&bb))) {
D3D11_TEXTURE2D_DESC bbDesc; bb->GetDesc(&bbDesc);
DXGI_FORMAT linear = UnormRtvFormat(bbDesc.Format);
if (linear != DXGI_FORMAT_UNKNOWN) {
D3D11_RENDER_TARGET_VIEW_DESC rd = {};
rd.Format = linear;
rd.ViewDimension = (bbDesc.SampleDesc.Count > 1)
? D3D11_RTV_DIMENSION_TEXTURE2DMS : D3D11_RTV_DIMENSION_TEXTURE2D;
g_device->CreateRenderTargetView(bb.Get(), &rd, &linearRtv);
}
}
}
if (linearRtv) {
ID3D11RenderTargetView* rtv = linearRtv.Get();
g_context->OMSetRenderTargets(1, &rtv, nullptr);
}
ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
g_ovWantMouse.store(io.WantCaptureMouse);
// Restore Unity's render state
saved.Restore(g_context);
}