v0.2.3 - MapEnhancer settings panel, opacity controls, right-click recenter

Extends the existing MapEnhancer gear menu integration with a full settings
panel covering the options added by the community fork: marker visibility
toggles, scale sliders for flares, junctions, track lines, and crossings,
and bulk switch reset buttons.

Also adds pan-cancels-follow and right-click to recenter on player toggles,
three independent opacity controls (Window, Map Elements, Map Background),
a per-element hover minimum of 50% for the window chrome so controls stay
reachable at low opacity, and fixes to make the compass and toolbar correctly
respond to their respective sliders. Both surfaces (in-game overlay and OS
popout) maintain parity on all new settings.
This commit is contained in:
Seton Carmichael 2026-06-19 06:08:24 -04:00
parent b85a492bd6
commit dd1af2ec30
12 changed files with 541 additions and 46 deletions

View file

@ -2,7 +2,7 @@
"Id": "S3",
"DisplayName": "S³ - Seton's Special Sauce",
"Author": "seton",
"Version": "0.2.2",
"Version": "0.2.3",
"ManagerVersion": "0.27.0",
"GameVersionPoint": "0",
"AssemblyName": "S3.dll",

View file

@ -29,7 +29,7 @@ Replaces the base-game map with a floating in-game overlay (toggle with **M** or
### Settings
Configure everything from the S³ UMM settings page: hotkey, drag-to-cancel follow behavior, theme, independent window and map opacity, the detach button, and a full per-element custom color editor.
Configure everything from the S³ UMM settings page: hotkey, drag-to-cancel follow behavior, right-click to recenter on player, theme, three independent opacity controls (Window, Map Elements, Map Background), the detach button, and a full per-element custom color editor.
![Map Module settings panel](img/map/map_settings_umm.png)
@ -52,7 +52,9 @@ Six themes built in: five named presets plus a **Custom** slot you tune with per
### Transparency
The in-game overlay has independent **Window** and **Map** opacity controls. Keep the chrome subtle while the track layout stays crisp, or dial both down to a ghost overlay sitting over the world.
The in-game overlay has three independent opacity controls. **Window** sets the chrome opacity (toolbar, compass, and frame), with an automatic minimum of 50% while the mouse is over the overlay so the controls stay reachable at any setting. **Map Elements** fades the map image independently. **Map Background** controls how transparent the empty space behind terrain is - turn it down and the game world shows through where there is no map content.
<video src="https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/raw/branch/main/img/map/opacity/opacity_controls.mp4" controls></video>
![Overlay transparency with the map visible through the game world](img/map/transparency.png)
@ -72,9 +74,9 @@ Pop the map into a detached native OS window. Drag it to any monitor, resize it
### MapEnhancer Integration
If [MapEnhancer](https://github.com/Refizar08/rr-mapenhancer-fix) is installed, the toolbar gear menu unlocks a full **Follow** submenu: follow player camera, follow the selected locomotive, or pick any consist from a live-updated list. Jump to named locations is also available.
If [MapEnhancer](https://github.com/Refizar08/rr-mapenhancer-fix) is installed, the toolbar gear menu expands with follow controls and a settings panel. The **Follow** submenu lets you follow the player camera, the selected locomotive, or any consist picked from a live-updated list. **Jump to Location** is also available. The **Settings** panel gives you live control over all MapEnhancer rendering options without leaving the map: marker visibility toggles, scale sliders for flares, junctions, track lines, and crossings, and bulk switch reset buttons.
![MapEnhancer follow menu with locomotive list](img/map/map_enhancer_follow_mode.png)
![MapEnhancer gear menu showing follow submenu and settings panel](img/map/map_enhancer_follow_mode.png)
---

View file

@ -18,12 +18,12 @@ enum InputEventType : int32_t {
// Command IDs for UICommand events.
// For FollowLoco / JumpToLocation the list index is carried in InputEvent::y.
enum UICmd : int32_t {
Recenter = 1,
FollowMode = 2, // toggle MapEnhancer follow on/off
FollowPlayerCam = 3, // follow Camera.main continuously
FollowSelectedLoco = 4, // follow TrainController.Shared.SelectedCar
FollowLoco = 5, // follow specific loco; y = 0-based index in loco list
JumpToLocation = 6, // jump map camera to named location; y = 0-based index
Recenter = 1,
FollowMode = 2, // toggle MapEnhancer follow on/off
FollowPlayerCam = 3, // follow Camera.main continuously
FollowSelectedLoco = 4, // follow TrainController.Shared.SelectedCar
FollowLoco = 5, // follow specific loco; y = 0-based index in loco list
JumpToLocation = 6, // jump map camera to named location; y = 0-based index
SetRotation = 7, // set map rotation; y = degrees [0,360)
RotateReset = 8, // reset map rotation to 0
RotateSyncPlayer = 9, // toggle sync-rotation-to-player-camera mode
@ -33,6 +33,36 @@ enum UICmd : int32_t {
SetAlpha = 13, // (in-game only) set overlay (chrome) alpha; y = [0.1, 1.0]
Close = 14, // (in-game only) hide the overlay (user clicked [X])
SetMapAlpha = 15, // (in-game only) set map image alpha; y = [0.0, 1.0]
// MapEnhancer settings (C# MapEnhancerBridge mirrors these indices)
SetMEBool = 16, // toggle ME bool setting; y = MEBoolBit index, delta = 0|1
SetMEFloat = 17, // set ME float setting; y = MEFloatIdx index, delta = value
MEResetSwitchesNormal = 18, // bulk reset: all switches to Normal
MEResetSwitchesThrown = 19, // bulk reset: all switches to Thrown
TogglePanDisablesFollow = 20, // toggle whether panning cancels follow mode
ToggleRightClickRecenter = 21, // toggle whether right-clicking recenters on player
SetMapBgAlpha = 22, // set map camera clear-colour opacity; y = [0.0, 1.0]
};
// Bit indices for ME bool settings packed into PopoutWindow::imMEFlags.
// Must match s_boolFieldNames[] in MapEnhancerBridge.cs exactly.
enum MEBoolBit : int {
MB_TurntableMarkers = 0, // ShowTurntableMarkers
MB_TurntableControl = 1, // EnableTurntableControl
MB_CheckClearance = 2, // CheckTurntableClearance
MB_CrossingMarkers = 3, // ShowRoadCrossingMarkers
MB_PassengerStops = 4, // EnablePassengerStopTracking
MB_IndustryAreaColors = 5, // EnableIndustryAreaColors
MB_ModdedSpawnPoints = 6, // EnableModdedSpawnPoints
MB_DoubleClick = 7, // DoubleClick (require double-click for interactions)
};
// Float setting indices for ME sliders.
// Must match s_floatFieldNames[] in MapEnhancerBridge.cs exactly.
enum MEFloatIdx : int {
MF_FlareScale = 0, // FlareScale [0.1, 1.0]
MF_JunctionScale = 1, // JunctionMarkerScale [0.5, 1.0]
MF_TrackThickness = 2, // TrackLineThickness [0.5, 2.0]
MF_CrossingScale = 3, // CrossingMarkerScale [0.1, 1.0]
};
// Theme data pushed from C# to native. Controls ImGui style colours and the

View file

@ -49,6 +49,7 @@ 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; };
@ -430,7 +431,7 @@ void Renderer_ReleaseSwapchain(PopoutWindow* win) {
// 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) {
const MapThemeData& theme, float mapAlpha = 1.0f) {
ImGuiIO& io = ImGui::GetIO();
// Shared command helper — usable from any window in this frame
@ -492,9 +493,10 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
ImVec2 wp = ImGui::GetWindowPos();
ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f };
// Derive compass colours from theme
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*255));
// 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)
@ -560,7 +562,6 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
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::SetNextWindowBgAlpha(1.f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.f, 2.f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f);
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 2.f));
@ -640,8 +641,32 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
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();
if (meOn) {
if (ImGui::BeginMenu("Map Enhancer")) {
@ -666,7 +691,7 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
}
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Jump to location")) {
if (ImGui::BeginMenu("Jump to Location")) {
std::lock_guard<std::mutex> lk(win->imLocationMutex);
if (win->imLocationList.empty())
ImGui::TextDisabled("(loading...)");
@ -676,10 +701,106 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
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 --
ImGui::TextDisabled("Markers");
{
bool v = meBit(MB_TurntableMarkers);
if (ImGui::MenuItem("Turntable Markers", nullptr, v)) pushMEBool(MB_TurntableMarkers, !v);
}
{
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::Separator();
// -- Behavior --
ImGui::TextDisabled("Behavior");
{
bool v = meBit(MB_ModdedSpawnPoints);
if (ImGui::MenuItem("Modded Spawn Points", nullptr, v)) pushMEBool(MB_ModdedSpawnPoints, !v);
}
{
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());
}
{
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::Separator();
// -- Switch Reset --
ImGui::TextDisabled("Switch Reset");
if (ImGui::MenuItem("All Switches - Normal"))
pushCmd(UICmd::MEResetSwitchesNormal);
if (ImGui::MenuItem("All Switches - Thrown"))
pushCmd(UICmd::MEResetSwitchesThrown);
ImGui::EndMenu();
}
ImGui::EndMenu();
}
} else {
if (ImGui::BeginMenu("Jump to location")) {
if (ImGui::BeginMenu("Jump to Location")) {
std::lock_guard<std::mutex> lk(win->imLocationMutex);
if (win->imLocationList.empty())
ImGui::TextDisabled("(loading...)");
@ -719,10 +840,17 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
// Map image alpha — independent of chrome
float mAlpha = g_mapAlpha.load();
ImGui::SetNextItemWidth(100.f);
if (ImGui::SliderFloat("Map##ov", &mAlpha, 0.0f, 1.0f, "%.2f"))
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();
@ -863,8 +991,9 @@ void Overlay_GetMapView(float* outW, float* outH) {
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_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.
@ -933,9 +1062,15 @@ void Renderer_PresentOverlay() {
// image has its own independent alpha, then popped finally after BuildMapUI.
const float ovAlpha = g_ovAlpha.load();
const float mapAlpha = g_mapAlpha.load();
const bool hasAlpha = ovAlpha < 0.999f;
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, ovAlpha);
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, effectiveAlpha);
g_ovMapSRV.Reset(); // release last frame's view before building this frame's
@ -954,6 +1089,11 @@ void Renderer_PresentOverlay() {
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
@ -963,6 +1103,7 @@ void Renderer_PresentOverlay() {
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();
@ -1003,6 +1144,7 @@ void Renderer_PresentOverlay() {
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
@ -1013,7 +1155,7 @@ void Renderer_PresentOverlay() {
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, ovAlpha);
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
@ -1050,7 +1192,7 @@ void Renderer_PresentOverlay() {
// 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);
/*reserveResizeGrip=*/true, /*showPopOutBtn=*/true, theme, mapAlpha);
// Pop the global alpha AFTER all chrome windows, before Render().
if (hasAlpha)

View file

@ -60,5 +60,8 @@ void Overlay_SetAlpha(float alpha);
// Set the map image alpha [0.0, 1.0]. Independent of chrome alpha. Thread-safe via atomic.
void Overlay_SetMapAlpha(float alpha);
// Set the map camera clear-colour opacity [0.0, 1.0]. Thread-safe via atomic.
void Overlay_SetMapBgAlpha(float alpha);
// Render the overlay into the currently bound RTV. Render thread only.
void Renderer_PresentOverlay();

View file

@ -209,6 +209,26 @@ void RRPOPOUT_SetMapEnhancerInstalled(int windowHandle, bool installed) {
if (win) win->imMapEnhancerInstalled.store(installed);
}
// Tell native whether panning the map should cancel follow mode (for the gear menu checkmark).
extern "C" __declspec(dllexport)
void RRPOPOUT_SetPanDisablesFollow(int windowHandle, bool active) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (win) win->imPanDisablesFollow.store(active);
}
// Tell native whether right-clicking the map recenters on the player (for the gear menu checkmark).
extern "C" __declspec(dllexport)
void RRPOPOUT_SetRightClickRecenter(int windowHandle, bool active) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (win) win->imRightClickRecenter.store(active);
}
// Seed the map camera clear-colour opacity slider (shared global, affects both surfaces).
extern "C" __declspec(dllexport)
void RRPOPOUT_SetOverlayMapBgAlpha(float alpha) {
Overlay_SetMapBgAlpha(alpha);
}
// Sync the compass display to the current map rotation (degrees, [0,360)).
// Called by C# after it applies a rotation or while in sync-to-player mode.
extern "C" __declspec(dllexport)
@ -276,6 +296,24 @@ void RRPOPOUT_SetTheme(const MapThemeData* data, int presetIndex) {
Renderer_SetTheme(data, presetIndex);
}
// Push MapEnhancer settings state to a window's render-thread atomics.
// flags: bit-packed bool settings (see MEBoolBit in shared_types.h).
// The scale values are used to initialise / update the slider positions;
// the render thread writes them back during drag and only pushes UICmd::SetMEFloat
// when the slider is released (IsItemDeactivatedAfterEdit).
extern "C" __declspec(dllexport)
void RRPOPOUT_SetMEState(int windowHandle, uint32_t flags,
float flareScale, float junctionScale,
float trackThickness, float crossingScale) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (!win) return;
win->imMEFlags.store(flags);
win->imMEFlareScale.store(flareScale);
win->imMEJunctionSc.store(junctionScale);
win->imMETrackThick.store(trackThickness);
win->imMECrossingSc.store(crossingScale);
}
// Set the in-game overlay's chrome (window + toolbar + compass) alpha [0.1, 1.0].
extern "C" __declspec(dllexport)
void RRPOPOUT_SetOverlayAlpha(float alpha) {

View file

@ -71,12 +71,26 @@ struct PopoutWindow {
// -----------------------------------------------------------------------
// Map rotation + ME flag (C# → render thread via exports)
// -----------------------------------------------------------------------
std::atomic<float> imMapRotationDeg {0.f};
std::atomic<float> imMapZoom {500.f};
std::atomic<bool> imMapSyncPlayer {false};
std::atomic<bool> imMapEnhancerInstalled{false};
std::atomic<bool> imFollowPlayer {false};
std::atomic<bool> imAlwaysOnTop {false};
std::atomic<float> imMapRotationDeg {0.f};
std::atomic<float> imMapZoom {500.f};
std::atomic<bool> imMapSyncPlayer {false};
std::atomic<bool> imMapEnhancerInstalled {false};
std::atomic<bool> imFollowPlayer {false};
std::atomic<bool> imAlwaysOnTop {false};
std::atomic<bool> imPanDisablesFollow {true};
std::atomic<bool> imRightClickRecenter {true};
// -----------------------------------------------------------------------
// MapEnhancer settings state (C# → render thread via RRPOPOUT_SetMEState)
// Bit layout of imMEFlags matches MEBoolBit enum in shared_types.h.
// Scale atomics are live-updated during slider drag so the slider doesn't
// snap back between frames, then the final value is pushed as UICmd::SetMEFloat.
// -----------------------------------------------------------------------
std::atomic<uint32_t> imMEFlags {0};
std::atomic<float> imMEFlareScale {0.6f};
std::atomic<float> imMEJunctionSc {0.6f};
std::atomic<float> imMETrackThick {1.25f};
std::atomic<float> imMECrossingSc {0.3f};
// Loaded geometry from the save file — consumed by MessagePumpThread before CreateWindowExW.
std::atomic<int> lastWinX {-1}, lastWinY {-1};

View file

@ -170,6 +170,7 @@ internal sealed class UiHost : MonoBehaviour
private bool _followPlayer;
private float _listTimer;
private bool _locationsSent;
private bool _meDirty;
private string _lastStatus = "";
private void Start()
@ -195,10 +196,11 @@ internal sealed class UiHost : MonoBehaviour
_nativeReady = true;
// Apply saved theme and both alpha settings so the overlay opens with the right look.
// Apply saved theme and opacity settings so the overlay opens with the right look.
MapThemes.ApplyFromSettings();
Native.RRPOPOUT_SetOverlayAlpha(PopoutModule.Settings.overlayAlpha);
Native.RRPOPOUT_SetOverlayMapAlpha(PopoutModule.Settings.overlayMapAlpha);
Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha);
StartCoroutine(RenderLoop());
Log.Info("[ui] in-game overlay ready - press F10 to toggle.");
@ -337,6 +339,7 @@ internal sealed class UiHost : MonoBehaviour
int cn = Native.RRPOPOUT_PollInputEvents(_overlayHandle, s_inputBuf, kMaxInput);
for (int i = 0; i < cn; i++)
ForwardCommand(s_inputBuf[i]);
if (_meDirty) { PushMEState(); _meDirty = false; }
}
}
}
@ -387,6 +390,17 @@ internal sealed class UiHost : MonoBehaviour
Native.RRPOPOUT_SetMapRotation(_overlayHandle, _mapRotationDeg);
}
private void PushMEState()
{
if (!MapEnhancerBridge.IsInstalled) return;
Native.RRPOPOUT_SetMEState(_overlayHandle,
MapEnhancerBridge.GetMEBoolFlags(),
MapEnhancerBridge.GetMEFloat(0),
MapEnhancerBridge.GetMEFloat(1),
MapEnhancerBridge.GetMEFloat(2),
MapEnhancerBridge.GetMEFloat(3));
}
// Pushes the toolbar status line (zoom + map/cam coordinates) when it changes.
private void UpdateMapStatus()
{
@ -494,11 +508,11 @@ internal sealed class UiHost : MonoBehaviour
case UICmd.SetTheme:
MapThemes.Apply((MapTheme)(int)e.y);
// Also update the map camera background colour immediately so it
// matches the new theme even before the next ActivateMap.
if (_mapCamera != null)
_mapCamera.backgroundColor =
MapThemes.GetMapBgColor((MapTheme)(int)e.y);
if (_mapCamera != null) {
var c = MapThemes.GetMapBgColor((MapTheme)(int)e.y);
c.a *= PopoutModule.Settings.overlayMapBgAlpha;
_mapCamera.backgroundColor = c;
}
break;
case UICmd.SetAlpha:
@ -518,6 +532,42 @@ internal sealed class UiHost : MonoBehaviour
PopoutModule.Persist();
Native.RRPOPOUT_SetOverlayMapAlpha(mapAlpha);
break;
case UICmd.SetMEBool:
MapEnhancerBridge.SetMEBool((int)e.y, e.delta != 0f);
if ((int)e.y == 6) _locationsSent = false; // EnableModdedSpawnPoints — re-push location list
_meDirty = true;
break;
case UICmd.SetMEFloat:
MapEnhancerBridge.SetMEFloat((int)e.y, e.delta);
_meDirty = true;
break;
case UICmd.MEResetSwitchesNormal:
MapEnhancerBridge.ResetAllSwitchesToNormal();
break;
case UICmd.MEResetSwitchesThrown:
MapEnhancerBridge.ResetAllSwitchesToThrown();
break;
case UICmd.TogglePanDisablesFollow:
PopoutModule.Settings.panDisablesFollow = !PopoutModule.Settings.panDisablesFollow;
PopoutModule.Persist();
Native.RRPOPOUT_SetPanDisablesFollow(_overlayHandle, PopoutModule.Settings.panDisablesFollow);
break;
case UICmd.ToggleRightClickRecenter:
PopoutModule.Settings.rightClickRecenter = !PopoutModule.Settings.rightClickRecenter;
PopoutModule.Persist();
Native.RRPOPOUT_SetRightClickRecenter(_overlayHandle, PopoutModule.Settings.rightClickRecenter);
break;
case UICmd.SetMapBgAlpha:
float bgAlpha = Mathf.Clamp(e.y, 0f, 1f);
PopoutModule.Settings.overlayMapBgAlpha = bgAlpha;
PopoutModule.Persist();
if (_mapCamera != null) {
var bgc2 = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
bgc2.a *= bgAlpha;
_mapCamera.backgroundColor = bgc2;
}
break;
}
}
@ -531,6 +581,11 @@ internal sealed class UiHost : MonoBehaviour
switch ((InputEventType)e.type)
{
case InputEventType.RButtonUp:
if (PopoutModule.Settings.rightClickRecenter)
UnityEngine.Object.FindObjectOfType<MapWindow>()?.ClickLocateMe();
break;
case InputEventType.MouseWheel:
// Lower floor than the popout's 100 so you can zoom in close like the
// base game map; UpdateForZoom rescales icons to match.
@ -628,11 +683,6 @@ internal sealed class UiHost : MonoBehaviour
_mapCamera.targetTexture = _ownRT;
_mapCamera.rect = new Rect(0f, 0f, 1f, 1f);
// Apply the theme's map camera background colour (visible as empty-space
// colour behind terrain/water). Safe to set anytime after camera takeover.
_mapCamera.backgroundColor =
MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
Canvas.ForceUpdateCanvases();
PanelFinder.UpdateMapForZoom();
@ -649,6 +699,13 @@ internal sealed class UiHost : MonoBehaviour
Native.RRPOPOUT_SetMapRotation(_overlayHandle, 0f);
Native.RRPOPOUT_SetMapSyncPlayer(_overlayHandle, false);
Native.RRPOPOUT_SetFollowPlayer(_overlayHandle, false);
Native.RRPOPOUT_SetPanDisablesFollow(_overlayHandle, PopoutModule.Settings.panDisablesFollow);
Native.RRPOPOUT_SetRightClickRecenter(_overlayHandle, PopoutModule.Settings.rightClickRecenter);
Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha);
var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
bgc.a *= PopoutModule.Settings.overlayMapBgAlpha;
_mapCamera!.backgroundColor = bgc;
PushMEState();
_mapActive = true;
Log.Info("[ui] in-game map activated.");

View file

@ -54,7 +54,7 @@ namespace S3.Modules.Popout {
// Follow player position (independent of MapEnhancer)
private bool _followPlayer = false;
private bool _meDirty = false;
private const int kMaxInputEvents = 64;
private static readonly InputEvent[] s_eventBuffer = new InputEvent[kMaxInputEvents];
@ -71,6 +71,13 @@ namespace S3.Modules.Popout {
_mapCamEulerZ = _mapCamera.transform.eulerAngles.z;
Native.RRPOPOUT_SetMapEnhancerInstalled(_windowHandle, MapEnhancerBridge.IsInstalled);
Native.RRPOPOUT_SetPanDisablesFollow(_windowHandle, PopoutModule.Settings.panDisablesFollow);
Native.RRPOPOUT_SetRightClickRecenter(_windowHandle, PopoutModule.Settings.rightClickRecenter);
Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha);
var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
bgc.a *= PopoutModule.Settings.overlayMapBgAlpha;
_mapCamera.backgroundColor = bgc;
if (MapEnhancerBridge.IsInstalled) PushMEState();
// Native loaded window_state.ini before showing the window (position/size/topmost
// already applied). Read back the C#-side state and apply it here.
@ -159,6 +166,7 @@ namespace S3.Modules.Popout {
int count = Native.RRPOPOUT_PollInputEvents(_windowHandle, s_eventBuffer, kMaxInputEvents);
for (int i = 0; i < count; i++)
ForwardInputEvent(s_eventBuffer[i]);
if (_meDirty) { PushMEState(); _meDirty = false; }
// Hotkey mirror
bool hotkeyNow = IsHotkeyDown();
@ -167,6 +175,16 @@ namespace S3.Modules.Popout {
}
// ---------------------------------------------------------------------------
private void PushMEState() {
if (!MapEnhancerBridge.IsInstalled) return;
Native.RRPOPOUT_SetMEState(_windowHandle,
MapEnhancerBridge.GetMEBoolFlags(),
MapEnhancerBridge.GetMEFloat(0),
MapEnhancerBridge.GetMEFloat(1),
MapEnhancerBridge.GetMEFloat(2),
MapEnhancerBridge.GetMEFloat(3));
}
private void ApplyMapRotation(float deg) {
_mapRotationDeg = ((deg % 360f) + 360f) % 360f;
if (_mapCamera != null)
@ -331,7 +349,8 @@ namespace S3.Modules.Popout {
break;
case InputEventType.RButtonUp:
UnityEngine.Object.FindObjectOfType<MapWindow>()?.ClickLocateMe();
if (PopoutModule.Settings.rightClickRecenter)
UnityEngine.Object.FindObjectOfType<MapWindow>()?.ClickLocateMe();
break;
case InputEventType.UICommand:
@ -380,8 +399,11 @@ namespace S3.Modules.Popout {
break;
case UICmd.SetTheme:
MapThemes.Apply((MapTheme)(int)e.y);
if (_mapCamera != null)
_mapCamera.backgroundColor = MapThemes.GetMapBgColor((MapTheme)(int)e.y);
if (_mapCamera != null) {
var c = MapThemes.GetMapBgColor((MapTheme)(int)e.y);
c.a *= PopoutModule.Settings.overlayMapBgAlpha;
_mapCamera.backgroundColor = c;
}
break;
case UICmd.SetAlpha:
float alpha = Mathf.Clamp(e.y, 0.1f, 1.0f);
@ -395,6 +417,41 @@ namespace S3.Modules.Popout {
PopoutModule.Persist();
Native.RRPOPOUT_SetOverlayMapAlpha(mapAlpha);
break;
case UICmd.SetMEBool:
MapEnhancerBridge.SetMEBool((int)e.y, e.delta != 0f);
if ((int)e.y == 6) _locationsSent = false; // EnableModdedSpawnPoints — re-push location list
_meDirty = true;
break;
case UICmd.SetMEFloat:
MapEnhancerBridge.SetMEFloat((int)e.y, e.delta);
_meDirty = true;
break;
case UICmd.MEResetSwitchesNormal:
MapEnhancerBridge.ResetAllSwitchesToNormal();
break;
case UICmd.MEResetSwitchesThrown:
MapEnhancerBridge.ResetAllSwitchesToThrown();
break;
case UICmd.TogglePanDisablesFollow:
PopoutModule.Settings.panDisablesFollow = !PopoutModule.Settings.panDisablesFollow;
PopoutModule.Persist();
Native.RRPOPOUT_SetPanDisablesFollow(_windowHandle, PopoutModule.Settings.panDisablesFollow);
break;
case UICmd.ToggleRightClickRecenter:
PopoutModule.Settings.rightClickRecenter = !PopoutModule.Settings.rightClickRecenter;
PopoutModule.Persist();
Native.RRPOPOUT_SetRightClickRecenter(_windowHandle, PopoutModule.Settings.rightClickRecenter);
break;
case UICmd.SetMapBgAlpha:
float bgAlpha = Mathf.Clamp(e.y, 0f, 1f);
PopoutModule.Settings.overlayMapBgAlpha = bgAlpha;
PopoutModule.Persist();
if (_mapCamera != null) {
var bgc2 = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
bgc2.a *= bgAlpha;
_mapCamera.backgroundColor = bgc2;
}
break;
}
break;
}

View file

@ -1,5 +1,6 @@
using System;
using System.Linq;
using System.Reflection;
using Character; // CameraSelector
using Game.State; // SpawnPoint
using HarmonyLib;
@ -22,6 +23,25 @@ namespace S3.Modules.Popout {
private static bool? _installed;
private static MonoBehaviour? _instance;
private static object? _meSettings;
// Ordered arrays — indices must match MEBoolBit / MEFloatIdx in shared_types.h.
private static readonly string[] s_boolFieldNames = {
"ShowTurntableMarkers", // 0 MB_TurntableMarkers
"EnableTurntableControl", // 1 MB_TurntableControl
"CheckTurntableClearance", // 2 MB_CheckClearance
"ShowRoadCrossingMarkers", // 3 MB_CrossingMarkers
"EnablePassengerStopTracking", // 4 MB_PassengerStops
"EnableIndustryAreaColors", // 5 MB_IndustryAreaColors
"EnableModdedSpawnPoints", // 6 MB_ModdedSpawnPoints
"DoubleClick", // 7 MB_DoubleClick
};
private static readonly string[] s_floatFieldNames = {
"FlareScale", // 0 MF_FlareScale
"JunctionMarkerScale", // 1 MF_JunctionScale
"TrackLineThickness", // 2 MF_TrackThickness
"CrossingMarkerScale", // 3 MF_CrossingScale
};
// True if MapEnhancer is installed and active this session.
public static bool IsInstalled {
@ -125,6 +145,90 @@ namespace S3.Modules.Popout {
} catch { return Array.Empty<string>(); }
}
// ---------------------------------------------------------------------------
// MapEnhancer settings access (via Loader.Settings reflection + Traverse).
// All methods are no-ops when ME is not installed.
// ---------------------------------------------------------------------------
public static bool GetMEBool(int idx) {
var s = GetMESettingsObj();
if (s == null || idx < 0 || idx >= s_boolFieldNames.Length) return false;
try { return Traverse.Create(s).Field<bool>(s_boolFieldNames[idx]).Value; }
catch { return false; }
}
public static void SetMEBool(int idx, bool val) {
var s = GetMESettingsObj();
if (s == null || idx < 0 || idx >= s_boolFieldNames.Length) return;
try {
Traverse.Create(s).Field(s_boolFieldNames[idx]).SetValue(val);
// EnableTurntableControl (1) also enables ShowTurntableMarkers when turned on,
// matching MapEnhancer's own OnSettingsChanged logic.
if (idx == 1 && val)
Traverse.Create(s).Field("ShowTurntableMarkers").SetValue(true);
// EnableModdedSpawnPoints (6) must flush ME's cached spawn-point list
// so it re-scans mods when the map is next opened.
if (idx == 6)
ClearModdedSpawnPointsCache();
ApplyAndSaveMESettings();
} catch { }
}
public static float GetMEFloat(int idx) {
var s = GetMESettingsObj();
if (s == null || idx < 0 || idx >= s_floatFieldNames.Length) return 0f;
try { return Traverse.Create(s).Field<float>(s_floatFieldNames[idx]).Value; }
catch { return 0f; }
}
public static void SetMEFloat(int idx, float val) {
var s = GetMESettingsObj();
if (s == null || idx < 0 || idx >= s_floatFieldNames.Length) return;
try {
Traverse.Create(s).Field(s_floatFieldNames[idx]).SetValue(val);
ApplyAndSaveMESettings();
} catch { }
}
// Pack all bool settings into a uint32 bitmask for the native atomic.
public static uint GetMEBoolFlags() {
uint flags = 0;
for (int i = 0; i < s_boolFieldNames.Length; i++)
if (GetMEBool(i)) flags |= (1u << i);
return flags;
}
public static void ResetAllSwitchesToNormal() {
var me = GetInstance();
if (me == null) return;
try { Traverse.Create(me).Method("ResetAllSwitchesToNormal").GetValue(); } catch { }
}
public static void ResetAllSwitchesToThrown() {
var me = GetInstance();
if (me == null) return;
try { Traverse.Create(me).Method("ResetAllSwitchesToThrown").GetValue(); } catch { }
}
// Force ME to re-scan modded spawn points on next map open.
public static void ClearModdedSpawnPointsCache() {
var me = GetInstance();
if (me == null) return;
try { Traverse.Create(me).Field("_modSpawnPointsLoaded").SetValue(false); } catch { }
}
// Trigger ME's in-memory apply (OnChange → OnSettingsChanged) then persist to XML.
public static void ApplyAndSaveMESettings() {
var s = GetMESettingsObj();
if (s == null) return;
try {
Traverse.Create(s).Method("OnChange").GetValue();
var modEntry = FindMod("MapEnhancer");
if (modEntry != null)
Traverse.Create(s).Method("Save", new object[] { modEntry }).GetValue();
} catch { }
}
// ---------------------------------------------------------------------------
private static Car[] GetLocosSorted() =>
TrainController.Shared?.Cars
@ -148,6 +252,17 @@ namespace S3.Modules.Popout {
} catch { }
}
private static object? GetMESettingsObj() {
if (_meSettings != null) return _meSettings;
if (!IsInstalled) return null;
var loaderType = Type.GetType("MapEnhancer.UMM.Loader, MapEnhancer");
if (loaderType == null) return null;
var fi = loaderType.GetField("Settings", BindingFlags.Public | BindingFlags.Static);
if (fi == null) return null;
_meSettings = fi.GetValue(null);
return _meSettings;
}
private static UnityModManager.ModEntry? FindMod(string id) {
foreach (var m in UnityModManager.modEntries)
if (m.Info.Id == id) return m;

View file

@ -38,6 +38,14 @@ namespace S3.Modules.Popout {
SetAlpha = 13, // (in-game only) set chrome alpha; InputEvent.y = [0.1, 1.0]
Close = 14, // (in-game only) user clicked [X] — hide the overlay
SetMapAlpha = 15, // (in-game only) set map image alpha; InputEvent.y = [0.0, 1.0]
// MapEnhancer settings
SetMEBool = 16, // toggle ME bool setting; y = MEBoolBit index, delta = 0|1
SetMEFloat = 17, // set ME float setting; y = MEFloatIdx index, delta = value
MEResetSwitchesNormal = 18, // bulk reset all switches to Normal
MEResetSwitchesThrown = 19, // bulk reset all switches to Thrown
TogglePanDisablesFollow = 20, // toggle whether panning cancels follow mode
ToggleRightClickRecenter = 21, // toggle whether right-clicking recenters on player
SetMapBgAlpha = 22, // set map camera clear-colour opacity; y = [0.0, 1.0]
}
// Must match MapThemeData in native/include/shared_types.h exactly (36 floats = 144 bytes).
@ -202,5 +210,27 @@ namespace S3.Modules.Popout {
// Set the map image alpha [0.0, 1.0]. Independent of chrome alpha.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetOverlayMapAlpha(float alpha);
// Tell native whether panning should cancel follow mode (for the gear menu checkmark).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetPanDisablesFollow(int windowHandle, bool active);
// Tell native whether right-clicking the map recenters on the player (gear menu checkmark).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetRightClickRecenter(int windowHandle, bool active);
// Seed the map camera clear-colour opacity slider (shared global, overlay + popout).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetOverlayMapBgAlpha(float alpha);
// Push MapEnhancer settings state to a window's render-thread atomics.
// flags: bit-packed booleans (see MEBoolBit in shared_types.h).
// Scale values seed slider positions; sliders update them live during drag
// and push UICmd::SetMEFloat only on release (IsItemDeactivatedAfterEdit).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetMEState(
int windowHandle, uint flags,
float flareScale, float junctionScale,
float trackThickness, float crossingScale);
}
}

View file

@ -31,6 +31,13 @@ public class PopoutSettings
// In-game overlay map image alpha [0.0, 1.0]. Independent of chrome alpha.
public float overlayMapAlpha = 1.0f;
// Map camera clear-colour opacity [0.0, 1.0]. At 0 the void/sky areas of the
// map are transparent, letting the game world show through in empty regions.
public float overlayMapBgAlpha = 1.0f;
// When true, right-clicking anywhere on the map image recenters on the player.
public bool rightClickRecenter = true;
// Custom theme colors — edited live in the Settings color picker.
// Initialized to S3 Dark so first-launch looks reasonable before the user tunes it.
public MapThemeData customTheme = new MapThemeData {