Skip to content

Commit ba5bf49

Browse files
authored
Merge pull request #9 from odwales20/master
Steam loco support and speed lookahead
2 parents e2c0c2c + 7f57497 commit ba5bf49

File tree

13 files changed

+301
-15
lines changed

13 files changed

+301
-15
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,9 @@
22
/.claude
33
/PORTING_NOTES.md
44
/SESSION_RESUME.md
5+
$staging/Resources/audio/wrongway.wav
6+
$staging/Resources/audio/trainend.wav
7+
$staging/Resources/audio/stoptrain.wav
8+
$staging/Resources/audio/set.wav
9+
$staging/Resources/audio/on.wav
10+
$staging/Resources/audio/off.wav

DVRouteManager.dll

12 KB
Binary file not shown.

DVRouteManager/LocoAI.cs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,40 @@ private float GetTrackSpeedLimit(RailTrack track)
6767
return limit;
6868
}
6969

70+
/// <summary>
71+
/// Returns the most restrictive speed limit within braking distance ahead on the route.
72+
/// Lookahead = v²/0.8 + 50 m (assumes ~0.4 m/s² deceleration), capped at 1500 m.
73+
/// Falls back to current track's limit if path not available.
74+
/// </summary>
75+
private float GetLookaheadSpeedLimit(RailTrack currentTrack, float currentSpeedKmh)
76+
{
77+
float currentLimit = GetTrackSpeedLimit(currentTrack);
78+
79+
var path = RouteTracker?.Route?.Path;
80+
if (path == null) return currentLimit;
81+
82+
int startIdx = path.IndexOf(currentTrack);
83+
if (startIdx < 0) return currentLimit;
84+
85+
// Match the game's sign placement: UpcomingSpeedDown signs appear ~speed*2 m
86+
// before the speed change. We use speed*3 to give a small safety margin on top.
87+
float lookaheadM = Mathf.Max(currentSpeedKmh * 3f, 100f);
88+
89+
float minLimit = currentLimit;
90+
float distAhead = 0f;
91+
92+
for (int i = startIdx + 1; i < path.Count && distAhead < lookaheadM; i++)
93+
{
94+
var t = path[i];
95+
if (t == null) break;
96+
float limit = GetTrackSpeedLimit(t);
97+
if (limit < minLimit) minLimit = limit;
98+
distAhead += (float)t.LogicTrack().length;
99+
}
100+
101+
return minLimit;
102+
}
103+
70104
/// <summary>
71105
/// Computes the speed limit for a track using the exact same method the game uses
72106
/// to place speed-limit signs: BezierArcApproximation finds the minimum curve radius,
@@ -257,12 +291,12 @@ private IEnumerator AICoroutine()
257291
}
258292
else
259293
{
260-
TargetSpeed = GetTrackSpeedLimit(trainCar.Bogies[0].track);
294+
TargetSpeed = GetLookaheadSpeedLimit(trainCar.Bogies[0].track, speed);
261295
}
262296
}
263297
else if (RouteTracker.TrackState == RouteTracker.TrackingState.OnStart)
264298
{
265-
TargetSpeed = GetTrackSpeedLimit(trainCar.Bogies[0].track);
299+
TargetSpeed = GetLookaheadSpeedLimit(trainCar.Bogies[0].track, speed);
266300
}
267301
else if (RouteTracker.TrackState == RouteTracker.TrackingState.StopTrainAfterSwitch)
268302
{
@@ -289,7 +323,7 @@ private IEnumerator AICoroutine()
289323
{
290324
yield return Module.StartCoroutine(Reverse());
291325
shouldreverse = false;
292-
TargetSpeed = GetTrackSpeedLimit(trainCar.Bogies[0].track);
326+
TargetSpeed = GetLookaheadSpeedLimit(trainCar.Bogies[0].track, speed);
293327
}
294328
}
295329

DVRouteManager/LocoCruiseControl.cs

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
using CommandTerminal;
22
using DV.HUD;
33
using DV.Simulation.Cars;
4+
using DV.Simulation.Controllers;
45
using System;
56
using System.Collections;
67
using System.Collections.Generic;
78
using System.Linq;
9+
using System.Reflection;
810
using System.Runtime.CompilerServices;
911
using System.Text;
1012
using System.Threading.Tasks;
@@ -42,6 +44,29 @@ public class LocoCruiseControl : IDisposable
4244
private InteriorControlsManager _interiorControls;
4345
private bool _isDM3;
4446

47+
// ── Steam (S060 / S282) state ────────────────────────────────────────
48+
private bool _isSteam;
49+
private BaseControlsOverrider _steamOverrider;
50+
51+
// Smoothed control positions
52+
private float _steamRegulator = 0f;
53+
private float _steamCutoff = 0.5f;
54+
private float _steamBrakeTarget = 0f;
55+
56+
// Pulse-braking state
57+
private bool _steamPulseBraking = false;
58+
private float _steamPulseTimer = 0f;
59+
private bool _steamPulseHigh = false;
60+
61+
// Averaged steam-chest pressure (sampled every 0.1 s, window = 10)
62+
private readonly Queue<float> _steamPressureSamples = new Queue<float>();
63+
private float _steamPressureTimer = 0f;
64+
65+
// Reflection cache for simFlow.TryGetPort
66+
private SimController _steamSimCtrl;
67+
private MethodInfo _tryGetPortMI;
68+
private PropertyInfo _portValueProp;
69+
4570
// ────────────────────────────────────────────────────────────────────
4671

4772
public LocoCruiseControl(ILocomotiveRemoteControl remoteControl, TrainCar car = null)
@@ -54,6 +79,11 @@ public LocoCruiseControl(ILocomotiveRemoteControl remoteControl, TrainCar car =
5479
_isDM3 = car.carLivery?.parentType?.id == LOCO_DM3;
5580
_indicators = car.GetComponentInChildren<LocoIndicatorReader>();
5681
// Interior controls loaded lazily (may be null until player enters cab)
82+
83+
string locoId = car.carLivery?.parentType?.id ?? "";
84+
_isSteam = locoId.Contains("S060") || locoId.Contains("S282");
85+
if (_isSteam)
86+
_steamOverrider = car.SimController?.controlsOverrider;
5787
}
5888
}
5989

@@ -77,6 +107,10 @@ public bool StartCruiseControl(float targetSpeed)
77107
//https://en.wikipedia.org/wiki/PID_controller
78108
protected float MaintainSpeed(float targetAcceleration, float dt, float speed, float acceleration)
79109
{
110+
// Steam locos use direct regulator/cutoff/brake control, not the PID
111+
if (_isSteam)
112+
return MaintainSpeedSteam(dt, speed);
113+
80114
if (remoteControl.GetReverserSymbol() == "N")
81115
{
82116
running = false;
@@ -220,6 +254,199 @@ private InteriorControlsManager GetInteriorControls()
220254
return _interiorControls;
221255
}
222256

257+
// ── Steam loco controller ────────────────────────────────────────────
258+
// Mirrors the algorithm from SteamCruiseControl mod's CruiseControlAI:
259+
// - Direct absolute Set() on regulator, cutoff, brake (no PID)
260+
// - Pressure-aware cutoff: reduce steam admission as chest pressure rises
261+
// - Pulse braking: on 2.5 s / off 1.5 s cycle when overspeeding
262+
private float MaintainSpeedSteam(float dt, float speed)
263+
{
264+
if (_steamOverrider == null) return 0f;
265+
266+
bool forward = TargetSpeed >= 0f;
267+
float absTarget = Mathf.Abs(TargetSpeed);
268+
float absSpeed = Mathf.Abs(speed);
269+
270+
float pressure = GetSteamChestPressure();
271+
float avgPressure = UpdateSteamPressureAvg(pressure, dt);
272+
273+
// ── Full stop ────────────────────────────────────────────────────
274+
if (absTarget < Mathf.Epsilon)
275+
{
276+
_steamPulseBraking = false;
277+
SteamSetBrake(1f);
278+
SteamSetRegulatorSmooth(0f);
279+
SteamSetCutoffSmooth(0.5f, dt);
280+
return 0f;
281+
}
282+
283+
float speedDiff = absSpeed - absTarget; // positive → too fast
284+
285+
if (speedDiff > 0.1f)
286+
{
287+
// ── Pulse braking ─────────────────────────────────────────────
288+
if (!_steamPulseBraking)
289+
{
290+
_steamPulseBraking = true;
291+
_steamPulseHigh = true;
292+
_steamPulseTimer = 0f;
293+
}
294+
295+
_steamPulseTimer += dt;
296+
float overshootFactor = Mathf.Clamp01(speedDiff / 5f);
297+
298+
const float PULSE_HIGH_DURATION = 2.5f;
299+
const float PULSE_LOW_DURATION = 1.5f;
300+
301+
if (_steamPulseHigh)
302+
{
303+
if (_steamPulseTimer >= PULSE_HIGH_DURATION)
304+
{ _steamPulseHigh = false; _steamPulseTimer = 0f; }
305+
}
306+
else
307+
{
308+
if (_steamPulseTimer >= PULSE_LOW_DURATION)
309+
{ _steamPulseHigh = true; _steamPulseTimer = 0f; }
310+
}
311+
312+
float brakeVal = _steamPulseHigh ? Mathf.Lerp(0.7f, 1f, overshootFactor) : 0f;
313+
SteamSetBrake(brakeVal);
314+
SteamSetRegulatorSmooth(0f);
315+
// Cutoff to neutral while braking — avoids steam fighting brakes
316+
SteamSetCutoffSmooth(forward ? 0.5f : 0.49f, dt);
317+
}
318+
else
319+
{
320+
// ── Not braking ───────────────────────────────────────────────
321+
_steamPulseBraking = false;
322+
_steamPulseHigh = false;
323+
SteamSetBrake(0f);
324+
325+
if (absSpeed < absTarget - 0.1f)
326+
{
327+
// ── Accelerating ──────────────────────────────────────────
328+
float targetRegulator, targetCutoff;
329+
330+
if (absSpeed < 10f)
331+
{
332+
// Gentle start — ramp up regulator, full cutoff
333+
targetRegulator = 0.1f + 0.9f * (absSpeed / 10f);
334+
targetCutoff = forward ? 1f : 0f;
335+
}
336+
else if (absSpeed < 20f)
337+
{
338+
// Full regulator, full cutoff until up to speed
339+
targetRegulator = 1f;
340+
targetCutoff = forward ? 1f : 0f;
341+
}
342+
else if (avgPressure < 5f)
343+
{
344+
// Low chest pressure: wide-open cutoff to admit more steam
345+
targetRegulator = 1f;
346+
targetCutoff = forward ? 0.35f : 0.15f;
347+
}
348+
else if (avgPressure < 8f)
349+
{
350+
targetRegulator = 1f;
351+
targetCutoff = forward
352+
? (0.4f + 0.1f * (avgPressure - 5f) / 3f)
353+
: 0.25f;
354+
}
355+
else if (avgPressure < 12f)
356+
{
357+
targetRegulator = 1f;
358+
float t = (avgPressure - 8f) / 4f;
359+
targetCutoff = forward ? (0.55f + 0.25f * t) : 0.35f;
360+
}
361+
else
362+
{
363+
// Good pressure: efficient cutoff (~75%)
364+
targetRegulator = 1f;
365+
targetCutoff = forward ? 0.75f : 0.25f;
366+
}
367+
368+
SteamSetRegulatorSmooth(targetRegulator);
369+
SteamSetCutoffSmooth(targetCutoff, dt);
370+
}
371+
else
372+
{
373+
// ── Coasting at target speed ──────────────────────────────
374+
// Keep tiny regulator so steam flows (engine sound + steam heat)
375+
SteamSetRegulatorSmooth(0.1f);
376+
SteamSetCutoffSmooth(0.5f, dt);
377+
}
378+
}
379+
380+
return 0f;
381+
}
382+
383+
private void SteamSetRegulatorSmooth(float target)
384+
{
385+
_steamRegulator = Mathf.Lerp(_steamRegulator, target, 0.15f);
386+
_steamOverrider?.Throttle?.Set(_steamRegulator);
387+
}
388+
389+
// cutoff 0 = full reverse, 0.5 = neutral, 1 = full forward
390+
private void SteamSetCutoffSmooth(float target, float dt)
391+
{
392+
float delta = target - _steamCutoff;
393+
if (Mathf.Abs(delta) > 0.005f)
394+
_steamCutoff += delta * 0.15f * dt;
395+
else
396+
_steamCutoff = target;
397+
398+
_steamOverrider?.Reverser?.Set(_steamCutoff);
399+
}
400+
401+
private void SteamSetBrake(float value)
402+
{
403+
_steamBrakeTarget = value;
404+
_steamOverrider?.Brake?.Set(value);
405+
}
406+
407+
private float UpdateSteamPressureAvg(float currentPressure, float dt)
408+
{
409+
_steamPressureTimer += dt;
410+
if (_steamPressureTimer >= 0.1f)
411+
{
412+
_steamPressureSamples.Enqueue(currentPressure);
413+
_steamPressureTimer = 0f;
414+
while (_steamPressureSamples.Count > 10)
415+
_steamPressureSamples.Dequeue();
416+
}
417+
418+
if (_steamPressureSamples.Count == 0) return currentPressure;
419+
420+
float sum = 0f;
421+
foreach (float s in _steamPressureSamples) sum += s;
422+
return sum / _steamPressureSamples.Count;
423+
}
424+
425+
private float GetSteamChestPressure()
426+
{
427+
try
428+
{
429+
if (_steamSimCtrl == null)
430+
_steamSimCtrl = trainCar?.GetComponentInChildren<SimController>();
431+
if (_steamSimCtrl?.simFlow == null) return 8f;
432+
433+
if (_tryGetPortMI == null)
434+
_tryGetPortMI = _steamSimCtrl.simFlow.GetType()
435+
.GetMethod("TryGetPort", BindingFlags.Instance | BindingFlags.Public);
436+
if (_tryGetPortMI == null) return 8f;
437+
438+
var args = new object[] { "steamEngine.STEAM_CHEST_PRESSURE", null, true };
439+
if (!(bool)_tryGetPortMI.Invoke(_steamSimCtrl.simFlow, args) || args[1] == null)
440+
return 8f;
441+
442+
if (_portValueProp == null)
443+
_portValueProp = args[1].GetType().GetProperty("Value");
444+
445+
return _portValueProp != null ? (float)_portValueProp.GetValue(args[1]) - 1f : 8f;
446+
}
447+
catch { return 8f; }
448+
}
449+
223450
// ────────────────────────────────────────────────────────────────────
224451

225452
public void Stop()

DVRouteManager/Properties/AssemblyInfo.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,5 @@
3333
// You can specify all the values or you can default the Build and Revision Numbers
3434
// by using the '*' as shown below:
3535
// [assembly: AssemblyVersion("1.0.*")]
36-
[assembly: AssemblyVersion("0.4.0.0")]
37-
[assembly: AssemblyFileVersion("0.4.0.0")]
36+
[assembly: AssemblyVersion("0.3.4.0")]
37+
[assembly: AssemblyFileVersion("0.3.4.0")]

DVRouteManager/Route.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -332,11 +332,11 @@ public RailTrack GetPrevTrack(RailTrack currentTrack)
332332

333333
public async Task<Route> FindOppositeRoute()
334334
{
335-
List<TrackTransition> trackTransitions = new List<TrackTransition>();
336-
337-
trackTransitions.Add(new TrackTransition() { track = FirstTrack, nextTrack = SecondTrack });
338-
339-
return await Route.FindRoute(FirstTrack.LogicTrack(), LastTrack.LogicTrack(), Module.settings.ReversingStrategy, Trainset, trackTransitions);
335+
// Route from the original destination back to the original start.
336+
// The old approach banned FirstTrack→SecondTrack and re-ran the same
337+
// start→end, which forced the pathfinder around an alternate loop
338+
// instead of simply reversing the path.
339+
return await Route.FindRoute(LastTrack.LogicTrack(), FirstTrack.LogicTrack(), Module.settings.ReversingStrategy, Trainset);
340340
}
341341

342342
public async static Task<Route> FindRoute(Track begin, Track end, ReversingStrategy reversingStrategy, Trainset trainset, List<TrackTransition> trackTransitions = null)
3 KB
Binary file not shown.
6 KB
Binary file not shown.
3 KB
Binary file not shown.
6 KB
Binary file not shown.

0 commit comments

Comments
 (0)