Keep scenery files intact on save and follow them on disk

Saves keep comments and every byte they do not change. Faults in a scenery's
text are found and described. Files that change while the starter runs are
picked up without a manual reload.
This commit is contained in:
maj00r
2026-11-01 16:06:15 +01:00
parent b6735fe345
commit 69aa075bfa
30 changed files with 3596 additions and 215 deletions

View File

@@ -11,4 +11,7 @@ public interface ISceneryRepository
IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null);
Scenery? Load(string path);
/// <summary>The scenery files there are now; null while their folder is away.</summary>
IReadOnlyList<string>? ListFiles();
}

View File

@@ -47,10 +47,19 @@ public sealed class GameLibrary
return;
progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt"));
if (_vehicles.Load(Vehicles) == 0)
_log.Log("dynamic: brak plikow textures.txt");
Parallel.Invoke(_minis.Preload, _physics.Preload);
// Whatever goes wrong with the rolling stock, the sceneries still get their turn.
try
{
if (_vehicles.Load(Vehicles) == 0)
_log.Log("dynamic: brak plikow textures.txt");
Parallel.Invoke(_minis.Preload, _physics.Preload);
}
catch (Exception ex)
{
_log.Log("dynamic: loading the rolling stock", ex);
}
ReloadSceneries(progress is null
? null

View File

@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Domain.Sceneries;
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Application;
public enum SceneryTrainsetSaveOutcome
{
Saved,
/// <summary>The file no longer holds the trainset as it was read - edited outside meanwhile.</summary>
NotFound,
Failed
}
/// <summary>
/// Writes a consist edited in the depot into the scenery file itself, so it is
/// there the next time too - not only in the copy written for one start. The
/// file's first version is kept beside it, once - under a name of the starter's
/// own, since the simulator's editor keeps its <c>.scn.bak</c> there and rewrites
/// it on every save.
/// </summary>
public sealed class SaveSceneryTrainset
{
public const string BackupSuffix = ".starter.bak";
private readonly IFileSystem _files;
private readonly IDiagnosticsLog _log;
public SaveSceneryTrainset(IFileSystem files, IDiagnosticsLog log)
{
_files = files;
_log = log;
}
public (SceneryTrainsetSaveOutcome Outcome, string? Detail) Execute(Scenery scenery, Trainset trainset)
{
string path = FileOf(scenery, trainset);
try
{
byte[] bytes = _files.ReadAllBytes(path);
var (encoding, bom) = LegacyText.Detect(bytes);
string text = encoding.GetString(bytes, bom.Length, bytes.Length - bom.Length);
// The trainset's own line ends, if it has any: a file can hold both kinds.
string lines = trainset.RawEntry.Contains('\n') ? trainset.RawEntry : text;
string newline = lines.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
string entry = SceneryTrainsetText.Compose(trainset, newline);
var sameFile = scenery.Trainsets.Where(t => t.SourceFile == trainset.SourceFile).ToList();
if (SceneryTrainsetText.Replace(text, trainset, sameFile, entry) is not { } updated)
return (SceneryTrainsetSaveOutcome.NotFound, null);
string backup = path + BackupSuffix;
if (!_files.FileExists(backup))
_files.WriteAllBytesAtomic(backup, bytes);
_files.WriteAllBytesAtomic(path, bom.Concat(encoding.GetBytes(updated)).ToArray());
// Written here, so no change from outside for the scenery to follow.
scenery.Sources = new Dictionary<string, FileStamp?>(scenery.Sources) { [path] = FileStamp.Of(_files, path) };
// What the file holds now is what the next save looks for.
trainset.RawEntry = entry;
trainset.OriginalVelocity = trainset.Velocity;
return (SceneryTrainsetSaveOutcome.Saved, null);
}
catch (Exception ex)
{
_log.Log($"saving trainset {trainset.Name} to {path}", ex);
return (SceneryTrainsetSaveOutcome.Failed, ex.Message);
}
}
/// <summary>
/// The scenery file, or the included one the trainset came from - named, as
/// includes are, from the scenery folder the scenery itself is in.
/// </summary>
public static string FileOf(Scenery scenery, Trainset trainset) =>
trainset.SourceFile is { } file
? System.IO.Path.Combine(System.IO.Path.GetDirectoryName(scenery.Path) ?? "", file.Replace('\\', '/'))
: scenery.Path;
}

View File

@@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Application;
/// <summary>What became of a scenery's files since they were read.</summary>
/// <param name="Current">The scenery as it is held; null for a file new in the folder.</param>
/// <param name="Fresh">As its files have it now; null for one gone from the folder.</param>
public sealed record SceneryChange(Scenery? Current, Scenery? Fresh);
public enum SceneryVerdict
{
/// <summary>Nothing to do about it, for now or for good.</summary>
Leave,
/// <summary>The library can take it in.</summary>
Take,
/// <summary>Taking it in loses edits, so that is for someone to say.</summary>
AskFirst
}
/// <summary>What is to become of a change.</summary>
/// <param name="Lost">The consists with unsaved edits that <see cref="SceneryVerdict.AskFirst"/> is about.</param>
public sealed record SceneryJudgement(SceneryVerdict Verdict, IReadOnlyList<Trainset> Lost)
{
public static readonly SceneryJudgement Leave = new(SceneryVerdict.Leave, Array.Empty<Trainset>());
public static readonly SceneryJudgement Take = new(SceneryVerdict.Take, Array.Empty<Trainset>());
}
/// <summary>
/// Keeps the sceneries in step with their files when those change outside the
/// starter - in a text editor, in the simulator's editor - without anyone having
/// to ask for a reload.
/// </summary>
public sealed class SceneryFileWatch
{
private readonly IFileSystem _files;
private readonly ISceneryRepository _sceneries;
private readonly GameLibrary _library;
private readonly AppState _state;
// Files that would not load, and how they stood then: not tried, and not logged,
// again before something about them changes.
private readonly Dictionary<string, string> _unreadable = new();
private HashSet<string> _away = new();
public SceneryFileWatch(IFileSystem files, ISceneryRepository sceneries, GameLibrary library, AppState state)
{
_files = files;
_sceneries = sceneries;
_library = library;
_state = state;
}
/// <summary>What has changed among the files of <paramref name="held"/>. Off the UI thread, one look at a time.</summary>
public IReadOnlyList<SceneryChange> Look(IReadOnlyList<Scenery> held)
{
var changes = new List<SceneryChange>();
if (_sceneries.ListFiles() is not { } files)
return changes;
var there = files.ToHashSet();
var away = new HashSet<string>();
foreach (var scenery in held)
{
if (!there.Contains(scenery.Path))
{
// A file being replaced is away for a moment: gone only if still so next time.
away.Add(scenery.Path);
if (_away.Contains(scenery.Path))
changes.Add(new SceneryChange(scenery, null));
}
else if (IsStale(scenery) && LoadOnce(scenery.Path, scenery.Sources.Keys) is { } fresh)
{
changes.Add(new SceneryChange(scenery, fresh));
}
}
_away = away;
var known = held.Select(scenery => scenery.Path).ToHashSet();
foreach (string file in files.Where(file => !known.Contains(file)))
if (LoadOnce(file, new[] { file }) is { } added)
changes.Add(new SceneryChange(null, added));
return changes;
}
/// <summary>Whether any of the files it was read from is no longer as it was.</summary>
public bool IsStale(Scenery scenery) =>
scenery.Sources.Any(source => FileStamp.Of(_files, source.Key) != source.Value);
private Scenery? LoadOnce(string path, IEnumerable<string> sources)
{
string stood = string.Join('|', sources.Select(source => FileStamp.Of(_files, source)));
if (_unreadable.TryGetValue(path, out string? before) && before == stood)
return null;
var loaded = _sceneries.Load(path);
if (loaded is null)
_unreadable[path] = stood;
else
_unreadable.Remove(path);
return loaded;
}
/// <summary>
/// Puts what was held into the scenery read anew. A consist whose text is still
/// what it was goes over as the object it is, with its edits and its undo steps;
/// the choices made by hand go with them.
/// </summary>
/// <returns>The consists with unsaved edits that have no place there: their text changed too.</returns>
public static IReadOnlyList<Trainset> CarryOver(Scenery current, Scenery fresh)
{
var waiting = current.Trainsets.ToList();
for (int i = 0; i < fresh.Trainsets.Count; i++)
{
var read = fresh.Trainsets[i];
int at = waiting.FindIndex(t => t.SourceFile == read.SourceFile && t.RawEntry == read.RawEntry);
if (at < 0)
continue;
fresh.Trainsets[i] = waiting[at];
waiting.RemoveAt(at);
}
fresh.Weather.KeepChoicesOf(current.Weather);
foreach (var include in fresh.Includes)
if (current.Includes.FirstOrDefault(i => i.FilePath == include.FilePath) is { } earlier)
include.Selected = earlier.Selected;
return waiting.Where(SceneryTrainsetText.IsModified).ToList();
}
/// <summary>
/// On the UI thread, once for each change and just before acting on it: what a
/// look found may be out of date by then. A scenery read anew has what was held
/// put into it here.
/// </summary>
public SceneryJudgement Judge(SceneryChange change)
{
var sceneries = _library.Sceneries;
if (change.Current is not { } current)
return sceneries.Any(s => s.Path == change.Fresh!.Path) ? SceneryJudgement.Leave : SceneryJudgement.Take;
// Saved from here since that look, or reloaded by hand.
if (!sceneries.Contains(current) || (change.Fresh is not null && !IsStale(current)))
return SceneryJudgement.Leave;
// One whose file went stays with what was edited in it, for that to be saved elsewhere.
if (change.Fresh is not { } fresh)
return current.Trainsets.Any(SceneryTrainsetText.IsModified) ? SceneryJudgement.Leave : SceneryJudgement.Take;
var lost = CarryOver(current, fresh);
return lost.Count > 0 ? new SceneryJudgement(SceneryVerdict.AskFirst, lost) : SceneryJudgement.Take;
}
/// <summary>On the UI thread: the library, and what is picked in it, take the change in.</summary>
public void Apply(SceneryChange change)
{
var sceneries = _library.Sceneries;
if (change.Current is not { } current)
{
sceneries.Add(change.Fresh!);
return;
}
if (change.Fresh is not { } fresh)
{
sceneries.Remove(current);
if (ReferenceEquals(_state.CurrentScenery, current))
{
_state.CurrentScenery = null;
_state.CurrentTrainset = null;
_state.StartingVehicleName = null;
}
return;
}
sceneries[sceneries.IndexOf(current)] = fresh;
if (ReferenceEquals(_state.CurrentScenery, current))
Follow(current, fresh);
}
private void Follow(Scenery current, Scenery fresh)
{
// Its text changed, the one of its name stands for it, or the one in its place.
var picked = _state.CurrentTrainset;
var kept = picked is null || fresh.Trainsets.Contains(picked)
? picked
: fresh.Trainsets.FirstOrDefault(t => t.Name == picked.Name)
?? fresh.Trainsets.ElementAtOrDefault(current.Trainsets.IndexOf(picked));
_state.CurrentScenery = fresh;
_state.CurrentTrainset = kept;
if (kept is null || kept.Vehicles.All(v => v.Name != _state.StartingVehicleName))
_state.StartingVehicleName = null;
}
}

View File

@@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Classes;
@@ -58,6 +59,28 @@ public class Trainset
public string? Mini;
/// <summary>
/// "reversed" ahead of the vehicles: the simulator turns the whole trainset round -
/// the first vehicle stays at the offset and leads towards the track's point 1,
/// every vehicle turned. The author's to set; the starter keeps it as found.
/// </summary>
public bool Reversed { get; private set; }
/// <summary>
/// The file the trainset is written in, as the scenery's include names it, for
/// one from a file put in place by <see cref="Domain.Sceneries.SceneryIncludes"/>;
/// null for the scenery's own.
/// </summary>
public string? SourceFile { get; init; }
/// <summary>
/// Where the speed, each vehicle and the <c>endtrainset</c> stand in the text, read
/// word by word as the fields above are - comments left out, a word taken for a
/// field whatever it says. With no <c>endtrainset</c> the end is the end of the text.
/// Known only of a trainset that was <see cref="Parsed"/>.
/// </summary>
internal (SceneryCode.Token? Speed, List<(int Start, int End)> Vehicles, int End) Layout { get; private set; }
public Trainset(string trainsetEntry)
{
RawEntry = trainsetEntry;
@@ -77,11 +100,7 @@ public class Trainset
private void ParseEntry(string trainsetEntry)
{
List<string> tokens = Regex
.Matches(trainsetEntry, @"/\*[\s\S]*?\*/|//[^\r\n]*|[^\s\r\n]+")
.Select(m => m.Value)
.Where(t => !t.StartsWith("//") && !t.StartsWith("/*"))
.ToList();
List<SceneryCode.Token> tokens = SceneryCode.Tokens(SceneryCode.Mask(trainsetEntry, out _));
var match = Regex.Match(
trainsetEntry,
@@ -99,66 +118,92 @@ public class Trainset
if (mini.Success)
Mini = mini.Groups[1].Value.Trim();
// The simulator reads its words in lower case: "Trainset" is a trainset too.
bool Is(int at, string word) => tokens[at].Is(word);
SceneryCode.Token? speed = null;
var spans = new List<(int Start, int End)>();
int end = trainsetEntry.Length;
for (int i = 0; i < tokens.Count; i++)
{
if (tokens[i] == "endtrainset")
break;
if (tokens[i] == "trainset")
if (Is(i, "endtrainset"))
{
this.Name = tokens[++i];
this.Track = tokens[++i];
this.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
this.Velocity = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
end = tokens[i].Index;
break;
}
if (Is(i, "trainset"))
{
this.Name = tokens[++i].Text;
this.Track = tokens[++i].Text;
this.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
speed = tokens[++i];
this.Velocity = float.Parse(tokens[i].Text, CultureInfo.InvariantCulture);
this.OriginalVelocity = this.Velocity;
continue;
}
if (tokens[i] == "assignments")
// Ahead of the vehicles; the simulator refuses it after the first one.
if (Is(i, "reversed"))
{
while (i < tokens.Count && tokens[i] != "endassignment")
i++;
i++;
Reversed |= Vehicles.Count == 0;
continue;
}
if (tokens[i] == "node")
if (Is(i, "assignment") || Is(i, "assignments"))
{
while (i < tokens.Count && !Is(i, "endassignment"))
i++;
continue;
}
if (Is(i, "node"))
{
int start = tokens[i].Index;
Dynamic nodeDynamic = new Dynamic();
nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.Name = tokens[++i];
nodeDynamic.RangeMax = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
nodeDynamic.RangeMin = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
nodeDynamic.Name = tokens[++i].Text;
i++;
nodeDynamic.DataFolder = tokens[++i];
nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i]);
nodeDynamic.MmdFile = tokens[++i];
nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.DataFolder = tokens[++i].Text;
nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i].Text);
nodeDynamic.MmdFile = tokens[++i].Text;
nodeDynamic.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
nodeDynamic.DriverType = tokens[++i] switch
{
"headdriver" => eDriverType.Headdriver,
"reardriver" => eDriverType.Reardriver,
"passenger" => eDriverType.Passenger,
_ => eDriverType.Nobody
};
nodeDynamic.DriverType = Dynamic.ParseDriver(tokens[++i].Text);
nodeDynamic.Coupling = Coupling.Parse(tokens[++i]);
nodeDynamic.Coupling = Coupling.Parse(tokens[++i].Text);
// A missing enddynamic does not take the next vehicle with it.
var trailing = new List<string>();
while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic")
trailing.Add(tokens[++i]);
while (i + 1 < tokens.Count && !Is(i + 1, "enddynamic") && !Is(i + 1, "node") &&
!Is(i + 1, "endtrainset"))
trailing.Add(tokens[++i].Text);
nodeDynamic.ReadTrailing(trailing);
i++;
if (i + 1 < tokens.Count && Is(i + 1, "enddynamic"))
i++;
Vehicles.Add(nodeDynamic);
spans.Add((start, tokens[i].End));
}
}
Layout = (speed, spans, end);
}
public string ToSceneryEntry() =>
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
/// <summary>No vehicles, in the export too: an entry that could not be read goes out as its raw text.</summary>
public void RemoveVehicles()
{
Vehicles = new List<Dynamic>();
if (!Parsed)
RawEntry = string.Empty;
}
public string GetTrainsetEntry()
{
@@ -177,13 +222,11 @@ public class Trainset
sb.Append(Track).Append(' ');
sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' ');
sb.Append(Velocity.ToString(CultureInfo.InvariantCulture));
if (Reversed)
sb.Append(" reversed");
sb.Append('\n');
string entry = sb.ToString();
foreach (Dynamic vehicle in Vehicles)
entry += vehicle.ToTrainsetNode();
return entry;
return sb.Append(Dynamic.ToTrainsetNodes(Vehicles)).ToString();
}
}
@@ -201,12 +244,6 @@ public class Dynamic
public Coupling Coupling = new();
public byte couplingData
{
get => (byte)(Coupling.AbsFlags & 0xFF);
set => Coupling.Flags = value;
}
public const string PantState = "pantstate";
public const int PantStateMax = 3;
@@ -244,8 +281,6 @@ public class Dynamic
public static bool IsPantStateType(string? type) =>
string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase);
public bool IsPantState => IsPantStateType(LoadType);
internal void ReadTrailing(List<string> t)
{
int p = 0;
@@ -278,14 +313,33 @@ public class Dynamic
return dot > 0 ? token[..dot] : token;
}
internal static eDriverType ParseDriver(string word) => word.ToLowerInvariant() switch
{
"headdriver" => eDriverType.Headdriver,
"reardriver" => eDriverType.Reardriver,
"passenger" => eDriverType.Passenger,
_ => eDriverType.Nobody
};
private string DriverWord => DriverType switch
{
eDriverType.Headdriver => "headdriver",
eDriverType.Reardriver => "reardriver",
eDriverType.Passenger => "passenger",
_ => "nobody"
};
internal string WriteTrailing()
{
var sb = new StringBuilder();
bool loaded = !string.IsNullOrEmpty(LoadType);
bool loaded = !string.IsNullOrEmpty(LoadType) && LoadCount > 0;
sb.Append(' ').Append((loaded ? LoadCount : 0).ToString(CultureInfo.InvariantCulture));
if (loaded && (LoadCount > 0 || IsPantState))
// The simulator reads a type only after a count above 0. One after a 0 it
// takes for a destination, and with it the next vehicle, up to its enddynamic.
// Pantographs set to none are the simulator's default anyway.
if (loaded)
sb.Append(' ').Append(LoadType);
return sb.ToString();
@@ -314,37 +368,26 @@ public class Dynamic
public string ToTrainsetNode()
{
string driver = DriverType switch
{
eDriverType.Headdriver => "headdriver",
eDriverType.Reardriver => "reardriver",
eDriverType.Passenger => "passenger",
_ => "nobody"
};
return
$"node {RangeMax} {RangeMin} {Name} dynamic " +
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
$"{DataFolder} {SkinFile} {MmdFile} " +
$"{Offset.ToString(CultureInfo.InvariantCulture)} " +
$"{driver} {Coupling}{WriteTrailing()} enddynamic\n";
$"{DriverWord} {Coupling}{WriteTrailing()} enddynamic\n";
}
public static string ToTrainsetNodes(IEnumerable<Dynamic> vehicles) =>
string.Concat(vehicles.Select(v => v.ToTrainsetNode()));
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}" +
$"{Offset.ToString(CultureInfo.InvariantCulture)} {DriverWord} {vel}" +
$"{WriteTrailing()} enddynamic\n";
}
}

View File

@@ -0,0 +1,23 @@
using System;
using StarterNG.Application.Abstractions;
namespace StarterNG.Domain.Sceneries;
/// <summary>A file as it stood, to tell whether it has been changed since.</summary>
public readonly record struct FileStamp(DateTime LastWriteUtc, long Size)
{
/// <summary>Null for one that is not there, or cannot be looked at just now.</summary>
public static FileStamp? Of(IFileSystem files, string path)
{
try
{
return files.FileExists(path)
? new FileStamp(files.GetLastWriteTimeUtc(path), files.GetFileSize(path))
: null;
}
catch (Exception)
{
return null;
}
}
}

View File

@@ -1,11 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Domain;
namespace StarterNG.Domain.Sceneries;
@@ -59,10 +57,16 @@ public sealed class Scenery
public List<string> LooseVehicleNames { get; } = new();
public List<string> Faults { get; } = new();
public List<SceneryFault> Faults { get; } = new();
public SceneryWeather Weather { get; } = new();
/// <summary>
/// The files it was read from, as each stood then: its own and those put in place
/// of their includes. Replaced whole, as it is looked at off the UI thread too.
/// </summary>
public IReadOnlyDictionary<string, FileStamp?> Sources { get; set; } = new Dictionary<string, FileStamp?>();
public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path);
public string LocalizedDescription(ISceneryTranslations translations) =>
@@ -81,7 +85,9 @@ public sealed class Scenery
for (int i = 0; i < Trainsets.Count; i++)
{
string entry = skipDecorTrainsets && Trainsets[i].Decor
// The simulator logs an empty trainset as a bad one and does nothing else with it.
bool empty = Trainsets[i].Parsed && Trainsets[i].Vehicles.Count == 0;
string entry = empty || (skipDecorTrainsets && Trainsets[i].Decor)
? string.Empty
: Trainsets[i].ToSceneryEntry();
result = result.Replace("{{" + i + "}}", entry);

View File

@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace StarterNG.Domain.Sceneries;
/// <summary>
/// A scenery's text read the way the simulator's parser reads it: comments -
/// <c>//</c> to the end of the line, <c>/* */</c> anywhere, even glued to a word -
/// left out, and the rest split into words at spaces, tabs, line ends and
/// semicolons. A trainset commented out is no trainset.
/// </summary>
public static class SceneryCode
{
public readonly record struct Token(string Text, int Index)
{
public int End => Index + Text.Length;
public bool Is(string word) => string.Equals(Text, word, StringComparison.OrdinalIgnoreCase);
}
private static readonly Regex Word = new(@"[^\s;]+");
/// <summary>
/// The text with every comment blanked out, line ends kept, so a place in one
/// is the same place in the other.
/// </summary>
/// <param name="unclosed">Where a <c>/*</c> that never ends starts, or -1.</param>
public static string Mask(string text, out int unclosed)
{
unclosed = -1;
char[]? masked = null;
for (int i = 0; i < text.Length; i++)
{
char c = text[i];
if (c == '"')
{
// Quotes glue what they enclose into one word, comment marks and all.
int close = text.IndexOf('"', i + 1);
i = close < 0 ? text.Length : close;
continue;
}
if (c != '/' || i + 1 >= text.Length)
continue;
int end;
if (text[i + 1] == '/')
{
end = text.IndexOf('\n', i + 2);
if (end < 0)
end = text.Length;
}
else if (text[i + 1] == '*')
{
int close = text.IndexOf("*/", i + 2, StringComparison.Ordinal);
if (close < 0)
unclosed = i;
end = close < 0 ? text.Length : close + 2;
}
else
{
continue;
}
masked ??= text.ToCharArray();
for (int j = i; j < end; j++)
if (masked[j] != '\n' && masked[j] != '\r')
masked[j] = ' ';
i = end - 1;
}
return masked is null ? text : new string(masked);
}
/// <param name="code">Text with its comments blanked out by <see cref="Mask"/>.</param>
public static List<Token> Tokens(string code)
{
var tokens = new List<Token>();
for (var m = Word.Match(code); m.Success; m = m.NextMatch())
tokens.Add(new Token(m.Value, m.Index));
return tokens;
}
}

View File

@@ -0,0 +1,104 @@
namespace StarterNG.Domain.Sceneries;
public enum SceneryFaultKind
{
/// <summary><c>/*</c> never closed: the simulator skips the rest of the file.</summary>
UnclosedComment,
/// <summary>A trainset with no <c>endtrainset</c>; the simulator ends it at the next one.</summary>
TrainsetNotClosed,
/// <summary>
/// A trainset's header written twice: the simulator ends the first as an empty
/// trainset, noting it in its log, and reads the trainset under the second.
/// </summary>
TrainsetHeaderRepeated,
/// <summary>The header lacks its name, track, offset or speed.</summary>
TrainsetIncomplete,
StrayEndTrainset,
/// <summary><c>reversed</c> outside a trainset, or after its first vehicle.</summary>
ReversedMisplaced,
/// <summary>A vehicle with no <c>enddynamic</c>, which swallows what follows.</summary>
VehicleNotClosed,
VehicleIncomplete,
/// <summary>
/// A vehicle with no load count: the simulator takes its <c>enddynamic</c> for
/// one and goes on to the next.
/// </summary>
LoadCountMissing,
/// <summary>
/// A load count other than 0 with no type after it - the simulator takes the
/// <c>enddynamic</c> for the type; <see cref="SceneryFault.Token"/> holds the count.
/// </summary>
LoadTypeMissing,
/// <summary>
/// One word between the load and <c>enddynamic</c>: the simulator takes it for
/// a destination, the <c>enddynamic</c> for its name, and goes on to the next.
/// <see cref="SceneryFault.Token"/> holds the word.
/// </summary>
WordBeforeEnd,
/// <summary>A vehicle written as a trainset's is, with no trainset around it.</summary>
VehicleOutsideTrainset,
/// <summary>
/// A trainset's track named with a space, which splits it into words; the
/// <see cref="SceneryFault.Token"/> holds the name as written.
/// </summary>
TrackNameSplit,
/// <summary>Text where a number belongs; <see cref="SceneryFault.Token"/> holds it.</summary>
NotANumber,
/// <summary>An include whose file is not there; <see cref="SceneryFault.Token"/> names it.</summary>
MissingInclude,
/// <summary>
/// An include whose file is there only under a name in other letter case: the
/// simulator looks for it in lower case, which on Linux misses it. The
/// <see cref="SceneryFault.Subject"/> holds the name on disk.
/// </summary>
IncludeLetterCase,
/// <summary>An include with no <c>end</c>, which swallows what follows it.</summary>
IncludeNotClosed,
/// <summary>A trainset the starter could not read, for a reason none of the above names.</summary>
TrainsetUnreadable,
/// <summary>A vehicle outside a trainset the starter could not read.</summary>
VehicleUnreadable
}
/// <summary>
/// A mistake in a scenery's text: where it is - the scenery's file or one it
/// includes, and the line, as an editor counts them - and what it concerns.
/// </summary>
/// <param name="Subject">The trainset or vehicle it concerns, when it has a name.</param>
public sealed record SceneryFault(SceneryFaultKind Kind, string File, int Line, string? Subject = null,
string? Token = null)
{
/// <summary>Where in the scenery's text it was found, to tell which trainset it falls in.</summary>
public int Offset { get; init; }
/// <summary>
/// What the simulator does not read because of it - the vehicles, trainsets and
/// includes it takes as part of this entry - or null when it loses none.
/// </summary>
public string? Lost { get; init; }
/// <summary>
/// The trainsets the simulator does not make because of it, their headers
/// swallowed by it or by what it set going - named, or placed when unnamed -
/// or null when there are none.
/// </summary>
public string? Unmade { get; init; }
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace StarterNG.Domain.Sceneries;
/// <summary>A scenery's faults as they are read: where each one is, in a column, and what it is.</summary>
public static class SceneryFaultText
{
/// <param name="limit">Most it lists; the rest are only counted.</param>
/// <param name="text">The wording for a key, in the language shown.</param>
public static string Format(IReadOnlyList<SceneryFault> faults, int limit, Func<string, string> text)
{
if (faults.Count == 0)
return "";
var shown = faults.Take(limit).Select(f => (Where: $"{f.File}:{f.Line}", Fault: f)).ToList();
int width = shown.Max(f => f.Where.Length) + 2;
var lines = new StringBuilder();
foreach (var (where, fault) in shown)
{
lines.Append(where.PadRight(width)).Append(Describe(fault, text)).Append('\n');
// What it costs, under it: too long to follow on the same line.
if (fault.Lost is { } lost)
lines.Append(' ', width).Append(string.Format(text("SyntaxLost"), lost)).Append('\n');
if (fault.Unmade is { } unmade)
lines.Append(' ', width).Append(string.Format(text("SyntaxUnmade"), unmade)).Append('\n');
}
if (faults.Count > limit)
lines.Append(string.Format(text("SyntaxMore"), faults.Count - limit));
return lines.ToString().TrimEnd();
}
private static string Describe(SceneryFault fault, Func<string, string> text) =>
string.Format(text("Syntax" + fault.Kind), fault.Subject ?? "?", fault.Token ?? "");
}

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace StarterNG.Domain.Sceneries;
/// <summary>
/// The scenario's own files - included after FirstInit - put in place of their
/// include line when they hold trainsets, as the old starter did. Decorations
/// and road traffic live there; inlined, their consists are listed, can be
/// started from, and the copy written for a start carries them instead of the
/// include, so they are not loaded twice.
/// </summary>
/// <remarks>
/// Only the plain <c>include path end</c> form: an include with parameters needs
/// them put in, and one whose <c>end</c> is not on its line is read by the
/// simulator in ways not worth guessing. One marked <c>$optional</c> stays too:
/// whether it is loaded is the player's to choose. Each inlined file is wrapped in
/// marker comments, which is how a trainset is told to belong to it.
/// </remarks>
public static class SceneryIncludes
{
public const string BeginMarker = "//$starter-include ";
public const string EndMarker = "//$starter-endinclude";
private static readonly Regex FirstInit = new(@"\bFirstInit\b", RegexOptions.IgnoreCase);
private static readonly Regex Include = new(
@"^[ \t]*include[ \t]+(\S+)[ \t]+end\b[^\r\n]*", RegexOptions.Multiline | RegexOptions.IgnoreCase);
private static readonly Regex HasTrainset = new(@"^[ \t]*trainset\b", RegexOptions.Multiline | RegexOptions.IgnoreCase);
private static readonly Regex BlockComment = new(@"/\*[\s\S]*?\*/");
private static readonly Regex Marked = new(
Regex.Escape(BeginMarker) + @"(\S+)[\s\S]*?" + Regex.Escape(EndMarker));
/// <param name="read">
/// A file named as an include names it: its name as it is on disk, which the
/// marker carries, and its text - or null when there is none.
/// </param>
public static string Inline(string text, Func<string, (string File, string Text)?> read)
{
var firstInit = FirstInit.Match(text);
if (!firstInit.Success)
return text;
string head = text[..firstInit.Index];
string tail = text[firstInit.Index..];
string newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
var comments = BlockComment.Matches(tail).Select(m => (m.Index, End: m.Index + m.Length)).ToList();
return head + Include.Replace(tail, m =>
{
if (comments.Any(c => m.Index >= c.Index && m.Index < c.End))
return m.Value;
if (m.Value.Contains("$optional", StringComparison.OrdinalIgnoreCase))
return m.Value;
if (read(m.Groups[1].Value) is not var (file, content) || !HasTrainset.IsMatch(content))
return m.Value;
return BeginMarker + file + newline + content.TrimEnd() + newline + EndMarker;
});
}
/// <summary>The text without the inlined files: what the scenery itself says.</summary>
public static string WithoutInlined(string text) => Marked.Replace(text, "");
/// <summary>Where each inlined file sits in the text, by the name its include gave.</summary>
public static IReadOnlyList<(int Start, int End, string File)> Ranges(string text) =>
Marked.Matches(text).Select(m => (m.Index, m.Index + m.Length, m.Groups[1].Value)).ToList();
public static string? FileAt(IReadOnlyList<(int Start, int End, string File)> ranges, int index) =>
ranges.FirstOrDefault(r => index >= r.Start && index < r.End).File;
}

View File

@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
namespace StarterNG.Domain.Sceneries;
/// <summary>
/// Which file and line a place in a scenery's text is - its own file, or one put
/// in place of its include by <see cref="SceneryIncludes"/>, counted from that
/// file's own first line.
/// </summary>
public sealed class SceneryLines
{
private readonly List<int> _starts = new();
private readonly List<(string File, int Line)> _lines = new();
public SceneryLines(string text, string file)
{
var outer = new Stack<(string File, int Line)>();
string current = file;
int number = 1;
for (int start = 0; ;)
{
int end = text.IndexOf('\n', start);
if (end < 0)
end = text.Length;
var line = text.AsSpan(start, end - start);
_starts.Add(start);
if (line.StartsWith(SceneryIncludes.BeginMarker, StringComparison.Ordinal))
{
// The marker stands where the include was; the file's lines follow.
_lines.Add((current, number));
outer.Push((current, number));
current = line[SceneryIncludes.BeginMarker.Length..].Trim().ToString();
number = 1;
}
else if (line.StartsWith(SceneryIncludes.EndMarker, StringComparison.Ordinal) && outer.Count > 0)
{
(current, number) = outer.Pop();
_lines.Add((current, number++));
}
else
{
_lines.Add((current, number++));
}
if (end == text.Length)
break;
start = end + 1;
}
}
public (string File, int Line) At(int index)
{
int found = _starts.BinarySearch(index);
return _lines[Math.Max(0, found >= 0 ? found : ~found - 1)];
}
/// <summary>A mistake at a place in the text, told by its file and line.</summary>
public SceneryFault Fault(SceneryFaultKind kind, int at, string? subject = null, string? token = null)
{
var (file, line) = At(at);
return new SceneryFault(kind, file, line, subject, token) { Offset = at };
}
}

View File

@@ -0,0 +1,528 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace StarterNG.Domain.Sceneries;
/// <summary>
/// What the simulator makes of an include: whether it opens the file - and when
/// it does not, the file that is there under a name differing only in letter case.
/// </summary>
public readonly record struct IncludeLookup(bool Opens, string? OnDisk = null);
/// <summary>
/// Goes through a scenery's text for the parts the starter reads - trainsets,
/// vehicles, includes - finding where each trainset and loose vehicle is, and
/// what in them is written wrong. Whatever else the text holds is passed over.
/// </summary>
/// <remarks>
/// The text is read twice. Once word by word as the simulator reads it, which
/// takes every field it expects whatever stands there, and after a vehicle
/// written wrong goes on to the next <c>enddynamic</c> - that finds the faults,
/// and what each makes the simulator lose. Once as the author meant it, each
/// entry ending where it was meant to - that finds the trainsets and vehicles,
/// so one the simulator would lose is still listed, and written right when
/// the starter writes it.
/// </remarks>
public static class ScenerySyntax
{
public sealed record Result(
IReadOnlyList<(int Start, int End)> Trainsets,
IReadOnlyList<(int Start, int End)> LooseVehicles,
IReadOnlyList<SceneryFault> Faults,
SceneryLines Lines);
/// <param name="text">The scenery's text, its includes put in place.</param>
/// <param name="file">The scenery's file name, for faults in its own text.</param>
/// <param name="findInclude">
/// What the simulator makes of an include, named as the include names it; null
/// leaves includes unchecked.
/// </param>
public static Result Scan(string text, string file, Func<string, IncludeLookup>? findInclude = null)
{
string code = SceneryCode.Mask(text, out int unclosed);
var tokens = SceneryCode.Tokens(code);
var lines = new SceneryLines(text, file);
var simulator = new Reading(tokens, lines, findInclude, asSimulator: true);
simulator.Run();
var author = new Reading(tokens, lines, null, asSimulator: false);
author.Run();
var faults = Unmade(simulator, author, lines);
if (unclosed >= 0)
faults.Insert(0, lines.Fault(SceneryFaultKind.UnclosedComment, unclosed));
return new Result(author.Trainsets.Select(t => (t.Start, t.End)).ToList(), author.Loose, faults, lines);
}
/// <summary>
/// The simulator's faults, with those in a trainset whose header it swallowed
/// left out: they come of reading its vehicles with no trainset, and the
/// trainset is told of instead, with the fault it came of - the first of a
/// run of them, where the run started.
/// </summary>
private static List<SceneryFault> Unmade(Reading simulator, Reading author, SceneryLines lines)
{
var unmade = new List<(int Start, int End, int Cause)>();
var lost = new Dictionary<int, List<string>>();
foreach (var (start, end, name) in author.Trainsets)
{
int cause = -1;
foreach (var (from, to, at) in simulator.Swallowed)
if (start >= from && start <= to)
{
cause = at;
break;
}
if (cause < 0)
continue;
foreach (var earlier in unmade)
if (cause >= earlier.Start && cause < earlier.End)
{
cause = earlier.Cause;
break;
}
unmade.Add((start, end, cause));
var (file, line) = lines.At(start);
bool named = name.Length > 0 && !name.Equals("none", StringComparison.OrdinalIgnoreCase);
if (!lost.TryGetValue(cause, out var names))
lost[cause] = names = new List<string>();
names.Add(named ? name : $"{file}:{line}");
}
if (unmade.Count == 0)
return simulator.Faults;
var faults = new List<SceneryFault>();
foreach (var fault in simulator.Faults)
{
if (unmade.Any(u => fault.Offset >= u.Start && fault.Offset < u.End))
continue;
faults.Add(fault.Lost is not null && lost.Remove(fault.Offset, out var names)
? fault with { Unmade = Listed(names) }
: fault);
}
return faults;
}
private static string Listed(List<string> entries)
{
const int Named = 6;
string named = string.Join(", ", entries.Take(Named));
return entries.Count > Named ? $"{named} … +{entries.Count - Named}" : named;
}
private sealed class Reading(
List<SceneryCode.Token> tokens, SceneryLines lines, Func<string, IncludeLookup>? findInclude, bool asSimulator)
{
public readonly List<(int Start, int End, string Name)> Trainsets = new();
public readonly List<(int, int)> Loose = new();
public readonly List<SceneryFault> Faults = new();
/// <summary>The text each fault makes the simulator pass over - from, to - and where the fault is.</summary>
public readonly List<(int From, int To, int At)> Swallowed = new();
// The trainset being read: where it starts, its name, how many vehicles so
// far, and the word its header starts at.
private (int Index, string Name, int Vehicles, int At)? _open;
public void Run()
{
for (int i = 0; i < tokens.Count; i++)
{
var token = tokens[i];
if (token.Is("trainset"))
i = Header(i);
else if (token.Is("endtrainset"))
{
// One with no vehicles is no mistake: a place for the player's own
// consist, filled in the depot.
if (_open is { } ended)
Trainsets.Add((ended.Index, token.End, ended.Name));
else
Fault(SceneryFaultKind.StrayEndTrainset, token.Index);
_open = null;
}
else if (token.Is("reversed"))
{
// Turns the trainset round only while none of its vehicles is placed yet.
if (_open is not { Vehicles: 0 })
Fault(SceneryFaultKind.ReversedMisplaced, token.Index);
}
else if (token.Is("assignment") || token.Is("assignments"))
{
while (i + 1 < tokens.Count && !tokens[i].Is("endassignment"))
i++;
}
else if (token.Is("node"))
{
// Not a vehicle: only its kind is stepped over, what follows is its own.
if (i + 4 >= tokens.Count || !tokens[i + 4].Is("dynamic"))
i += 4;
else
i = Vehicle(i);
}
else if (token.Is("include"))
i = Include(i);
}
if (_open is { } last)
NotClosed(last);
}
// Unless its header is told incomplete already: the endtrainset taken for a field.
private void NotClosed((int Index, string Name, int Vehicles, int At) trainset)
{
if (!Faults.Any(f => f.Offset == trainset.Index))
Fault(SceneryFaultKind.TrainsetNotClosed, trainset.Index, trainset.Name);
}
// Each reader below takes the entry starting at the given word and gives
// back the last word it took.
private int Header(int at)
{
var token = tokens[at];
if (_open is { } unended)
{
// The same header twice over, the first with nothing under it: the
// simulator ends that one as empty - noting it in its log - and reads on.
if (unended.Vehicles == 0 && Same(unended.At, at, 4))
Fault(SceneryFaultKind.TrainsetHeaderRepeated, unended.Index, unended.Name);
else
NotClosed(unended);
}
_open = null;
if (!Fields(at, 4))
{
// Still opened, so its endtrainset ends it rather than standing alone.
int given = 0;
while (given < 4 && at + given + 1 < tokens.Count && !Keyword(tokens[at + given + 1]))
given++;
string? partial = given > 0 ? tokens[at + 1].Text : null;
_open = (token.Index, partial ?? "", 0, at);
if (!asSimulator)
return at + given;
// The simulator takes four words, whatever they are.
int taken = Math.Min(at + 4, tokens.Count - 1);
Fault(SceneryFaultKind.TrainsetIncomplete, token.Index, partial, swallowed: (at + given, taken));
return taken;
}
string name = tokens[at + 1].Text;
if (SplitTrack(tokens, at) is { } words)
{
// Its offset is the rest of the track's name - a lost letter, often.
string track = string.Join(' ', tokens.Skip(at + 2).Take(words).Select(t => t.Text));
Fault(SceneryFaultKind.TrackNameSplit, tokens[at + 2].Index, name, track);
}
else
{
Number(tokens[at + 3], name);
Number(tokens[at + 4], name);
}
_open = (token.Index, name, 0, at);
return at + 4;
}
private int Vehicle(int at)
{
bool inTrainset = _open is not null;
// node range range name dynamic, then folder skin mmd [track] offset driver [coupling | speed]
int fields = inTrainset ? 10 : 11;
string name = tokens[at + 3].Text;
bool complete = Fields(at, fields);
// Where the author meant it to end: its enddynamic, or with that missing
// the word before whatever comes next.
int meant = at + 5;
for (; meant < tokens.Count && !Keyword(tokens[meant]); meant++)
if (tokens[meant].Is("enddynamic"))
break;
bool closed = meant < tokens.Count && tokens[meant].Is("enddynamic");
if (!closed)
meant--;
if (!asSimulator)
return ListVehicle(at, complete, closed, meant);
if (at + fields + 1 >= tokens.Count)
{
Fault(complete ? SceneryFaultKind.VehicleNotClosed : SceneryFaultKind.VehicleIncomplete,
tokens[at].Index, name);
return tokens.Count - 1;
}
var (fault, trackless) = CheckFields(at, complete, inTrainset, name);
if (fault is null && !closed)
fault = SceneryFaultKind.VehicleNotClosed;
int end = LoadEnd(at + fields + 1, trackless, meant, ref fault, out string? faulty);
if (fault is { } kind)
Fault(kind, tokens[at].Index, name, faulty, end > meant ? (meant, end) : null);
if (_open is { } open)
_open = open with { Vehicles = open.Vehicles + 1 };
return end;
}
// As the author meant it: there to be listed, whatever is wrong with it.
private int ListVehicle(int at, bool complete, bool closed, int meant)
{
// Without even the fields every vehicle has it is no vehicle to list.
if (!complete)
return at;
// One with its end missing still counts in its trainset: the starter
// reads it all the same, and writes it whole for a start. One written
// for a trainset with none around it is no loose vehicle either.
if (_open is { } current)
_open = current with { Vehicles = current.Vehicles + 1 };
else if (closed && !TrainsetStyle(at))
Loose.Add((tokens[at].Index, tokens[meant].End));
return meant;
}
// What is written wrong in a vehicle's fields, the numbers told of right away.
private (SceneryFaultKind? Fault, bool Trackless) CheckFields(int at, bool complete, bool inTrainset, string name)
{
SceneryFaultKind? fault = null;
// A track named by a number is none the simulator has: it drops the vehicle
// right after its load, going to the next enddynamic.
bool trackless = false;
if (!complete)
fault = SceneryFaultKind.VehicleIncomplete;
else
{
Number(tokens[at + 1], name);
Number(tokens[at + 2], name);
if (inTrainset)
{
Number(tokens[at + 8], name);
string coupling = tokens[at + 10].Text;
int dot = coupling.IndexOf('.');
string flags = dot < 0 ? coupling : coupling[..dot];
if (flags.Length > 0 && !int.TryParse(flags, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
Fault(SceneryFaultKind.NotANumber, tokens[at + 10].Index, name, coupling);
}
else if (TrainsetStyle(at))
{
// Its header missing or commented out.
fault = SceneryFaultKind.VehicleOutsideTrainset;
trackless = true;
}
else
{
Number(tokens[at + 9], name);
Number(tokens[at + 11], name);
trackless = IsNumber(tokens[at + 8]);
}
}
return (fault, trackless);
}
// The last word of a vehicle, from its load count on, with what is written wrong
// there unless something before it already was.
private int LoadEnd(int next, bool trackless, int meant, ref SceneryFaultKind? fault, out string? faulty)
{
faulty = null;
// Then the load: its count, and only after a count other than 0 its type.
var count = tokens[next++];
if (Counted(count.Text))
{
if (next < tokens.Count && tokens[next].Is("enddynamic") && fault is null)
(fault, faulty) = (SceneryFaultKind.LoadTypeMissing, count.Text);
next++;
}
else if (count.Is("enddynamic"))
fault ??= SceneryFaultKind.LoadCountMissing;
// Then enddynamic - or a destination, one word more, and on to the next enddynamic.
int end;
if (trackless)
end = EndDynamic(next);
else if (next < tokens.Count && tokens[next].Is("enddynamic"))
end = next;
else
{
end = EndDynamic(next + 2);
if (fault is null && end > meant)
(fault, faulty) = (SceneryFaultKind.WordBeforeEnd, tokens[next].Text);
}
return end;
}
private int Include(int at)
{
var token = tokens[at];
string name = at + 1 < tokens.Count ? tokens[at + 1].Text : "";
if (!asSimulator)
{
// Its parameters run to "end". Another include, a trainset or a node on
// the way means the end is missing - read on as what they are.
int stop = at + 2;
while (stop < tokens.Count && !tokens[stop].Is("end") && !Keyword(tokens[stop]) &&
!tokens[stop].Is("include") && !tokens[stop].Is("firstinit"))
stop++;
return stop < tokens.Count && tokens[stop].Is("end") ? stop : stop - 1;
}
if (findInclude is not null && Plain(name) && findInclude(name) is { Opens: false } lookup)
{
if (lookup.OnDisk is { } onDisk)
Fault(SceneryFaultKind.IncludeLetterCase, token.Index, onDisk, name);
else
Fault(SceneryFaultKind.MissingInclude, token.Index, token: name);
}
// The simulator takes its parameters up to the word "end", written just so,
// whatever comes before it.
int end = at + 2;
while (end < tokens.Count && tokens[end].Text != "end")
end++;
int last = Math.Min(end, tokens.Count - 1);
if (Lost(at + 1, last) is not null)
Fault(SceneryFaultKind.IncludeNotClosed, token.Index, token: name, swallowed: (at + 1, last));
return last;
}
// A vehicle with no trainset around it, written as within one - offset, crew,
// coupling - where its track, offset and crew belong.
private bool TrainsetStyle(int at) =>
at + 9 < tokens.Count && IsNumber(tokens[at + 8]) && Driver(tokens[at + 9]);
// The first enddynamic from the given word on, or the last word there is.
private int EndDynamic(int from)
{
int at = from;
while (at < tokens.Count && !tokens[at].Is("enddynamic"))
at++;
return Math.Min(at, tokens.Count - 1);
}
/// <summary>
/// The entries starting past <paramref name="after"/> up to <paramref name="last"/>,
/// as they would be named in the text - vehicles by their names - or null
/// when none start there.
/// </summary>
private string? Lost(int after, int last)
{
var entries = new List<string>();
for (int at = after + 1; at <= last && at < tokens.Count; at++)
{
var token = tokens[at];
if (token.Is("node") && at + 4 < tokens.Count)
entries.Add(tokens[at + 4].Is("dynamic")
? tokens[at + 3].Text
: $"{tokens[at + 4].Text} {tokens[at + 3].Text}");
else if ((token.Is("trainset") || token.Is("include")) && at + 1 < tokens.Count)
entries.Add($"{token.Text} {tokens[at + 1].Text}");
else if (token.Is("endtrainset") || token.Is("firstinit"))
entries.Add(token.Text);
}
return entries.Count == 0 ? null : Listed(entries);
}
// Whether the words after the two given are the same, so many of them.
private bool Same(int first, int second, int count)
{
for (int i = 1; i <= count; i++)
if (second + i >= tokens.Count || !tokens[first + i].Text.Equals(tokens[second + i].Text, StringComparison.OrdinalIgnoreCase))
return false;
return true;
}
/// <summary>Whether the <paramref name="count"/> words after the one at <paramref name="at"/> are all there and none starts something else.</summary>
private bool Fields(int at, int count)
{
if (at + count >= tokens.Count)
return false;
for (int i = at + 1; i <= at + count; i++)
if (Keyword(tokens[i]) || tokens[i].Is("enddynamic"))
return false;
return true;
}
private void Number(SceneryCode.Token field, string subject)
{
if (!IsNumber(field))
Fault(SceneryFaultKind.NotANumber, field.Index, subject, field.Text);
}
/// <param name="swallowed">The words the simulator takes along with it, past the one given up to the last.</param>
private void Fault(SceneryFaultKind kind, int index, string? subject = null, string? token = null,
(int After, int Last)? swallowed = null)
{
// Faults are the simulator's reading's to tell.
if (!asSimulator)
return;
string? lost = null;
if (swallowed is var (after, last) && Lost(after, last) is { } entries)
{
lost = entries;
Swallowed.Add((tokens[after + 1].Index, tokens[last].Index, index));
}
Faults.Add(lines.Fault(kind, index, subject, token) with { Lost = lost });
}
}
/// <summary>
/// How many words a trainset's track name was split into by spaces, judged by
/// the two numbers - offset and speed - that follow them; null when it was not.
/// </summary>
private static int? SplitTrack(List<SceneryCode.Token> tokens, int header)
{
if (IsNumber(tokens[header + 3]))
return null;
for (int words = 2; words <= 4; words++)
{
int offset = header + 2 + words;
if (offset + 1 >= tokens.Count || Keyword(tokens[offset - 1]) || IsNumber(tokens[offset - 1]))
return null;
if (IsNumber(tokens[offset]) && IsNumber(tokens[offset + 1]))
return words;
}
return null;
}
/// <summary>
/// Whether a load count reads as other than 0, the way the simulator reads a
/// whole number: the digits it starts with, and 0 for one that does not.
/// </summary>
private static bool Counted(string text)
{
int at = text.Length > 0 && text[0] is '+' or '-' ? 1 : 0;
bool any = false;
for (; at < text.Length && char.IsAsciiDigit(text[at]); at++)
any |= text[at] != '0';
return any;
}
private static bool Driver(SceneryCode.Token token) =>
token.Is("headdriver") || token.Is("reardriver") || token.Is("passenger") || token.Is("nobody");
private static bool IsNumber(SceneryCode.Token token) =>
double.TryParse(token.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out _);
private static bool Keyword(SceneryCode.Token token) =>
token.Is("node") || token.Is("trainset") || token.Is("endtrainset");
/// <summary>
/// An include named outright - not one built from a template's parameters or
/// picked at random, which only the simulator can tell.
/// </summary>
private static bool Plain(string name) =>
name.Length > 0 && !name.Contains("(p", StringComparison.OrdinalIgnoreCase) &&
name.IndexOfAny(new[] { '*', '?' }) < 0;
}

View File

@@ -0,0 +1,183 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using StarterNG.Classes;
namespace StarterNG.Domain.Sceneries;
/// <summary>
/// A trainset written back into its own scenery file. Only the vehicles that
/// changed are the starter's to rewrite: everything else the author put there -
/// the header as typed, the description, comments, an assignments block, the other
/// vehicles as they are spelled - stays as it was, and the rest of the file is not
/// touched at all.
/// </summary>
public static class SceneryTrainsetText
{
private static readonly Regex LoneLineFeed = new(@"(?<!\r)\n");
private static readonly Regex RestOfLine = new(@"^[ \t]*\r?\n?");
/// <summary>
/// The trainset as the file should now hold it, in the file's own line ends.
/// Ends at <c>endtrainset</c>, as the parser's match of the old one did.
/// </summary>
/// <remarks>
/// What stands between the vehicles stays in its place among those that stay.
/// A comment inside a vehicle's own node goes when that node is rewritten or
/// the vehicle removed: there is no longer a place for it.
/// </remarks>
public static string Compose(Trainset trainset, string newline)
{
string raw = trainset.RawEntry;
var saved = new Trainset(raw);
// Not read to its end: what stands there is not known, so it all stays.
if (!saved.Parsed)
return raw;
var (speed, vehicles, end) = saved.Layout;
// Line ends of both kinds are left as they are: there is no one kind to give them.
bool mixed = raw.Contains("\r\n", StringComparison.Ordinal) && LoneLineFeed.IsMatch(raw);
string Own(string text) => mixed ? text : Normalize(text).Replace("\n", newline);
int first = vehicles.Count > 0 ? vehicles[0].Start : end;
string head = raw[..first];
// The speed is the one header field the depot changes - the ready switch.
// Rewritten only when it differs, so "0.0" stays "0.0".
if (saved.Velocity != trainset.Velocity && speed is { } field)
head = raw[..field.Index] + trainset.Velocity.ToString(CultureInfo.InvariantCulture) + raw[field.End..first];
string[] was = saved.Vehicles.Select(v => v.ToTrainsetNode()).ToArray();
string[] now = trainset.Vehicles.Select(v => v.ToTrainsetNode()).ToArray();
string ending = end < raw.Length ? raw[end..] : "endtrainset";
if (was.SequenceEqual(now))
return Own(head + raw[first..end] + ending);
string Written(int i) => raw[vehicles[i].Start..vehicles[i].End];
string After(int i) => raw[vehicles[i].End..(i + 1 < vehicles.Count ? vehicles[i + 1].Start : end)];
var sb = new StringBuilder(Own(head));
void AtLineStart()
{
if (sb.Length > 0 && sb[^1] != '\n')
sb.Append(newline);
}
// One moved here is still written as it was.
void Rewrite(int j)
{
AtLineStart();
int written = Array.IndexOf(was, now[j]);
sb.Append(written >= 0 ? Own(Written(written)) : now[j].TrimEnd('\n'));
}
int[,] staying = Staying(was, now);
for (int i = 0, j = 0; i < was.Length || j < now.Length;)
{
bool both = i < was.Length && j < now.Length;
if (both && was[i] == now[j])
{
sb.Append(Own(Written(i) + After(i)));
i++;
j++;
}
else if (both && staying[i + 1, j + 1] == staying[i, j])
{
// In the place of the one there before, so what followed that one stays.
Rewrite(j++);
sb.Append(Own(After(i++)));
}
else if (j < now.Length && (i == was.Length || staying[i, j + 1] == staying[i, j]))
{
Rewrite(j++);
sb.Append(newline);
}
else
{
// Gone, the end of its line with it; what followed it stays.
string after = After(i++);
if (!string.IsNullOrWhiteSpace(after))
{
AtLineStart();
sb.Append(Own(RestOfLine.Replace(after, "", 1)));
}
}
}
AtLineStart();
return sb.Append(Own(ending)).ToString();
}
/// <summary>
/// How many vehicles can stay in the order they are in, of those from each place on
/// in the old list and in the new one: the longest common run of the two.
/// </summary>
private static int[,] Staying(string[] was, string[] now)
{
var staying = new int[was.Length + 1, now.Length + 1];
for (int i = was.Length - 1; i >= 0; i--)
for (int j = now.Length - 1; j >= 0; j--)
staying[i, j] = was[i] == now[j]
? staying[i + 1, j + 1] + 1
: Math.Max(staying[i + 1, j], staying[i, j + 1]);
return staying;
}
/// <summary>
/// The file with the trainset's old text swapped for <paramref name="entry"/>,
/// or null when that text cannot be told apart in the file: missing, or found
/// more often than the scenery has trainsets written that way.
/// </summary>
public static string? Replace(string fileText, Trainset trainset, IReadOnlyList<Trainset> sceneryTrainsets,
string entry)
{
var twins = sceneryTrainsets.Where(t => t.RawEntry == trainset.RawEntry).ToList();
int ordinal = twins.FindIndex(t => ReferenceEquals(t, trainset));
if (ordinal < 0 || trainset.RawEntry.Length == 0)
return null;
var found = new List<int>();
for (int at = fileText.IndexOf(trainset.RawEntry, StringComparison.Ordinal);
at >= 0;
at = fileText.IndexOf(trainset.RawEntry, at + trainset.RawEntry.Length, StringComparison.Ordinal))
found.Add(at);
if (found.Count != twins.Count)
return null;
int start = found[ordinal];
return fileText[..start] + entry + fileText[(start + trainset.RawEntry.Length)..];
}
/// <summary>
/// Whether the trainset now differs from what its file holds, compared the way
/// both would be written so a different spelling of the same number is no change.
/// </summary>
public static bool IsModified(Trainset trainset) =>
trainset.Parsed && Signature(trainset) != SavedSignature(trainset.RawEntry);
private static readonly Dictionary<string, string> SavedSignatures = new();
private static string SavedSignature(string raw)
{
lock (SavedSignatures)
{
if (!SavedSignatures.TryGetValue(raw, out string? signature))
SavedSignatures[raw] = signature = Signature(new Trainset(raw));
return signature;
}
}
private static string Signature(Trainset trainset) =>
trainset.Velocity.ToString(CultureInfo.InvariantCulture) + "\n" +
Dynamic.ToTrainsetNodes(trainset.Vehicles);
private static string Normalize(string text) => text.Replace("\r\n", "\n");
}

View File

@@ -69,6 +69,23 @@ public sealed class SceneryWeather
Dirty = true;
}
/// <summary>What was set by hand stays over a file read anew; untouched, the file's own stands.</summary>
public void KeepChoicesOf(SceneryWeather earlier)
{
if (!earlier.Changed)
return;
Time = earlier.Time;
ScenarioTimeOverride = earlier.ScenarioTimeOverride;
Day = earlier.Day;
Temperature = earlier.Temperature;
FogStart = earlier.FogStart;
FogEnd = earlier.FogEnd;
Overcast = earlier.Overcast;
OvercastRandom = earlier.OvercastRandom;
Dirty = earlier.Dirty;
}
public void SetFogRange(int start, int end, IRandomSource random)
{
FogStart = Math.Clamp(start, FogMin, FogMax);

View File

@@ -1,5 +1,5 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions;
@@ -8,9 +8,26 @@ namespace StarterNG.Domain.Sceneries;
public static class SceneryWeatherWriter
{
private static readonly Regex ManagedConfigLine = new(
@"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Whole words, as the simulator splits them: "atmo/lasy.scm" is a path, no atmo.
private const string WordStart = @"(?<![^\s;])";
private const string WordEnd = @"(?![^\s;])";
private static readonly Regex ConfigBlock = new(
$@"(?is){WordStart}config{WordEnd}(.*?){WordStart}endconfig{WordEnd}");
private static readonly Regex ManagedSetting = new(
$@"(?i){WordStart}(movelight|scenario\.weather\.temperature|scenario\.time\.override)\s+\S+");
/// <summary>What the written block takes the place of - looked for in the code alone, never in a comment.</summary>
private static readonly Regex[] Replaced =
{
ConfigBlock,
new($@"(?is){WordStart}time\s+\d[^\r\n]*?{WordStart}endtime{WordEnd}"),
new($@"(?is){WordStart}atmo{WordEnd}.*?{WordStart}endatmo{WordEnd}"),
new(@"(?im)^[ \t]*movelight\s+\S+"),
new($@"(?i){WordStart}scenario\.weather\.temperature\s+\S+"),
new($@"(?i){WordStart}scenario\.time\.override\s+\S+")
};
public static string Rewrite(string text, SceneryWeather weather, IRandomSource random)
{
@@ -19,12 +36,8 @@ public static class SceneryWeatherWriter
string carried = CarriedConfigLines(text);
string stripped = text;
stripped = Regex.Replace(stripped, @"(?is)\bconfig\b.*?\bendconfig\b", " ");
stripped = Regex.Replace(stripped, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " ");
stripped = Regex.Replace(stripped, @"(?is)\batmo\b.*?\bendatmo\b", " ");
stripped = Regex.Replace(stripped, @"(?im)^[ \t]*movelight\s+\S+", " ");
stripped = Regex.Replace(stripped, @"(?i)scenario\.weather\.temperature\s+\S+", " ");
stripped = Regex.Replace(stripped, @"(?i)scenario\.time\.override\s+\S+", " ");
foreach (var pattern in Replaced)
stripped = Cut(stripped, pattern);
var inv = CultureInfo.InvariantCulture;
string config =
@@ -46,6 +59,38 @@ public static class SceneryWeatherWriter
}
}
/// <summary>
/// The text without what the pattern finds in its code. A comment inside what
/// is taken out stays where it is, line ends and all, for a <c>//</c> to end
/// where it did.
/// </summary>
private static string Cut(string text, Regex pattern)
{
string code = SceneryCode.Mask(text, out _);
var sb = new StringBuilder(text.Length);
int at = 0;
foreach (Match found in pattern.Matches(code))
{
sb.Append(text, at, found.Index - at);
string written = text.Substring(found.Index, found.Length);
if (written == found.Value)
{
sb.Append(' ');
}
else
{
var comments = new StringBuilder(written);
foreach (var word in Enumerable.Reverse(SceneryCode.Tokens(found.Value)))
comments.Remove(word.Index, word.Text.Length);
sb.Append(comments);
}
at = found.Index + found.Length;
}
return sb.Append(text, at, text.Length - at).ToString();
}
private static string DelphiHourMinute(string hhmm)
{
int colon = hhmm.IndexOf(':');
@@ -57,20 +102,17 @@ public static class SceneryWeatherWriter
: "0" + hhmm[colon..];
}
/// <summary>The settings of every config block but those the starter writes itself.</summary>
private static string CarriedConfigLines(string text)
{
var block = Regex.Match(text, @"(?is)\bconfig\b(.*?)\bendconfig\b");
if (!block.Success)
return "";
var sb = new StringBuilder();
foreach (string raw in block.Groups[1].Value.Split('\n'))
{
string line = raw.TrimEnd('\r').Trim();
if (line.Length == 0 || ManagedConfigLine.IsMatch(line))
continue;
sb.Append(line).Append("\r\n");
}
foreach (Match block in ConfigBlock.Matches(SceneryCode.Mask(text, out _)))
foreach (string raw in ManagedSetting.Replace(block.Groups[1].Value, " ").Split('\n'))
{
string line = raw.Trim();
if (line.Length > 0)
sb.Append(line).Append("\r\n");
}
return sb.ToString();
}
}

View File

@@ -0,0 +1,18 @@
namespace StarterNG.Domain;
/// <summary>
/// File names as the simulator makes them: its parser folds every word it reads
/// to lower case - ASCII letters only - the files a scenery includes and the
/// scenario given with <c>-s</c> among them, and opens the file by that name.
/// Where letter case counts, as on Linux, a file is found only when it is named
/// so on disk.
/// </summary>
public static class SimulatorNames
{
public static string Lower(string name) =>
string.Create(name.Length, name, static (folded, source) =>
{
for (int i = 0; i < source.Length; i++)
folded[i] = source[i] is >= 'A' and <= 'Z' ? (char)(source[i] + ('a' - 'A')) : source[i];
});
}

View File

@@ -5,7 +5,6 @@ using System.Linq;
using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Infrastructure.Sceneries;
@@ -21,58 +20,97 @@ public sealed class SceneryParser
_random = random;
}
public Scenery Parse(string path, string content)
/// <param name="findInclude">
/// What the simulator makes of an include, as the include names it; null leaves
/// includes unchecked.
/// </param>
public Scenery Parse(string path, string content, Func<string, IncludeLookup>? findInclude = null)
{
string original = content;
var faults = new List<string>();
var attachments = ParseAttachments(original);
// A file put in place of its include has directives of its own, for itself.
string own = SceneryIncludes.WithoutInlined(original);
var attachments = ParseAttachments(own);
var includes = new List<SceneryInclude>();
content = StripOptionalIncludes(content, includes);
var trainsetEntries = new List<string>();
content = Regex.Replace(content, @"trainset\b[\s\S]*?\bendtrainset\b",
match =>
{
trainsetEntries.Add(match.Value);
return $"{{{{{trainsetEntries.Count - 1}}}}}";
},
RegexOptions.IgnoreCase);
// Read from the text with its optional includes blanked, which keeps every
// place where it was, so a fault's line is the line in the file.
var syntax = ScenerySyntax.Scan(content, System.IO.Path.GetFileName(path), findInclude);
var faults = syntax.Faults.ToList();
var inlined = SceneryIncludes.Ranges(content);
var trainsets = new List<Trainset>();
var looseVehicles = new List<Dynamic>();
var looseNames = new List<string>();
content = ExtractLooseVehicles(content, looseVehicles, looseNames, faults);
var cuts = new List<(int Start, int End, string With)>();
var scenery = new Scenery(path, content)
foreach (var (start, end) in syntax.Trainsets)
{
Group = MatchDirective(original, "l"),
Name = MatchDirective(original, "n"),
Description = MatchAllDirectives(original, "d"),
ImageName = MatchDirective(original, "i"),
Archival = MatchDirective(original, "a") != null
var trainset = new Trainset(content[start..end]) { SourceFile = SceneryIncludes.FileAt(inlined, start) };
if (!trainset.Parsed && !faults.Any(f => f.Offset >= start && f.Offset < end))
faults.Add(syntax.Lines.Fault(SceneryFaultKind.TrainsetUnreadable, start, trainset.Name));
cuts.Add((start, end, $"{{{{{trainsets.Count}}}}}"));
trainsets.Add(trainset);
}
foreach (var (start, end) in syntax.LooseVehicles)
{
// Included files' loose vehicles - road traffic, mostly - stay where they
// are as written, only their names are noted.
var vehicle = ReadLooseVehicle(content[start..end]);
looseNames.Add(vehicle?.Name ?? LooseName(content[start..end]));
if (SceneryIncludes.FileAt(inlined, start) != null)
continue;
if (vehicle is null)
{
if (!faults.Any(f => f.Offset >= start && f.Offset < end))
faults.Add(syntax.Lines.Fault(SceneryFaultKind.VehicleUnreadable, start, LooseName(content[start..end])));
continue;
}
looseVehicles.Add(vehicle);
cuts.Add((start, end, "\n"));
}
var scenery = new Scenery(path, Cut(content, cuts))
{
Group = MatchDirective(own, "l"),
Name = MatchDirective(own, "n"),
Description = MatchAllDirectives(own, "d"),
ImageName = MatchDirective(own, "i"),
Archival = MatchDirective(own, "a") != null
};
scenery.Attachments.AddRange(attachments);
scenery.Includes.AddRange(includes);
scenery.Trainsets.AddRange(trainsets);
scenery.LooseVehicles.AddRange(looseVehicles);
scenery.LooseVehicleNames.AddRange(looseNames);
scenery.Faults.AddRange(faults);
scenery.Faults.AddRange(faults.OrderBy(f => f.Offset));
ParseWeather(original, scenery.Weather);
// Without its comments: what is read here is what the writer later puts down as active.
ParseWeather(SceneryCode.Mask(original, out _), scenery.Weather);
scenery.Weather.AcceptAsAuthored();
foreach (string entry in trainsetEntries)
{
var trainset = new Trainset(entry);
scenery.Trainsets.Add(trainset);
if (!trainset.Parsed)
scenery.Faults.Add($"# trainset parse fault: {trainset.Name} ({trainset.Track})");
}
return scenery;
}
private static string Cut(string text, List<(int Start, int End, string With)> cuts)
{
var sb = new System.Text.StringBuilder(text.Length);
int at = 0;
foreach (var (start, end, with) in cuts.OrderBy(c => c.Start))
{
sb.Append(text, at, start - at).Append(with);
at = end;
}
return sb.Append(text, at, text.Length - at).ToString();
}
private static List<SceneryAttachment> ParseAttachments(string content)
{
var attachments = new List<SceneryAttachment>();
@@ -115,7 +153,7 @@ public sealed class SceneryParser
return Regex.Replace(
content,
@"include\s+(\S+(?:\s+\S+)*?)\s+end\s*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b",
@"include[ \t]+(\S+(?:[ \t]+\S+)*?)[ \t]+end[ \t]*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b",
m =>
{
string includePath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " ");
@@ -137,65 +175,55 @@ public sealed class SceneryParser
Kind = kind,
Selected = kind == 1
});
return " ";
return Regex.Replace(m.Value, @"[^\r\n]", " ");
},
RegexOptions.IgnoreCase);
}
private static string ExtractLooseVehicles(string content, List<Dynamic> vehicles, List<string> names,
List<string> faults)
private static readonly Regex LooseNode = new(
@"(?is)^node\s+(\S+)\s+(\S+)\s+(\S+)\s+dynamic\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)((?:\s+(?!enddynamic\b)\S+)*)\s*enddynamic$");
private static string LooseName(string node) =>
SceneryCode.Tokens(SceneryCode.Mask(node, out _)).ElementAtOrDefault(3).Text ?? "";
/// <summary>A vehicle standing on its own, or null when its node cannot be read.</summary>
private static Dynamic? ReadLooseVehicle(string node)
{
var inv = CultureInfo.InvariantCulture;
var m = LooseNode.Match(SceneryCode.Mask(node, out _));
if (!m.Success)
return null;
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 =>
try
{
var vehicle = new Dynamic
{
string name = m.Groups[3].Value;
RangeMax = float.Parse(m.Groups[1].Value, inv),
RangeMin = float.Parse(m.Groups[2].Value, inv),
Name = m.Groups[3].Value,
DataFolder = m.Groups[4].Value,
SkinFile = Dynamic.StripSkinExtension(m.Groups[5].Value),
MmdFile = m.Groups[6].Value,
PathName = m.Groups[7].Value,
Offset = float.Parse(m.Groups[8].Value, inv),
DriverType = Dynamic.ParseDriver(m.Groups[9].Value),
HasVelocity = true,
Velocity = float.Parse(m.Groups[10].Value, inv)
};
if (name.StartsWith("{{", StringComparison.Ordinal))
return m.Value;
try
{
var vehicle = 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 = Dynamic.StripSkinExtension(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();
vehicle.ReadTrailing(trailing);
vehicles.Add(vehicle);
names.Add(vehicle.Name);
return "\n";
}
catch
{
faults.Add($"# loose dynamic parse fault: {name}");
names.Add(name);
return m.Value;
}
});
vehicle.ReadTrailing(m.Groups[11].Value
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.ToList());
return vehicle;
}
catch (FormatException)
{
return null;
}
catch (OverflowException)
{
return null;
}
}
private void ParseWeather(string content, SceneryWeather weather)

View File

@@ -3,10 +3,10 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using StarterNG.Application.Abstractions;
using StarterNG.Domain;
using StarterNG.Domain.Sceneries;
using StarterNG.Infrastructure.Adapters;
@@ -21,6 +21,9 @@ public sealed class SceneryRepository : ISceneryRepository
private readonly IDiagnosticsLog _log;
private readonly SceneryParser _parser;
/// <summary>The scenery folders' files and folders, listed once per load of them all.</summary>
private readonly ConcurrentDictionary<(string, bool), IReadOnlyList<string>> _listings = new();
public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser)
{
_files = files;
@@ -31,6 +34,7 @@ public sealed class SceneryRepository : ISceneryRepository
public IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null)
{
_listings.Clear();
var files = SceneryFiles().ToList();
var loaded = new ConcurrentBag<Scenery>();
int total = Math.Max(1, files.Count);
@@ -40,7 +44,7 @@ public sealed class SceneryRepository : ISceneryRepository
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
file =>
{
var scenery = Load(file);
var scenery = LoadOne(file);
if (scenery is not null)
loaded.Add(scenery);
@@ -52,14 +56,44 @@ public sealed class SceneryRepository : ISceneryRepository
}
public Scenery? Load(string path)
{
_listings.Clear();
return LoadOne(path);
}
public IReadOnlyList<string>? ListFiles() =>
_files.DirectoryExists(_paths.Scenery) ? SceneryFiles().ToList() : null;
private Scenery? LoadOne(string path)
{
try
{
if (!_files.FileExists(path))
return null;
var scenery = _parser.Parse(path, LegacyText.Decode(_files.ReadAllBytes(path)));
scenery.HasCompanionTimetable = _files.FileExists(Path.ChangeExtension(path, ".sbt"));
// Looked at ahead of the reading: changed meanwhile, it counts as changed.
var sources = new Dictionary<string, FileStamp?> { [path] = FileStamp.Of(_files, path) };
// With its own .sbt the scenery's terrain comes from it, and the simulator
// passes its terrain includes over without opening them.
bool binaryTerrain = Opens(Path.GetFileNameWithoutExtension(path) + ".sbt");
IncludeLookup FindInclude(string name) =>
Opens(name) || (binaryTerrain && SimulatorNames.Lower(name).Contains("_ter.scm"))
? new IncludeLookup(true)
: new IncludeLookup(false, ResolveInclude(name));
(string File, string Text)? ReadInclude(string name)
{
string? file = ResolveInclude(name);
string full = Path.Combine(_paths.Scenery, file ?? name.Replace('\\', '/'));
sources[full] = FileStamp.Of(_files, full);
return file is null ? null : (file, LegacyText.Decode(_files.ReadAllBytes(full)));
}
string text = SceneryIncludes.Inline(LegacyText.Decode(_files.ReadAllBytes(path)), ReadInclude);
var scenery = _parser.Parse(path, text, FindInclude);
scenery.HasCompanionTimetable = binaryTerrain;
scenery.Sources = sources;
return scenery;
}
catch (Exception ex)
@@ -69,6 +103,58 @@ public sealed class SceneryRepository : ISceneryRepository
}
}
/// <summary>
/// Whether the simulator opens a file of the scenery folder named so: by the
/// name in lower case, which on Linux has to be how it is named on disk.
/// </summary>
private bool Opens(string name) =>
_files.FileExists(Path.Combine(_paths.Scenery, SimulatorNames.Lower(name.Replace('\\', '/'))));
/// <summary>
/// An include's file as it is named on disk, from the scenery folder, whatever
/// the case of its letters - as Windows, or Wine, finds it: whose consists the
/// copy written for a start carries in place of the include, so they come along
/// on Linux too.
/// </summary>
private string? ResolveInclude(string name)
{
string relative = name.Replace('\\', '/');
if (_files.FileExists(Path.Combine(_paths.Scenery, relative)))
return relative;
string[] parts = relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
string directory = _paths.Scenery;
var found = new List<string>();
for (int i = 0; i < parts.Length; i++)
{
if (parts[i] is "." or "..")
{
directory = Path.Combine(directory, parts[i]);
found.Add(parts[i]);
continue;
}
bool last = i == parts.Length - 1;
string? match = Listing(directory, last)
.FirstOrDefault(entry => string.Equals(Path.GetFileName(entry), parts[i],
StringComparison.OrdinalIgnoreCase));
if (match is null)
return null;
directory = match;
found.Add(Path.GetFileName(match));
}
return string.Join('/', found);
}
private IReadOnlyList<string> Listing(string directory, bool files) =>
_listings.GetOrAdd((directory, files), key =>
{
if (!_files.DirectoryExists(key.Item1))
return Array.Empty<string>();
return key.Item2 ? _files.GetFiles(key.Item1) : _files.GetDirectories(key.Item1);
});
private IEnumerable<string> SceneryFiles() =>
_files.GetFiles(_paths.Scenery, "*.scn")
.Where(path => Path.GetFileName(path).FirstOrDefault() != ExcludedPrefix);

View File

@@ -1,8 +1,10 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Sceneries;
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Infrastructure.Sceneries;
@@ -10,6 +12,10 @@ public sealed class TimetableLocator
{
private const string NoTimetable = "none";
// The heading of a timetable: [ Rodzaj i numer pociagu | EIE1531 ]
private static readonly Regex NumberLine = new(@"^\s*\[\s*Rodzaj\b[^|\r\n]*\|([^\]\r\n]+)",
RegexOptions.Multiline | RegexOptions.IgnoreCase);
private readonly IFileSystem _files;
public TimetableLocator(IFileSystem files)
@@ -36,4 +42,22 @@ public sealed class TimetableLocator
return candidates.FirstOrDefault(_files.FileExists);
}
/// <summary>The number the timetable gives its train; null with no timetable or no such heading in it.</summary>
public string? TrainNumber(Scenery scenery, string? name)
{
if (Resolve(scenery, name) is not { } path)
return null;
try
{
var heading = NumberLine.Match(LegacyText.Decode(_files.ReadAllBytes(path)));
string number = heading.Groups[1].Value.Trim();
return number.Length > 0 ? number : null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
}

View File

@@ -353,7 +353,7 @@ public partial class Scenarios : UserControl
private void PopulateFaults(Scenery scenery)
{
var lines = new List<string>();
lines.AddRange(scenery.Faults);
lines.Add(StarterNG.Domain.Sceneries.SceneryFaultText.Format(scenery.Faults, 500, key => App.Loc[key]));
var db = AppServices.Current.Library.Vehicles;
foreach (var ts in scenery.Trainsets)