Compare commits

...

19 commits
0.2.0 ... main

Author SHA1 Message Date
aec4d2ecc4 Bump version to 0.2.7
Skip 0.2.6: this rolls up two releases' worth of work (the Mesh LOD and
Profiler modules and the map labels). Add 0.2.7 to the update manifest and
drop the oldest entry to keep the last three releases listed.
2026-06-25 20:15:02 -04:00
92649801c2 Document Mesh LOD, Profiler, and map labels
Add README sections for the two new modules (Mesh LOD and Profiler) and the
track and industry label feature, with settings screenshots, the unified
overlay shot, the three-level LOD comparison, and the label demo video. Update
the module table and remove the profiler-overlay mention from Physics
Optimizer now that the overlay lives in its own module.
2026-06-25 20:14:57 -04:00
eb6699f23f Fix proxy box color, mesh leak, and stale comment
The Mesh LOD proxy box rendered flat gray instead of the car's color. It was
being given a shared material tinted via Material.color (the legacy _Color
property), which URP ignores in favor of _BaseColor, and a single shared
material cannot match each car anyway. Reuse the car's own material so the box
matches its color again.

Also destroy the proxy box mesh when tearing down or refreshing a car's LOD
(the GameObject was destroyed but the Mesh leaked until scene unload), and
prune dead weak-refs from the tracked-car list periodically so it cannot grow
unbounded across a long session.

Fix a stale comment in PhysicsTimer that still referenced PhysicsOverlayGUI
after the overlay moved to the Profiler module.
2026-06-25 20:14:52 -04:00
4853015eff Profiler: new unified overlay module; absorb PhysicsOverlayGUI
Add a standalone Profiler module (S3.profiler.json, disabled by default)
that hosts the in-game frame-time overlay previously owned by Physics
Optimizer.  The overlay now adapts to whichever modules are enabled:

- Always shows: render + physics frame-time graph, timing report.
- Physics Optimizer section (if enabled): LOD fast-path and auto-freeze
  quick-toggles with live stats, debug car count line.
- Mesh LOD section (if enabled): total tracked cars, loco/freight split,
  per-LOD-level counts refreshed once per second.

PhysicsOptimizerModule retains only the Harmony patches and
CarDebugVisualizer; ShowOverlay/OverlayOpacity removed from PhysicsSettings.
MeshLodInjector gains GetLodStats() and GetLodLevel() for the overlay.
BBox material shader search now tries URP/Lit before Standard.
/rpf overlay toggle redirected to ProfilerOverlayGUI.Instance.
2026-06-25 19:06:46 -04:00
1b4ab97be5 MeshLOD: polish settings fields, bbox material, tracked car count
- Rename all MeshLodSettings public fields to camelCase (consistency
  with every other settings class in the project).
- Replace the proxy-box renderer's borrowed car material with a shared
  matte Standard material tinted to the car body albedo; cheaper at
  LOD3 distances and responds correctly to scene lighting at night.
- Expose GetTrackedCounts() and display loco/freight breakdown next to
  the Refresh Now button in the settings panel.
2026-06-25 18:57:50 -04:00
fcfdc6fba0 Map: fix T key teleport in overlay and popout; pass M through when module disabled
Overlay: our IsMouseOverGameWindow patch was blocking StrategyCameraController.TeleportToMouse()
as a side effect. Added native export RRPOPOUT_GetOverlayMouseMapPos (stores imgPos atomically
each render frame, returns normalized map-image cursor coords) and handle the T key directly in
UiHost.Update(), invoking MapDrag.OnTeleport with the correct viewport position. MapDrag.Update()
stays gated behind _pointerOver which never fires on our collapsed canvas, so there is no
double-teleport.

Popout: T key does not fire through Unity Input System when the game window lacks focus. Added
GetAsyncKeyState(VK_T) rising-edge detection in DetachedPanel.Update(), using the last MouseMove
event position (tracked before the drag-only guard) as the viewport coordinate.

Module disabled: all three MapWindow_Toggle/Show/ShowPos Harmony prefixes now pass through to the
game's native map when PopoutModule.Settings.enabled is false. Previously UiService.Install()
always applied the intercepts regardless of module state.

Also removed F10 as a shortcut to switch to the popout - F9 already does this and F10 conflicts
with the Shift+F10 UMM hotkey.
2026-06-25 18:16:49 -04:00
c5e75ad54a track and industry labels on map; Mesh LOD module skeleton
Map: per-track industry labels rendered via Dear ImGui on both the popout
and in-game overlay. Three zoom stages - per-track labels, merged same-name
clusters, and one-per-industry labels at wider zoom. Settings: font size
(min/max with zoom scaling), leader lines, parallel rotation, collision
spread, utility track filters (repair/diesel/loader/interchange), zoom
thresholds per category. C# runs a world-space AABB solver at rebuild so
offsets are rotation-invariant; native runs a per-frame screen-space
fine-tuning pass and draws leader lines.

Mesh LOD: new module skeleton with 4-level LOD group injected on
HandleModelsLoaded - three progressive renderer cull tiers (sorted by
bounds volume) plus a 12-tri proxy box at max distance. lodBias-corrected
distance-to-screenHeight conversion. Debounced live refresh on settings
change. Disabled by default.
2026-06-25 12:33:37 -04:00
c918dd7867 Fix release URL pattern in repository.json (no v prefix on tags) 2026-06-22 19:14:12 -04:00
29689f8a34 Include last 3 releases in UMM repository manifest 2026-06-22 19:12:21 -04:00
55bc84412a Add UMM auto-update repository manifest 2026-06-22 19:11:43 -04:00
2343f05b18 README: note 3-12 FPS improvement from icon culling 2026-06-22 18:53:18 -04:00
edf6a8a14e Map Module 0.2.5: icon culling, EOTD, gear menu icons, font fix
Icon culling hides car icons when zoomed out past a configurable threshold
(default 40%), keeping only locomotive icons visible. Locos scale up
automatically when culling is active (configurable boost). An option also
hides MU trailing units. All managed by the new MapIconCuller class with
smooth fade transitions.

EOTD (End-of-Train Device): a blinking red dot at the rear of each consist.
Shown by default when car icons are hidden. Dot size and visibility conditions
are configurable. Implemented in EotdSystem as a programmatically generated
circle texture to avoid asset dependencies.

Added an Icons submenu to the gear menu containing all icon and EOTD settings.
Controls grey out to reflect dependencies: culling sub-settings (MU hide, loco
boost, EOTD) grey out when culling is off; EOTD dot size and hide-when-visible
grey out when EOTD is off. Changes round-trip via UICmd events, persist to
PopoutSettings, and call MapIconCuller.Refresh(). Native state is seeded from
C# on window init via RRPOPOUT_SetIconCullingState.

Settings completeness: added Right-click recenters toggle and Map BG opacity
slider to the UMM settings panel (were only accessible from the gear menu).

Replaced four FindObjectOfType<MapWindow> calls with PanelFinder.GetMapWindow().

Fixed AV false-positive on release zips: ImGui's built-in font blob contained
the substring "UPX", matching the heuristic for UPX-packed binaries. Added
IMGUI_DISABLE_DEFAULT_FONT to strip the blob; ProggyClean.ttf is now shipped
as a separate file alongside the DLL and loaded at runtime instead.
2026-06-22 18:46:12 -04:00
c8918851aa v0.2.4 - MapEnhancer version detection
Probe for version-specific types at startup to determine which MapEnhancer
build is installed, then gray out features unavailable in that build.

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

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

Updates README with version compatibility notes and comparison screenshots.
2026-06-21 15:12:56 -04:00
4391d96e70 add opacity media and fix readme paths 2026-06-19 08:52:54 -04:00
dd1af2ec30 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.
2026-06-19 08:32:05 -04:00
b85a492bd6 fix video embeds with absolute raw URLs 2026-06-18 21:54:37 -04:00
97eb320e0f v0.2.2 - map module themes, custom colors, in-game overlay
Map module:
- In-game overlay (UiService) with full map chrome matching the popout
- Six themes (S3 Dark, Railroader Classic, Night Mode, Hi-Vis, Retro Terminal, Custom)
- Per-element custom color editor with hex input and live R/G/B/A sliders
- Independent window and map opacity controls
- Theme and opacity changes apply from both the in-game overlay and the popout window
- Renamed module display name from "Map Popout" to "Map Module"

Native engine:
- SnapshotTheme() helper reduces per-frame mutex acquisitions from two to one
- Toolbar button text color auto-contrasts against the button background
- Compass colors driven by theme data

README:
- Logo, screenshots, and videos for all features
- Physics Optimizer before/after profiler comparison
- Correct MapEnhancer link (maintained fork)
2026-06-18 21:30:53 -04:00
914597b717 Update .gitignore 2026-06-18 10:58:54 -04:00
a4d3cd1621 feat(map): S3 v0.2.1 - in-game overlay, M/F10, popout restore
Replace the bare-bones stock map with a full Dear ImGui in-game overlay
(M key). The overlay shares the same toolbar and compass as the OS popout
window: Follow, Pop Out, Gear, rotation compass.

Pop Out button is now in the bottom toolbar where it is actually visible.
Pressing it (or F10) closes the overlay, waits 0.5 s for the camera to
release, then opens the OS popout window. Closing the popout restores the
overlay if it was running when the launch was triggered. Pressing M while
the popout is open closes the popout and reopens the overlay.

Harmony patches intercept MapWindow.Toggle and Show so base-game "Show on
map" links and the map hotkey both route through the S3 overlay. MapBypass
lets internal S3 calls through without triggering the patch recursively.

Camera ownership is enforced in LateUpdate so no base-game script can reset
targetTexture before the camera renders. Object.Destroy replaces Release()
on both RT teardown paths so the D3D address stays live until end-of-frame
and cannot be recycled into a new RT mid-frame.
2026-06-17 22:25:48 -04:00
71 changed files with 6260 additions and 671 deletions

5
.gitignore vendored
View file

@ -10,7 +10,4 @@ native/out/
# IDE
.vs/
*.user
# Agent briefing — shared out-of-band, not published
AGENTS.md
*.user

View file

@ -2,10 +2,11 @@
"Id": "S3",
"DisplayName": "S³ - Seton's Special Sauce",
"Author": "seton",
"Version": "0.2.0",
"Version": "0.2.7",
"ManagerVersion": "0.27.0",
"GameVersionPoint": "0",
"AssemblyName": "S3.dll",
"EntryMethod": "S3.Main.Load",
"Requirements": []
"Requirements": [],
"Repository": "https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/raw/branch/main/repository.json"
}

164
README.md
View file

@ -1,5 +1,7 @@
# S³ - Seton's Special Sauce
<p align="center"><img src="S3 Logo - clean.png" width="480" alt="S3 - Seton's Special Sauce"></p>
An umbrella "everything mod" for [Railroader](https://store.steampowered.com/app/1638770/Railroader/),
built on [Unity Mod Manager](https://www.nexusmods.com/site/mods/21).
@ -14,11 +16,163 @@ I originally planned on releasing individual mods, but considering my workflow o
| Module | What it does |
|---|---|
| Physics Optimizer | Cuts CPU spent on train physics (LOD fast-path + auto-freeze), with a profiler overlay and debug car tinting. Console: `/rpf` |
| Map Popout | Detaches the in-game map into a separate resizable window for a second monitor. Optional MapEnhancer integration. |
| Map Module | In-game map overlay and detachable popout window for a second monitor. Themes, custom colors, opacity controls, map rotation, track & industry labels, and optional MapEnhancer integration. |
| Physics Optimizer | Cuts CPU spent on train physics (LOD fast-path + auto-freeze), with debug car tinting. Console: `/rpf` |
| Mesh LOD | Adds level-of-detail to rolling stock: distant cars progressively shed detail and finally collapse to a cheap proxy box, cutting triangle count on large saves. |
| Profiler | Unified in-game performance overlay: a frame-time graph plus live readouts that adapt to whichever optimization modules are enabled. Console: `/rpf overlay` |
Both are disabled by default - You must enable them per-module from the S³ settings page, a game restart is required for this to take effect.
More modules will follow - S³ is designed to grow.
All modules are disabled by default; enable them per-module from the S³ settings page. A game restart is required for enable/disable to take effect. More modules will follow; S³ is designed to grow.
![All S³ modules in the UMM settings page](img/all-umm-modules.png)
---
## Map Module
Replaces the base-game map with a floating in-game overlay (toggle with **M** or your configured hotkey) and lets you pop it out into a detached resizable OS window for a second monitor. The toolbar, compass, and follow/jump menus are identical in both surfaces.
### 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), 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.
![Map Module settings panel](img/map/map_settings_umm.png)
### Themes
Six themes built in: five named presets plus a **Custom** slot you tune with per-element hex input and live R/G/B/A sliders. Any preset can be copied into Custom as a starting point. Theme changes apply instantly without restarting.
<table>
<tr>
<td align="center"><img src="img/map/map_themes/s3-default.png" alt="S3 Dark"><br><b>S3 Dark</b> (default)</td>
<td align="center"><img src="img/map/map_themes/railroader.png" alt="Railroader Classic"><br><b>Railroader Classic</b></td>
<td align="center"><img src="img/map/map_themes/high-vis.png" alt="High Visibility"><br><b>High Visibility</b></td>
</tr>
<tr>
<td align="center"><img src="img/map/map_themes/red-night.png" alt="Night Mode"><br><b>Night Mode</b></td>
<td align="center"><img src="img/map/map_themes/retro_green.png" alt="Retro Terminal"><br><b>Retro Terminal</b></td>
<td align="center"><i>+ fully customizable</i></td>
</tr>
</table>
### Transparency
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/map_opacity/opacity_controls.mp4" controls></video>
![Opacity controls settings panel](img/map/map_opacity/opacity_settings.png)
### Map Rotation
Rotate the map manually with the rotation control, or lock it to your player camera heading so it always faces the direction you're looking. Switch between free and locked at any time without losing your current view.
<video src="https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/raw/branch/main/img/map/map_rotation/rotate_manual_and_player_locked.mp4" controls></video>
### Icon Controls
Each car icon on the map is a live Canvas element. On a large session with many cars visible, the rendering cost adds up - testing showed a **3-12 FPS improvement** when zoomed out over a busy portion of the map with culling enabled.
When zoomed out past a configurable threshold (default 40%), car icons fade out 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 so you can still gauge train length at a glance.
All of these options are available from the gear menu Icons submenu on the map toolbar, or from the S³ UMM settings page.
<video src="https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/raw/branch/main/img/map/map_optimizations/hiding_cars_and_eotd_demo.mp4" controls></video>
![Icon controls settings](img/map/map_icons_config.png)
### Track & Industry Labels
The map labels named tracks and industries, read live from the game's industry data so mod-added tracks appear automatically. Labels adapt to zoom in three stages: individual per-track labels (aligned to the track, with leader lines) up close, merged labels at medium zoom, and one large label per industry area when zoomed out. Font size, leader-line thickness, merge distance, and every zoom threshold are configurable from the gear-menu Labels submenu or the S³ settings page.
<video src="https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/raw/branch/main/img/map/map_track_and_industry_labels/3-stages-demo.mp4" controls></video>
![Track and industry labels on the map](img/map/map_track_and_industry_labels/lables_still.png)
### 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.
<video src="https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/raw/branch/main/img/map/map_popout/map_popout_reattach.mp4" controls></video>
![Always on Top option in the popout window title bar menu](img/map/map_popout/always_on_top.png)
### MapEnhancer Integration
S³ auto-detects any installed version of MapEnhancer at startup with no configuration required. If MapEnhancer 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 gear menu showing follow submenu and settings panel](img/map/map_enhancer_follow_mode.png)
Both the original official builds and the [community fork](https://github.com/Refizar08/rr-mapenhancer-fix) are supported. Features added by the community fork - turntable control, road crossing markers, passenger stop tracking, industry area colors, modded spawn points, and bulk switch reset - are grayed out when an older build is detected. All controls that the installed version supports remain fully functional.
<table>
<tr>
<td align="center"><img src="img/map/map_enhancer_full.png" alt="Community build - all features available"><br><b>Community build</b></td>
<td align="center"><img src="img/map/map_enhancer_grayedout.png" alt="Older official build - community features grayed out"><br><b>Older build - community features grayed</b></td>
</tr>
</table>
---
## Physics Optimizer
Reduces CPU time spent on train physics with two complementary strategies:
- **LOD Fast-Path**: cars beyond a configurable distance switch to dead-reckoning, skipping expensive 3D constraint updates. A staggered resync keeps them accurate without a sudden full-update spike.
- **Auto Freeze**: consists that are far away and nearly stopped skip the Verlet integration tick entirely, replacing the stock freeze heuristic with a tighter distance + speed threshold.
Locomotives can be excluded from either strategy. Debug car tinting (yellow = frozen, cyan = fast-path, magenta = full) lets you verify the LOD and freeze behavior in your session, and the **Profiler** module's overlay reports live per-car physics timings and fast-path/freeze counts.
### Settings
![Physics Optimizer settings](img/physics_optimizer/umm_settings.png)
### Performance
LOD fast-path and Auto Freeze together cut per-car physics cost from ~28.6 µs to ~10.5 µs (63% reduction) and FixedUpdate time from 6.7 ms to 3.8 ms on the same 156-car scene. Numbers from the built-in profiler overlay:
| Before | After |
|:---:|:---:|
| ![Profiler before, Physics Optimizer OFF](img/physics_optimizer/pofiler_before.png) | ![Profiler after, Physics Optimizer ON](img/physics_optimizer/profiler_after.png) |
| Physics Optimizer **OFF** (6.7 ms/frame) | Physics Optimizer **ON** (3.8 ms/frame) |
---
## Mesh LOD
Railroader has no level-of-detail system for rolling stock, so every car renders at full geometry no matter how far away it is. Mesh LOD adds one. Each car gets a four-level LODGroup built at load time:
- **LOD0**: full detail, up close.
- **LOD1 / LOD2**: progressive culling. Renderers are ranked by bounding-box volume (largest is the car body and trucks, smallest are bolts, grab irons, and rivets), and each level keeps a progressively smaller top fraction, so structural geometry survives while fine detail drops first.
- **LOD3**: a single 12-triangle proxy box, tinted to the car's own color, for cars far enough away that only their shape reads.
Transition distances are specified in metres (corrected for the game's LOD quality bias internally), and you can apply the system to locomotives, freight, or both independently. Because the LODGroup is rebuilt from the live car list, mod-added equipment is handled automatically.
This is a GPU-side optimization: it lowers triangle and draw cost for distant cars, so it helps most on large saves with many cars in view when you are GPU-bound.
![Mesh LOD settings](img/mesh_lod/umm_settings.png)
All three LOD reductions side by side, with the transition distances set artificially short to put every level in one frame: full detail at right, structural-only with trucks and accessories culled in the middle, and the flat color-matched proxy box at left.
![The three Mesh LOD levels side by side](img/mesh_lod/proxy_box_comparison.png)
---
## Profiler
A unified in-game performance overlay. It always shows a render + physics frame-time graph (render, FixedUpdate, Tick, and PosCars times) and a timing report, and it grows extra sections for whichever optimization modules are enabled:
- **Physics Optimizer**: LOD fast-path and auto-freeze quick-toggles with live fast/full and frozen counts.
- **Mesh LOD**: total tracked cars, the locomotive/freight split, and how many cars sit at each LOD level right now.
Toggle the overlay with `/rpf overlay`, or from the Profiler settings page.
![Profiler settings](img/profiler/umm_settings.png)
![Unified profiler overlay with Physics Optimizer and Mesh LOD sections](img/profiler/overlay_unified.png)
---
## Migrating from the standalone mods
@ -28,6 +182,8 @@ Settings do not carry over, so re-configure each module from the S³ settings pa
If these mods are detected you will be prompted to disable them in the S³ settings page.
---
## Building
Requires the .NET SDK (`dotnet`). The native module additionally needs CMake +

BIN
S3 Logo - clean.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

41
dist/build-common.ps1 vendored
View file

@ -37,31 +37,58 @@ function Invoke-NativeBuild {
return $null
}
Write-Host "=== Building native (RRPopout.dll, $Configuration) ===" -ForegroundColor Cyan
Write-Host "=== Building native (S3Native.dll, $Configuration) ===" -ForegroundColor Cyan
$buildDir = Join-Path $RepoRoot "native\build"
if (-not (Test-Path $buildDir)) { cmake -B $buildDir -A x64 (Join-Path $RepoRoot "native") | Out-Host }
cmake --build $buildDir --config $Configuration | Out-Host
if ($LASTEXITCODE -ne 0) { throw "Native build failed." }
# MSVC multi-config generators place the DLL in a per-config subfolder.
$dll = Join-Path $buildDir "bin\$Configuration\RRPopout.dll"
if (-not (Test-Path $dll)) { $dll = Join-Path $buildDir "bin\RRPopout.dll" } # single-config fallback
if (-not (Test-Path $dll)) { throw "RRPopout.dll not found under $buildDir\bin" }
$dll = Join-Path $buildDir "bin\$Configuration\S3Native.dll"
if (-not (Test-Path $dll)) { $dll = Join-Path $buildDir "bin\S3Native.dll" } # single-config fallback
if (-not (Test-Path $dll)) { throw "S3Native.dll not found under $buildDir\bin" }
return $dll
}
# Assembles the Mods\S3 layout into $DestModDir (the S3 folder itself).
#
# Overwrites in place rather than wiping the folder. A full wipe is dangerous: if
# the game is running, a loaded DLL is locked, so the wipe deletes Info.json and
# the managed DLL, then aborts on the locked native DLL — leaving the mod with no
# manifest, so it silently vanishes from the loader. Overwriting in place keeps
# the last-good install if a copy fails, and preserves the user's settings files.
function New-ModLayout {
param([Parameter(Mandatory)][string]$DestModDir,
[Parameter(Mandatory)][string]$ManagedDll,
[string]$NativeDll = $null)
if (Test-Path $DestModDir) { Remove-Item $DestModDir -Recurse -Force }
New-Item -ItemType Directory -Force -Path $DestModDir | Out-Null
Copy-Item $ManagedDll (Join-Path $DestModDir "S3.dll") -Force
# Fail fast with a clear message if a DLL we're about to overwrite is locked
# (game still running) — before touching anything.
foreach ($name in @("S3.dll", "S3Native.dll")) {
$path = Join-Path $DestModDir $name
if (Test-Path $path) {
try { [System.IO.File]::Open($path, 'Open', 'ReadWrite', 'None').Close() }
catch { throw "$name is locked - close Railroader before installing." }
}
}
# Drop stale artifacts from older builds (the pre-rename native DLL, ngen
# caches) so they don't linger, but leave Info.json and settings intact.
$stale = Join-Path $DestModDir "RRPopout.dll"
if (Test-Path $stale) { Remove-Item $stale -Force }
Get-ChildItem $DestModDir -Filter "*.cache" -ErrorAction SilentlyContinue | Remove-Item -Force
Copy-Item (Join-Path $RepoRoot "Info.json") (Join-Path $DestModDir "Info.json") -Force
Copy-Item $ManagedDll (Join-Path $DestModDir "S3.dll") -Force
if ($NativeDll) {
Copy-Item $NativeDll (Join-Path $DestModDir "RRPopout.dll") -Force
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
}
}
}

View file

@ -3,7 +3,7 @@
# Produces dist\SetonsSpecialSauce-<version>.zip with the layout:
# S3/Info.json
# S3/S3.dll
# S3/RRPopout.dll (when the native module is present)
# S3/S3Native.dll (when the native module is present)
param(
[string]$Configuration = "Release"

BIN
img/all-umm-modules.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

BIN
img/map/transparency.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View file

@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.21)
project(RRPopout CXX)
project(S3Native CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@ -31,27 +31,32 @@ 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)
endif()
# ---------------------------------------------------------------------------
# RRPopout native plugin DLL
# S3Native native plugin DLL
# ---------------------------------------------------------------------------
add_library(RRPopout SHARED
add_library(S3Native SHARED
src/dllmain.cpp
src/exports.cpp
src/popout_windows.cpp
src/d3d11_renderer.cpp
)
target_include_directories(RRPopout PRIVATE
target_include_directories(S3Native PRIVATE
include
external
)
target_link_libraries(RRPopout PRIVATE
target_link_libraries(S3Native PRIVATE
imgui
d3d11
dxgi
@ -60,13 +65,13 @@ target_link_libraries(RRPopout PRIVATE
shell32 # ExtractIconExW (EXE icon loading)
)
set_target_properties(RRPopout PROPERTIES
OUTPUT_NAME "RRPopout"
set_target_properties(S3Native PROPERTIES
OUTPUT_NAME "S3Native"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
)
if(MSVC)
target_compile_options(RRPopout PRIVATE /W3 /WX- /GR- /EHsc)
target_compile_options(RRPopout PRIVATE $<$<CONFIG:Release>:/Zi>)
target_link_options(RRPopout PRIVATE $<$<CONFIG:Release>:/DEBUG /OPT:REF /OPT:ICF>)
target_compile_options(S3Native PRIVATE /W3 /WX- /GR- /EHsc)
target_compile_options(S3Native PRIVATE $<$<CONFIG:Release>:/Zi>)
target_link_options(S3Native PRIVATE $<$<CONFIG:Release>:/DEBUG /OPT:REF /OPT:ICF>)
endif()

Binary file not shown.

View file

@ -1,7 +1,7 @@
#pragma once
#include <cstdint>
// Shared between RRPopout.dll (C++) and RRPopout.Managed.dll (C#).
// Shared between S3Native.dll (C++) and S3.dll (C#).
// The C# struct in NativeInterop.cs MUST match this layout exactly.
enum InputEventType : int32_t {
@ -18,18 +18,97 @@ 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
ToggleFollowPlayer = 10, // toggle continuous follow of player world position
PopOut = 11, // (in-game only) close the overlay and open the OS popout
SetTheme = 12, // (in-game only) apply a theme preset; y = preset index
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]
// 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]
ToggleTrackLabels = 30, // toggle industry track name labels on the map
TrackLabelSetFontSize = 31, // y = font size px [8, 24]
TrackLabelSetLineThick = 32, // y = leader line thickness [1, 4]
TrackLabelSetZoomLimit = 33, // y = orthographicSize zoom-out limit [200, 8000]
ToggleLeaderLines = 34, // toggle leader lines from label to track anchor
ToggleCollision = 35, // toggle collision-avoidance label spread
ToggleParallelLabels = 36, // rotate labels to run parallel to their track
ToggleMergeLabels = 37, // merge same-name nearby spans into one label
TrackLabelSetMergeZoom = 38, // y = orthographicSize threshold for auto-merge
TrackLabelSetIndustryZoom = 39, // y = orthographicSize threshold for Stage 3 industry labels
ToggleUtilityRepairLabels = 40, // toggle repair-track labels
ToggleUtilityDieselLabels = 41, // toggle diesel-stand labels
ToggleUtilityLoaderLabels = 42, // toggle coal-loader labels
ToggleUtilityInterchangeLabels = 43, // toggle interchange labels (names auto-stripped)
TrackLabelSetUtilityZoom = 44, // y = orthographicSize beyond which utility labels hide
TrackLabelSetAllZoom = 45, // y = orthographicSize beyond which ALL labels hide
ToggleAvoidTrackLabels = 46, // push labels off their own track line
TrackLabelSetFontSizeMin = 47, // y = minimum font size px [4, max]
};
// 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
// compass rose colours drawn via DrawList. Must match MapThemeData in NativeInterop.cs.
#pragma pack(push, 4)
struct MapThemeData {
float wBgR, wBgG, wBgB, wBgA; // window / toolbar background
float accR, accG, accB, accA; // accent (TitleBgActive, buttons, resize grip)
float txtR, txtG, txtB, txtA; // status text and labels
float popR, popG, popB, popA; // settings popup background
float mapR, mapG, mapB, mapA; // map Image() tint (1,1,1,1 = no tint)
float cmpR, cmpG, cmpB, cmpA; // compass background disk
float nNR, nNG, nNB, nNA; // compass needle N-pointing half
float nSR, nSG, nSB, nSA; // compass needle S-pointing half
float mapBgR, mapBgG, mapBgB, mapBgA; // map camera clear colour (alpha unused, kept for alignment)
};
#pragma pack(pop)
static_assert(sizeof(MapThemeData) == 144, "MapThemeData size mismatch — update NativeInterop.cs");
#pragma pack(push, 4)
struct InputEvent {
int32_t type; // InputEventType

File diff suppressed because it is too large Load diff

View file

@ -13,3 +13,59 @@ void Renderer_ReleaseSwapchain(PopoutWindow* win);
// Blits pendingTexture into the swapchain and calls Present. Render thread only.
void Renderer_Present(PopoutWindow* win);
// ---------------------------------------------------------------------------
// In-game overlay (spike): draw a Dear ImGui frame directly into whatever
// render target Unity has bound (the final backbuffer when issued after
// WaitForEndOfFrame). No swapchain, no Present — composites on top of the game.
// All functions below run on Unity's render thread except the *Set* setters,
// which are called from the C# main thread and only touch atomics.
// ---------------------------------------------------------------------------
// Stash a texture whose owning device we lazily init from (mirrors the popout's
// tex->GetDevice() path) when UnityPluginLoad never fired. Call once from C#.
void Overlay_SetDeviceTexture(void* texturePtr);
// Per-frame input snapshot from C# (top-left origin, pixels). wheel in WHEEL_DELTA units.
void Overlay_SetInput(float displayW, float displayH,
float mouseX, float mouseY,
bool lButton, bool rButton, int wheel);
// The Unity map render texture to display inside the in-game ImGui window, plus
// the UV sub-rect (V flipped: v0=1,v1=0 for a Unity RT). Pass nullptr to clear.
void Overlay_SetMapTexture(void* texturePtr, float u0, float v0, float u1, float v1);
// Drains queued in-game map input (image drag/zoom) for C# to forward to the map
// camera. Returns the number of events written (<= maxEvents).
int Overlay_PollInput(InputEvent* out, int maxEvents);
// Size (px) of the map image region last frame, so C# can set the map camera's
// aspect to the window shape (map fills the window, no letterbox bars).
void Overlay_GetMapView(float* outW, float* outH);
// Current mouse position normalised within the map image (top-left origin, [0,1]).
// Returns (-1, -1) when the map is not currently rendered (no save or map inactive).
void Overlay_GetMouseMapPos(float* outX, float* outY);
// Show/hide the overlay UI.
void Overlay_SetVisible(bool visible);
// True when ImGui wants the mouse (hovering a widget) — C# uses this to swallow input.
bool Overlay_WantsMouse();
// Apply a new theme. Thread-safe: stores data then applies on the render thread at
// next frame start (before NewFrame), so ImGui style is never written from main thread.
// presetIndex is echoed back into the gear menu checkmarks.
void Renderer_SetTheme(const MapThemeData* t, int presetIndex);
// Set the in-game overlay's chrome (window + toolbar + compass) alpha [0.1, 1.0].
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

@ -43,7 +43,12 @@ UnityPluginUnload() {
// ---------------------------------------------------------------------------
// Render event callback — called by Unity on the render thread
// ---------------------------------------------------------------------------
// Sentinel event id for the in-game overlay. Window handles start at 1 and grow
// by one (see CreatePopoutWindow), so this high value can never collide.
static const int kOverlayEventId = 0x05E70001;
static void UNITY_INTERFACE_API OnRenderEvent(int eventId) {
if (eventId == kOverlayEventId) { Renderer_PresentOverlay(); return; }
PopoutWindow* win = GetPopoutWindow(eventId);
if (win) Renderer_Present(win);
}
@ -76,6 +81,70 @@ void RRPOPOUT_GetWindowSize(int windowHandle, int* outWidth, int* outHeight) {
GetPopoutWindowSize(windowHandle, outWidth, outHeight);
}
// ---------------------------------------------------------------------------
// In-game overlay (spike) — exports
// ---------------------------------------------------------------------------
// Returns the event id to pass to GL.IssuePluginEvent for the overlay render.
extern "C" __declspec(dllexport)
int RRPOPOUT_GetOverlayEventId() { return kOverlayEventId; }
// One-time: hand native a texture so it can lazily acquire Unity's D3D device.
extern "C" __declspec(dllexport)
void RRPOPOUT_SetOverlayDeviceTexture(void* texturePtr) {
Overlay_SetDeviceTexture(texturePtr);
}
// Per-frame input snapshot (top-left origin, pixels).
extern "C" __declspec(dllexport)
void RRPOPOUT_SetOverlayInput(float displayW, float displayH,
float mouseX, float mouseY,
int lButton, int rButton, int wheel) {
Overlay_SetInput(displayW, displayH, mouseX, mouseY,
lButton != 0, rButton != 0, wheel);
}
extern "C" __declspec(dllexport)
void RRPOPOUT_SetOverlayVisible(int visible) { Overlay_SetVisible(visible != 0); }
// Map render texture to show in the in-game window (+ UV rect; V flipped for a
// Unity RT). Call each frame before issuing the overlay render event.
extern "C" __declspec(dllexport)
void RRPOPOUT_SetOverlayMapTexture(void* texturePtr,
float u0, float v0, float u1, float v1) {
Overlay_SetMapTexture(texturePtr, u0, v0, u1, v1);
}
extern "C" __declspec(dllexport)
int RRPOPOUT_OverlayWantsMouse() { return Overlay_WantsMouse() ? 1 : 0; }
// Drain queued in-game map input (drag/zoom over the map image) for C# to apply.
extern "C" __declspec(dllexport)
int RRPOPOUT_PollOverlayInput(InputEvent* outEvents, int maxEvents) {
return Overlay_PollInput(outEvents, maxEvents);
}
// Read back the map image region size so C# can match the camera aspect to it.
extern "C" __declspec(dllexport)
void RRPOPOUT_GetOverlayMapView(float* outW, float* outH) {
Overlay_GetMapView(outW, outH);
}
// Current mouse position normalised within the map image (top-left origin, [0,1]).
// Returns (-1, -1) when the map image is not visible this frame.
extern "C" __declspec(dllexport)
void RRPOPOUT_GetOverlayMouseMapPos(float* outX, float* outY) {
Overlay_GetMouseMapPos(outX, outY);
}
// Handle of the in-game overlay's shared UI-state holder. C# pushes status text,
// loco/location lists, rotation, follow flags, etc. to this handle with the same
// RRPOPOUT_Set* / RRPOPOUT_PollInputEvents functions the popout uses.
extern "C" __declspec(dllexport)
int RRPOPOUT_GetOverlayWindowHandle() {
return GetOrCreateOverlayStateHandle();
}
// Poll input events — mouse-type events are suppressed while ImGui has capture
// (e.g. cursor is over the toolbar) to prevent accidental map pan/zoom.
extern "C" __declspec(dllexport)
@ -147,6 +216,55 @@ void RRPOPOUT_SetMapEnhancerInstalled(int windowHandle, bool installed) {
if (win) win->imMapEnhancerInstalled.store(installed);
}
// Set ME capability flags.
// hasTurntable — ShowTurntableMarkers field exists (v1.6.0 or community); gates Turntable Markers toggle.
// community — full community build (crossing/spawn/switch-reset); gates all other community items.
extern "C" __declspec(dllexport)
void RRPOPOUT_SetMapEnhancerCaps(int windowHandle, bool hasTurntable, bool community) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (!win) return;
win->imMEHasTurntable.store(hasTurntable);
win->imMECommunity.store(community);
}
// 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 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) {
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)
@ -207,6 +325,131 @@ void RRPOPOUT_SetStatusText(int windowHandle, const wchar_t* text) {
strncpy_s(win->imStatusText, buf, _TRUNCATE);
}
// Apply a theme preset. data is a pointer to a MapThemeData struct; presetIndex is
// stored for checkmark display in the gear menu. Thread-safe: stores via mutex + atomic.
extern "C" __declspec(dllexport)
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) {
Overlay_SetAlpha(alpha);
}
// Set the map image alpha [0.0, 1.0]. Independent of chrome alpha.
extern "C" __declspec(dllexport)
void RRPOPOUT_SetOverlayMapAlpha(float alpha) {
Overlay_SetMapAlpha(alpha);
}
// Push industry track name labels for the map.
// namesBlobW: UTF-16 track names joined by '\n'.
// us/vs: centroid UV per label (C# projects TrackSpan centroid through the map camera).
// anchorUs/anchorVs: flat UV arrays for all span anchor positions.
// anchorStarts/anchorCounts: per-label index + count into the anchor arrays.
// count=0 clears everything (zoomed out or map inactive).
extern "C" __declspec(dllexport)
void RRPOPOUT_SetTrackLabels(int windowHandle, const wchar_t* namesBlobW,
const float* us, const float* vs,
const float* anchorUs, const float* anchorVs,
const int* anchorStarts, const int* anchorCounts,
const float* angles, const float* scales,
int count) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (!win) return;
std::vector<PopoutWindow::ImTrackLabel> tmpLabels;
std::vector<float> tmpAnchorUs, tmpAnchorVs;
if (namesBlobW && count > 0) {
tmpLabels.reserve(count);
const wchar_t* p = namesBlobW;
for (int i = 0; i < count; ++i) {
const wchar_t* end = p;
while (*end && *end != L'\n') ++end;
PopoutWindow::ImTrackLabel lbl{};
WideCharToMultiByte(CP_UTF8, 0, p, (int)(end - p),
lbl.name, (int)sizeof(lbl.name) - 1, nullptr, nullptr);
lbl.u = us[i]; lbl.v = vs[i];
lbl.angle = angles[i];
lbl.scale = scales ? scales[i] : 1.0f;
lbl.anchorStart = anchorStarts[i];
lbl.anchorCount = anchorCounts[i];
tmpLabels.push_back(lbl);
p = (*end == L'\n') ? end + 1 : end;
}
int totalAnchors = anchorStarts[count - 1] + anchorCounts[count - 1];
tmpAnchorUs.assign(anchorUs, anchorUs + totalAnchors);
tmpAnchorVs.assign(anchorVs, anchorVs + totalAnchors);
}
std::lock_guard<std::mutex> lk(win->imTrackLabelMutex);
win->imTrackLabels = std::move(tmpLabels);
win->imAnchorUs = std::move(tmpAnchorUs);
win->imAnchorVs = std::move(tmpAnchorVs);
}
// Enable or disable track label rendering for this window. Default: enabled.
extern "C" __declspec(dllexport)
void RRPOPOUT_SetTrackLabelsEnabled(int windowHandle, bool enabled) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (win) win->imTrackLabelsEnabled.store(enabled);
}
// Seed all track-label style settings at once. Call on map activate and on any change.
extern "C" __declspec(dllexport)
void RRPOPOUT_SetTrackLabelStyle(int windowHandle,
float fontSize, float lineThickness, float zoomLimit,
bool leaderLinesEnabled, bool collisionEnabled,
bool parallelEnabled, bool mergeEnabled, float mergeZoom,
float industryZoom,
bool utilityRepairEnabled, bool utilityDieselEnabled,
bool utilityLoaderEnabled, bool utilityInterchangeEnabled,
float utilityZoom,
float allLabelsZoom,
bool avoidTrack,
float fontSizeMin) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (!win) return;
win->imTrackLabelFontSize.store(fontSize);
win->imTrackLabelLineThickness.store(lineThickness);
win->imTrackLabelZoomLimit.store(zoomLimit);
win->imTrackLeaderLinesEnabled.store(leaderLinesEnabled);
win->imTrackCollisionEnabled.store(collisionEnabled);
win->imTrackParallelEnabled.store(parallelEnabled);
win->imTrackMergeEnabled.store(mergeEnabled);
win->imTrackLabelMergeZoom.store(mergeZoom);
win->imTrackIndustryZoom.store(industryZoom);
win->imTrackUtilityRepair.store(utilityRepairEnabled);
win->imTrackUtilityDiesel.store(utilityDieselEnabled);
win->imTrackUtilityLoader.store(utilityLoaderEnabled);
win->imTrackUtilityInterchange.store(utilityInterchangeEnabled);
win->imTrackUtilityZoom.store(utilityZoom);
win->imTrackAllLabelsZoom.store(allLabelsZoom);
win->imTrackLabelAvoidTrack.store(avoidTrack);
win->imTrackLabelFontSizeMin.store(fontSizeMin);
}
// ---------------------------------------------------------------------------
// Plugin_Initialize / Plugin_Shutdown (called from dllmain.cpp)
// ---------------------------------------------------------------------------

View file

@ -71,19 +71,85 @@ 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> imMEHasTurntable {false}; // true if ShowTurntableMarkers exists (v1.6.0 or community)
std::atomic<bool> imMECommunity {false}; // true = community build (has crossing/spawn/switch-reset)
std::atomic<bool> imFollowPlayer {false};
std::atomic<bool> imAlwaysOnTop {false};
std::atomic<bool> imPanDisablesFollow {true};
std::atomic<bool> imRightClickRecenter {true};
// -----------------------------------------------------------------------
// Icon culling + EOTD settings (C# → render thread via RRPOPOUT_SetIconCullingState)
// -----------------------------------------------------------------------
std::atomic<bool> imIconCullingEnabled {true};
std::atomic<float> imCullThreshold {0.40f};
std::atomic<bool> imHideMuLocos {true};
std::atomic<float> imLocoBoostedScale {1.5f};
std::atomic<bool> imEotdEnabled {true};
std::atomic<bool> imEotdOnlyWhenCulled {true};
std::atomic<float> imEotdSizeScale {8.0f};
// -----------------------------------------------------------------------
// 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};
// -----------------------------------------------------------------------
// Track name labels (industry track names + viewport UVs, pushed from C# each frame).
// C# projects TrackSpan centroids through the map camera, culls off-screen entries,
// and pushes (name, centroid_uv, anchors). Native runs per-frame collision avoidance
// (AABB push in pixel space) then draws leader lines to each anchor before the text.
// -----------------------------------------------------------------------
struct ImTrackLabel {
char name[64];
float u, v; // centroid UV — starting position for the layout solver
float angle; // track direction in screen space (degrees; 0=right, +ve=CW)
float scale; // font size multiplier (1.0 = track label, 1.5 = industry label)
int anchorStart; // index into imAnchorUs/Vs
int anchorCount; // 1 for single span; >1 for merged same-name cluster
};
std::vector<ImTrackLabel> imTrackLabels;
std::vector<float> imAnchorUs; // flat anchor UV arrays, indexed by anchorStart
std::vector<float> imAnchorVs;
std::mutex imTrackLabelMutex;
std::atomic<bool> imTrackLabelsEnabled {true};
std::atomic<float> imTrackLabelFontSize {14.f};
std::atomic<float> imTrackLabelLineThickness {1.5f};
std::atomic<float> imTrackLabelZoomLimit {3000.f};
std::atomic<bool> imTrackLeaderLinesEnabled {true};
std::atomic<bool> imTrackCollisionEnabled {true};
std::atomic<bool> imTrackParallelEnabled {false};
std::atomic<bool> imTrackMergeEnabled {true};
std::atomic<float> imTrackLabelMergeZoom {150.f};
std::atomic<float> imTrackIndustryZoom {200.f};
std::atomic<bool> imTrackUtilityRepair {true};
std::atomic<bool> imTrackUtilityDiesel {true};
std::atomic<bool> imTrackUtilityLoader {true};
std::atomic<bool> imTrackUtilityInterchange {true};
std::atomic<float> imTrackUtilityZoom {200.f};
std::atomic<float> imTrackAllLabelsZoom {8000.f};
std::atomic<bool> imTrackLabelAvoidTrack {false};
std::atomic<float> imTrackLabelFontSizeMin {8.f};
// Loaded geometry from the save file — consumed by MessagePumpThread before CreateWindowExW.
std::atomic<int> lastWinX {-1}, lastWinY {-1};
std::atomic<int> lastWinW {900}, lastWinH {700};
PopoutWindow() {
strncpy_s(imStatusText, sizeof(imStatusText),
"Railroader \xe2\x80\x94 Map", _TRUNCATE); // UTF-8 em-dash
// Status starts empty (filled live with zoom/coords). Avoids a default label
// that would be redundant with the window title bar and would render any
// non-ASCII glyph as '?' in ImGui's default font.
imStatusText[0] = '\0';
}
};

View file

@ -15,15 +15,18 @@
#include "d3d11_renderer.h"
// ---------------------------------------------------------------------------
// Persistent state — saved to <GameRoot>/RRPopout/window_state.ini on close,
// loaded and applied in MessagePumpThread before ShowWindow.
// Persistent state — saved to <GameRoot>/Mods/S3/popout_window.ini on close,
// loaded and applied in MessagePumpThread before ShowWindow. Deliberately NOT
// <GameRoot>/RRPopout — that is the old standalone PopOut's folder and may still
// be present, so we keep our state in our own mod folder (which always exists)
// to avoid reading its stale config.
// ---------------------------------------------------------------------------
static std::wstring GetStatePath() {
wchar_t buf[MAX_PATH] = {};
GetModuleFileNameW(nullptr, buf, MAX_PATH); // Railroader.exe full path
wchar_t* sep = wcsrchr(buf, L'\\');
if (sep) sep[1] = L'\0'; // trim to directory (keep slash)
return std::wstring(buf) + L"RRPopout\\config.ini";
return std::wstring(buf) + L"Mods\\S3\\popout_window.ini";
}
static void SaveWindowState(HWND hwnd, PopoutWindow* win) {
@ -76,8 +79,8 @@ static std::unordered_map<int, PopoutWindow*> g_windows;
static std::mutex g_windowsMutex;
static std::atomic<int> g_nextHandle {1};
static const wchar_t* kWindowClass = L"RRPopout_FloatWindow";
static const wchar_t* kContentClass = L"RRPopout_Content";
static const wchar_t* kWindowClass = L"S3Native_FloatWindow";
static const wchar_t* kContentClass = L"S3Native_Content";
static bool g_classRegistered = false;
static bool g_contentClassRegistered = false;
@ -477,6 +480,31 @@ PopoutWindow* GetPopoutWindow(int handle) {
return (it != g_windows.end()) ? it->second : nullptr;
}
// Registers a PopoutWindow with no OS window or message pump — a pure UI-state
// holder. Safe to query/update via the same handle-based exports as real windows.
int CreateHeadlessWindow() {
int handle = g_nextHandle.fetch_add(1);
auto* win = new PopoutWindow();
win->alive.store(true);
{ std::lock_guard<std::mutex> lock(g_windowsMutex); g_windows[handle] = win; }
return handle;
}
// Lazily creates the single shared state holder for the in-game overlay and
// returns its handle. Callable from both the render thread and the main thread.
static std::atomic<int> g_overlayStateHandle {0};
static std::mutex g_overlayStateMutex;
int GetOrCreateOverlayStateHandle() {
int h = g_overlayStateHandle.load();
if (h != 0) return h;
std::lock_guard<std::mutex> lk(g_overlayStateMutex);
h = g_overlayStateHandle.load();
if (h != 0) return h;
h = CreateHeadlessWindow();
g_overlayStateHandle.store(h);
return h;
}
void GetPopoutWindowSize(int handle, int* w, int* h) {
PopoutWindow* win = GetPopoutWindow(handle);
if (win && win->alive.load()) { *w = win->width.load(); *h = win->height.load(); }

View file

@ -9,3 +9,9 @@ void DestroyPopoutWindow(int handle);
PopoutWindow* GetPopoutWindow(int handle);
void GetPopoutWindowSize(int handle, int* w, int* h);
int PollPopoutInputEvents(int handle, InputEvent* outEvents, int maxEvents);
// A registered PopoutWindow with no OS window/thread — used as a shared UI-state
// holder for the in-game overlay so it can reuse all the popout's state exports
// (status text, loco/location lists, rotation, follow, etc.) and BuildMapUI.
int CreateHeadlessWindow();
int GetOrCreateOverlayStateHandle();

19
repository.json Normal file
View file

@ -0,0 +1,19 @@
{
"Releases": [
{
"Id": "S3",
"Version": "0.2.7",
"DownloadUrl": "https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/releases/download/0.2.7/SetonsSpecialSauce-0.2.7.zip"
},
{
"Id": "S3",
"Version": "0.2.5",
"DownloadUrl": "https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/releases/download/0.2.5/SetonsSpecialSauce-0.2.5.zip"
},
{
"Id": "S3",
"Version": "0.2.4",
"DownloadUrl": "https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/releases/download/0.2.4/SetonsSpecialSauce-0.2.4.zip"
}
]
}

1498
src/Core/Ui/UiService.cs Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
using S3.Core;
using S3.Core.Ui;
using UnityModManagerNet;
namespace S3;
@ -28,11 +29,17 @@ public static class Main
// Modules are registered here. Each module loads its own settings in its
// constructor. Order here is display order in the settings panel.
_registry.Register(new Modules.PhysicsOptimizer.PhysicsOptimizerModule());
_registry.Register(new Modules.MeshLod.MeshLodModule());
_registry.Register(new Modules.Profiler.ProfilerModule());
_registry.Register(new Modules.Popout.PopoutModule());
_registry.EnableConfigured();
ModConflicts.CheckAtLoad();
// Always-on core UI service: hosts Dear ImGui inside the game, intercepts
// the base-game map hotkey, and enforces popout<->in-game mutual exclusion.
UiService.Install();
modEntry.OnGUI = _ => SettingsPanel.Draw(_registry);
modEntry.OnSaveGUI = _ => _registry.SaveAll();

View file

@ -0,0 +1,376 @@
using System;
using System.Collections.Generic;
using System.Linq;
using HarmonyLib;
using Model;
using S3.Core;
using UnityEngine;
namespace S3.Modules.MeshLod;
static class MeshLodInjector
{
static MeshLodSettings _settings = new();
static readonly List<WeakReference<Car>> _trackedCars = new();
// Debounced auto-refresh: set by MarkDirty, consumed by CheckAutoRefresh (called from Update).
static float _pendingRefreshAt = -1f;
const float RefreshDebounce = 0.5f;
// ── Lifecycle ─────────────────────────────────────────────────────────────
public static void Init(MeshLodSettings settings)
{
_settings = settings;
Log.Info($"MeshLOD settings: frac1={settings.lod1RendererFraction:F2} frac2={settings.lod2RendererFraction:F2} " +
$"d1={settings.lod1Distance}m d2={settings.lod2Distance}m d3={settings.lod3Distance}m " +
$"locos={settings.applyToLocomotives} freight={settings.applyToFreightCars} " +
$"lodBias={QualitySettings.lodBias:F2}");
}
public static void ClearTracking()
{
_trackedCars.Clear();
_pendingRefreshAt = -1f;
}
public static (int total, int locos, int freight) GetTrackedCounts()
{
int total = 0, locos = 0;
foreach (var wr in _trackedCars)
{
if (!wr.TryGetTarget(out Car? car) || car == null) continue;
total++;
if (car is BaseLocomotive) locos++;
}
return (total, locos, total - locos);
}
// LOD level distribution — called at most once per second by the Profiler overlay.
public static (int total, int locos, int freight, int atLod0, int atLod1, int atLod2, int atLod3) GetLodStats()
{
int total = 0, locos = 0, freight = 0;
int atLod0 = 0, atLod1 = 0, atLod2 = 0, atLod3 = 0;
foreach (var wr in _trackedCars)
{
if (!wr.TryGetTarget(out Car? car) || car == null || car.BodyTransform == null) continue;
total++;
bool isLoco = car is BaseLocomotive;
if (isLoco) locos++; else freight++;
switch (GetLodLevel(car))
{
case 0: atLod0++; break;
case 1: atLod1++; break;
case 2: atLod2++; break;
default: atLod3++; break; // 3 = proxy box, -1 = no group (treated same)
}
}
return (total, locos, freight, atLod0, atLod1, atLod2, atLod3);
}
// Detect current LOD level by reading which renderer sets are active on the LODGroup.
// lods[0] = all renderers; lods[1] = lod1Keep subset; lods[2] = lod2Keep subset;
// lods[3] = bbox. Counting enabled renderers in lods[0] vs the subset sizes
// avoids storing extra per-car state.
static int GetLodLevel(Car car)
{
var lg = car.BodyTransform?.GetComponent<LODGroup>();
if (lg == null) return -1;
LOD[] lods = lg.GetLODs();
if (lods.Length < 4) return -1; // not our LODGroup
// LOD3: proxy box renderer is enabled
if (lods[3].renderers.Length > 0 && lods[3].renderers[0] is { } bbox && bbox.enabled)
return 3;
int enabled = 0;
foreach (var r in lods[0].renderers)
if (r != null && r.enabled) enabled++;
int total = lods[0].renderers.Length;
int lod2Keep = lods[2].renderers.Length;
if (enabled == total) return 0; // all renderers on: full detail
if (enabled == 0) return 3; // culled entirely: treat as LOD3
return enabled > lod2Keep ? 1 : 2; // more than structural count: LOD1, else LOD2
}
// Called from the UI whenever any setting changes.
// Triggers RefreshAll after RefreshDebounce seconds with no further changes,
// so slider drags don't spam scene rebuilds.
public static void MarkDirty() =>
_pendingRefreshAt = Time.realtimeSinceStartup + RefreshDebounce;
// Called every Update by MeshLodHost.
public static void CheckAutoRefresh()
{
if (_pendingRefreshAt < 0f) return;
if (Time.realtimeSinceStartup < _pendingRefreshAt) return;
_pendingRefreshAt = -1f;
RefreshAll();
}
// ── Harmony entry point ───────────────────────────────────────────────────
public static void OnModelLoaded(Car car)
{
if (car?.BodyTransform == null) return;
if (car.BodyTransform.GetComponent<LODGroup>() != null) return;
bool isLoco = car is BaseLocomotive;
if ( isLoco && !_settings.applyToLocomotives) return;
if (!isLoco && !_settings.applyToFreightCars) return;
MeshRenderer[] renderers = CollectRenderers(car.BodyTransform);
if (renderers.Length == 0) return;
try
{
ApplyLods(car, renderers);
// Opportunistically drop dead weak-refs so the list can't grow unbounded over a
// long session of cars loading/unloading (RefreshAll also compacts, but only runs
// when a setting changes). Cheap: the modulo check runs once per car load.
if (_trackedCars.Count > 0 && _trackedCars.Count % 128 == 0)
_trackedCars.RemoveAll(wr => !wr.TryGetTarget(out _));
_trackedCars.Add(new WeakReference<Car>(car));
}
catch (Exception ex) { Log.Warn($"MeshLOD: apply failed for {car.DisplayName}: {ex.Message}"); }
}
// ── Explicit refresh ──────────────────────────────────────────────────────
public static void RefreshAll()
{
int refreshed = 0, skipped = 0;
foreach (WeakReference<Car> wr in _trackedCars.ToArray())
{
if (!wr.TryGetTarget(out Car? car) || car == null) continue;
if (car.BodyTransform == null) { skipped++; continue; }
RemoveLods(car);
bool isLoco = car is BaseLocomotive;
if ( isLoco && !_settings.applyToLocomotives) { skipped++; continue; }
if (!isLoco && !_settings.applyToFreightCars) { skipped++; continue; }
MeshRenderer[] renderers = CollectRenderers(car.BodyTransform);
if (renderers.Length == 0) { skipped++; continue; }
try { ApplyLods(car, renderers); refreshed++; }
catch (Exception ex) { Log.Warn($"MeshLOD: refresh failed for {car.DisplayName}: {ex.Message}"); }
}
_trackedCars.RemoveAll(wr => !wr.TryGetTarget(out _));
Log.Info($"MeshLOD: refreshed {refreshed} car(s), skipped {skipped}");
}
// ── Renderer collection ───────────────────────────────────────────────────
static MeshRenderer[] CollectRenderers(Transform bodyRoot)
{
// Build exclusion set: renderers already claimed by the game's own child LODGroups
// (coupler-lods, gauge objects, etc.) must not be given to our outer LODGroup.
var alreadyClaimed = new HashSet<Renderer>();
foreach (LODGroup existing in bodyRoot.GetComponentsInChildren<LODGroup>())
foreach (LOD lod in existing.GetLODs())
foreach (Renderer r in lod.renderers)
if (r != null) alreadyClaimed.Add(r);
var list = new List<MeshRenderer>();
foreach (MeshRenderer mr in bodyRoot.GetComponentsInChildren<MeshRenderer>())
{
if (alreadyClaimed.Contains(mr)) continue;
if (IsBBoxChild(mr.transform)) continue;
if (mr.GetComponent<MeshFilter>()?.sharedMesh == null) continue;
list.Add(mr);
}
return list.ToArray();
}
// Our proxy box GO is named "S3_BBox"; skip renderers that live inside it.
static bool IsBBoxChild(Transform t) => t.parent?.name == "S3_BBox";
// ── LOD application ───────────────────────────────────────────────────────
static void ApplyLods(Car car, MeshRenderer[] allRenderers)
{
Transform bodyRoot = car.BodyTransform;
// Sort by world-bounds volume descending: largest = car body / trucks (structural),
// smallest = bolts, grab irons, rivets (detail).
MeshRenderer[] sorted = allRenderers
.OrderByDescending(r => BoundsVolume(r.bounds))
.ToArray();
// LOD1: minor cull — keep the top lod1RendererFraction of renderers
int lod1Keep = Mathf.Max(1, Mathf.RoundToInt(sorted.Length * _settings.lod1RendererFraction));
// LOD2: major cull — keep only the top lod2RendererFraction (must be ≤ lod1Keep)
int lod2Keep = Mathf.Clamp(Mathf.RoundToInt(sorted.Length * _settings.lod2RendererFraction), 1, lod1Keep);
Renderer[] lod0Rend = allRenderers.Cast<Renderer>().ToArray();
Renderer[] lod1Rend = sorted.Take(lod1Keep).Cast<Renderer>().ToArray();
Renderer[] lod2Rend = sorted.Take(lod2Keep).Cast<Renderer>().ToArray();
// LOD3: 12-triangle proxy box derived from the largest renderer's mesh-local AABB.
// Reuse the largest renderer's own material so the box matches the car's colour
// (a shared/tinted material can't match per-car, and Material.color sets the legacy
// _Color, not URP's _BaseColor — both reasons the previous matte material rendered gray).
Bounds localBounds = ComputeMeshLocalBounds(sorted[0], bodyRoot);
MeshRenderer bboxMr = CreateBoundingBoxRenderer(bodyRoot, localBounds,
sorted[0].sharedMaterial,
sorted[0].gameObject.layer);
Renderer[] lod3Rend = new[] { (Renderer)bboxMr };
// Convert distance settings → screenRelativeTransitionHeight.
// Multiply by lodBias so that transitions fire at the specified metre distance
// even when QualitySettings.lodBias ≠ 1. (Without this, transitions fire at
// d × lodBias instead of d — Railroader uses lodBias=2.5.)
float sphereDiameter = Mathf.Max(localBounds.extents.magnitude * 2f, 0.5f);
float fov = Camera.main != null ? Camera.main.fieldOfView : 60f;
float invFovFactor = 2f * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
float lodBias = Mathf.Max(QualitySettings.lodBias, 0.01f);
float k = sphereDiameter * lodBias / invFovFactor;
float lod1H = Mathf.Clamp(k / _settings.lod1Distance, 0.001f, 0.999f);
float lod2H = Mathf.Clamp(k / _settings.lod2Distance, 0.001f, lod1H * 0.9f);
float lod3H = Mathf.Clamp(k / _settings.lod3Distance, 0.001f, lod2H * 0.9f);
var lodGroup = bodyRoot.gameObject.AddComponent<LODGroup>();
lodGroup.SetLODs(new LOD[]
{
new(lod1H, lod0Rend), // full detail
new(lod2H, lod1Rend), // minor cull
new(lod3H, lod2Rend), // structural only
new(0f, lod3Rend), // proxy box
});
lodGroup.RecalculateBounds();
Log.Info($"MeshLOD: {car.DisplayName} " +
$"lod0={lod0Rend.Length} lod1={lod1Keep} lod2={lod2Keep} lod3=bbox " +
$"sphere={sphereDiameter:F1}m bias={lodBias:F2} " +
$"thresh={lod1H:F3}/{lod2H:F3}/{lod3H:F3}");
}
// Tears down our LODGroup and proxy box, re-enabling any renderers that the
// LODGroup had disabled during a LOD1/LOD2/LOD3 transition.
static void RemoveLods(Car car)
{
if (car?.BodyTransform == null) return;
LODGroup? lg = car.BodyTransform.GetComponent<LODGroup>();
if (lg != null)
{
LOD[] lods = lg.GetLODs();
if (lods.Length > 0)
foreach (Renderer r in lods[0].renderers)
if (r != null) r.enabled = true;
UnityEngine.Object.Destroy(lg);
}
Transform? bbox = car.BodyTransform.Find("S3_BBox");
if (bbox != null)
{
// Destroy the procedural box mesh too: destroying the GameObject alone leaves
// the Mesh asset alive until scene unload (a small leak on every refresh).
MeshFilter? bf = bbox.GetComponent<MeshFilter>();
if (bf != null && bf.sharedMesh != null) UnityEngine.Object.Destroy(bf.sharedMesh);
UnityEngine.Object.Destroy(bbox.gameObject);
}
}
// ── Bounds helpers ────────────────────────────────────────────────────────
static Bounds ComputeMeshLocalBounds(MeshRenderer mr, Transform bodyRoot)
{
MeshFilter? mf = mr.GetComponent<MeshFilter>();
if (mf?.sharedMesh != null)
{
try
{
Bounds mb = mf.sharedMesh.bounds;
Matrix4x4 m2b = bodyRoot.worldToLocalMatrix * mr.transform.localToWorldMatrix;
Vector3 c = mb.center, e = mb.extents;
bool init = false;
Bounds local = default;
for (int sx = -1; sx <= 1; sx += 2)
for (int sy = -1; sy <= 1; sy += 2)
for (int sz = -1; sz <= 1; sz += 2)
{
Vector3 lp = m2b.MultiplyPoint3x4(c + new Vector3(e.x * sx, e.y * sy, e.z * sz));
if (!init) { local = new Bounds(lp, Vector3.zero); init = true; }
else local.Encapsulate(lp);
}
return local;
}
catch { /* fall through */ }
}
return WorldToLocalBounds(new[] { mr }, bodyRoot);
}
static Bounds WorldToLocalBounds(MeshRenderer[] renderers, Transform bodyRoot)
{
Matrix4x4 w2l = bodyRoot.worldToLocalMatrix;
bool init = false;
Bounds local = default;
foreach (MeshRenderer mr in renderers)
{
Bounds wb = mr.bounds;
Vector3 c = wb.center, ex = wb.extents;
for (int sx = -1; sx <= 1; sx += 2)
for (int sy = -1; sy <= 1; sy += 2)
for (int sz = -1; sz <= 1; sz += 2)
{
Vector3 lp = w2l.MultiplyPoint3x4(c + new Vector3(ex.x * sx, ex.y * sy, ex.z * sz));
if (!init) { local = new Bounds(lp, Vector3.zero); init = true; }
else local.Encapsulate(lp);
}
}
return local;
}
// ── Proxy box mesh ────────────────────────────────────────────────────────
static MeshRenderer CreateBoundingBoxRenderer(Transform parent, Bounds localBounds,
Material mat, int layer)
{
var go = new GameObject("S3_BBox");
go.transform.SetParent(parent, worldPositionStays: false);
go.layer = layer;
Vector3 c = localBounds.center, e = localBounds.extents;
var verts = new[]
{
c + new Vector3(-e.x,-e.y,-e.z), c + new Vector3( e.x,-e.y,-e.z),
c + new Vector3( e.x, e.y,-e.z), c + new Vector3(-e.x, e.y,-e.z),
c + new Vector3(-e.x,-e.y, e.z), c + new Vector3( e.x,-e.y, e.z),
c + new Vector3( e.x, e.y, e.z), c + new Vector3(-e.x, e.y, e.z),
};
var tris = new[]
{
0,2,1, 0,3,2, 4,5,6, 4,6,7,
0,1,5, 0,5,4, 2,3,7, 2,7,6,
0,4,7, 0,7,3, 1,2,6, 1,6,5,
};
var mesh = new Mesh { name = "S3_BBox" };
mesh.SetVertices(verts);
mesh.SetTriangles(tris, 0);
mesh.RecalculateNormals();
mesh.RecalculateBounds();
mesh.UploadMeshData(markNoLongerReadable: true);
go.AddComponent<MeshFilter>().sharedMesh = mesh;
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = mat;
return mr;
}
static float BoundsVolume(Bounds b) { Vector3 s = b.size; return s.x * s.y * s.z; }
}
// ── Harmony patch ─────────────────────────────────────────────────────────────
[HarmonyPatch(typeof(Car), "HandleModelsLoaded")]
static class HandleModelsLoadedLodPatch
{
static void Postfix(Car __instance) => MeshLodInjector.OnModelLoaded(__instance);
}

View file

@ -0,0 +1,65 @@
using HarmonyLib;
using S3.Core;
using UnityEngine;
namespace S3.Modules.MeshLod;
public sealed class MeshLodModule : IModule
{
private const string SettingsFile = "S3.meshlod.json";
public static MeshLodSettings Settings { get; private set; } = new();
private static Harmony? _harmony;
private static GameObject? _hostGo;
public MeshLodModule() => Settings = SettingsStore.Load<MeshLodSettings>(SettingsFile);
public string Id => "meshlod";
public string DisplayName => "Mesh LOD";
public string Description =>
"Reduces rendering triangle count for rolling stock and locomotives at distance " +
"by progressively hiding small-detail renderers (bolts, grab irons, rivets) and " +
"replacing very distant cars with a single bounding-box proxy mesh.";
public bool Enabled
{
get => Settings.enabled;
set => Settings.enabled = value;
}
public void OnEnable()
{
MeshLodInjector.Init(Settings);
_harmony = new Harmony("S3.meshlod");
var applied = _harmony.CreateClassProcessor(typeof(HandleModelsLoadedLodPatch)).Patch();
Log.Info($"MeshLOD: patched {applied?.Count ?? 0} method(s)");
// Lightweight MonoBehaviour solely to drive the auto-refresh debounce timer.
_hostGo = new GameObject("[S3] MeshLodHost");
_hostGo.AddComponent<MeshLodHost>();
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
}
public void OnDisable()
{
_harmony?.UnpatchAll("S3.meshlod");
_harmony = null;
MeshLodInjector.ClearTracking();
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
_hostGo = null;
}
public void SaveSettings() => Persist();
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
public void DrawSettings() => MeshLodSettingsUI.Draw();
}
// Drives the debounce timer. Exists only while the module is enabled.
sealed class MeshLodHost : MonoBehaviour
{
void Update() => MeshLodInjector.CheckAutoRefresh();
}

View file

@ -0,0 +1,22 @@
using System;
namespace S3.Modules.MeshLod;
[Serializable]
public class MeshLodSettings
{
public bool enabled = false;
// Fraction of renderers kept at each progressive cull level (sorted by bounds volume desc).
public float lod1RendererFraction = 0.50f; // minor cull: remove tiny details
public float lod2RendererFraction = 0.15f; // major cull: structural geometry only
// Distance in metres for each LOD transition. Converted to Unity's
// screenRelativeTransitionHeight at apply time, corrected for lodBias.
public float lod1Distance = 80f; // LOD0 -> LOD1 (full detail -> minor cull)
public float lod2Distance = 200f; // LOD1 -> LOD2 (minor cull -> structural)
public float lod3Distance = 600f; // LOD2 -> LOD3 (structural -> proxy box)
public bool applyToLocomotives = true;
public bool applyToFreightCars = true;
}

View file

@ -0,0 +1,126 @@
using UnityEngine;
namespace S3.Modules.MeshLod;
static class MeshLodSettingsUI
{
public static void Draw()
{
MeshLodSettings s = MeshLodModule.Settings;
bool changed = false;
GUILayout.BeginVertical();
GUILayout.Label("<b>Mesh LOD</b> — 4-level renderer culling for distant rolling stock");
GUILayout.Space(4f);
GUILayout.Label(
" Renderers are ranked by bounding-box volume (largest = car body, trucks).\n" +
" Each LOD level keeps a progressively smaller subset of the highest-ranked renderers.",
GUI.skin.label);
// ── Apply to ──────────────────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label("<b>Apply to</b>");
GUILayout.Space(4f);
bool newLocos = GUILayout.Toggle(s.applyToLocomotives, " Locomotives");
if (newLocos != s.applyToLocomotives) { s.applyToLocomotives = newLocos; changed = true; }
bool newCars = GUILayout.Toggle(s.applyToFreightCars, " Freight / passenger cars");
if (newCars != s.applyToFreightCars) { s.applyToFreightCars = newCars; changed = true; }
// ── Renderer fractions ────────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label("<b>Detail cull fractions</b> (fraction of renderers to keep per LOD level)");
GUILayout.Space(4f);
int ex60 = 60; // example car with 60 renderers
GUILayout.Label(
$" LOD1 (minor cull): {s.lod1RendererFraction * 100f:F0}% kept " +
$"— {Mathf.Max(1, Mathf.RoundToInt(ex60 * s.lod1RendererFraction))}/{ex60} for a 60-renderer car",
GUI.skin.label);
GUILayout.BeginHorizontal();
GUILayout.Space(4f);
float newF1 = GUILayout.HorizontalSlider(s.lod1RendererFraction, 0.15f, 0.80f, GUILayout.Width(220f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newF1 - s.lod1RendererFraction) > 0.005f)
{
s.lod1RendererFraction = Mathf.Round(newF1 * 20f) / 20f; // snap to 5% steps
if (s.lod1RendererFraction <= s.lod2RendererFraction)
s.lod2RendererFraction = Mathf.Max(0.05f, s.lod1RendererFraction - 0.10f);
changed = true;
}
GUILayout.Space(4f);
GUILayout.Label(
$" LOD2 (structural): {s.lod2RendererFraction * 100f:F0}% kept " +
$"— {Mathf.Max(1, Mathf.RoundToInt(ex60 * s.lod2RendererFraction))}/{ex60} for a 60-renderer car",
GUI.skin.label);
GUILayout.BeginHorizontal();
GUILayout.Space(4f);
float newF2 = GUILayout.HorizontalSlider(s.lod2RendererFraction, 0.05f, 0.40f, GUILayout.Width(220f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newF2 - s.lod2RendererFraction) > 0.005f)
{
s.lod2RendererFraction = Mathf.Round(newF2 * 20f) / 20f;
if (s.lod2RendererFraction >= s.lod1RendererFraction)
s.lod1RendererFraction = Mathf.Min(0.80f, s.lod2RendererFraction + 0.10f);
changed = true;
}
// ── Transition distances ──────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label("<b>LOD transition distances</b> (metres from camera)");
GUILayout.Space(4f);
GUILayout.Label(
" Converted to Unity screen-height thresholds at runtime using the car's actual\n" +
" bounding sphere and camera FOV, corrected for the game's LOD quality bias (2.5×).",
GUI.skin.label);
GUILayout.Space(4f);
DrawDistanceSlider(ref s.lod1Distance, "LOD0→LOD1 (minor cull)", 30f, 500f, ref changed);
GUILayout.Space(2f);
DrawDistanceSlider(ref s.lod2Distance, "LOD1→LOD2 (structural)", s.lod1Distance + 20f, 1000f, ref changed);
if (s.lod2Distance <= s.lod1Distance) { s.lod2Distance = s.lod1Distance + 50f; changed = true; }
GUILayout.Space(2f);
DrawDistanceSlider(ref s.lod3Distance, "LOD2→LOD3 (proxy box) ", s.lod2Distance + 20f, 1500f, ref changed);
if (s.lod3Distance <= s.lod2Distance) { s.lod3Distance = s.lod2Distance + 100f; changed = true; }
// ── Manual refresh ────────────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label(" Settings auto-refresh loaded cars ~0.5 s after the last change.", GUI.skin.label);
GUILayout.Space(4f);
GUILayout.BeginHorizontal();
if (GUILayout.Button("Refresh Now", GUILayout.Width(120f)))
{
MeshLodModule.Persist();
MeshLodInjector.RefreshAll();
}
var (total, locos, freight) = MeshLodInjector.GetTrackedCounts();
GUILayout.Label(
$" {total} tracked ({locos} loco{(locos == 1 ? "" : "s")}, {freight} car{(freight == 1 ? "" : "s")})",
GUI.skin.label);
GUILayout.EndHorizontal();
GUILayout.EndVertical();
if (changed)
{
MeshLodModule.Persist();
MeshLodInjector.MarkDirty();
}
}
static void DrawDistanceSlider(ref float value, string label, float min, float max, ref bool changed)
{
GUILayout.BeginHorizontal();
GUILayout.Label($" {label}: {value:F0} m", GUILayout.Width(270f));
float newVal = GUILayout.HorizontalSlider(value, min, max, GUILayout.Width(200f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newVal - value) > 1f)
{
value = Mathf.Round(newVal / 10f) * 10f;
changed = true;
}
}
}

View file

@ -105,8 +105,8 @@ static class RpfCommands
static string ToggleOverlay()
{
var gui = PhysicsOverlayGUI.Instance;
if (gui == null) return "Overlay not initialized.";
var gui = Profiler.ProfilerOverlayGUI.Instance;
if (gui == null) return "Overlay not initialized (enable the Profiler module).";
gui.Visible = !gui.Visible;
return $"Overlay {(gui.Visible ? "shown" : "hidden")}.";
}

View file

@ -20,6 +20,7 @@ public sealed class PhysicsOptimizerModule : IModule
public static PhysicsSettings Settings { get; private set; } = new();
private static Harmony? _harmony;
private static GameObject? _hostGo;
// Attribute-annotated patch classes the module owns. PosCarsTimerPatch is NOT
// here — it has no [HarmonyPatch] attribute and is applied dynamically by
@ -40,7 +41,7 @@ public sealed class PhysicsOptimizerModule : IModule
public string DisplayName => "Physics Optimizer";
public string Description =>
"Cuts CPU time spent on train physics (LOD fast-path + auto-freeze for far/slow consists). " +
"Includes a profiler overlay and debug car tinting. Console: /rpf";
"Includes debug car tinting. Enable the Profiler module for the overlay. Console: /rpf";
public bool Enabled
{
@ -57,21 +58,17 @@ public sealed class PhysicsOptimizerModule : IModule
_harmony.CreateClassProcessor(t).Patch();
PhysicsTimer.TryPatchPositionCars(_harmony, Main.ModEntry.Logger);
var go = new GameObject("S3.PhysicsOptimizer.Host");
UnityEngine.Object.DontDestroyOnLoad(go);
PhysicsOverlayGUI overlay = go.AddComponent<PhysicsOverlayGUI>();
overlay.Visible = Settings.ShowOverlay;
overlay.Opacity = Settings.OverlayOpacity;
go.AddComponent<CarDebugVisualizer>();
_hostGo = new GameObject("[S3] PhysicsOptimizerHost");
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
_hostGo.AddComponent<CarDebugVisualizer>();
}
public void OnDisable()
{
// Reserved for live toggling. Today modules apply on next launch, so this
// is not called; when it is, unpatch via _harmony.UnpatchAll("S3.physics")
// and destroy the host GameObject.
_harmony?.UnpatchAll("S3.physics");
_harmony = null;
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
_hostGo = null;
}
public void SaveSettings() => Persist();

View file

@ -1,286 +0,0 @@
using UnityEngine;
namespace S3.Modules.PhysicsOptimizer;
public class PhysicsOverlayGUI : MonoBehaviour
{
public static PhysicsOverlayGUI Instance { get; private set; }
public bool Visible = true;
public float Opacity = 1.0f;
Rect _windowRect = new(10f, 10f, 420f, 10f);
GUIStyle _blueLabel;
GUIStyle _greenLabel;
GUIStyle _yellowLabel;
GUIStyle _orangeLabel;
GUIStyle _dimLabel;
GUIStyle _fps60Label;
GUIStyle _fps30Label;
GUIStyle _onStyle; // bold green, no button background
GUIStyle _offStyle; // bold red, no button background
string _lodStatsStr = "";
string _freezeStatsStr = "";
string _debugStatsStr = "";
int _lodStatsFrame;
GUIStyle _windowStyle;
Texture2D _solidTex; // 1×1 white — lets GUI.backgroundColor.a control opacity 0100%
// Graph rendered as a texture — avoids all GL coordinate-space issues.
// Unity textures are Y-up (row 0 = bottom pixel), IMGUI is Y-down,
// but GUI.DrawTexture just stretches the texture into the target rect,
// so we flip Y in our write formula and the result renders correctly.
Texture2D _graphTex;
Color32[] _graphPixels;
const int TexW = 300; // matches RingSize exactly — 1 pixel column per sample
const int TexH = 90;
static readonly Color32 ColBg = new(16, 16, 16, 220);
static readonly Color32 Col60 = new(210, 210, 210, 255); // 60fps reference line
static readonly Color32 Col30 = new(210, 210, 210, 255); // 30fps reference line
static readonly Color32 ColRender = new(50, 140, 215, 255); // render frame time (blue)
static readonly Color32 ColFrame = new(70, 200, 70, 255); // FixedUpdate total (green)
static readonly Color32 ColTick = new(210, 185, 50, 255); // Tick() only (yellow)
static readonly Color32 ColPosCars = new(220, 110, 30, 255); // PositionCars (orange)
static readonly int WindowId = "S3PhysicsOverlay".GetHashCode();
const float RefFps60Ms = 16.7f;
const float RefFps30Ms = 33.3f;
void Awake()
{
Instance = this;
_graphTex = new Texture2D(TexW, TexH, TextureFormat.RGBA32, false)
{ filterMode = FilterMode.Point, hideFlags = HideFlags.HideAndDontSave };
_graphPixels = new Color32[TexW * TexH];
_solidTex = new Texture2D(1, 1, TextureFormat.RGBA32, false)
{ hideFlags = HideFlags.HideAndDontSave };
_solidTex.SetPixel(0, 0, Color.white);
_solidTex.Apply();
}
void LateUpdate()
{
// Record render frame time each rendered frame (not each physics tick).
// Time.unscaledDeltaTime = wall-clock ms since last render frame.
PhysicsTimer.RecordRenderFrame(Time.unscaledDeltaTime * 1000f);
}
void OnDestroy()
{
if (_graphTex != null) Destroy(_graphTex);
if (_solidTex != null) Destroy(_solidTex);
Instance = null;
}
void OnGUI()
{
if (!Visible) return;
EnsureStyles();
// Tint the solid-white window background to dark gray at the chosen opacity.
// Using a custom style (solid texture) means Opacity=1 → fully opaque, not
// capped by whatever alpha the default IMGUI skin has baked in.
Color prevBg = GUI.backgroundColor;
GUI.backgroundColor = new Color(0.063f, 0.063f, 0.063f, Opacity);
_windowRect = GUILayout.Window(WindowId, _windowRect, DrawWindow,
"Physics Profiler", _windowStyle, GUILayout.MinWidth(420f));
GUI.backgroundColor = prevBg;
}
void DrawWindow(int _)
{
// The window background was already drawn with the opacity tint — restore
// backgroundColor so buttons and labels inside use their normal skin colors.
GUI.backgroundColor = Color.white;
GUILayout.Label(PhysicsTimer.GetReport());
// Reserve space for graph in window-local coords.
Rect localRect = GUILayoutUtility.GetRect(
GUIContent.none, GUIStyle.none,
GUILayout.Height(TexH), GUILayout.ExpandWidth(true));
if (Event.current.type == EventType.Repaint)
{
UpdateGraphTexture();
// GUI.DrawTexture uses window-local coords — no coordinate conversion needed.
GUI.DrawTexture(localRect, _graphTex, ScaleMode.StretchToFill);
// 10% headroom above the 30fps line so it's never squashed against the top pixel row.
float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f);
// Reference-line labels: centered vertically on the line, anchored to right edge.
float y60Gui = LocalGaugeY(localRect, maxMs, RefFps60Ms);
GUI.Label(new Rect(localRect.xMax - 54f, y60Gui - 9f, 52f, 18f), "60 fps", _fps60Label);
float y30Gui = LocalGaugeY(localRect, maxMs, RefFps30Ms);
GUI.Label(new Rect(localRect.xMax - 54f, y30Gui - 9f, 52f, 18f), "30 fps", _fps30Label);
// Current sample readout (top-left of graph).
int prev = (PhysicsTimer.RingWrite - 1 + PhysicsTimer.RingSize) % PhysicsTimer.RingSize;
GUI.Label(new Rect(localRect.x + 4f, localRect.y + 2f, 340f, 20f),
$"render:{PhysicsTimer.RingRender[prev]:F2}ms phys:{PhysicsTimer.RingFrame[prev]:F2}ms tick:{PhysicsTimer.RingTick[prev]:F2}ms",
_dimLabel);
}
// Legend row
GUILayout.BeginHorizontal();
GUILayout.Label(" ■ Render", _blueLabel);
GUILayout.Label(" ■ FixedUpdate", _greenLabel);
GUILayout.Label(" ■ Tick()", _yellowLabel);
if (PhysicsTimer.HasPosCarsData)
GUILayout.Label(" ■ PosCars", _orangeLabel);
GUILayout.Label($" [{PhysicsTimer.RingSize} frames]", _dimLabel);
GUILayout.EndHorizontal();
// Physics Optimizer row: plain-text toggle (no button background), then slow-updating stats
GUILayout.BeginHorizontal();
GUILayout.Label("Physics Optimizer", _dimLabel, GUILayout.Width(130f));
if (GUILayout.Button(ConsistLOD.Enabled ? "ON" : "OFF",
ConsistLOD.Enabled ? _onStyle : _offStyle, GUILayout.Width(34f)))
{
ConsistLOD.Enabled = !ConsistLOD.Enabled;
// Persist toggle state so it survives a game restart.
PhysicsOptimizerModule.Settings.OptimizerEnabled = ConsistLOD.Enabled;
PhysicsOptimizerModule.Persist();
}
// Stats refresh once per second — readable, not flickering
if (Time.frameCount != _lodStatsFrame && Time.frameCount % 60 == 0)
{
_lodStatsFrame = Time.frameCount;
if (ConsistLOD.Enabled)
{
int fast = ConsistLOD.LastFastPathCount;
int full = ConsistLOD.LastFullPathCount;
int total = fast + full;
_lodStatsStr = $"fast:{fast}/{total} full:{full}/{total} dist:{ConsistLOD.DistanceThreshold:F0}m resync:1/{ConsistLOD.ResyncInterval}";
}
else
{
_lodStatsStr = "";
}
int atRest = ConsistFreezer.LastAtRestCars;
int byDist = ConsistFreezer.LastDistanceCars;
_freezeStatsStr = $"stopped:{atRest} dist/spd:{byDist}";
bool anyDbg = PhysicsOptimizerModule.Settings.DebugHighlightFrozen ||
PhysicsOptimizerModule.Settings.DebugHighlightFastPath ||
PhysicsOptimizerModule.Settings.DebugHighlightFullPath;
_debugStatsStr = anyDbg
? $"dbg — frozen:{ConsistFreezer.FrozenCars.Count} fast:{ConsistLOD.FastPathCars.Count} full:{ConsistLOD.FullPathCars.Count} tinted:{CarDebugVisualizer.Instance?.TintedCount ?? 0}"
: "";
}
GUILayout.Label(_lodStatsStr, _dimLabel);
GUILayout.EndHorizontal();
if (_debugStatsStr.Length > 0)
GUILayout.Label(_debugStatsStr, _dimLabel);
// Auto-freeze row — shows cars skipping the Verlet tick each physics step
GUILayout.BeginHorizontal();
GUILayout.Label("Auto Freeze", _dimLabel, GUILayout.Width(130f));
if (GUILayout.Button(ConsistFreezer.AutoFreezeEnabled ? "ON" : "OFF",
ConsistFreezer.AutoFreezeEnabled ? _onStyle : _offStyle, GUILayout.Width(34f)))
{
ConsistFreezer.AutoFreezeEnabled = !ConsistFreezer.AutoFreezeEnabled;
PhysicsOptimizerModule.Settings.AutoFreezeEnabled = ConsistFreezer.AutoFreezeEnabled;
PhysicsOptimizerModule.Persist();
}
GUILayout.Label(_freezeStatsStr, _dimLabel);
GUILayout.EndHorizontal();
GUI.DragWindow(new Rect(0f, 0f, _windowRect.width, 20f));
}
// Y in IMGUI window-local space: top of rect = maxMs, bottom = 0ms.
static float LocalGaugeY(Rect r, float maxMs, float ms) =>
r.yMax - Mathf.Clamp01(ms / maxMs) * r.height;
void UpdateGraphTexture()
{
float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f);
int write = PhysicsTimer.RingWrite;
int n = PhysicsTimer.RingSize;
float[] render = PhysicsTimer.RingRender;
float[] frame = PhysicsTimer.RingFrame;
float[] tick = PhysicsTimer.RingTick;
float[] posCars = PhysicsTimer.RingPosCars;
// Background fill.
for (int i = 0; i < _graphPixels.Length; i++)
_graphPixels[i] = ColBg;
// Stacked filled areas — drawn back-to-front so later layers paint over earlier ones.
//
// Layer 1 (bottom): render frame time.
DrawFilledArea(render, null, n, write, maxMs, ColRender);
// Layer 2: FixedUpdate total, stacked on top of render.
DrawFilledArea(frame, render, n, write, maxMs, ColFrame);
// Layer 3: Tick(), painted over the lower part of the FixedUpdate band (same baseline).
DrawFilledArea(tick, render, n, write, maxMs, ColTick);
// Layer 4: PosCars, painted over the lower part of the Tick band (same baseline).
if (PhysicsTimer.HasPosCarsData)
DrawFilledArea(posCars, render, n, write, maxMs, ColPosCars);
// Reference lines on top of all data.
DrawHLine(maxMs, RefFps60Ms, Col60);
DrawHLine(maxMs, RefFps30Ms, Col30);
_graphTex.SetPixels32(_graphPixels);
_graphTex.Apply(false);
}
// Pixel row for a given ms value: row 0 = bottom (0ms), row TexH-1 = top (maxMs).
static int MsToRow(float ms, float maxMs) =>
Mathf.Clamp((int)(ms / maxMs * TexH), 0, TexH - 1);
void DrawHLine(float maxMs, float ms, Color32 color)
{
int row = MsToRow(ms, maxMs);
int offset = row * TexW;
for (int x = 0; x < TexW; x++)
_graphPixels[offset + x] = color;
}
// Fills a solid area from [baselines[i]] to [baselines[i] + values[i]] for each sample column.
// baselines == null means 0 (fill from the bottom of the chart).
void DrawFilledArea(float[] values, float[] baselines, int n, int write, float maxMs, Color32 color)
{
for (int x = 0; x < TexW; x++)
{
int si = (write + (int)((float)x / TexW * n)) % n;
float bot = baselines != null ? baselines[si] : 0f;
int rowBot = MsToRow(bot, maxMs);
int rowTop = MsToRow(bot + values[si], maxMs);
int lo = Mathf.Min(rowBot, rowTop);
int hi = Mathf.Max(rowBot, rowTop);
for (int r = lo; r <= hi; r++)
_graphPixels[r * TexW + x] = color;
}
}
void EnsureStyles()
{
if (_blueLabel != null) return;
_blueLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.2f, 0.55f, 0.85f) } };
_greenLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.4f, 1f, 0.4f) } };
_yellowLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(1f, 0.85f, 0.2f) } };
_orangeLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.9f, 0.45f, 0.1f) } };
_dimLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.55f, 0.55f, 0.55f) } };
_fps60Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } };
_fps30Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } };
_onStyle = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.4f, 1f, 0.4f) }, hover = { textColor = new Color(0.7f, 1f, 0.7f) } };
_offStyle = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(1f, 0.35f, 0.3f) }, hover = { textColor = new Color(1f, 0.6f, 0.55f) } };
// Custom window style: solid white background texture so GUI.backgroundColor.a
// gives true 0100% opacity rather than being capped by the skin's baked-in alpha.
_windowStyle = new GUIStyle(GUI.skin.window);
_windowStyle.normal.background = _solidTex;
_windowStyle.onNormal.background = _solidTex;
_windowStyle.focused.background = _solidTex;
_windowStyle.onFocused.background = _solidTex;
}
}

View file

@ -20,10 +20,6 @@ public class PhysicsSettings
public float AutoFreezeDistance = 200f; // meters
public float AutoFreezeSpeedThreshold = 0.09f; // m/s (~0.2 mph)
// Profiler overlay
public bool ShowOverlay = true;
public float OverlayOpacity = 0.75f;
// Locomotive exclusions
public bool ExcludeLocosFromLOD = true;
public bool ExcludeLocosFromFreeze = true;

View file

@ -230,35 +230,6 @@ static class PhysicsSettingsUI
GUILayout.Space(12f);
// ── Profiler Overlay ──────────────────────────────────────────────────────
GUILayout.Label("<b>Profiler Overlay</b>", GUILayout.ExpandWidth(false));
GUILayout.Space(4f);
bool newOverlay = GUILayout.Toggle(s.ShowOverlay, " Show in-game profiler overlay");
if (newOverlay != s.ShowOverlay)
{
s.ShowOverlay = newOverlay;
if (PhysicsOverlayGUI.Instance != null)
PhysicsOverlayGUI.Instance.Visible = newOverlay;
changed = true;
}
GUILayout.Space(4f);
GUILayout.BeginHorizontal();
GUILayout.Label($"Opacity: {s.OverlayOpacity * 100f:F0}%", GUILayout.Width(110f));
float newOpacity = GUILayout.HorizontalSlider(s.OverlayOpacity, 0.1f, 1.0f, GUILayout.Width(200f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newOpacity - s.OverlayOpacity) > 0.01f)
{
s.OverlayOpacity = newOpacity;
if (PhysicsOverlayGUI.Instance != null)
PhysicsOverlayGUI.Instance.Opacity = s.OverlayOpacity;
changed = true;
}
GUILayout.Space(12f);
// ── Debug Visualization ───────────────────────────────────────────────────
GUILayout.Label("<b>Debug Visualization</b>", GUILayout.ExpandWidth(false));
GUILayout.Space(4f);

View file

@ -102,7 +102,7 @@ public static class PhysicsTimer
_frames = 0;
}
// Called from PhysicsOverlayGUI.LateUpdate() to capture render frame time.
// Called from ProfilerOverlayGUI.LateUpdate() to capture render frame time.
public static void RecordRenderFrame(float deltaMs)
{
_lastInstantRenderMs = deltaMs; // sampled into RingRender on the next RecordFrame call

View file

@ -1,10 +1,8 @@
using System;
using System.Runtime.InteropServices;
using Helpers; // WorldTransformer (in Assembly-CSharp)
using S3.Core; // Log
using UI.Map;
using UnityEngine;
using UnityEngine.Rendering;
namespace S3.Modules.Popout {
@ -38,8 +36,14 @@ namespace S3.Modules.Popout {
[DllImport("user32.dll")] private static extern short GetAsyncKeyState(int vKey);
private bool _prevHotkeyDown;
private bool _prevTeleportDown;
public bool CloseRequested { get; private set; }
// Last mouse position (normalised [0,1], top-left origin) received via MouseMove.
// Updated by ForwardInputEvent; used for popout teleport key handling.
private float _lastMouseX = -1f;
private float _lastMouseY = -1f;
// Status bar: only push to native when the string changes.
private string _lastStatusText = "";
@ -55,7 +59,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];
@ -72,6 +76,15 @@ namespace S3.Modules.Popout {
_mapCamEulerZ = _mapCamera.transform.eulerAngles.z;
Native.RRPOPOUT_SetMapEnhancerInstalled(_windowHandle, MapEnhancerBridge.IsInstalled);
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;
_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.
@ -96,6 +109,7 @@ namespace S3.Modules.Popout {
if (followPlayer != 0) { _followPlayer = true; Native.RRPOPOUT_SetFollowPlayer(_windowHandle, true); }
CreateOwnRT();
_mapCamera.enabled = true; // SetVisible(false) may have disabled it; re-enable now
_prevHotkeyDown = IsHotkeyDown();
}
@ -113,10 +127,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);
@ -159,14 +169,34 @@ 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();
if (hotkeyNow && !_prevHotkeyDown) CloseRequested = true;
_prevHotkeyDown = hotkeyNow;
// T key ("Jump to Mouse"): teleport the player to the last-known cursor position
// on the map. We use GetAsyncKeyState so this fires even when the game window
// doesn't have focus. Unity's Input System won't fire Teleport from the popout
// window, so we poll here instead. VK_T = 0x54 (matches the game's default binding).
bool teleportNow = IsDown(0x54);
if (teleportNow && !_prevTeleportDown && _lastMouseX >= 0f)
PanelFinder.GetMapDrag()?.OnTeleport?.Invoke(new Vector2(_lastMouseX, 1f - _lastMouseY));
_prevTeleportDown = teleportNow;
}
// ---------------------------------------------------------------------------
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)
@ -176,25 +206,7 @@ namespace S3.Modules.Popout {
// ---------------------------------------------------------------------------
private void UpdateStatusText() {
string follow = MapEnhancerBridge.IsInstalled
? (MapEnhancerBridge.FollowMode ? " · Following" : "")
: "";
string mapCoord = "";
string camCoord = "";
try {
// Map camera position → game coordinates.
var mapPt = WorldTransformer.WorldToGame(_mapCamera!.transform.position);
mapCoord = $" · Map ({(int)mapPt.x}, {(int)mapPt.z})";
// Player/main camera position → game coordinates.
if (Camera.main != null) {
var camPt = WorldTransformer.WorldToGame(Camera.main.transform.position);
camCoord = $" · Cam ({(int)camPt.x}, {(int)camPt.z})";
}
} catch { /* WorldTransformer not yet ready — silently omit coords */ }
string status = $"Zoom: {(int)_mapCamera!.orthographicSize}{follow}{mapCoord}{camCoord}";
string status = PanelFinder.BuildStatusText(_mapCamera!);
if (status == _lastStatusText) return;
Native.RRPOPOUT_SetStatusText(_windowHandle, status);
Native.RRPOPOUT_SetMapZoom(_windowHandle, _mapCamera!.orthographicSize);
@ -247,7 +259,9 @@ namespace S3.Modules.Popout {
_mapCamera = null;
}
if (_ownRT != null) {
_ownRT.Release();
// Skip Release() — let Destroy handle GPU teardown so the address stays
// live until end-of-frame. This prevents the pool from reissuing the same
// D3D handle to whoever creates a new RT in the same Update call.
UnityEngine.Object.Destroy(_ownRT);
_ownRT = null;
}
@ -322,10 +336,18 @@ namespace S3.Modules.Popout {
break;
case InputEventType.MouseMove:
_lastMouseX = e.x;
_lastMouseY = e.y;
if (!_isDragging) break;
if (Mathf.Abs(e.x - _dragStartX) > kClickThreshold ||
Mathf.Abs(e.y - _dragStartY) > kClickThreshold)
if (!_didDrag &&
(Mathf.Abs(e.x - _dragStartX) > kClickThreshold ||
Mathf.Abs(e.y - _dragStartY) > kClickThreshold))
{
_didDrag = true;
if (PopoutModule.Settings.panDisablesFollow &&
MapEnhancerBridge.IsInstalled && MapEnhancerBridge.FollowMode)
MapEnhancerBridge.ToggleFollowMode();
}
if (!_didDrag) break;
float orthoSize = cam.orthographicSize;
float aspect = cam.aspect > 0f ? cam.aspect : 1.7f;
@ -341,13 +363,14 @@ namespace S3.Modules.Popout {
break;
case InputEventType.RButtonUp:
UnityEngine.Object.FindObjectOfType<MapWindow>()?.ClickLocateMe();
if (PopoutModule.Settings.rightClickRecenter)
PanelFinder.GetMapWindow()?.ClickLocateMe();
break;
case InputEventType.UICommand:
switch ((UICmd)(int)e.x) {
case UICmd.Recenter:
UnityEngine.Object.FindObjectOfType<MapWindow>()?.ClickLocateMe();
PanelFinder.GetMapWindow()?.ClickLocateMe();
break;
case UICmd.FollowMode:
MapEnhancerBridge.ToggleFollowMode();
@ -385,9 +408,103 @@ namespace S3.Modules.Popout {
MapEnhancerBridge.ToggleFollowMode();
Native.RRPOPOUT_SetFollowPlayer(_windowHandle, _followPlayer);
break;
case UICmd.Close:
CloseRequested = true;
break;
case UICmd.SetTheme:
MapThemes.Apply((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);
PopoutModule.Settings.overlayAlpha = alpha;
PopoutModule.Persist();
Native.RRPOPOUT_SetOverlayAlpha(alpha);
break;
case UICmd.SetMapAlpha:
float mapAlpha = Mathf.Clamp(e.y, 0.0f, 1.0f);
PopoutModule.Settings.overlayMapAlpha = mapAlpha;
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;
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);
}
}
}

View file

@ -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<Car, EotdMarker> _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<Car>();
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<Car>();
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<Transform>();
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<MapIcon>());
// 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<Image>();
img.color = Color.red;
img.sprite = MakeCircleSprite(32);
img.type = Image.Type.Simple;
var dotRt = dotGo.GetComponent<RectTransform>();
dotRt.sizeDelta = new Vector2(1f, 1f);
dotRt.anchoredPosition = Vector2.zero;
var marker = go.AddComponent<EotdMarker>();
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>("MapIcon").Value;
if (icon != null) return icon;
}
foreach (Car car in tc.Cars)
{
var icon = Traverse.Create(car).Field<MapIcon>("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>("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<Sprite>("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<Canvas>();
}
private void Update()
{
if (_car == null) { Destroy(gameObject); return; }
var s = PopoutModule.Settings;
// Optionally hide the dot when car icons are visible (zoom inside cull threshold).
bool show = true;
if (s.eotdOnlyWhenCulled && s.iconCullingEnabled)
{
var mb2 = MapBuilder.Shared;
if (mb2 != null)
{
float zoom = Traverse.Create(mb2).Property<float>("NormalizedScale").Value;
show = zoom >= s.iconCullingThreshold;
}
}
if (_canvas != null) _canvas.enabled = show;
if (!show) return;
try
{
var worldPos = WorldTransformer.GameToWorld(_car.GetCenterPosition(Graph.Shared));
worldPos.y += 3200f;
// Euler(-90, 0, 0) lays the canvas flat under the overhead map camera.
transform.SetPositionAndRotation(worldPos, Quaternion.Euler(-90f, 0f, 0f));
// Scale = orthographicSize * (eotdSizeScale * 0.002).
// eotdSizeScale ranges 110; at 3 (default) and orthoSize ~4000 this gives
// a dot ~24 world units ≈ about 3-4× the track-line width.
var mb = MapBuilder.Shared;
if (mb?.mapCamera != null)
transform.localScale = Vector3.one * (mb.mapCamera.orthographicSize * s.eotdSizeScale * 0.002f);
}
catch { }
if (_img != null)
{
float t = (Mathf.Sin(Time.time * Mathf.PI * 2.4f) + 1f) * 0.5f;
var colour = _img.color;
colour.a = t;
_img.color = colour;
}
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Linq;
using System.Reflection;
using Character; // CameraSelector
using Game.State; // SpawnPoint
using HarmonyLib;
@ -21,7 +22,27 @@ namespace S3.Modules.Popout {
internal static class MapEnhancerBridge {
private static bool? _installed;
private static bool? _isCommunity;
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 {
@ -33,6 +54,31 @@ namespace S3.Modules.Popout {
}
}
// True if ShowTurntableMarkers exists — present in v1.6.0 (lstealth) and community,
// but NOT in the original official v1.5.2 (mricher-git). Gates the Turntable Markers toggle.
public static bool HasTurntable {
get {
if (_hasTurntable.HasValue) return _hasTurntable.Value;
if (!IsInstalled) { _hasTurntable = false; return false; }
_hasTurntable = Type.GetType("MapEnhancer.TurntableHelper, MapEnhancer") != null;
return _hasTurntable.Value;
}
}
private static bool? _hasTurntable;
// True if this is the Refizar08 community build — adds crossing/spawn/switch-reset
// features absent from both official builds. Detected by SwitchResetAuditState,
// a type that only exists in that fork.
public static bool IsCommunity {
get {
if (_isCommunity.HasValue) return _isCommunity.Value;
if (!IsInstalled) { _isCommunity = false; return false; }
_isCommunity = Type.GetType("MapEnhancer.SwitchResetAuditState, MapEnhancer") != null;
S3.Core.Log.Info($"[S3] MapEnhancer: HasTurntable={HasTurntable} IsCommunity={_isCommunity.Value}");
return _isCommunity.Value;
}
}
// Current value of the private mapFollowMode field.
public static bool FollowMode {
get {
@ -125,6 +171,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 +278,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

@ -0,0 +1,208 @@
using System.Collections.Generic;
using HarmonyLib;
using Model;
using UI.Map;
using UnityEngine;
using UnityEngine.UI;
namespace S3.Modules.Popout;
// Harmony patches on MapBuilder.UpdateForZoom (postfix) and MapBuilder.Add (postfix)
// that cull non-locomotive car icons when the map is zoomed far out, and optionally
// boost visible loco icon scale so they remain readable.
//
// Two patches are needed:
// UpdateForZoom postfix — handles the steady-state case (zoom scroll events).
// Add postfix — catches icons registered AFTER UpdateForZoom runs during a
// map rebuild, closing the 1-frame flash window.
//
// Fade: CanvasGroup.alpha is lerped to 0/1 each frame via TickFade(). The canvas is
// only disabled once alpha reaches 0 so the GPU stops work at the same moment the
// visual disappears. Canvas.enabled is re-set to true before fading in.
//
// Access notes (live game DLL):
// MapBuilder.NormalizedScale / IconScale — private, accessed via Traverse
// Car.MapIcon — protected field, accessed via Traverse
// Car.LogicalEnd — nested enum inside Car class
internal static class MapIconCuller
{
private static Harmony? _harmony;
// Target alpha for fading icons. Populated by SetVisible; drained by TickFade.
private static readonly Dictionary<MapIcon, float> _fadeTargets = new();
private const float kFadeSpeed = 3f; // alpha units/second → full fade in ~0.33 s
public static void Install()
{
if (_harmony != null) return;
_harmony = new Harmony("S3.popout.culler");
_harmony.CreateClassProcessor(typeof(MapBuilder_UpdateForZoom_Patch)).Patch();
_harmony.CreateClassProcessor(typeof(MapBuilder_Add_Patch)).Patch();
}
public static void Uninstall()
{
if (_harmony == null) return;
RestoreAll();
_harmony.UnpatchAll("S3.popout.culler");
_harmony = null;
_fadeTargets.Clear();
}
public static void Refresh()
{
var mb = MapBuilder.Shared;
if (mb != null) Apply(mb);
}
// Called each frame by PopoutModule.Tick() to drive icon fade animations.
public static void TickFade(float dt)
{
if (_fadeTargets.Count == 0) return;
var done = new List<MapIcon>();
foreach (var kv in _fadeTargets)
{
var icon = kv.Key;
float target = kv.Value;
if (icon == null) { done.Add(icon); continue; }
var cg = icon.GetComponent<CanvasGroup>();
if (cg == null) { done.Add(icon); continue; }
float next = Mathf.MoveTowards(cg.alpha, target, kFadeSpeed * dt);
cg.alpha = next;
if (Mathf.Approximately(next, target))
{
if (target < 0.5f)
icon.GetComponent<Canvas>().enabled = false;
done.Add(icon);
}
}
foreach (var icon in done) _fadeTargets.Remove(icon);
}
// ---------------------------------------------------------------------------
internal static void Apply(MapBuilder mb)
{
var s = PopoutModule.Settings;
if (!s.iconCullingEnabled) { RestoreAll(); return; }
float zoom = Traverse.Create(mb).Property<float>("NormalizedScale").Value;
bool culling = zoom >= s.iconCullingThreshold;
float iconScale = Traverse.Create(mb).Property<float>("IconScale").Value;
var tc = TrainController.Shared;
if (tc == null) return;
foreach (Car car in tc.Cars)
{
var icon = Traverse.Create(car).Field<MapIcon>("MapIcon").Value;
if (icon == null) continue;
if (car.IsLocomotive)
{
bool show = !culling || !s.hideMuLocos || IsLeadLoco(car);
SetVisible(icon, show);
if (show && culling)
icon.SetZoom(iconScale * s.locoBoostedScale);
}
else
{
SetVisible(icon, !culling);
}
}
}
private static void RestoreAll()
{
var tc = TrainController.Shared;
if (tc == null) return;
foreach (Car car in tc.Cars)
{
var icon = Traverse.Create(car).Field<MapIcon>("MapIcon").Value;
if (icon != null) SetVisible(icon, true);
}
}
private static void SetVisible(MapIcon icon, bool visible)
{
var canvas = icon.GetComponent<Canvas>();
if (canvas == null) return;
var cg = icon.GetComponent<CanvasGroup>();
if (cg == null) cg = icon.gameObject.AddComponent<CanvasGroup>();
if (visible)
{
canvas.enabled = true; // must re-enable before fading in
_fadeTargets[icon] = 1f;
}
else if (canvas.enabled)
{
// Only start a fade-out if the canvas is currently rendering.
_fadeTargets[icon] = 0f;
}
}
// A loco is the lead of its consist when nothing (or a non-loco) is on its A-end.
internal static bool IsLeadLoco(Car loco)
{
try
{
var atA = loco.CoupledTo(Car.LogicalEnd.A);
return atA == null || !atA.IsLocomotive;
}
catch
{
return true;
}
}
// ---------------------------------------------------------------------------
[HarmonyPatch(typeof(MapBuilder), "UpdateForZoom")]
private static class MapBuilder_UpdateForZoom_Patch
{
private static void Postfix(MapBuilder __instance) => Apply(__instance);
}
// Catches icons added to _mapIcons AFTER UpdateForZoom runs during a map rebuild.
// Car MapIcons are parented to car.transform; non-car icons (junctions, stations)
// have no Car component on their parent — those are left alone.
[HarmonyPatch(typeof(MapBuilder), nameof(MapBuilder.Add))]
private static class MapBuilder_Add_Patch
{
private static void Postfix(MapIcon icon)
{
var s = PopoutModule.Settings;
if (!s.iconCullingEnabled) return;
var mb = MapBuilder.Shared;
if (mb == null) return;
float zoom = Traverse.Create(mb).Property<float>("NormalizedScale").Value;
if (zoom < s.iconCullingThreshold) return;
// S3 EOTD clones and junction/station icons have no Car on their parent.
var car = icon.transform.parent?.GetComponent<Car>();
if (car == null) return;
float iconScale = Traverse.Create(mb).Property<float>("IconScale").Value;
if (car.IsLocomotive)
{
bool show = !s.hideMuLocos || IsLeadLoco(car);
SetVisible(icon, show);
if (show) icon.SetZoom(iconScale * s.locoBoostedScale);
}
else
{
SetVisible(icon, false);
}
}
}
}

View file

@ -0,0 +1,127 @@
using S3.Core;
using UnityEngine;
namespace S3.Modules.Popout;
public enum MapTheme {
S3Dark = 0,
RailroaderClassic = 1,
NightMode = 2,
HighVisibility = 3,
RetroTerminal = 4,
Custom = 5, // uses PopoutModule.Settings.customTheme
}
internal static class MapThemes
{
// ---------------------------------------------------------------------------
// Named preset definitions (indices 0-4).
// Custom (index 5) is stored in PopoutModule.Settings.customTheme instead.
// ---------------------------------------------------------------------------
private static readonly MapThemeData kS3Dark = new() {
wBgR=0.12f, wBgG=0.12f, wBgB=0.12f, wBgA=1.00f,
accR=0.26f, accG=0.59f, accB=0.98f, accA=1.00f,
txtR=1.00f, txtG=1.00f, txtB=1.00f, txtA=1.00f,
popR=0.08f, popG=0.08f, popB=0.08f, popA=0.94f,
mapR=1.00f, mapG=1.00f, mapB=1.00f, mapA=1.00f,
cmpR=0.086f,cmpG=0.086f,cmpB=0.086f,cmpA=0.784f,
nNR=0.804f, nNG=0.196f, nNB=0.196f, nNA=1.00f,
nSR=0.804f, nSG=0.804f, nSB=0.804f, nSA=0.863f,
mapBgR=0.082f,mapBgG=0.122f,mapBgB=0.157f,mapBgA=1.00f,
};
// Colors sourced from game decompilation (Assembly-CSharp.dll):
// text = RailroaderButton._defaultTextColor (0.851, 0.776, 0.651)
// accent = TextStyles.Yellow (#DBB95C = 0.859, 0.725, 0.361)
// Window/panel backgrounds are in binary Unity prefabs; estimated from in-game visuals.
private static readonly MapThemeData kRailroaderClassic = new() {
wBgR=0.078f,wBgG=0.051f,wBgB=0.020f,wBgA=0.97f,
accR=0.859f,accG=0.725f,accB=0.361f,accA=1.00f,
txtR=0.851f,txtG=0.776f,txtB=0.651f,txtA=1.00f,
popR=0.063f,popG=0.039f,popB=0.016f,popA=0.97f,
mapR=1.00f, mapG=1.00f, mapB=1.00f, mapA=1.00f,
cmpR=0.094f,cmpG=0.059f,cmpB=0.024f,cmpA=0.88f,
nNR=0.90f, nNG=0.15f, nNB=0.10f, nNA=1.00f,
nSR=0.85f, nSG=0.85f, nSB=0.85f, nSA=0.90f,
mapBgR=0.059f,mapBgG=0.039f,mapBgB=0.016f,mapBgA=1.00f,
};
private static readonly MapThemeData kNightMode = new() {
wBgR=0.05f, wBgG=0.03f, wBgB=0.03f, wBgA=0.90f,
accR=0.65f, accG=0.12f, accB=0.12f, accA=1.00f,
txtR=0.80f, txtG=0.55f, txtB=0.55f, txtA=1.00f,
popR=0.06f, popG=0.03f, popB=0.03f, popA=0.96f,
mapR=1.00f, mapG=1.00f, mapB=1.00f, mapA=1.00f,
cmpR=0.08f, cmpG=0.04f, cmpB=0.04f, cmpA=0.85f,
nNR=0.90f, nNG=0.15f, nNB=0.15f, nNA=1.00f,
nSR=0.80f, nSG=0.80f, nSB=0.80f, nSA=0.80f,
mapBgR=0.05f,mapBgG=0.02f,mapBgB=0.02f,mapBgA=1.00f,
};
private static readonly MapThemeData kHighVisibility = new() {
wBgR=0.20f, wBgG=0.20f, wBgB=0.22f, wBgA=1.00f,
accR=0.00f, accG=0.72f, accB=0.85f, accA=1.00f,
txtR=1.00f, txtG=1.00f, txtB=1.00f, txtA=1.00f,
popR=0.16f, popG=0.16f, popB=0.18f, popA=0.97f,
mapR=1.00f, mapG=1.00f, mapB=1.00f, mapA=1.00f,
cmpR=0.16f, cmpG=0.16f, cmpB=0.17f, cmpA=0.88f,
nNR=0.95f, nNG=0.20f, nNB=0.10f, nNA=1.00f,
nSR=0.90f, nSG=0.90f, nSB=0.90f, nSA=1.00f,
mapBgR=0.14f,mapBgG=0.18f,mapBgB=0.20f,mapBgA=1.00f,
};
private static readonly MapThemeData kRetroTerminal = new() {
wBgR=0.03f, wBgG=0.07f, wBgB=0.03f, wBgA=0.96f,
accR=0.00f, accG=0.80f, accB=0.30f, accA=1.00f,
txtR=0.00f, txtG=0.80f, txtB=0.30f, txtA=1.00f,
popR=0.02f, popG=0.05f, popB=0.02f, popA=0.98f,
mapR=1.00f, mapG=1.00f, mapB=1.00f, mapA=1.00f,
cmpR=0.03f, cmpG=0.08f, cmpB=0.03f, cmpA=0.88f,
nNR=0.95f, nNG=0.20f, nNB=0.10f, nNA=1.00f,
nSR=0.00f, nSG=0.75f, nSB=0.28f, nSA=1.00f,
mapBgR=0.02f,mapBgG=0.05f,mapBgB=0.02f,mapBgA=1.00f,
};
private static readonly MapThemeData[] _presets = {
kS3Dark, kRailroaderClassic, kNightMode, kHighVisibility, kRetroTerminal,
};
// ---------------------------------------------------------------------------
/// <summary>
/// Push <paramref name="preset"/> to native and save the index to settings.
/// For Custom (index 5), reads <see cref="PopoutSettings.customTheme"/>.
/// </summary>
public static void Apply(MapTheme preset)
{
int idx = Mathf.Clamp((int)preset, 0, 5);
MapThemeData data = idx == (int)MapTheme.Custom
? PopoutModule.Settings.customTheme
: _presets[idx];
Native.RRPOPOUT_SetTheme(ref data, idx);
PopoutModule.Settings.mapTheme = idx;
PopoutModule.Persist();
Log.Info($"[map] theme: {preset}");
}
/// <summary>Apply the theme stored in settings (call once after native loads).</summary>
public static void ApplyFromSettings() =>
Apply((MapTheme)Mathf.Clamp(PopoutModule.Settings.mapTheme, 0, 5));
/// <summary>
/// Return the named preset's data (indices 0-4). Used by the settings color
/// editor's "Copy from" buttons to seed the custom theme.
/// </summary>
public static MapThemeData GetPresetData(MapTheme preset) =>
_presets[Mathf.Clamp((int)preset, 0, _presets.Length - 1)];
/// <summary>Return the map camera clear colour for <paramref name="preset"/>.</summary>
public static Color GetMapBgColor(MapTheme preset)
{
MapThemeData d = preset == MapTheme.Custom
? PopoutModule.Settings.customTheme
: GetPresetData(preset);
return new Color(d.mapBgR, d.mapBgG, d.mapBgB, 1f);
}
}

View file

@ -33,13 +33,69 @@ namespace S3.Modules.Popout {
RotateReset = 8, // reset map rotation to 0
RotateSyncPlayer = 9, // toggle sync-rotation-to-player-camera mode
ToggleFollowPlayer = 10, // toggle continuous follow of player world position
PopOut = 11, // (in-game only) close the overlay and open the OS popout
SetTheme = 12, // (in-game only) apply theme preset; InputEvent.y = preset index
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]
// Icon culling + EOTD (gear menu Icons submenu)
ToggleIconCulling = 23,
SetCullThreshold = 24, // y = [0.3, 1.0]
ToggleHideMuLocos = 25,
SetLocoBoostedScale = 26, // y = [1.0, 3.0]
ToggleEotd = 27,
ToggleEotdOnlyWhenCulled = 28,
SetEotdSizeScale = 29, // y = [1.0, 10.0]
ToggleTrackLabels = 30, // toggle industry track name labels on the map
TrackLabelSetFontSize = 31, // y = font size px [8, 24]
TrackLabelSetLineThick = 32, // y = leader line thickness [1, 4]
TrackLabelSetZoomLimit = 33, // y = orthographicSize zoom-out limit [200, 8000]
ToggleLeaderLines = 34, // toggle leader lines from label to track anchor
ToggleCollision = 35, // toggle collision-avoidance label spread
ToggleParallelLabels = 36, // rotate labels to run parallel to their track
ToggleMergeLabels = 37, // merge same-name nearby spans into one label
TrackLabelSetMergeZoom = 38, // y = orthographicSize threshold for auto-merge
TrackLabelSetIndustryZoom = 39, // y = orthographicSize threshold for Stage 3 industry labels
ToggleUtilityRepairLabels = 40, // toggle repair-track labels
ToggleUtilityDieselLabels = 41, // toggle diesel-stand labels
ToggleUtilityLoaderLabels = 42, // toggle coal-loader labels
ToggleUtilityInterchangeLabels = 43, // toggle interchange labels
TrackLabelSetUtilityZoom = 44, // y = orthographicSize beyond which utility labels hide
TrackLabelSetAllZoom = 45, // y = orthographicSize beyond which ALL labels hide
ToggleAvoidTrackLabels = 46, // push labels off their own track line
TrackLabelSetFontSizeMin = 47, // y = minimum font size px [4, max]; labels auto-scale with zoom
}
// Must match MapThemeData in native/include/shared_types.h exactly (36 floats = 144 bytes).
[Serializable]
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct MapThemeData {
public float wBgR, wBgG, wBgB, wBgA; // window / toolbar background
public float accR, accG, accB, accA; // accent (TitleBgActive, buttons)
public float txtR, txtG, txtB, txtA; // status text
public float popR, popG, popB, popA; // settings popup background
public float mapR, mapG, mapB, mapA; // map image tint (1,1,1,1 = no tint)
public float cmpR, cmpG, cmpB, cmpA; // compass background disk
public float nNR, nNG, nNB, nNA; // compass needle N half
public float nSR, nSG, nSB, nSA; // compass needle S half
public float mapBgR, mapBgG, mapBgB, mapBgA; // map camera clear colour
}
internal static class Native {
// RRPopout.dll lives in the mod folder (Mods/S3) and is loaded by full path
// S3Native.dll lives in the mod folder (Mods/S3) and is loaded by full path
// via NativeLoader before any of these are called; once loaded, [DllImport]
// binds to it by name. (Pure-UMM install — no game-root copy, no winhttp proxy.)
private const string Dll = "RRPopout";
// Named S3Native (not RRPopout) so it can never collide with a leftover copy
// of the old standalone PopOut's RRPopout.dll still injected in someone's game.
private const string Dll = "S3Native";
// Create a floating window. Returns a handle (> 0) or 0 on failure.
// Native loads saved position/size from its state file; width/height are the first-run defaults.
@ -93,6 +149,12 @@ namespace S3.Modules.Popout {
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetMapEnhancerInstalled(int windowHandle, bool installed);
// Push ME capability flags to native.
// hasTurntable — ShowTurntableMarkers exists (v1.6.0 or community); gates Turntable Markers.
// community — full community build (crossing/spawn/switch-reset); gates community items.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetMapEnhancerCaps(int windowHandle, bool hasTurntable, bool community);
// Update the compass display to reflect the current map rotation (degrees).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetMapRotation(int windowHandle, float degrees);
@ -115,5 +177,139 @@ namespace S3.Modules.Popout {
public static extern void RRPOPOUT_GetPersistedState(int windowHandle,
out int followPlayer, out int syncRotation,
out float mapRotation, out float mapZoom);
// -------------------------------------------------------------------
// In-game overlay. Shares the native engine's shared ImGui context
// and D3D11 device; renders into Unity's bound backbuffer after frame.
// -------------------------------------------------------------------
// Event id to pass to GL.IssuePluginEvent to drive the overlay render.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern int RRPOPOUT_GetOverlayEventId();
// One-time: hand native a texture so it can acquire Unity's D3D device.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetOverlayDeviceTexture(IntPtr texturePtr);
// Per-frame input snapshot (top-left origin, pixels; wheel in WHEEL_DELTA units).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetOverlayInput(
float displayW, float displayH,
float mouseX, float mouseY,
int lButton, int rButton, int wheel);
// Show or hide the overlay.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetOverlayVisible(int visible);
// Map render texture to display inside the in-game overlay window, plus the
// UV sub-rect (V flipped for a Unity RT: v0=1, v1=0). IntPtr.Zero clears it.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetOverlayMapTexture(
IntPtr texturePtr, float u0, float v0, float u1, float v1);
// 1 when ImGui wants the mouse this frame (hovering a widget).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern int RRPOPOUT_OverlayWantsMouse();
// Drains queued in-game map input (drag/zoom over the map image). Events are
// in normalized [0,1] image space (top-left origin); forward to the map camera.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern int RRPOPOUT_PollOverlayInput(
[Out] InputEvent[] outEvents, int maxEvents);
// Reads the map image region size (px) so C# can set camera.aspect to match,
// filling the window with no letterbox bars.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_GetOverlayMapView(out float outW, out float outH);
// Current mouse position normalised within the map image (top-left origin, [0,1]).
// Returns (-1, -1) when the map is not visible this frame.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_GetOverlayMouseMapPos(out float outX, out float outY);
// Handle of the in-game overlay's shared UI-state holder. Push state to it
// (status/loco/location/rotation/follow) and poll it for toolbar commands
// with the same handle-based functions the popout uses.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern int RRPOPOUT_GetOverlayWindowHandle();
// Apply a theme to the shared ImGui context. Affects both the in-game overlay
// and the popout (they share one context). presetIndex is stored for the gear
// menu checkmarks. Thread-safe: stored on main thread, applied on render thread.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetTheme(ref MapThemeData data, int presetIndex);
// Set the in-game overlay's chrome alpha [0.1, 1.0]. Thread-safe via atomic.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetOverlayAlpha(float alpha);
// 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 all icon-culling and EOTD state for the gear menu Icons submenu.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetIconCullingState(int windowHandle,
bool cullEnabled, float cullThresh, bool hideMU, float locoBoost,
bool eotdEnabled, bool eotdOnlyWhenCulled, float eotdSize);
// 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);
// Push industry track name labels for the map.
// namesBlob: track names joined by '\n'.
// us/vs: centroid UV per label (C# projects TrackSpan centroid through the map camera).
// anchorUs/anchorVs: flat UV arrays for all anchor span positions across all labels.
// anchorStarts/anchorCounts: per-label start index and count into the anchor arrays.
// count=0 clears everything (call when zoomed out or map inactive).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
public static extern void RRPOPOUT_SetTrackLabels(
int windowHandle, string namesBlob,
[In] float[] us, [In] float[] vs,
[In] float[] anchorUs, [In] float[] anchorVs,
[In] int[] anchorStarts, [In] int[] anchorCounts,
[In] float[] angles, // screen-space angle per label (degrees; 0=right, +ve=CW in screen space)
[In] float[] scales, // font size multiplier per label (1.0=track label, 1.5=industry label)
int count);
// Enable or disable track label rendering for this window. Default: enabled.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetTrackLabelsEnabled(int windowHandle, bool enabled);
// Seed all track-label style settings at once. Called on map activate and
// whenever any of the settings change via the gear menu.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetTrackLabelStyle(
int windowHandle,
float fontSize, float lineThickness, float zoomLimit,
bool leaderLinesEnabled, bool collisionEnabled, bool parallelEnabled,
bool mergeEnabled, float mergeZoom,
float industryZoom,
bool utilityRepairEnabled, bool utilityDieselEnabled,
bool utilityLoaderEnabled, bool utilityInterchangeEnabled,
float utilityZoom,
float allLabelsZoom,
bool avoidTrack,
float fontSizeMin);
}
}

View file

@ -6,11 +6,11 @@ using S3.Core;
namespace S3.Modules.Popout;
/// <summary>
/// Preloads the native RRPopout.dll from the mod folder before any P/Invoke.
/// Preloads the native S3Native.dll from the mod folder before any P/Invoke.
///
/// S³ is a pure-UMM install: there is no winhttp proxy and Unity never calls
/// UnityPluginLoad for our plugin. By loading the DLL ourselves via its full path,
/// every [DllImport("RRPopout")] in <see cref="Native"/> then binds to it by name.
/// every [DllImport("S3Native")] in <see cref="Native"/> then binds to it by name.
/// The native renderer lazily initializes its D3D device from the supplied texture,
/// so the missing UnityPluginLoad is fine — that lazy path becomes the normal one.
/// </summary>
@ -25,7 +25,7 @@ internal static class NativeLoader
{
if (_loaded) return true;
string dll = Path.Combine(Main.ModEntry.Path, "RRPopout.dll");
string dll = Path.Combine(Main.ModEntry.Path, "S3Native.dll");
if (!File.Exists(dll))
{
Log.Error($"[popout] native DLL not found: {dll}");
@ -40,7 +40,7 @@ internal static class NativeLoader
}
_loaded = true;
Log.Info($"[popout] native RRPopout.dll loaded from {dll}");
Log.Info($"[popout] native S3Native.dll loaded from {dll}");
return true;
}
}

View file

@ -1,4 +1,5 @@
using HarmonyLib;
using Helpers;
using UI.Common;
using UI.Map;
using UnityEngine;
@ -15,9 +16,16 @@ namespace S3.Modules.Popout {
internal static class PanelFinder {
private static MapWindow? _cachedMapWindow;
// Returns the live MapWindow MonoBehaviour, or null if the map isn't loaded.
public static MapWindow? GetMapWindow() =>
Object.FindObjectOfType<MapWindow>();
// Cached after first find; Unity's == null catches destroyed instances and
// triggers a fresh search on the next call.
public static MapWindow? GetMapWindow() {
if (_cachedMapWindow == null)
_cachedMapWindow = Object.FindObjectOfType<MapWindow>();
return _cachedMapWindow;
}
// Returns the RenderTexture the map camera renders to each frame.
public static RenderTexture? GetMapRenderTexture() {
@ -55,5 +63,23 @@ namespace S3.Modules.Popout {
if (mb != null)
Traverse.Create(mb).Method("UpdateForZoom").GetValue();
}
// Builds the toolbar status string shared by the popout and the in-game overlay.
// ASCII separators only — ImGui's default font has no non-ASCII glyphs.
public static string BuildStatusText(Camera mapCamera) {
string follow = MapEnhancerBridge.IsInstalled
? (MapEnhancerBridge.FollowMode ? " | Following" : "")
: "";
string mapCoord = "", camCoord = "";
try {
var mapPt = WorldTransformer.WorldToGame(mapCamera.transform.position);
mapCoord = $" | Map ({(int)mapPt.x}, {(int)mapPt.z})";
if (Camera.main != null) {
var camPt = WorldTransformer.WorldToGame(Camera.main.transform.position);
camCoord = $" | Cam ({(int)camPt.x}, {(int)camPt.z})";
}
} catch { /* WorldTransformer not ready yet — omit coords */ }
return $"Zoom: {(int)mapCamera.orthographicSize}{follow}{mapCoord}{camCoord}";
}
}
}

View file

@ -1,4 +1,5 @@
using S3.Core;
using S3.Core.Ui;
using UI.Common;
using UI.Map;
using UnityEngine;
@ -10,7 +11,7 @@ namespace S3.Modules.Popout;
/// S³ module that detaches the in-game map into a resizable native OS window
/// (rendered by the shared Win32 + D3D11 + Dear ImGui native engine).
///
/// Loads the native RRPopout.dll on enable, then spawns a <see cref="PopoutHost"/>
/// Loads the native S3Native.dll on enable, then spawns a <see cref="PopoutHost"/>
/// MonoBehaviour that drives the per-frame map button, hotkey, and panel lifecycle.
/// </summary>
public sealed class PopoutModule : IModule
@ -25,10 +26,32 @@ public sealed class PopoutModule : IModule
private static DetachedPanel? _activePanel;
private static Window? _hiddenWindow;
private static Vector2 _savedWindowSize;
private static Vector3 _savedWindowScale;
private static bool _mapWasOpen;
private static GameObject? _host;
// Delayed-open state: after the in-game overlay closes we wait before handing the
// camera to DetachedPanel, giving Unity a full half-second to tear down the RT.
private static bool _pendingOpen;
private static float _pendingTimer;
// True when the in-game overlay was active at the moment the popout was triggered.
// Restored when the popout closes so the user gets their overlay back automatically.
private static bool _inGameWasOpen;
// Short delay before reopening the overlay after a popout closes.
// Gives Unity time to finish its end-of-frame Destroy for the popout's RT so the
// D3D address is fully gone before UiHost.ActivateMap allocates a new one.
private static bool _pendingOverlayOpen;
private static float _pendingOverlayTimer;
// Deferred window scale restore: a 1-frame counter so any Toggle() close animation
// plays at scale=0 (invisible) before we put the panel back to its normal size.
// Static class has no MonoBehaviour, so we tick this down in Tick() instead of a coroutine.
private static Window? _pendingRestoreWindow;
private static Vector3 _pendingRestoreScale;
private static int _pendingRestoreFrames;
public static bool IsDetached => _activePanel != null;
public PopoutModule()
@ -38,7 +61,7 @@ public sealed class PopoutModule : IModule
}
public string Id => "popout";
public string DisplayName => "Map Popout";
public string DisplayName => "Map Module";
public string Description =>
"Detaches the in-game map into a separate resizable window you can put on a " +
"second monitor. Open a save, then press the hotkey or use the Pop Out button " +
@ -58,6 +81,9 @@ public sealed class PopoutModule : IModule
return;
}
MapIconCuller.Install();
EotdSystem.Install();
_host = new GameObject("S3.Popout.Host");
Object.DontDestroyOnLoad(_host);
_host.AddComponent<PopoutHost>();
@ -66,7 +92,11 @@ public sealed class PopoutModule : IModule
public void OnDisable()
{
// Reserved for live toggling. When wired, destroy the active panel + host here.
if (_activePanel != null) { _activePanel.Destroy(); _activePanel = null; RestoreInGameWindow(); }
if (_activePanel != null) CloseActivePanel("[popout] Module disabled.");
_pendingOpen = false;
_pendingOverlayOpen = false;
MapIconCuller.Uninstall();
EotdSystem.Uninstall();
if (_host != null) { Object.Destroy(_host); _host = null; }
}
@ -78,12 +108,78 @@ public sealed class PopoutModule : IModule
internal static void RebuildHotkey() =>
_hotkey = new KeyBinding { modifiers = (byte)Settings.hotkeyModifiers, keyCode = (KeyCode)Settings.hotkeyKeyCode };
// Called by UiService (Pop Out toolbar button or F10) to transition the map from
// the in-game overlay to the OS popout window. Captures the overlay state BEFORE
// closing so the overlay can be restored when the popout is later dismissed.
internal static void ScheduleExternalLaunch()
{
if (_activePanel != null || _pendingOpen) return; // already detached or pending
if (_host == null)
{
if (!NativeLoader.EnsureLoaded()) return;
_host = new GameObject("S3.Popout.Host");
Object.DontDestroyOnLoad(_host);
_host.AddComponent<PopoutHost>();
}
_inGameWasOpen = UiService.IsOverlayVisible; // capture before closing
UiService.CloseOverlay(); // release camera + RT now
_pendingOpen = true;
_pendingTimer = 0.5f;
}
// Called each frame by PopoutHost.
internal static void Tick()
{
MapWindowButton.TryInstall();
MapWindowButton.UpdateLabel(_activePanel != null);
// Delayed open: wait for the overlay's RT to fully tear down before DetachedPanel
// grabs the camera.
if (_pendingOpen)
{
_pendingTimer -= UnityEngine.Time.deltaTime;
if (_pendingTimer <= 0f)
{
_pendingOpen = false;
DoOpenPopout();
}
}
// Delayed overlay restore: wait one frame for the popout's RT Destroy to process
// at end-of-frame before UiHost.ActivateMap allocates a new RT.
if (_pendingOverlayOpen)
{
_pendingOverlayTimer -= UnityEngine.Time.deltaTime;
if (_pendingOverlayTimer <= 0f)
{
_pendingOverlayOpen = false;
UiService.OpenOverlay();
}
}
// While the popout is live, keep the in-game window zeroed every frame.
// MapWindow.Show() starts a Unity animation that can re-set localScale to
// non-zero across subsequent frames — this override wins each tick.
if (_activePanel != null && _hiddenWindow != null &&
_hiddenWindow.transform is RectTransform suppRect && suppRect.localScale != Vector3.zero)
{
suppRect.localScale = Vector3.zero;
}
// Deferred window scale restore (see RestoreInGameWindow).
if (_pendingRestoreFrames > 0 && --_pendingRestoreFrames == 0)
{
if (_pendingRestoreWindow != null && _pendingRestoreWindow.transform is RectTransform r)
r.localScale = _pendingRestoreScale;
_pendingRestoreWindow = null;
}
float dt = UnityEngine.Time.deltaTime;
MapIconCuller.TickFade(dt);
EotdSystem.Tick(dt);
if (_hotkey.Down())
Toggle();
@ -91,10 +187,7 @@ public sealed class PopoutModule : IModule
if (_activePanel != null && (!_activePanel.IsAlive || _activePanel.CloseRequested))
{
_activePanel.Destroy();
_activePanel = null;
RestoreInGameWindow();
Log.Info("[popout] Map popout closed.");
CloseActivePanel("[popout] Map popout closed.");
}
}
@ -102,35 +195,89 @@ public sealed class PopoutModule : IModule
{
if (_activePanel != null)
{
_activePanel.Destroy();
_activePanel = null;
RestoreInGameWindow();
Log.Info("[popout] Map re-attached.");
CloseActivePanel("[popout] Map re-attached.");
return;
}
// Remember whether the map was already open — if we opened it ourselves, we
// close it again when the popout closes.
// If the in-game overlay owns the camera, schedule a delayed open so the RT
// has time to fully tear down before DetachedPanel grabs the camera.
if (UiService.IsOverlayVisible)
{
_inGameWasOpen = true;
UiService.CloseOverlay();
_pendingOpen = true;
_pendingTimer = 0.5f;
return;
}
DoOpenPopout();
}
// Closes the active popout panel and optionally restores the in-game overlay.
private static void CloseActivePanel(string logMsg)
{
_activePanel!.Destroy();
_activePanel = null;
bool wasInGame = _inGameWasOpen;
_inGameWasOpen = false;
RestoreInGameWindow();
if (wasInGame)
{
// Delay overlay reopen by ~0.1 s so Unity's end-of-frame Destroy for the
// popout's RT finishes before UiHost.ActivateMap creates a new one. Without
// this, the pool can reissue the same D3D address and the deferred Destroy
// then invalidates the brand-new RT from under ActivateMap.
_pendingOverlayOpen = true;
_pendingOverlayTimer = 0.1f;
}
Log.Info(logMsg);
}
// Creates the DetachedPanel after the camera is free.
private static void DoOpenPopout()
{
// Remember whether the map was already open so we can close it again on teardown.
Window? win = PanelFinder.GetMapWindowUI();
_mapWasOpen = win != null && win.IsShown;
// Belt 1: pre-hide the window BEFORE Show() so any open/restore animation
// plays at scale=0 and cannot flash through for one frame.
if (win != null && win.transform is RectTransform preRect)
{
_hiddenWindow = win;
_savedWindowScale = preRect.localScale;
preRect.localScale = Vector3.zero;
}
UiService.MapBypass = true;
MapWindow.Show();
UiService.MapBypass = false;
if (!PanelFinder.IsMapReady())
{
Log.Warn("[popout] Map not ready — ensure a save is loaded.");
Log.Warn("[popout] Map not ready - ensure a save is loaded.");
return;
}
// Collapse the in-game panel to zero size (invisible, all components active).
_hiddenWindow = PanelFinder.GetMapWindowUI();
if (_hiddenWindow != null && _hiddenWindow.transform is RectTransform rect)
// Belt 2: re-zero in case Show() reset the scale; also captures the window if
// belt 1 missed because the window didn't exist before Show().
// localScale (not sizeDelta) scales away corner-anchored chrome reliably.
win = PanelFinder.GetMapWindowUI();
if (_hiddenWindow == null)
{
_savedWindowSize = rect.sizeDelta;
rect.sizeDelta = Vector2.zero;
_hiddenWindow = win;
if (_hiddenWindow != null && _hiddenWindow.transform is RectTransform rect)
{
_savedWindowScale = rect.localScale;
rect.localScale = Vector3.zero;
}
}
else if (_hiddenWindow.transform is RectTransform postRect && postRect.localScale != Vector3.zero)
{
postRect.localScale = Vector3.zero;
}
_activePanel = new DetachedPanel("Railroader — Map");
_activePanel = new DetachedPanel("Railroader Map");
if (!_activePanel.IsAlive)
{
Log.Error("[popout] RRPOPOUT_CreateWindow returned 0.");
@ -146,15 +293,20 @@ public sealed class PopoutModule : IModule
{
if (_hiddenWindow == null) return;
if (_hiddenWindow.transform is RectTransform rect)
rect.sizeDelta = _savedWindowSize;
// If the map wasn't open before the popout, close it logically. Toggle() is
// used instead of SetActive(false) — SetActive can make MapWindow unfindable
// by FindObjectOfType on the next open.
if (!_mapWasOpen)
{
// Close the window while localScale is still zero (invisible) to prevent a
// 1-frame flash. Toggle() is used instead of SetActive(false) so MapWindow
// remains findable by FindObjectOfType on the next open.
UiService.MapBypass = true;
MapWindow.Toggle();
UiService.MapBypass = false;
}
// Defer scale restore by 1 frame so any Toggle() close animation plays at
// scale=0 before the panel snaps back to its normal size.
_pendingRestoreWindow = _hiddenWindow;
_pendingRestoreScale = _savedWindowScale;
_pendingRestoreFrames = 1;
_hiddenWindow = null;
}
}

View file

@ -6,6 +6,8 @@ namespace S3.Modules.Popout;
// Flat settings block for the Map Popout module (own file: S3.popout.json).
// Hotkey is stored as flat primitives rather than a UMM KeyBinding so JsonUtility
// round-trips it reliably (no nested custom-class serialization).
// customTheme is a [Serializable] struct — JsonUtility inlines value-type structs
// correctly (unlike nested classes, which Mono silently drops).
[Serializable]
public class PopoutSettings
{
@ -14,4 +16,77 @@ public class PopoutSettings
// UMM modifier bitmask: 1=Shift, 2=Ctrl, 4=Alt. Default: F9, no modifier.
public int hotkeyModifiers = 0;
public int hotkeyKeyCode = (int)KeyCode.F9;
// When true, dragging the map cancels follow modes (MapEnhancer follow +
// follow-player) so the drag takes over — like grab-to-pan in Google Maps.
public bool panDisablesFollow = true;
// Active theme index (0-4 = named preset, 5 = Custom).
// See MapTheme enum in MapThemes.cs.
public int mapTheme = 0;
// In-game overlay chrome (window + toolbar + compass) alpha [0.1, 1.0].
public float overlayAlpha = 1.0f;
// 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;
// --- Icon culling ---
// When the map is zoomed out past iconCullingThreshold (NormalizedScale 0=close, 1=far),
// non-locomotive car icons are hidden to reduce Canvas draw calls.
public bool iconCullingEnabled = true;
public float iconCullingThreshold = 0.40f;
// When culling is active, hide MU trailing units (locos with another loco on their A-end).
public bool hideMuLocos = true;
// Scale multiplier applied to visible loco icons when culling is active.
public float locoBoostedScale = 1.5f;
// Show a blinking red dot on the map at the rear end of each train (EOTD).
public bool eotdEnabled = true;
// Hide the EOTD when car icons are visible (i.e. zoom is inside the cull threshold).
public bool eotdOnlyWhenCulled = true;
// Dot size: 1 (tiny) to 10 (large). Applied as orthographicSize * value * 0.002.
public float eotdSizeScale = 8f;
// Track name labels — shown on the map at each industry track, hidden when zoomed out.
public bool trackLabelsEnabled = true;
public float trackLabelFontSize = 12f; // px; range 8-32
public float trackLabelLineThickness = 1.5f; // px; range 1-4
public float trackLabelZoomLimit = 200f; // orthographicSize beyond which labels hide
public bool trackLeaderLinesEnabled = true; // draw lines from label to each track anchor
public bool trackCollisionEnabled = true; // spread overlapping labels apart
public bool trackLabelParallel = false; // rotate labels parallel to their track
public bool trackLabelMergeEnabled = true; // merge same-name nearby spans into one label
public float trackLabelMergeZoom = 150f; // auto-merge when orthographicSize exceeds this
public float trackIndustryLabelZoom = 200f; // Stage 3: show industry labels beyond this zoom
// Utility track filters — per-type visibility toggle + shared zoom limit.
// Interchange tracks are also name-normalized (strip " to <Business>" suffix).
public bool trackUtilityRepairEnabled = true;
public bool trackUtilityDieselEnabled = true;
public bool trackUtilityLoaderEnabled = true;
public bool trackUtilityInterchangeEnabled = true;
public float trackUtilityZoomLimit = 200f; // utility tracks hide before this zoom
public float trackAllLabelsZoomLimit = 8000f; // all labels (incl. industry) hide beyond this zoom
public bool trackLabelAvoidTrack = false; // push labels perpendicular so they don't cover track lines
public float trackLabelFontSizeMin = 10f; // smallest pixel size (at zoom limit); auto-scales between this and trackLabelFontSize
// 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 {
wBgR=0.12f, wBgG=0.12f, wBgB=0.12f, wBgA=1.00f,
accR=0.26f, accG=0.59f, accB=0.98f, accA=1.00f,
txtR=1.00f, txtG=1.00f, txtB=1.00f, txtA=1.00f,
popR=0.08f, popG=0.08f, popB=0.08f, popA=0.94f,
mapR=1.00f, mapG=1.00f, mapB=1.00f, mapA=1.00f,
cmpR=0.086f,cmpG=0.086f,cmpB=0.086f,cmpA=0.784f,
nNR=0.804f, nNG=0.196f, nNB=0.196f, nNA=1.00f,
nSR=0.804f, nSG=0.804f, nSB=0.804f, nSA=0.863f,
mapBgR=0.082f,mapBgG=0.122f,mapBgB=0.157f,mapBgA=1.00f,
};
}

View file

@ -1,13 +1,44 @@
using System;
using UnityEngine;
namespace S3.Modules.Popout;
// Renders the Map Popout settings as the body of its foldout: hotkey rebinding,
// detach/re-attach button, and current status.
// Renders the Map Popout settings: hotkey, behaviour toggles, theme preset buttons,
// a live per-element color editor for the Custom theme, and overlay opacity sliders.
static class PopoutSettingsUI
{
private static bool _capturing;
// ---------------------------------------------------------------------------
// Color editor state — one hex string per theme element, kept in sync with
// the sliders. Reset by InitColorEditor when a preset is copied or on first draw.
// ---------------------------------------------------------------------------
private static bool _colorEditorInit;
private static string _hexWBg = "1F1F1F";
private static string _hexAcc = "4296FA";
private static string _hexTxt = "FFFFFF";
private static string _hexPop = "141414";
private static string _hexMap = "FFFFFF";
private static string _hexCmp = "161616";
private static string _hexNN = "CD3232";
private static string _hexNS = "CDCDCD";
private static string _hexMapBg = "151F28";
private static bool _showColorEditor = true; // expanded by default
private static readonly string[] s_themeNames = { "S3 Dark", "Classic", "Night", "Hi-Vis", "Retro", "Custom" };
private static readonly string[] s_presetNames = { "S3 Dark", "Classic", "Night", "Hi-Vis", "Retro" };
private const float kLabelW = 92f;
private const float kThemeW = 68f; // wide enough for "S3 Dark" / "Classic"
private const float kHexW = 60f;
private const float kSwatchW = 20f;
private const float kChanL = 12f;
private const float kSliderW = 52f;
private const float kValW = 26f;
// ---------------------------------------------------------------------------
public static void Draw()
{
PopoutSettings s = PopoutModule.Settings;
@ -41,12 +72,259 @@ static class PopoutSettingsUI
GUILayout.Space(8f);
// ── Behaviour ───────────────────────────────────────────────────────────
bool pan = GUILayout.Toggle(s.panDisablesFollow, " Dragging the map cancels follow mode");
if (pan != s.panDisablesFollow) { s.panDisablesFollow = pan; PopoutModule.Persist(); }
bool rcr = GUILayout.Toggle(s.rightClickRecenter, " Right-click on map recenters on player");
if (rcr != s.rightClickRecenter) { s.rightClickRecenter = rcr; PopoutModule.Persist(); }
GUILayout.Space(8f);
// ── Icon culling ─────────────────────────────────────────────────────────
bool cullOn = GUILayout.Toggle(s.iconCullingEnabled, " Hide car icons when zoomed out (keep locomotives)");
if (cullOn != s.iconCullingEnabled)
{
s.iconCullingEnabled = cullOn;
PopoutModule.Persist();
MapIconCuller.Refresh();
}
if (s.iconCullingEnabled)
{
GUILayout.BeginHorizontal();
GUILayout.Space(20f);
GUILayout.Label("Cull at zoom-out:", GUILayout.Width(115f));
float newThresh = GUILayout.HorizontalSlider(s.iconCullingThreshold, 0.3f, 1.0f, GUILayout.Width(160f));
GUILayout.Label($"{s.iconCullingThreshold:P0}", GUILayout.Width(44f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newThresh - s.iconCullingThreshold) > 0.005f)
{
s.iconCullingThreshold = newThresh;
PopoutModule.Persist();
MapIconCuller.Refresh();
}
GUILayout.BeginHorizontal();
GUILayout.Space(20f);
bool hideMU = GUILayout.Toggle(s.hideMuLocos, " Hide MU trailing units (show lead loco only)");
GUILayout.EndHorizontal();
if (hideMU != s.hideMuLocos)
{
s.hideMuLocos = hideMU;
PopoutModule.Persist();
MapIconCuller.Refresh();
}
GUILayout.BeginHorizontal();
GUILayout.Space(20f);
GUILayout.Label("Loco icon boost:", GUILayout.Width(115f));
float newBoost = GUILayout.HorizontalSlider(s.locoBoostedScale, 1.0f, 3.0f, GUILayout.Width(160f));
GUILayout.Label($"{s.locoBoostedScale:F1}x", GUILayout.Width(36f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newBoost - s.locoBoostedScale) > 0.05f)
{
s.locoBoostedScale = newBoost;
PopoutModule.Persist();
MapIconCuller.Refresh();
}
GUILayout.BeginHorizontal();
GUILayout.Space(20f);
bool eotd = GUILayout.Toggle(s.eotdEnabled, " Show EOTD (blinking red dot at rear of each train)");
GUILayout.EndHorizontal();
if (eotd != s.eotdEnabled) { s.eotdEnabled = eotd; PopoutModule.Persist(); }
if (s.eotdEnabled)
{
GUILayout.BeginHorizontal();
GUILayout.Space(40f);
bool onlyWhenCulled = GUILayout.Toggle(s.eotdOnlyWhenCulled, " Hide when car icons are visible");
GUILayout.EndHorizontal();
if (onlyWhenCulled != s.eotdOnlyWhenCulled) { s.eotdOnlyWhenCulled = onlyWhenCulled; PopoutModule.Persist(); }
GUILayout.BeginHorizontal();
GUILayout.Space(40f);
GUILayout.Label("Dot size:", GUILayout.Width(72f));
float newSz = GUILayout.HorizontalSlider(s.eotdSizeScale, 1f, 10f, GUILayout.Width(160f));
GUILayout.Label($"{s.eotdSizeScale:F1}", GUILayout.Width(36f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newSz - s.eotdSizeScale) > 0.05f) { s.eotdSizeScale = newSz; PopoutModule.Persist(); }
}
}
GUILayout.Space(8f);
// ── Theme preset quick-switch ────────────────────────────────────────────
GUILayout.BeginHorizontal();
GUILayout.Label("Theme:", GUILayout.Width(kLabelW));
for (int i = 0; i < s_themeNames.Length; i++)
{
GUI.color = s.mapTheme == i ? new Color(0.5f, 0.9f, 1.0f) : Color.white;
if (GUILayout.Button(s_themeNames[i], GUILayout.Width(kThemeW)))
MapThemes.Apply((MapTheme)i);
}
GUI.color = Color.white;
GUILayout.EndHorizontal();
GUILayout.Space(4f);
// ── Overlay opacity ──────────────────────────────────────────────────────
OpacityRow("Window opacity:", ref s.overlayAlpha, 0.1f, 1.0f, Native.RRPOPOUT_SetOverlayAlpha);
OpacityRow("Map opacity:", ref s.overlayMapAlpha, 0.0f, 1.0f, Native.RRPOPOUT_SetOverlayMapAlpha);
OpacityRow("Map BG opacity:", ref s.overlayMapBgAlpha, 0.0f, 1.0f, Native.RRPOPOUT_SetOverlayMapBgAlpha);
GUILayout.Space(8f);
// ── Status + detach button ──────────────────────────────────────────────
GUILayout.Label(PopoutModule.IsDetached ? "Status: Detached" : "Status: Docked");
if (GUILayout.Button(PopoutModule.IsDetached ? "Re-attach map" : "Detach map", GUILayout.Width(160f)))
bool det = PopoutModule.IsDetached;
GUILayout.Label(det ? "Status: Detached" : "Status: Docked");
if (GUILayout.Button(det ? "Re-attach map" : "Detach map", GUILayout.Width(160f)))
PopoutModule.Toggle();
GUILayout.Space(8f);
// ── Custom color editor (collapsible) ────────────────────────────────────
string editorHeader = _showColorEditor ? "▲ Custom Colors (click to collapse)" : "▼ Custom Colors (click to expand)";
if (GUILayout.Button(editorHeader, GUILayout.Width(300f)))
_showColorEditor = !_showColorEditor;
if (_showColorEditor)
{
GUILayout.Space(4f);
GUILayout.Label("Hex = RRGGBB (no #). Drag sliders to tune live; changes switch to Custom.");
// "Copy from:" seeds the editor from a named preset and applies it as Custom
GUILayout.BeginHorizontal();
GUILayout.Label("Copy from:", GUILayout.Width(kLabelW));
for (int i = 0; i < s_presetNames.Length; i++)
{
if (GUILayout.Button(s_presetNames[i], GUILayout.Width(kThemeW)))
{
MapThemeData pd = MapThemes.GetPresetData((MapTheme)i);
s.customTheme = pd;
InitColorEditor(ref s.customTheme);
MapThemes.Apply(MapTheme.Custom);
}
}
GUILayout.EndHorizontal();
GUILayout.Space(4f);
if (!_colorEditorInit)
InitColorEditor(ref s.customTheme);
// Each row writes directly through ref to the struct field on the heap-allocated
// PopoutSettings class, so no copy-modify-write boilerplate is needed.
bool anyChanged = false;
anyChanged |= ColorRow("Window BG", ref s.customTheme.wBgR, ref s.customTheme.wBgG, ref s.customTheme.wBgB, ref s.customTheme.wBgA, showAlpha: true, ref _hexWBg);
anyChanged |= ColorRow("Accent", ref s.customTheme.accR, ref s.customTheme.accG, ref s.customTheme.accB, ref s.customTheme.accA, showAlpha: false, ref _hexAcc);
anyChanged |= ColorRow("Text", ref s.customTheme.txtR, ref s.customTheme.txtG, ref s.customTheme.txtB, ref s.customTheme.txtA, showAlpha: false, ref _hexTxt);
anyChanged |= ColorRow("Popup BG", ref s.customTheme.popR, ref s.customTheme.popG, ref s.customTheme.popB, ref s.customTheme.popA, showAlpha: true, ref _hexPop);
anyChanged |= ColorRow("Map Tint", ref s.customTheme.mapR, ref s.customTheme.mapG, ref s.customTheme.mapB, ref s.customTheme.mapA, showAlpha: true, ref _hexMap);
anyChanged |= ColorRow("Compass BG", ref s.customTheme.cmpR, ref s.customTheme.cmpG, ref s.customTheme.cmpB, ref s.customTheme.cmpA, showAlpha: true, ref _hexCmp);
anyChanged |= ColorRow("N Needle", ref s.customTheme.nNR, ref s.customTheme.nNG, ref s.customTheme.nNB, ref s.customTheme.nNA, showAlpha: false, ref _hexNN);
anyChanged |= ColorRow("S Needle", ref s.customTheme.nSR, ref s.customTheme.nSG, ref s.customTheme.nSB, ref s.customTheme.nSA, showAlpha: false, ref _hexNS);
anyChanged |= ColorRow("Map Camera", ref s.customTheme.mapBgR, ref s.customTheme.mapBgG, ref s.customTheme.mapBgB, ref s.customTheme.mapBgA, showAlpha: false, ref _hexMapBg);
if (anyChanged)
MapThemes.Apply(MapTheme.Custom);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private static void OpacityRow(string label, ref float field, float min, float max, Action<float> setNative)
{
GUILayout.BeginHorizontal();
GUILayout.Label(label, GUILayout.Width(110f));
float nv = GUILayout.HorizontalSlider(field, min, max, GUILayout.Width(180f));
GUILayout.Label($"{field:P0}", GUILayout.Width(44f));
GUILayout.EndHorizontal();
if (Mathf.Abs(nv - field) > 0.001f)
{
field = nv;
setNative(nv);
PopoutModule.Persist();
}
}
// Syncs all nine hex strings from the given theme data snapshot.
// Call after "Copy from preset" or on first draw.
private static void InitColorEditor(ref MapThemeData t)
{
_hexWBg = ToHex(t.wBgR, t.wBgG, t.wBgB);
_hexAcc = ToHex(t.accR, t.accG, t.accB);
_hexTxt = ToHex(t.txtR, t.txtG, t.txtB);
_hexPop = ToHex(t.popR, t.popG, t.popB);
_hexMap = ToHex(t.mapR, t.mapG, t.mapB);
_hexCmp = ToHex(t.cmpR, t.cmpG, t.cmpB);
_hexNN = ToHex(t.nNR, t.nNG, t.nNB);
_hexNS = ToHex(t.nSR, t.nSG, t.nSB);
_hexMapBg = ToHex(t.mapBgR, t.mapBgG, t.mapBgB);
_colorEditorInit = true;
}
// Renders one theme element row and returns true if any value changed.
// hexStr shows RRGGBB (no #). Typing a valid 6-char hex updates R/G/B;
// moving a slider updates R/G/B and also rebuilds hexStr.
private static bool ColorRow(
string label,
ref float r, ref float g, ref float b, ref float a,
bool showAlpha, ref string hexStr)
{
bool changed = false;
GUILayout.BeginHorizontal();
GUILayout.Label(label, GUILayout.Width(kLabelW));
// Hex input
string newHex = GUILayout.TextField(hexStr, 6, GUILayout.Width(kHexW));
if (newHex != hexStr)
{
hexStr = newHex;
if (newHex.Length == 6 && ColorUtility.TryParseHtmlString("#" + newHex, out Color hc))
{
r = hc.r; g = hc.g; b = hc.b;
changed = true;
}
}
// Live color swatch
GUI.color = new Color(r, g, b, 1f);
GUILayout.Box("", GUILayout.Width(kSwatchW), GUILayout.Height(16f));
GUI.color = Color.white;
// R, G, B sliders; rebuild hex if any change
bool rC = ChanSlider("R", ref r);
bool gC = ChanSlider("G", ref g);
bool bC = ChanSlider("B", ref b);
if (rC || gC || bC) { hexStr = ToHex(r, g, b); changed = true; }
// Optional alpha slider (no hex involvement)
if (showAlpha && ChanSlider("A", ref a))
changed = true;
GUILayout.EndHorizontal();
return changed;
}
// Renders a single channel: letter label + slider (0-1) + integer value (0-255).
// Returns true if the value moved more than the movement threshold.
private static bool ChanSlider(string label, ref float v)
{
GUILayout.Label(label, GUILayout.Width(kChanL));
float nv = GUILayout.HorizontalSlider(v, 0f, 1f, GUILayout.Width(kSliderW));
GUILayout.Label(Mathf.RoundToInt(nv * 255).ToString(), GUILayout.Width(kValW));
if (Mathf.Abs(nv - v) > 0.002f) { v = nv; return true; }
return false;
}
private static string ToHex(float r, float g, float b) =>
$"{Mathf.RoundToInt(r * 255):X2}{Mathf.RoundToInt(g * 255):X2}{Mathf.RoundToInt(b * 255):X2}";
private static string HotkeyLabel(PopoutSettings s)
{
string prefix = "";

View file

@ -0,0 +1,48 @@
using S3.Core;
using UnityEngine;
namespace S3.Modules.Profiler;
public sealed class ProfilerModule : IModule
{
private const string SettingsFile = "S3.profiler.json";
public static ProfilerSettings Settings { get; private set; } = new();
private static GameObject? _hostGo;
public ProfilerModule() => Settings = SettingsStore.Load<ProfilerSettings>(SettingsFile);
public string Id => "profiler";
public string DisplayName => "Profiler";
public string Description =>
"In-game performance overlay: render and physics frame-time graph, " +
"Physics Optimizer quick-toggles, and Mesh LOD car counts. " +
"Sections appear only when the corresponding module is enabled.";
public bool Enabled
{
get => Settings.enabled;
set => Settings.enabled = value;
}
public void OnEnable()
{
_hostGo = new GameObject("[S3] ProfilerHost");
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
var overlay = _hostGo.AddComponent<ProfilerOverlayGUI>();
overlay.Visible = Settings.visible;
overlay.Opacity = Settings.opacity;
}
public void OnDisable()
{
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
_hostGo = null;
}
public void SaveSettings() => Persist();
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
public void DrawSettings() => ProfilerSettingsUI.Draw();
}

View file

@ -0,0 +1,290 @@
using S3.Modules.MeshLod;
using S3.Modules.PhysicsOptimizer;
using UnityEngine;
namespace S3.Modules.Profiler;
public class ProfilerOverlayGUI : MonoBehaviour
{
public static ProfilerOverlayGUI? Instance { get; private set; }
public bool Visible = true;
public float Opacity = 0.85f;
Rect _windowRect = new(10f, 10f, 420f, 10f);
GUIStyle _blueLabel;
GUIStyle _greenLabel;
GUIStyle _yellowLabel;
GUIStyle _orangeLabel;
GUIStyle _dimLabel;
GUIStyle _fps60Label;
GUIStyle _fps30Label;
GUIStyle _onStyle;
GUIStyle _offStyle;
GUIStyle _windowStyle;
Texture2D _solidTex;
Texture2D _graphTex;
Color32[] _graphPixels;
const int TexW = 300;
const int TexH = 90;
static readonly Color32 ColBg = new(16, 16, 16, 220);
static readonly Color32 Col60 = new(210, 210, 210, 255);
static readonly Color32 Col30 = new(210, 210, 210, 255);
static readonly Color32 ColRender = new(50, 140, 215, 255);
static readonly Color32 ColFrame = new(70, 200, 70, 255);
static readonly Color32 ColTick = new(210, 185, 50, 255);
static readonly Color32 ColPosCars = new(220, 110, 30, 255);
static readonly int WindowId = "S3ProfilerOverlay".GetHashCode();
const float RefFps60Ms = 16.7f;
const float RefFps30Ms = 33.3f;
// Per-second stats cache
string _physLodStr = "";
string _physFreezeStr = "";
string _physDebugStr = "";
string _meshLodStr = "";
int _statsFrame;
void Awake()
{
Instance = this;
_graphTex = new Texture2D(TexW, TexH, TextureFormat.RGBA32, false)
{ filterMode = FilterMode.Point, hideFlags = HideFlags.HideAndDontSave };
_graphPixels = new Color32[TexW * TexH];
_solidTex = new Texture2D(1, 1, TextureFormat.RGBA32, false)
{ hideFlags = HideFlags.HideAndDontSave };
_solidTex.SetPixel(0, 0, Color.white);
_solidTex.Apply();
}
void LateUpdate() => PhysicsTimer.RecordRenderFrame(Time.unscaledDeltaTime * 1000f);
void OnDestroy()
{
if (_graphTex != null) Destroy(_graphTex);
if (_solidTex != null) Destroy(_solidTex);
Instance = null;
}
void OnGUI()
{
if (!Visible) return;
EnsureStyles();
Color prevBg = GUI.backgroundColor;
GUI.backgroundColor = new Color(0.063f, 0.063f, 0.063f, Opacity);
_windowRect = GUILayout.Window(WindowId, _windowRect, DrawWindow,
"Profiler", _windowStyle, GUILayout.MinWidth(420f));
GUI.backgroundColor = prevBg;
}
void DrawWindow(int _)
{
GUI.backgroundColor = Color.white;
// ── Frame-time report ─────────────────────────────────────────────────
GUILayout.Label(PhysicsTimer.GetReport());
Rect graphRect = GUILayoutUtility.GetRect(
GUIContent.none, GUIStyle.none,
GUILayout.Height(TexH), GUILayout.ExpandWidth(true));
if (Event.current.type == EventType.Repaint)
{
UpdateGraphTexture();
GUI.DrawTexture(graphRect, _graphTex, ScaleMode.StretchToFill);
float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f);
GUI.Label(new Rect(graphRect.xMax - 54f, LocalGaugeY(graphRect, maxMs, RefFps60Ms) - 9f, 52f, 18f), "60 fps", _fps60Label);
GUI.Label(new Rect(graphRect.xMax - 54f, LocalGaugeY(graphRect, maxMs, RefFps30Ms) - 9f, 52f, 18f), "30 fps", _fps30Label);
int prev = (PhysicsTimer.RingWrite - 1 + PhysicsTimer.RingSize) % PhysicsTimer.RingSize;
GUI.Label(new Rect(graphRect.x + 4f, graphRect.y + 2f, 340f, 20f),
$"render:{PhysicsTimer.RingRender[prev]:F2}ms phys:{PhysicsTimer.RingFrame[prev]:F2}ms tick:{PhysicsTimer.RingTick[prev]:F2}ms",
_dimLabel);
}
GUILayout.BeginHorizontal();
GUILayout.Label(" ■ Render", _blueLabel);
GUILayout.Label(" ■ FixedUpdate", _greenLabel);
GUILayout.Label(" ■ Tick()", _yellowLabel);
if (PhysicsTimer.HasPosCarsData)
GUILayout.Label(" ■ PosCars", _orangeLabel);
GUILayout.Label($" [{PhysicsTimer.RingSize} frames]", _dimLabel);
GUILayout.EndHorizontal();
// Refresh per-second stats cache
if (Time.frameCount != _statsFrame && Time.frameCount % 60 == 0)
{
_statsFrame = Time.frameCount;
RefreshStats();
}
// ── Physics Optimizer section ─────────────────────────────────────────
if (PhysicsOptimizerModule.Settings.enabled && ProfilerModule.Settings.showPhysicsSection)
{
GUILayout.Space(4f);
GUILayout.BeginHorizontal();
GUILayout.Label("Physics Optimizer", _dimLabel, GUILayout.Width(130f));
if (GUILayout.Button(ConsistLOD.Enabled ? "ON" : "OFF",
ConsistLOD.Enabled ? _onStyle : _offStyle, GUILayout.Width(34f)))
{
ConsistLOD.Enabled = !ConsistLOD.Enabled;
PhysicsOptimizerModule.Settings.OptimizerEnabled = ConsistLOD.Enabled;
PhysicsOptimizerModule.Persist();
}
GUILayout.Label(_physLodStr, _dimLabel);
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
GUILayout.Label("Auto Freeze", _dimLabel, GUILayout.Width(130f));
if (GUILayout.Button(ConsistFreezer.AutoFreezeEnabled ? "ON" : "OFF",
ConsistFreezer.AutoFreezeEnabled ? _onStyle : _offStyle, GUILayout.Width(34f)))
{
ConsistFreezer.AutoFreezeEnabled = !ConsistFreezer.AutoFreezeEnabled;
PhysicsOptimizerModule.Settings.AutoFreezeEnabled = ConsistFreezer.AutoFreezeEnabled;
PhysicsOptimizerModule.Persist();
}
GUILayout.Label(_physFreezeStr, _dimLabel);
GUILayout.EndHorizontal();
if (_physDebugStr.Length > 0)
GUILayout.Label(_physDebugStr, _dimLabel);
}
// ── Mesh LOD section ──────────────────────────────────────────────────
if (MeshLodModule.Settings.enabled && ProfilerModule.Settings.showMeshLodSection)
{
GUILayout.Space(4f);
GUILayout.Label(_meshLodStr, _dimLabel);
}
GUI.DragWindow(new Rect(0f, 0f, _windowRect.width, 20f));
}
void RefreshStats()
{
bool physOn = PhysicsOptimizerModule.Settings.enabled;
if (physOn && ConsistLOD.Enabled)
{
int fast = ConsistLOD.LastFastPathCount;
int full = ConsistLOD.LastFullPathCount;
_physLodStr = $"fast:{fast}/{fast + full} full:{full}/{fast + full} dist:{ConsistLOD.DistanceThreshold:F0}m resync:1/{ConsistLOD.ResyncInterval}";
}
else
{
_physLodStr = "";
}
if (physOn)
{
_physFreezeStr = $"stopped:{ConsistFreezer.LastAtRestCars} dist/spd:{ConsistFreezer.LastDistanceCars}";
bool anyDbg = PhysicsOptimizerModule.Settings.DebugHighlightFrozen ||
PhysicsOptimizerModule.Settings.DebugHighlightFastPath ||
PhysicsOptimizerModule.Settings.DebugHighlightFullPath;
_physDebugStr = anyDbg
? $"dbg frozen:{ConsistFreezer.FrozenCars.Count} fast:{ConsistLOD.FastPathCars.Count} full:{ConsistLOD.FullPathCars.Count} tinted:{CarDebugVisualizer.Instance?.TintedCount ?? 0}"
: "";
}
else
{
_physFreezeStr = _physDebugStr = "";
}
if (MeshLodModule.Settings.enabled)
{
var (tot, locos, freight, l0, l1, l2, l3) = MeshLodInjector.GetLodStats();
_meshLodStr = tot == 0
? "Mesh LOD — no cars loaded yet"
: $"Mesh LOD — {tot} tracked ({locos}L / {freight}C) LOD0:{l0} LOD1:{l1} LOD2:{l2} LOD3:{l3}";
}
else
{
_meshLodStr = "";
}
}
// ── Graph rendering ───────────────────────────────────────────────────────
void UpdateGraphTexture()
{
float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f);
int write = PhysicsTimer.RingWrite;
int n = PhysicsTimer.RingSize;
for (int i = 0; i < _graphPixels.Length; i++)
_graphPixels[i] = ColBg;
DrawFilledArea(PhysicsTimer.RingRender, null, n, write, maxMs, ColRender);
DrawFilledArea(PhysicsTimer.RingFrame, PhysicsTimer.RingRender, n, write, maxMs, ColFrame);
DrawFilledArea(PhysicsTimer.RingTick, PhysicsTimer.RingRender, n, write, maxMs, ColTick);
if (PhysicsTimer.HasPosCarsData)
DrawFilledArea(PhysicsTimer.RingPosCars, PhysicsTimer.RingRender, n, write, maxMs, ColPosCars);
DrawHLine(maxMs, RefFps60Ms, Col60);
DrawHLine(maxMs, RefFps30Ms, Col30);
_graphTex.SetPixels32(_graphPixels);
_graphTex.Apply(false);
}
static float LocalGaugeY(Rect r, float maxMs, float ms) =>
r.yMax - Mathf.Clamp01(ms / maxMs) * r.height;
static int MsToRow(float ms, float maxMs) =>
Mathf.Clamp((int)(ms / maxMs * TexH), 0, TexH - 1);
void DrawHLine(float maxMs, float ms, Color32 color)
{
int row = MsToRow(ms, maxMs);
for (int x = 0; x < TexW; x++)
_graphPixels[row * TexW + x] = color;
}
void DrawFilledArea(float[] values, float[]? baselines, int n, int write, float maxMs, Color32 color)
{
for (int x = 0; x < TexW; x++)
{
int si = (write + (int)((float)x / TexW * n)) % n;
float bot = baselines != null ? baselines[si] : 0f;
int rowBot = MsToRow(bot, maxMs);
int rowTop = MsToRow(bot + values[si], maxMs);
int lo = Mathf.Min(rowBot, rowTop);
int hi = Mathf.Max(rowBot, rowTop);
for (int r = lo; r <= hi; r++)
_graphPixels[r * TexW + x] = color;
}
}
void EnsureStyles()
{
if (_blueLabel != null) return;
_blueLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.2f, 0.55f, 0.85f) } };
_greenLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.4f, 1f, 0.4f) } };
_yellowLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(1f, 0.85f, 0.2f) } };
_orangeLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.9f, 0.45f, 0.1f) } };
_dimLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.55f, 0.55f, 0.55f) } };
_fps60Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } };
_fps30Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } };
_onStyle = new GUIStyle(GUI.skin.label)
{
normal = { textColor = new Color(0.4f, 1f, 0.4f) },
hover = { textColor = new Color(0.7f, 1f, 0.7f) },
};
_offStyle = new GUIStyle(GUI.skin.label)
{
normal = { textColor = new Color(1f, 0.35f, 0.3f) },
hover = { textColor = new Color(1f, 0.6f, 0.55f) },
};
_windowStyle = new GUIStyle(GUI.skin.window);
_windowStyle.normal.background = _solidTex;
_windowStyle.onNormal.background = _solidTex;
_windowStyle.focused.background = _solidTex;
_windowStyle.onFocused.background = _solidTex;
}
}

View file

@ -0,0 +1,13 @@
using System;
namespace S3.Modules.Profiler;
[Serializable]
public class ProfilerSettings
{
public bool enabled = false;
public bool visible = true;
public float opacity = 0.85f;
public bool showPhysicsSection = true;
public bool showMeshLodSection = true;
}

View file

@ -0,0 +1,76 @@
using S3.Modules.MeshLod;
using S3.Modules.PhysicsOptimizer;
using UnityEngine;
namespace S3.Modules.Profiler;
static class ProfilerSettingsUI
{
public static void Draw()
{
ProfilerSettings s = ProfilerModule.Settings;
bool changed = false;
GUILayout.BeginVertical();
GUILayout.Label("<b>Profiler</b> — in-game frame-time overlay with module readouts");
GUILayout.Space(4f);
GUILayout.Label(
" Always shows the render and physics frame-time graph.\n" +
" Physics Optimizer and Mesh LOD sections appear when those modules are enabled.",
GUI.skin.label);
// ── Overlay visibility ────────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label("<b>Display</b>");
GUILayout.Space(4f);
bool newVisible = GUILayout.Toggle(s.visible, " Show overlay in-game");
if (newVisible != s.visible)
{
s.visible = newVisible;
if (ProfilerOverlayGUI.Instance != null)
ProfilerOverlayGUI.Instance.Visible = newVisible;
changed = true;
}
GUILayout.Space(4f);
GUILayout.BeginHorizontal();
GUILayout.Label($"Opacity: {s.opacity * 100f:F0}%", GUILayout.Width(110f));
float newOpacity = GUILayout.HorizontalSlider(s.opacity, 0.1f, 1.0f, GUILayout.Width(200f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newOpacity - s.opacity) > 0.01f)
{
s.opacity = newOpacity;
if (ProfilerOverlayGUI.Instance != null)
ProfilerOverlayGUI.Instance.Opacity = newOpacity;
changed = true;
}
// ── Section visibility ────────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label("<b>Sections</b>");
GUILayout.Space(4f);
bool physAvail = PhysicsOptimizerModule.Settings.enabled;
GUI.enabled = physAvail;
bool newPhys = GUILayout.Toggle(s.showPhysicsSection && physAvail,
" Physics Optimizer" + (physAvail ? "" : " (module not enabled)"));
GUI.enabled = true;
if (newPhys != s.showPhysicsSection && physAvail)
{ s.showPhysicsSection = newPhys; changed = true; }
bool meshAvail = MeshLodModule.Settings.enabled;
GUI.enabled = meshAvail;
bool newMesh = GUILayout.Toggle(s.showMeshLodSection && meshAvail,
" Mesh LOD" + (meshAvail ? "" : " (module not enabled)"));
GUI.enabled = true;
if (newMesh != s.showMeshLodSection && meshAvail)
{ s.showMeshLodSection = newMesh; changed = true; }
GUILayout.EndVertical();
if (changed) ProfilerModule.Persist();
}
}

View file

@ -10,7 +10,7 @@
<AssemblyName>S3</AssemblyName>
<RootNamespace>S3</RootNamespace>
<ModVersion>0.2.0</ModVersion>
<ModVersion>0.2.2</ModVersion>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>