From 8fad37c6e8c5d06a44ec6e96c5576f456b937d67 Mon Sep 17 00:00:00 2001 From: maj00r Date: Sat, 22 Aug 2026 23:15:18 +0200 Subject: [PATCH] Parse loose vehicles, optional includes, attachments and dual weather clocks #17 keep empty/loose consists; #19 scenery display name; two weather clocks (time + scenario.time.override) with atmo fog randomisation; scenery fault list; reload sceneries. --- StarterNG/Classes/GameData.cs | 11 ++ StarterNG/Classes/Scenery.cs | 306 ++++++++++++++++++++++++++++++++-- StarterNG/Classes/Trainset.cs | 81 ++++++++- 3 files changed, 375 insertions(+), 23 deletions(-) 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,