#define WIN32_LEAN_AND_MEAN #define NOMINMAX #include #include #include #include #include #include #include #include #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 g_vs; static ComPtr g_ps; static ComPtr g_uvRectCB; static ComPtr g_sampler; static ComPtr g_blendOpaque; static ComPtr g_rasterState; static ComPtr g_depthState; static bool g_imguiInited = false; // --------------------------------------------------------------------------- // 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 g_themeChanged {false}; static std::atomic g_currentThemePreset {0}; static std::atomic g_ovAlpha {1.0f}; // chrome (window + toolbar + compass) alpha static std::atomic g_mapAlpha {1.0f}; // map Image() alpha, independent of chrome static std::atomic g_mapBgAlpha {1.0f}; // camera clear colour opacity // 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 rtv[8]; ComPtr dsv; UINT numViewports = D3D11_VIEWPORT_AND_SCISSORRECT_OBJECT_COUNT_PER_PIPELINE; D3D11_VIEWPORT viewports[D3D11_VIEWPORT_AND_SCISSORRECT_OBJECT_COUNT_PER_PIPELINE]; ComPtr vs; ComPtr ps; ComPtr vsCB; ComPtr inputLayout; D3D11_PRIMITIVE_TOPOLOGY topology = D3D11_PRIMITIVE_TOPOLOGY_UNDEFINED; ComPtr psSRV[1]; ComPtr psSampler[1]; ComPtr blendState; FLOAT blendFactor[4]; UINT sampleMask; ComPtr rasterizerState; ComPtr depthStencilState; UINT stencilRef; void Capture(ID3D11DeviceContext* ctx) { ctx->OMGetRenderTargets(8, &rtv[0], &dsv); ctx->RSGetViewports(&numViewports, viewports); ctx->VSGetShader(&vs, nullptr, nullptr); ctx->VSGetConstantBuffers(0, 1, &vsCB); ctx->PSGetShader(&ps, nullptr, nullptr); ctx->IAGetInputLayout(&inputLayout); ctx->IAGetPrimitiveTopology(&topology); ctx->PSGetShaderResources(0, 1, &psSRV[0]); ctx->PSGetSamplers(0, 1, &psSampler[0]); ctx->OMGetBlendState(&blendState, blendFactor, &sampleMask); ctx->RSGetState(&rasterizerState); ctx->OMGetDepthStencilState(&depthStencilState, &stencilRef); } void Restore(ID3D11DeviceContext* ctx) { ID3D11RenderTargetView* rtvPtrs[8]; for (int i = 0; i < 8; i++) rtvPtrs[i] = rtv[i].Get(); ctx->OMSetRenderTargets(8, rtvPtrs, dsv.Get()); ctx->RSSetViewports(numViewports, viewports); ctx->VSSetShader(vs.Get(), nullptr, 0); ID3D11Buffer* cb = vsCB.Get(); ctx->VSSetConstantBuffers(0, 1, &cb); ctx->PSSetShader(ps.Get(), nullptr, 0); ctx->IASetInputLayout(inputLayout.Get()); ctx->IASetPrimitiveTopology(topology); ctx->PSSetShaderResources(0, 1, &psSRV[0]); ctx->PSSetSamplers(0, 1, &psSampler[0]); ctx->OMSetBlendState(blendState.Get(), blendFactor, sampleMask); ctx->RSSetState(rasterizerState.Get()); ctx->OMSetDepthStencilState(depthStencilState.Get(), stencilRef); } }; // --------------------------------------------------------------------------- // 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 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 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 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 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 dxgiDevice; ComPtr adapter; ComPtr factory; if (FAILED(g_device->QueryInterface(__uuidof(IDXGIDevice), (void**)&dxgiDevice))) return false; if (FAILED(dxgiDevice->GetAdapter(&adapter))) return false; if (FAILED(adapter->GetParent(__uuidof(IDXGIFactory), (void**)&factory))) return false; DXGI_SWAP_CHAIN_DESC scd = {}; scd.BufferCount = 2; scd.BufferDesc.Width = static_cast(w); scd.BufferDesc.Height = static_cast(h); scd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; scd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; scd.OutputWindow = win->contentHwnd; scd.SampleDesc.Count = 1; scd.Windowed = TRUE; scd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD; scd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH; if (FAILED(factory->CreateSwapChain(g_device, &scd, &win->swapChain))) return false; factory->MakeWindowAssociation(win->contentHwnd, DXGI_MWA_NO_ALT_ENTER); ID3D11Texture2D* bb = nullptr; win->swapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&bb); if (bb) { g_device->CreateRenderTargetView(bb, nullptr, &win->rtv); bb->Release(); } return win->rtv != nullptr; } void Renderer_ReleaseSwapchain(PopoutWindow* win) { if (!win) return; if (win->rtv) { win->rtv->Release(); win->rtv = nullptr; } if (win->swapChain) { win->swapChain->Release(); win->swapChain = nullptr; } } // --------------------------------------------------------------------------- // 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(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 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(UICmd::SetMEBool); ev.y = static_cast(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(UICmd::SetMEFloat); ev.y = static_cast(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 lk(win->imLocoMutex); if (!win->imLocoList.empty()) { ImGui::Separator(); for (int i = 0; i < (int)win->imLocoList.size(); ++i) if (ImGui::MenuItem(win->imLocoList[i].label)) pushCmd(UICmd::FollowLoco, (float)i); } } ImGui::EndMenu(); } if (ImGui::BeginMenu("Jump to Location")) { std::lock_guard lk(win->imLocationMutex); if (win->imLocationList.empty()) ImGui::TextDisabled("(loading...)"); else for (int i = 0; i < (int)win->imLocationList.size(); ++i) if (ImGui::MenuItem(win->imLocationList[i].label)) pushCmd(UICmd::JumpToLocation, (float)i); ImGui::EndMenu(); } ImGui::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 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(rawTex); ID3D11Device* dev = nullptr; tex->GetDevice(&dev); if (!dev) return; Renderer_OnDeviceInit(dev); dev->Release(); } if (!g_device || !g_context) return; if (!Renderer_EnsureSwapchain(win)) return; auto* srcTex = static_cast(rawTex); D3D11_TEXTURE2D_DESC texDesc; srcTex->GetDesc(&texDesc); D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc = {}; srvDesc.Format = TypedFormat(texDesc.Format); srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D; srvDesc.Texture2D.MostDetailedMip = 0; srvDesc.Texture2D.MipLevels = 1; ComPtr srv; if (FAILED(g_device->CreateShaderResourceView(srcTex, &srvDesc, &srv))) return; // Save Unity's render state D3D11StateBlock saved; saved.Capture(g_context); int w = win->width.load(), h = win->height.load(); D3D11_VIEWPORT vp { 0.f, 0.f, (float)w, (float)h, 0.f, 1.f }; // Update UV rect constant buffer if (g_uvRectCB) { D3D11_MAPPED_SUBRESOURCE mapped = {}; if (SUCCEEDED(g_context->Map(g_uvRectCB.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped))) { auto* cb = static_cast(mapped.pData); cb->u0 = win->srcU0.load(); cb->v0 = win->srcV0.load(); cb->u1 = win->srcU1.load(); cb->v1 = win->srcV1.load(); g_context->Unmap(g_uvRectCB.Get(), 0); } } // Blit map texture into swapchain back buffer float bf[4] = {}; g_context->OMSetRenderTargets(1, &win->rtv, nullptr); g_context->RSSetViewports(1, &vp); g_context->VSSetShader(g_vs.Get(), nullptr, 0); ID3D11Buffer* cbPtr = g_uvRectCB.Get(); g_context->VSSetConstantBuffers(0, 1, &cbPtr); g_context->PSSetShader(g_ps.Get(), nullptr, 0); g_context->IASetInputLayout(nullptr); g_context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); ID3D11ShaderResourceView* srvPtr = srv.Get(); g_context->PSSetShaderResources(0, 1, &srvPtr); ID3D11SamplerState* sampPtr = g_sampler.Get(); g_context->PSSetSamplers(0, 1, &sampPtr); g_context->OMSetBlendState(g_blendOpaque.Get(), bf, 0xFFFFFFFF); g_context->RSSetState(g_rasterState.Get()); g_context->OMSetDepthStencilState(g_depthState.Get(), 0); g_context->Draw(3, 0); // ----------------------------------------------------------------------- // ImGui overlay — compass rose on map + toolbar strip at bottom // ----------------------------------------------------------------------- if (g_imguiInited) { 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 g_ovDeviceTex {nullptr}; static std::atomic g_ovW {0.f}, g_ovH {0.f}; static std::atomic g_ovMouseX {-1.f}, g_ovMouseY {-1.f}; static std::atomic g_ovLButton {false}, g_ovRButton {false}; static std::atomic g_ovWheelRaw {0}; static std::atomic g_ovWantMouse {false}; static std::atomic g_ovVisible {false}; // Map texture to show in the in-game window (set from C# each frame) + UV rect. static std::atomic g_ovMapTex {nullptr}; static std::atomic g_ovMapU0 {0.f}, g_ovMapV0 {1.f}; static std::atomic 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 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 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(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(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(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 linearRtv; if (saved.rtv[0]) { ComPtr res; saved.rtv[0]->GetResource(&res); ComPtr 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); }