diff --git a/StarterNG/Classes/GameData.cs b/StarterNG/Classes/GameData.cs
index ae17654..e14b37d 100644
--- a/StarterNG/Classes/GameData.cs
+++ b/StarterNG/Classes/GameData.cs
@@ -80,6 +80,17 @@ public sealed class GameData
Loaded = true;
}
+ /// Re-reads scenery/*.scn (Pascal actReloadScenarios).
+ public void ReloadSceneries(string sceneryDir = "scenery/")
+ {
+ Sceneries.Clear();
+ foreach (string file in EnumerateScenery(sceneryDir))
+ {
+ try { Sceneries.Add(new Scenery(file)); }
+ catch { /* skip */ }
+ }
+ }
+
private static List EnumerateScenery(string sceneryDir)
{
if (!Directory.Exists(sceneryDir))
diff --git a/StarterNG/Classes/Scenery.cs b/StarterNG/Classes/Scenery.cs
index bcf3583..c2b238f 100644
--- a/StarterNG/Classes/Scenery.cs
+++ b/StarterNG/Classes/Scenery.cs
@@ -1,15 +1,43 @@
+using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
+using StarterNG.Domain;
namespace StarterNG.Classes;
+public sealed class SceneryAttachment
+{
+ public string FilePath = "";
+ public string Label = "";
+}
+
+public sealed class SceneryInclude
+{
+ public string FilePath = "";
+ public string Desc = "";
+ /// 0 = default off, 1 = default on, 2 = terrain (auto if no .sbt).
+ public int Kind;
+ public bool Selected;
+}
+
public class Scenery
{
public List Lines;
public List Trainsets;
+ public List Attachments = new();
+ public List Includes = new();
+ ///
+ /// Names of node … dynamic entries outside trainsets (kept in the
+ /// template on export; collected so the depot can avoid name clashes).
+ ///
+ public List LooseVehicleNames = new();
+ /// Parsed loose dynamics (Pascal SCN.Vehicles).
+ public List LooseVehicles = new();
+ /// Parse / vehicle faults for the Info tab (Pascal FaultList).
+ public List Faults = new();
public string Group;
public string Path;
@@ -24,10 +52,16 @@ public class Scenery
// Weather / environment. Like the original Starter, these are editable and are
// written into the scenery's "config" block on launch (see RewriteWeather).
- // Defaults mirror the original (15 °C, day 0, 10:30, clear sky).
- public string WeatherTime = "10:30"; // h:mm -> "time"/"scenario.time.override"
+ //
+ // Two clocks, matching Pascal TConfig.StartTime / TConfig.Time:
+ // Time -> classic "time … endtime" (from the SCN; UI does not edit it)
+ // ScenarioTimeOverride -> "scenario.time.override" (what the Weather picker edits)
+ // Defaults: classic start 10:30; override falls back to "now" when the SCN has neither.
+ public string Time = "10:30"; // h:mm -> "time … endtime"
+ public string ScenarioTimeOverride = "10:30"; // h:mm -> "scenario.time.override"
public int Day = 0; // "movelight " (day of year / season)
public double Temperature = 15; // "scenario.weather.temperature"
+ public int FogStart = 10; // atmo fog start (metres)
public int FogEnd = 2000; // visibility in metres (atmo fog range)
public double Overcast = 0; // atmo overcast factor (-1.5 .. 1.5)
@@ -37,10 +71,34 @@ public class Scenery
/// Set once the user edits the weather, so export rewrites the config.
public bool WeatherDirty;
+ // Snapshot of weather as loaded from the SCN (Pascal actRestoreWeather).
+ private string _origTime = "10:30";
+ private string _origOverride = "10:30";
+ private int _origDay;
+ private double _origTemperature = 15;
+ private int _origFogEnd = 2000;
+ private double _origOvercast;
+
// The file content with each trainset block replaced by a {{i}} placeholder,
// used to rebuild the .scn on export.
private readonly string _template;
+ /// Display label: //$n when set (with @token i18n), otherwise the file name.
+ public string DisplayName
+ {
+ get
+ {
+ if (!string.IsNullOrWhiteSpace(Name))
+ return SceneryI18n.T(Name);
+ return System.IO.Path.GetFileNameWithoutExtension(Path);
+ }
+ }
+
+ /// //$d lines with @token translation applied.
+ public string LocalizedDescription =>
+ string.IsNullOrEmpty(Description) ? "" :
+ string.Join("\n", Description.Split('\n').Select(SceneryI18n.T));
+
// Lazily resolved, cached path to the //$i image on disk (null if not found).
private string _imagePath;
private bool _imagePathResolved;
@@ -68,7 +126,10 @@ public class Scenery
// weather/environment is read from the raw text before trainset blocks
// are stripped out below (the atmosphere commands live outside trainsets)
ParseWeather(content);
+ SnapshotWeather();
+ ParseAttachments(content);
+ content = ParseAndStripOptionalIncludes(content);
// parsing trainsets
List trainsetEntries = new List();
@@ -82,12 +143,73 @@ public class Scenery
trainsetEntries.Add(match.Value);
return $"{{{{{idx++}}}}}";
});
+ content = ExtractLooseVehicles(content);
_template = content;
// 1:1 with placeholders - the Trainset ctor never throws (unparsable
// blocks are kept verbatim), so indices stay aligned for export.
foreach (string trainsetEntry in trainsetEntries)
- Trainsets.Add(new Trainset(trainsetEntry));
+ {
+ var ts = new Trainset(trainsetEntry);
+ Trainsets.Add(ts);
+ if (!ts.Parsed)
+ Faults.Add($"# trainset parse fault: {ts.Name} ({ts.Track})");
+ }
+ }
+
+ // Pull standalone node…dynamic blocks out of the template (Pascal SCN.Vehicles)
+ // so export can re-emit them via PrepareNode(false) and names stay editable.
+ private string ExtractLooseVehicles(string content)
+ {
+ LooseVehicles.Clear();
+ LooseVehicleNames.Clear();
+ var inv = System.Globalization.CultureInfo.InvariantCulture;
+
+ return Regex.Replace(
+ content,
+ @"(?is)\bnode\s+(\S+)\s+(\S+)\s+(\S+)\s+dynamic\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)((?:\s+(?!enddynamic)\S+)*)\s*enddynamic\b",
+ m =>
+ {
+ string name = m.Groups[3].Value;
+ if (name.StartsWith("{{", StringComparison.Ordinal))
+ return m.Value;
+ try
+ {
+ var d = new Dynamic
+ {
+ RangeMax = float.Parse(m.Groups[1].Value, inv),
+ RangeMin = float.Parse(m.Groups[2].Value, inv),
+ Name = name,
+ DataFolder = m.Groups[4].Value,
+ SkinFile = m.Groups[5].Value,
+ MmdFile = m.Groups[6].Value,
+ PathName = m.Groups[7].Value,
+ Offset = float.Parse(m.Groups[8].Value, inv),
+ DriverType = m.Groups[9].Value.ToLowerInvariant() switch
+ {
+ "headdriver" => eDriverType.Headdriver,
+ "reardriver" => eDriverType.Reardriver,
+ "passenger" => eDriverType.Passenger,
+ _ => eDriverType.Nobody
+ },
+ HasVelocity = true,
+ Velocity = float.Parse(m.Groups[10].Value, inv)
+ };
+ var trailing = m.Groups[11].Value
+ .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
+ .ToList();
+ d.ReadTrailing(trailing);
+ LooseVehicles.Add(d);
+ LooseVehicleNames.Add(d.Name);
+ return "\n";
+ }
+ catch
+ {
+ Faults.Add($"# loose dynamic parse fault: {name}");
+ LooseVehicleNames.Add(name);
+ return m.Value;
+ }
+ });
}
///
@@ -98,12 +220,11 @@ public class Scenery
{
string result = _template;
- // Inject the (edited) weather into the config block before substituting
- // trainsets, so the {{i}} placeholders shield the trainset text from the
- // weather rewrite. Only done when the user actually changed the weather.
if (WeatherDirty)
result = RewriteWeather(result);
+ result = InjectIncludes(result);
+
for (int i = 0; i < Trainsets.Count; i++)
{
string entry = skipDecorTrainsets && Trainsets[i].Decor
@@ -111,9 +232,120 @@ public class Scenery
: Trainsets[i].ToSceneryEntry();
result = result.Replace("{{" + i + "}}", entry);
}
+
+ if (LooseVehicles.Count > 0)
+ result = AppendLooseVehicles(result);
+
return result;
}
+ private string AppendLooseVehicles(string text)
+ {
+ var sb = new StringBuilder();
+ foreach (var v in LooseVehicles)
+ sb.Append(v.ToLooseNode());
+ if (sb.Length == 0) return text;
+
+ var fi = Regex.Match(text, @"\bFirstInit\b", RegexOptions.IgnoreCase);
+ if (fi.Success)
+ return text[..fi.Index] + sb + text[fi.Index..];
+ return text + sb;
+ }
+
+ private void ParseAttachments(string content)
+ {
+ Attachments.Clear();
+ foreach (Match m in Regex.Matches(content, @"^//\$f\b[ \t]*([^\r\n]*)", RegexOptions.Multiline))
+ {
+ string rest = m.Groups[1].Value.Trim();
+ if (rest.Length == 0) continue;
+ string[] parts = rest.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+ // "$f XX path label…" (Pascal) or "$f path label"
+ if (parts.Length >= 3 && parts[0].Length <= 2)
+ Attachments.Add(new SceneryAttachment
+ {
+ FilePath = parts[1],
+ Label = string.Join(" ", parts.Skip(2))
+ });
+ else if (parts.Length >= 2)
+ Attachments.Add(new SceneryAttachment
+ {
+ FilePath = parts[0],
+ Label = string.Join(" ", parts.Skip(1))
+ });
+ else
+ Attachments.Add(new SceneryAttachment
+ {
+ FilePath = parts[0],
+ Label = System.IO.Path.GetFileName(parts[0])
+ });
+ }
+ }
+
+ // Pulls include … end //$optional… … endoptional out of the template so Start
+ // can re-inject only the checked ones (Pascal ParseInc / actStartExecute).
+ private string ParseAndStripOptionalIncludes(string content)
+ {
+ Includes.Clear();
+ return Regex.Replace(
+ content,
+ @"include\s+(\S+(?:\s+\S+)*?)\s+end\s*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b",
+ m =>
+ {
+ string incPath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " ");
+ string raw = m.Groups[2].Value.Trim().TrimStart(',', ' ');
+ string[] par = raw.Split(new[] { ',', '|', ';' },
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ int kind = 1;
+ string desc = incPath;
+ if (par.Length >= 1 && int.TryParse(par[0], out int k))
+ kind = k;
+ if (par.Length >= 2)
+ desc = par[1];
+
+ Includes.Add(new SceneryInclude
+ {
+ FilePath = incPath,
+ Desc = desc,
+ Kind = kind,
+ Selected = kind == 1
+ });
+ return " ";
+ },
+ RegexOptions.IgnoreCase);
+ }
+
+ private string InjectIncludes(string text)
+ {
+ if (Includes.Count == 0)
+ return text;
+
+ var sb = new StringBuilder();
+ string sbt = System.IO.Path.ChangeExtension(Path, ".sbt");
+ bool hasSbt = File.Exists(sbt);
+
+ foreach (var inc in Includes)
+ {
+ if (inc.Kind == 2)
+ {
+ if (!hasSbt)
+ sb.Append("include ").Append(inc.FilePath).Append(" end\r\n");
+ continue;
+ }
+ if (inc.Selected)
+ sb.Append("include ").Append(inc.FilePath).Append(" end\r\n");
+ }
+
+ if (sb.Length == 0)
+ return text;
+
+ var fi = Regex.Match(text, @"\bFirstInit\b", RegexOptions.IgnoreCase);
+ if (fi.Success)
+ return text[..fi.Index] + sb + text[fi.Index..];
+ return sb + text;
+ }
+
///
/// Reads the scenery's environment commands into the editable weather fields.
/// Mirrors the original Starter (config: movelight / scenario.weather.temperature
@@ -121,16 +353,28 @@ public class Scenery
///
private void ParseWeather(string content)
{
- // start time: "scenario.time.override h:mm" wins, else top-level "time h:mm"
- var ovr = Regex.Match(content, @"(?i)scenario\.time\.override\s+(\d{1,2})[:.](\d{2})");
- var time = ovr.Success
- ? ovr
- : Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b");
+ // Classic "time h:mm" seeds both clocks (Pascal: Config.Time + Config.StartTime).
+ // "scenario.time.override" seeds only the UI clock (Pascal ignored it on load;
+ // we honour it so a previously exported $scn round-trips correctly).
+ var time = Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b");
if (time.Success)
{
- WeatherTime = $"{time.Groups[1].Value.PadLeft(2, '0')}:{time.Groups[2].Value}";
+ Time = $"{time.Groups[1].Value.PadLeft(2, '0')}:{time.Groups[2].Value}";
+ ScenarioTimeOverride = Time;
+ }
+
+ var ovr = Regex.Match(content, @"(?i)scenario\.time\.override\s+(\d{1,2})[:.](\d{2})");
+ if (ovr.Success)
+ {
+ ScenarioTimeOverride = $"{ovr.Groups[1].Value.PadLeft(2, '0')}:{ovr.Groups[2].Value}";
HasWeather = true;
}
+ else if (!time.Success)
+ {
+ // No clock in the SCN → UI shows "now", classic export stays 10:30 (Pascal).
+ var now = System.DateTime.Now;
+ ScenarioTimeOverride = $"{now.Hour:D2}:{now.Minute:D2}";
+ }
// "movelight " - day of the year (sun elevation / season)
var move = Regex.Match(content, @"(?im)\bmovelight\s+(-?\d+)");
@@ -151,14 +395,21 @@ public class Scenery
}
// "atmo R G B fogStart fogEnd R G B overcast endatmo"
+ // Pascal ParseAtmo: FogEnd := RandomRange(FogStart, FogEnd).
var atmo = Regex.Match(content, @"(?is)\batmo\b(.*?)\bendatmo\b");
if (atmo.Success)
{
var nums = Regex.Matches(atmo.Groups[1].Value, @"-?\d+(?:\.\d+)?")
.Select(m => m.Value).ToList();
- if (nums.Count >= 5 && int.TryParse(nums[4], out int fog))
+ if (nums.Count >= 5 &&
+ int.TryParse(nums[3], out int fogStart) &&
+ int.TryParse(nums[4], out int fogEnd))
{
- FogEnd = fog;
+ FogStart = Math.Clamp(fogStart, 10, 2500);
+ fogEnd = Math.Clamp(fogEnd, FogStart, 2500);
+ FogEnd = fogEnd > FogStart
+ ? Random.Shared.Next(FogStart, fogEnd + 1)
+ : fogEnd;
HasWeather = true;
}
if (nums.Count >= 6 &&
@@ -168,6 +419,28 @@ public class Scenery
}
}
+ private void SnapshotWeather()
+ {
+ _origTime = Time;
+ _origOverride = ScenarioTimeOverride;
+ _origDay = Day;
+ _origTemperature = Temperature;
+ _origFogEnd = FogEnd;
+ _origOvercast = Overcast;
+ }
+
+ /// Restores weather fields to the values parsed from the SCN file.
+ public void RestoreWeather()
+ {
+ Time = _origTime;
+ ScenarioTimeOverride = _origOverride;
+ Day = _origDay;
+ Temperature = _origTemperature;
+ FogEnd = _origFogEnd;
+ Overcast = _origOvercast;
+ WeatherDirty = true;
+ }
+
///
/// Replaces the scenery's environment commands with a fresh config block built
/// from the current weather fields (the C# equivalent of the original Starter's
@@ -188,12 +461,13 @@ public class Scenery
s = Regex.Replace(s, @"(?i)scenario\.time\.override\s+\S+", " ");
var inv = System.Globalization.CultureInfo.InvariantCulture;
+ // Override = picker value; classic time = original SCN start (unchanged by UI).
string config =
"config\r\n" +
$"movelight {Day}\r\n" +
$"scenario.weather.temperature {Temperature.ToString(inv)}\r\n" +
- $"scenario.time.override {WeatherTime}\r\n" +
- $"time {WeatherTime} 0 0 endtime\r\n" +
+ $"scenario.time.override {ScenarioTimeOverride}\r\n" +
+ $"time {Time} 0 0 endtime\r\n" +
$"atmo 0 0 0 {FogEnd} {FogEnd} 0 0 0 {Overcast.ToString(inv)} endatmo\r\n" +
"endconfig\r\n";
diff --git a/StarterNG/Classes/Trainset.cs b/StarterNG/Classes/Trainset.cs
index f857168..2733bad 100644
--- a/StarterNG/Classes/Trainset.cs
+++ b/StarterNG/Classes/Trainset.cs
@@ -34,6 +34,12 @@ public class Trainset
/// True when the block carries the //$decor flag (decoration, not drivable).
public bool Decor;
+ /// Loading-screen logo key from //$il.
+ public string? Logo;
+
+ /// Optional trainset mini from //$it.
+ public string? Mini;
+
public Trainset(string trainsetEntry)
{
RawEntry = trainsetEntry;
@@ -68,6 +74,14 @@ public class Trainset
);
if (match.Success)
this.Description = match.Groups[1].Value.Trim();
+
+ var logo = Regex.Match(trainsetEntry, @"^\s*//\$il\b[ \t]*([^\r\n]*)", RegexOptions.Multiline);
+ if (logo.Success)
+ Logo = logo.Groups[1].Value.Trim();
+
+ var mini = Regex.Match(trainsetEntry, @"^\s*//\$it\b[ \t]*([^\r\n]*)", RegexOptions.Multiline);
+ if (mini.Success)
+ Mini = mini.Groups[1].Value.Trim();
// parse trainset
@@ -145,14 +159,27 @@ public class Trainset
public string GetTrainsetEntry()
{
- string entry = "";
+ // Starter metadata comments (parsed for filters/UI). Pascal's launch rewrite
+ // omits them from $scn; we keep them so a regenerated block stays filterable
+ // if the template is re-opened or compared.
+ var sb = new StringBuilder();
+ if (Decor)
+ sb.Append("//$decor\n");
+ if (!string.IsNullOrEmpty(Description))
+ sb.Append("//$o ").Append(Description).Append('\n');
+ if (!string.IsNullOrEmpty(Logo))
+ sb.Append("//$il ").Append(Logo).Append('\n');
+ if (!string.IsNullOrEmpty(Mini))
+ sb.Append("//$it ").Append(Mini).Append('\n');
- entry += "trainset ";
- entry += this.Name + " ";
- entry += this.Track + " ";
- entry += this.Offset.ToString(CultureInfo.InvariantCulture) + " ";
- entry += this.Velocity.ToString(CultureInfo.InvariantCulture) + " ";
- entry += "\n";
+ sb.Append("trainset ");
+ sb.Append(Name).Append(' ');
+ sb.Append(Track).Append(' ');
+ sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' ');
+ sb.Append(Velocity.ToString(CultureInfo.InvariantCulture));
+ sb.Append('\n');
+
+ string entry = sb.ToString();
foreach (Dynamic vehicle in Vehicles)
{
string driverType = "";
@@ -323,6 +350,35 @@ public class Dynamic
MiniName = MiniName,
Flipped = Flipped
};
+
+ /// Pascal PrepareNode(..., TrainSet=False).
+ public string ToLooseNode()
+ {
+ string driver = DriverType switch
+ {
+ eDriverType.Headdriver => "headdriver",
+ eDriverType.Reardriver => "reardriver",
+ eDriverType.Passenger => "passenger",
+ _ => "nobody"
+ };
+ string path = string.IsNullOrEmpty(PathName) ? "none" : PathName;
+ string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture);
+ return
+ $"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
+ $"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
+ $"{DataFolder} {SkinFile} {MmdFile} {path} " +
+ $"{Offset.ToString(CultureInfo.InvariantCulture)} {driver} {vel}" +
+ $"{WriteTrailing()} enddynamic\n";
+ }
+}
+
+/// One entry in the depot consist: a single vehicle or a locked multi-car unit.
+public sealed class ConsistItem
+{
+ public List Cars { get; set; } = new();
+ public bool Grouped { get; set; }
+ public bool Flipped { get; set; }
+ public eDriverType Driver { get; set; } = eDriverType.Nobody;
}
///
@@ -414,6 +470,17 @@ public sealed class Coupling
Parameters.Add(wheels.ToParameter());
}
+ /// Coolant at ambient temperature (.TA), like Pascal ThermoDynamic.
+ public bool ThermoDynamic
+ {
+ get => Parameters.Exists(p => p.Equals("TA", StringComparison.OrdinalIgnoreCase));
+ set
+ {
+ Parameters.RemoveAll(p => p.Equals("TA", StringComparison.OrdinalIgnoreCase));
+ if (value) Parameters.Add("TA");
+ }
+ }
+
public Coupling Clone() => new()
{
Flags = Flags,