diff --git a/Info.json b/Info.json
index 8acd767..73c9a46 100644
--- a/Info.json
+++ b/Info.json
@@ -2,7 +2,7 @@
"Id": "S3",
"DisplayName": "S³ - Seton's Special Sauce",
"Author": "seton",
- "Version": "0.2.4",
+ "Version": "0.2.5",
"ManagerVersion": "0.27.0",
"GameVersionPoint": "0",
"AssemblyName": "S3.dll",
diff --git a/README.md b/README.md
index 78c46c6..f2f2dd6 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,9 @@ 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, 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.
+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), icon culling and EOTD settings, the detach button, and a full per-element custom color editor.
+
+Most settings are also reachable directly from the gear menu on the map toolbar without opening UMM.

@@ -64,6 +66,16 @@ Rotate the map manually with the rotation control, or lock it to your player cam
+### Icon Controls
+
+When zoomed out past a configurable threshold (default 40%), car icons are hidden and only locomotive icons remain. Locomotives scale up automatically so they stay easy to spot. A separate option hides MU trailing units, keeping just the lead locomotive per consist. A blinking red EOTD dot marks the rear of each consist, giving you a quick sense of train length without cluttering the map with car icons.
+
+All of these options are available from the gear menu Icons submenu on the map toolbar, or from the S³ UMM settings page.
+
+
+
+
+
### Popout Window
Pop the map into a detached native OS window. Drag it to any monitor, resize it freely, and pin it always-on-top via the window's right-click title bar menu. Re-attach it back into the game overlay at any time from the settings panel without losing your position, zoom, or rotation.
diff --git a/dist/build-common.ps1 b/dist/build-common.ps1
index e5ff4f7..60adffe 100644
--- a/dist/build-common.ps1
+++ b/dist/build-common.ps1
@@ -84,5 +84,11 @@ function New-ModLayout {
Copy-Item $ManagedDll (Join-Path $DestModDir "S3.dll") -Force
if ($NativeDll) {
Copy-Item $NativeDll (Join-Path $DestModDir "S3Native.dll") -Force
+ # Ship ProggyClean.ttf alongside the native DLL so IMGUI_DISABLE_DEFAULT_FONT
+ # can load it at runtime without the base85 blob in the binary.
+ $fontSrc = Join-Path $RepoRoot "native\fonts\ProggyClean.ttf"
+ if (Test-Path $fontSrc) {
+ Copy-Item $fontSrc (Join-Path $DestModDir "ProggyClean.ttf") -Force
+ }
}
}
diff --git a/img/map/map_icons_config.png b/img/map/map_icons_config.png
new file mode 100644
index 0000000..f41ba0d
Binary files /dev/null and b/img/map/map_icons_config.png differ
diff --git a/img/map/map_optimizations/hiding_cars_and_eotd_demo.mp4 b/img/map/map_optimizations/hiding_cars_and_eotd_demo.mp4
new file mode 100644
index 0000000..984ee21
Binary files /dev/null and b/img/map/map_optimizations/hiding_cars_and_eotd_demo.mp4 differ
diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt
index 01fdf80..41427c8 100644
--- a/native/CMakeLists.txt
+++ b/native/CMakeLists.txt
@@ -31,6 +31,11 @@ target_include_directories(imgui PUBLIC
# imgui_impl_dx11.cpp calls D3DCompile at runtime to build its own shaders.
target_link_libraries(imgui PUBLIC d3d11 d3dcompiler)
+# Remove the built-in ProggyClean base85 blob. That blob incidentally contains
+# the 3-char string "UPX" which triggers AV false-positives on every release zip.
+# We ship ProggyClean.ttf as a standalone file and load it at runtime instead.
+target_compile_definitions(imgui PUBLIC IMGUI_DISABLE_DEFAULT_FONT)
+
# Suppress MSVC warnings inside third-party ImGui source.
if(MSVC)
target_compile_options(imgui PRIVATE /W0)
diff --git a/native/fonts/ProggyClean.ttf b/native/fonts/ProggyClean.ttf
new file mode 100644
index 0000000..0270cdf
Binary files /dev/null and b/native/fonts/ProggyClean.ttf differ
diff --git a/native/include/shared_types.h b/native/include/shared_types.h
index 6f87834..8fca52e 100644
--- a/native/include/shared_types.h
+++ b/native/include/shared_types.h
@@ -41,6 +41,14 @@ enum UICmd : int32_t {
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]
+ // Icon culling + EOTD (gear menu Icons submenu)
+ ToggleIconCulling = 23, // toggle car-icon culling
+ SetCullThreshold = 24, // set cull zoom threshold; y = [0.3, 1.0]
+ ToggleHideMuLocos = 25, // toggle hiding MU trailing units
+ SetLocoBoostedScale = 26, // set loco icon boost when culling; y = [1.0, 3.0]
+ ToggleEotd = 27, // toggle EOTD dot visibility
+ ToggleEotdOnlyWhenCulled = 28, // toggle hiding EOTD when car icons are visible
+ SetEotdSizeScale = 29, // set EOTD dot size; y = [1.0, 10.0]
};
// Bit indices for ME bool settings packed into PopoutWindow::imMEFlags.
diff --git a/native/src/d3d11_renderer.cpp b/native/src/d3d11_renderer.cpp
index e7e625f..78f09bc 100644
--- a/native/src/d3d11_renderer.cpp
+++ b/native/src/d3d11_renderer.cpp
@@ -276,9 +276,29 @@ static void InitImGui(ID3D11Device* device, ID3D11DeviceContext* context) {
{ 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();
+ // Locate ProggyClean.ttf relative to this DLL. IMGUI_DISABLE_DEFAULT_FONT strips
+ // the built-in base85 blob from the binary (that blob incidentally contains "UPX",
+ // which AV scanners mistake for a UPX-packed executable). We ship the TTF file
+ // alongside the DLL so end users see identical visual output.
+ {
+ char fontPath[MAX_PATH] = {};
+ HMODULE self = GetModuleHandleA("S3Native.dll");
+ if (self && GetModuleFileNameA(self, fontPath, MAX_PATH)) {
+ char* slash = strrchr(fontPath, '\\');
+ if (slash) { *++slash = '\0'; strcat_s(fontPath, "ProggyClean.ttf"); }
+ }
+ // Fallback chain: mod dir → Consolas → Lucida Console.
+ // At least one must succeed or ImGui renders blank glyphs (no crash).
+ static const char* kFallbacks[] = {
+ "C:\\Windows\\Fonts\\consola.ttf",
+ "C:\\Windows\\Fonts\\lucon.ttf",
+ nullptr
+ };
+ if (!fontPath[0] || !io.Fonts->AddFontFromFileTTF(fontPath, 13.f)) {
+ for (const char** fb = kFallbacks; *fb; ++fb)
+ if (io.Fonts->AddFontFromFileTTF(*fb, 13.f)) break;
+ }
+ }
{
ImFontConfig fc;
fc.MergeMode = true;
@@ -651,6 +671,69 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
ImGui::Separator();
+ if (ImGui::BeginMenu("Icons")) {
+ bool cullOn = win->imIconCullingEnabled.load();
+ if (ImGui::MenuItem("Hide cars when zoomed out", nullptr, cullOn)) {
+ win->imIconCullingEnabled.store(!cullOn);
+ pushCmd(UICmd::ToggleIconCulling);
+ }
+
+ ImGui::BeginDisabled(!cullOn);
+
+ // Display as percentage (0.40 → 40%); store as fraction.
+ float threshPct = win->imCullThreshold.load() * 100.f;
+ ImGui::SetNextItemWidth(100.f);
+ if (ImGui::SliderFloat("Cull threshold##icons", &threshPct, 30.f, 100.f, "%.0f%%"))
+ win->imCullThreshold.store(threshPct / 100.f);
+ if (ImGui::IsItemDeactivatedAfterEdit())
+ pushCmd(UICmd::SetCullThreshold, win->imCullThreshold.load());
+
+ ImGui::Separator();
+
+ bool hideMU = win->imHideMuLocos.load();
+ if (ImGui::MenuItem("Hide MU trailing units", nullptr, hideMU)) {
+ win->imHideMuLocos.store(!hideMU);
+ pushCmd(UICmd::ToggleHideMuLocos);
+ }
+
+ float boost = win->imLocoBoostedScale.load();
+ ImGui::SetNextItemWidth(100.f);
+ if (ImGui::SliderFloat("Loco icon boost##icons", &boost, 1.0f, 3.0f, "%.1fx"))
+ win->imLocoBoostedScale.store(boost);
+ if (ImGui::IsItemDeactivatedAfterEdit())
+ pushCmd(UICmd::SetLocoBoostedScale, win->imLocoBoostedScale.load());
+
+ ImGui::Separator();
+
+ bool eotdOn = win->imEotdEnabled.load();
+ if (ImGui::MenuItem("Show EOTD", nullptr, eotdOn)) {
+ win->imEotdEnabled.store(!eotdOn);
+ pushCmd(UICmd::ToggleEotd);
+ }
+
+ ImGui::BeginDisabled(!eotdOn);
+
+ bool eotdWC = win->imEotdOnlyWhenCulled.load();
+ if (ImGui::MenuItem("Hide EOTD when icons visible", nullptr, eotdWC)) {
+ win->imEotdOnlyWhenCulled.store(!eotdWC);
+ pushCmd(UICmd::ToggleEotdOnlyWhenCulled);
+ }
+
+ float eotdSz = win->imEotdSizeScale.load();
+ ImGui::SetNextItemWidth(100.f);
+ if (ImGui::SliderFloat("EOTD dot size##icons", &eotdSz, 1.0f, 10.0f, "%.1f"))
+ win->imEotdSizeScale.store(eotdSz);
+ if (ImGui::IsItemDeactivatedAfterEdit())
+ pushCmd(UICmd::SetEotdSizeScale, win->imEotdSizeScale.load());
+
+ ImGui::EndDisabled(); // !eotdOn
+ ImGui::EndDisabled(); // !cullOn
+
+ ImGui::EndMenu();
+ }
+
+ ImGui::Separator();
+
// Helper lambdas for ME setting commands (y = index, delta = value)
auto pushMEBool = [&](int idx, bool val) {
InputEvent ev{}; ev.type = UICommand;
diff --git a/native/src/exports.cpp b/native/src/exports.cpp
index b37738f..efc377a 100644
--- a/native/src/exports.cpp
+++ b/native/src/exports.cpp
@@ -234,6 +234,24 @@ void RRPOPOUT_SetRightClickRecenter(int windowHandle, bool active) {
if (win) win->imRightClickRecenter.store(active);
}
+// Seed all icon-culling and EOTD settings for the gear menu Icons submenu.
+// Called on window/overlay init and whenever C# settings change.
+extern "C" __declspec(dllexport)
+void RRPOPOUT_SetIconCullingState(int windowHandle,
+ bool cullEnabled, float cullThresh, bool hideMU, float locoBoost,
+ bool eotdEnabled, bool eotdOnlyWhenCulled, float eotdSize)
+{
+ PopoutWindow* win = GetPopoutWindow(windowHandle);
+ if (!win) return;
+ win->imIconCullingEnabled.store(cullEnabled);
+ win->imCullThreshold.store(cullThresh);
+ win->imHideMuLocos.store(hideMU);
+ win->imLocoBoostedScale.store(locoBoost);
+ win->imEotdEnabled.store(eotdEnabled);
+ win->imEotdOnlyWhenCulled.store(eotdOnlyWhenCulled);
+ win->imEotdSizeScale.store(eotdSize);
+}
+
// Seed the map camera clear-colour opacity slider (shared global, affects both surfaces).
extern "C" __declspec(dllexport)
void RRPOPOUT_SetOverlayMapBgAlpha(float alpha) {
diff --git a/native/src/popout_window.h b/native/src/popout_window.h
index 36c63b0..e572b34 100644
--- a/native/src/popout_window.h
+++ b/native/src/popout_window.h
@@ -82,6 +82,17 @@ struct PopoutWindow {
std::atomic imPanDisablesFollow {true};
std::atomic imRightClickRecenter {true};
+ // -----------------------------------------------------------------------
+ // Icon culling + EOTD settings (C# → render thread via RRPOPOUT_SetIconCullingState)
+ // -----------------------------------------------------------------------
+ std::atomic imIconCullingEnabled {true};
+ std::atomic imCullThreshold {0.40f};
+ std::atomic imHideMuLocos {true};
+ std::atomic imLocoBoostedScale {1.5f};
+ std::atomic imEotdEnabled {true};
+ std::atomic imEotdOnlyWhenCulled {true};
+ std::atomic imEotdSizeScale {8.0f};
+
// -----------------------------------------------------------------------
// MapEnhancer settings state (C# → render thread via RRPOPOUT_SetMEState)
// Bit layout of imMEFlags matches MEBoolBit enum in shared_types.h.
diff --git a/src/Core/Ui/UiService.cs b/src/Core/Ui/UiService.cs
index 25e8ecd..c5dc9b9 100644
--- a/src/Core/Ui/UiService.cs
+++ b/src/Core/Ui/UiService.cs
@@ -469,7 +469,7 @@ internal sealed class UiHost : MonoBehaviour
switch ((UICmd)(int)e.x)
{
case UICmd.Recenter:
- UnityEngine.Object.FindObjectOfType()?.ClickLocateMe();
+ PanelFinder.GetMapWindow()?.ClickLocateMe();
break;
case UICmd.FollowMode: MapEnhancerBridge.ToggleFollowMode(); break;
case UICmd.FollowPlayerCam: MapEnhancerBridge.FollowPlayerCamera(); break;
@@ -568,6 +568,34 @@ internal sealed class UiHost : MonoBehaviour
_mapCamera.backgroundColor = bgc2;
}
break;
+ case UICmd.ToggleIconCulling:
+ PopoutModule.Settings.iconCullingEnabled = !PopoutModule.Settings.iconCullingEnabled;
+ PopoutModule.Persist(); MapIconCuller.Refresh();
+ break;
+ case UICmd.SetCullThreshold:
+ PopoutModule.Settings.iconCullingThreshold = Mathf.Clamp(e.y, 0.3f, 1.0f);
+ PopoutModule.Persist(); MapIconCuller.Refresh();
+ break;
+ case UICmd.ToggleHideMuLocos:
+ PopoutModule.Settings.hideMuLocos = !PopoutModule.Settings.hideMuLocos;
+ PopoutModule.Persist(); MapIconCuller.Refresh();
+ break;
+ case UICmd.SetLocoBoostedScale:
+ PopoutModule.Settings.locoBoostedScale = Mathf.Clamp(e.y, 1.0f, 3.0f);
+ PopoutModule.Persist(); MapIconCuller.Refresh();
+ break;
+ case UICmd.ToggleEotd:
+ PopoutModule.Settings.eotdEnabled = !PopoutModule.Settings.eotdEnabled;
+ PopoutModule.Persist();
+ break;
+ case UICmd.ToggleEotdOnlyWhenCulled:
+ PopoutModule.Settings.eotdOnlyWhenCulled = !PopoutModule.Settings.eotdOnlyWhenCulled;
+ PopoutModule.Persist();
+ break;
+ case UICmd.SetEotdSizeScale:
+ PopoutModule.Settings.eotdSizeScale = Mathf.Clamp(e.y, 1.0f, 10.0f);
+ PopoutModule.Persist();
+ break;
}
}
@@ -583,7 +611,7 @@ internal sealed class UiHost : MonoBehaviour
{
case InputEventType.RButtonUp:
if (PopoutModule.Settings.rightClickRecenter)
- UnityEngine.Object.FindObjectOfType()?.ClickLocateMe();
+ PanelFinder.GetMapWindow()?.ClickLocateMe();
break;
case InputEventType.MouseWheel:
@@ -702,6 +730,7 @@ internal sealed class UiHost : MonoBehaviour
Native.RRPOPOUT_SetFollowPlayer(_overlayHandle, false);
Native.RRPOPOUT_SetPanDisablesFollow(_overlayHandle, PopoutModule.Settings.panDisablesFollow);
Native.RRPOPOUT_SetRightClickRecenter(_overlayHandle, PopoutModule.Settings.rightClickRecenter);
+ SeedIconCullingState(_overlayHandle);
Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha);
var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
bgc.a *= PopoutModule.Settings.overlayMapBgAlpha;
@@ -763,4 +792,12 @@ internal sealed class UiHost : MonoBehaviour
_drag = false;
_mapActive = false;
}
+
+ private static void SeedIconCullingState(int handle)
+ {
+ var s = PopoutModule.Settings;
+ Native.RRPOPOUT_SetIconCullingState(handle,
+ s.iconCullingEnabled, s.iconCullingThreshold, s.hideMuLocos, s.locoBoostedScale,
+ s.eotdEnabled, s.eotdOnlyWhenCulled, s.eotdSizeScale);
+ }
}
diff --git a/src/Modules/Popout/DetachedPanel.cs b/src/Modules/Popout/DetachedPanel.cs
index 93549db..d2aaeca 100644
--- a/src/Modules/Popout/DetachedPanel.cs
+++ b/src/Modules/Popout/DetachedPanel.cs
@@ -3,7 +3,6 @@ using System.Runtime.InteropServices;
using S3.Core; // Log
using UI.Map;
using UnityEngine;
-using UnityEngine.Rendering;
namespace S3.Modules.Popout {
@@ -74,6 +73,7 @@ namespace S3.Modules.Popout {
Native.RRPOPOUT_SetMapEnhancerCaps(_windowHandle, MapEnhancerBridge.HasTurntable, MapEnhancerBridge.IsCommunity);
Native.RRPOPOUT_SetPanDisablesFollow(_windowHandle, PopoutModule.Settings.panDisablesFollow);
Native.RRPOPOUT_SetRightClickRecenter(_windowHandle, PopoutModule.Settings.rightClickRecenter);
+ SeedIconCullingState(_windowHandle);
Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha);
var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
bgc.a *= PopoutModule.Settings.overlayMapBgAlpha;
@@ -121,10 +121,6 @@ namespace S3.Modules.Popout {
_mapCamera.rect = new Rect(0f, 0f, 1f, 1f);
_mapCamera.aspect = (float)w / h;
- var inGameRT = PanelFinder.GetMapRenderTexture();
- if (inGameRT != null && inGameRT != _ownRT)
- Graphics.Blit(_ownRT, inGameRT);
-
Native.RRPOPOUT_SetFrameTexture(_windowHandle,
_ownRT.GetNativeTexturePtr(), 0f, 1f, 1f, 0f);
GL.IssuePluginEvent(_renderEventFunc, _windowHandle);
@@ -351,13 +347,13 @@ namespace S3.Modules.Popout {
case InputEventType.RButtonUp:
if (PopoutModule.Settings.rightClickRecenter)
- UnityEngine.Object.FindObjectOfType()?.ClickLocateMe();
+ PanelFinder.GetMapWindow()?.ClickLocateMe();
break;
case InputEventType.UICommand:
switch ((UICmd)(int)e.x) {
case UICmd.Recenter:
- UnityEngine.Object.FindObjectOfType()?.ClickLocateMe();
+ PanelFinder.GetMapWindow()?.ClickLocateMe();
break;
case UICmd.FollowMode:
MapEnhancerBridge.ToggleFollowMode();
@@ -453,9 +449,45 @@ namespace S3.Modules.Popout {
_mapCamera.backgroundColor = bgc2;
}
break;
+ case UICmd.ToggleIconCulling:
+ PopoutModule.Settings.iconCullingEnabled = !PopoutModule.Settings.iconCullingEnabled;
+ PopoutModule.Persist(); MapIconCuller.Refresh();
+ break;
+ case UICmd.SetCullThreshold:
+ PopoutModule.Settings.iconCullingThreshold = Mathf.Clamp(e.y, 0.3f, 1.0f);
+ PopoutModule.Persist(); MapIconCuller.Refresh();
+ break;
+ case UICmd.ToggleHideMuLocos:
+ PopoutModule.Settings.hideMuLocos = !PopoutModule.Settings.hideMuLocos;
+ PopoutModule.Persist(); MapIconCuller.Refresh();
+ break;
+ case UICmd.SetLocoBoostedScale:
+ PopoutModule.Settings.locoBoostedScale = Mathf.Clamp(e.y, 1.0f, 3.0f);
+ PopoutModule.Persist(); MapIconCuller.Refresh();
+ break;
+ case UICmd.ToggleEotd:
+ PopoutModule.Settings.eotdEnabled = !PopoutModule.Settings.eotdEnabled;
+ PopoutModule.Persist();
+ break;
+ case UICmd.ToggleEotdOnlyWhenCulled:
+ PopoutModule.Settings.eotdOnlyWhenCulled = !PopoutModule.Settings.eotdOnlyWhenCulled;
+ PopoutModule.Persist();
+ break;
+ case UICmd.SetEotdSizeScale:
+ PopoutModule.Settings.eotdSizeScale = Mathf.Clamp(e.y, 1.0f, 10.0f);
+ PopoutModule.Persist();
+ break;
}
break;
}
}
+
+ private static void SeedIconCullingState(int handle)
+ {
+ var s = PopoutModule.Settings;
+ Native.RRPOPOUT_SetIconCullingState(handle,
+ s.iconCullingEnabled, s.iconCullingThreshold, s.hideMuLocos, s.locoBoostedScale,
+ s.eotdEnabled, s.eotdOnlyWhenCulled, s.eotdSizeScale);
+ }
}
}
diff --git a/src/Modules/Popout/EotdSystem.cs b/src/Modules/Popout/EotdSystem.cs
new file mode 100644
index 0000000..ed0c457
--- /dev/null
+++ b/src/Modules/Popout/EotdSystem.cs
@@ -0,0 +1,261 @@
+using System.Collections.Generic;
+using HarmonyLib;
+using Helpers;
+using Model;
+using Track;
+using UI.Map;
+using UnityEngine;
+using UnityEngine.UI;
+
+namespace S3.Modules.Popout;
+
+// Places a blinking red dot on the map at the rear end of each active train consist.
+// The dot is a cloned car MapIcon (red Image, no label) parented to a DontDestroyOnLoad
+// holder — NOT to the car's own MapIcon Canvas — so culling the car icons never hides it.
+//
+// Consists are rebuilt every kRebuildInterval seconds. A consist qualifies when it has a
+// lead locomotive (no loco on A-end) with at least one car trailing on the A-end chain.
+//
+// EnumerateCoupled(A) from the lead loco walks FORWARD through the consist (lead → rear),
+// ending at the last car. EnumerateCoupled(B) walks in reverse (rear → lead). We always
+// use the A-end chain and take the final element as the EOTD placement car.
+internal static class EotdSystem
+{
+ private static GameObject? _holder;
+ private static readonly Dictionary _markers = new();
+ private static float _rebuildTimer;
+ private const float kRebuildInterval = 3f;
+
+ public static void Install()
+ {
+ if (_holder != null) return;
+ _holder = new GameObject("S3.EOTD.Holder");
+ Object.DontDestroyOnLoad(_holder);
+ _rebuildTimer = 0f;
+ }
+
+ public static void Uninstall()
+ {
+ ClearAll();
+ if (_holder != null) { Object.Destroy(_holder); _holder = null; }
+ }
+
+ public static void Tick(float dt)
+ {
+ if (_holder == null) return;
+ if (!PopoutModule.Settings.eotdEnabled) { ClearAll(); return; }
+ _rebuildTimer -= dt;
+ if (_rebuildTimer <= 0f) { _rebuildTimer = kRebuildInterval; Rebuild(); }
+ }
+
+ // ---------------------------------------------------------------------------
+
+ private static void Rebuild()
+ {
+ if (_holder == null) return;
+
+ var tc = TrainController.Shared;
+ if (tc == null) { ClearAll(); return; }
+
+ // Identify the current set of rear cars (one per consist with a lead loco).
+ var rearCars = new HashSet();
+ foreach (Car car in tc.Cars)
+ {
+ if (!car.IsLocomotive || !MapIconCuller.IsLeadLoco(car)) continue;
+ var rear = FindRearCar(car);
+ if (rear != null) rearCars.Add(rear);
+ }
+
+ // Remove markers whose rear car is no longer valid.
+ var stale = new List();
+ foreach (var kv in _markers)
+ {
+ if (!rearCars.Contains(kv.Key))
+ {
+ if (kv.Value != null) Object.Destroy(kv.Value.gameObject);
+ stale.Add(kv.Key);
+ }
+ }
+ foreach (var k in stale) _markers.Remove(k);
+
+ int mapLayer = GetMapLayer(tc);
+
+ // Create markers for newly discovered rear cars.
+ foreach (Car rear in rearCars)
+ {
+ if (_markers.ContainsKey(rear)) continue;
+
+ var template = GetAnyCarIcon(tc);
+ if (template == null) continue;
+
+ var go = Object.Instantiate(template.gameObject, _holder!.transform);
+ go.name = "S3_EOTD_Marker";
+ SetLayerRecursive(go, mapLayer);
+
+ // Strip all children (car-body shape, text labels).
+ var children = new System.Collections.Generic.List();
+ foreach (Transform child in go.transform) children.Add(child);
+ foreach (var child in children) Object.Destroy(child.gameObject);
+
+ // Destroy MapIcon so MapBuilder.SetZoom() never overrides our manually-set
+ // localScale. MapIcon.OnDisable() removes us from _mapIcons cleanly.
+ Object.Destroy(go.GetComponent());
+
+ // sizeDelta=(1,1) — scale is set each frame in EotdMarker.Update() as a
+ // fraction of the map camera's orthographicSize, giving a stable ~2%
+ // screen-height dot at any zoom level.
+ var dotGo = new GameObject("dot");
+ dotGo.layer = mapLayer;
+ dotGo.transform.SetParent(go.transform, false);
+ var img = dotGo.AddComponent();
+ img.color = Color.red;
+ img.sprite = MakeCircleSprite(32);
+ img.type = Image.Type.Simple;
+ var dotRt = dotGo.GetComponent();
+ dotRt.sizeDelta = new Vector2(1f, 1f);
+ dotRt.anchoredPosition = Vector2.zero;
+
+ var marker = go.AddComponent();
+ marker.Init(rear, img);
+ _markers[rear] = marker;
+ }
+ }
+
+ private static void ClearAll()
+ {
+ foreach (var kv in _markers)
+ if (kv.Value != null) Object.Destroy(kv.Value.gameObject);
+ _markers.Clear();
+ }
+
+ // EnumerateCoupled(A) starts at the lead loco and walks forward through the consist.
+ // The last element is the rear car. Returns null if nothing is coupled (solo loco).
+ private static Car? FindRearCar(Car leadLoco)
+ {
+ Car last = leadLoco;
+ try
+ {
+ foreach (Car c in leadLoco.EnumerateCoupled(Car.LogicalEnd.A))
+ last = c;
+ }
+ catch { }
+ return last == leadLoco ? null : last;
+ }
+
+ private static MapIcon? GetAnyCarIcon(TrainController tc)
+ {
+ foreach (Car car in tc.Cars)
+ {
+ if (car.IsLocomotive) continue;
+ var icon = Traverse.Create(car).Field("MapIcon").Value;
+ if (icon != null) return icon;
+ }
+ foreach (Car car in tc.Cars)
+ {
+ var icon = Traverse.Create(car).Field("MapIcon").Value;
+ if (icon != null) return icon;
+ }
+ return null;
+ }
+
+ private static int GetMapLayer(TrainController tc)
+ {
+ foreach (Car car in tc.Cars)
+ {
+ var icon = Traverse.Create(car).Field("MapIcon").Value;
+ if (icon != null) return icon.gameObject.layer;
+ }
+ return LayerMask.NameToLayer("Map");
+ }
+
+ private static void SetLayerRecursive(GameObject go, int layer)
+ {
+ go.layer = layer;
+ foreach (Transform child in go.transform)
+ SetLayerRecursive(child.gameObject, layer);
+ }
+
+ // Generates a white anti-aliased circle sprite at runtime.
+ // Used instead of Resources.GetBuiltinResource("UI/Skin/Knob.psd") which
+ // is not available in stripped Unity builds.
+ private static Sprite MakeCircleSprite(int radius)
+ {
+ int size = radius * 2;
+ var tex = new Texture2D(size, size, TextureFormat.RGBA32, mipChain: false);
+ tex.filterMode = FilterMode.Bilinear;
+ var pixels = new Color32[size * size];
+ float c = radius - 0.5f;
+ for (int y = 0; y < size; y++)
+ for (int x = 0; x < size; x++)
+ {
+ float dist = Mathf.Sqrt((x - c) * (x - c) + (y - c) * (y - c));
+ byte a = (byte)(Mathf.Clamp01(radius - dist) * 255f);
+ pixels[y * size + x] = new Color32(255, 255, 255, a);
+ }
+ tex.SetPixels32(pixels);
+ tex.Apply();
+ return Sprite.Create(tex, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f));
+ }
+}
+
+// Attached to each EOTD marker GameObject. Tracks the rear car's world position each
+// frame and animates a 1.2 Hz sine-wave pulse on the red Image alpha.
+internal class EotdMarker : MonoBehaviour
+{
+ private Car? _car;
+ private Image? _img;
+ private Canvas? _canvas;
+
+ public void Init(Car rearCar, Image dot)
+ {
+ _car = rearCar;
+ _img = dot;
+ _canvas = GetComponent