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.
This commit is contained in:
maj00r
2026-08-22 23:15:18 +02:00
parent 2298d6c803
commit 8fad37c6e8
3 changed files with 375 additions and 23 deletions

View File

@@ -80,6 +80,17 @@ public sealed class GameData
Loaded = true;
}
/// <summary>Re-reads scenery/*.scn (Pascal actReloadScenarios).</summary>
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<string> EnumerateScenery(string sceneryDir)
{
if (!Directory.Exists(sceneryDir))

View File

@@ -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 = "";
/// <summary>0 = default off, 1 = default on, 2 = terrain (auto if no .sbt).</summary>
public int Kind;
public bool Selected;
}
public class Scenery
{
public List<string> Lines;
public List<Trainset> Trainsets;
public List<SceneryAttachment> Attachments = new();
public List<SceneryInclude> Includes = new();
/// <summary>
/// Names of <c>node … dynamic</c> entries outside trainsets (kept in the
/// template on export; collected so the depot can avoid name clashes).
/// </summary>
public List<string> LooseVehicleNames = new();
/// <summary>Parsed loose dynamics (Pascal <c>SCN.Vehicles</c>).</summary>
public List<Dynamic> LooseVehicles = new();
/// <summary>Parse / vehicle faults for the Info tab (Pascal FaultList).</summary>
public List<string> 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>" (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
/// <summary>Set once the user edits the weather, so export rewrites the config.</summary>
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;
/// <summary>Display label: //$n when set (with @token i18n), otherwise the file name.</summary>
public string DisplayName
{
get
{
if (!string.IsNullOrWhiteSpace(Name))
return SceneryI18n.T(Name);
return System.IO.Path.GetFileNameWithoutExtension(Path);
}
}
/// <summary>//$d lines with @token translation applied.</summary>
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<string> trainsetEntries = new List<string>();
@@ -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;
}
});
}
/// <summary>
@@ -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;
}
/// <summary>
/// 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
/// </summary>
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>" - 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;
}
/// <summary>Restores weather fields to the values parsed from the SCN file.</summary>
public void RestoreWeather()
{
Time = _origTime;
ScenarioTimeOverride = _origOverride;
Day = _origDay;
Temperature = _origTemperature;
FogEnd = _origFogEnd;
Overcast = _origOvercast;
WeatherDirty = true;
}
/// <summary>
/// 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";

View File

@@ -34,6 +34,12 @@ public class Trainset
/// <summary>True when the block carries the //$decor flag (decoration, not drivable).</summary>
public bool Decor;
/// <summary>Loading-screen logo key from <c>//$il</c>.</summary>
public string? Logo;
/// <summary>Optional trainset mini from <c>//$it</c>.</summary>
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
};
/// <summary>Pascal <c>PrepareNode(..., TrainSet=False)</c>.</summary>
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";
}
}
/// <summary>One entry in the depot consist: a single vehicle or a locked multi-car unit.</summary>
public sealed class ConsistItem
{
public List<Dynamic> Cars { get; set; } = new();
public bool Grouped { get; set; }
public bool Flipped { get; set; }
public eDriverType Driver { get; set; } = eDriverType.Nobody;
}
/// <summary>
@@ -414,6 +470,17 @@ public sealed class Coupling
Parameters.Add(wheels.ToParameter());
}
/// <summary>Coolant at ambient temperature (<c>.TA</c>), like Pascal ThermoDynamic.</summary>
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,