diff --git a/StarterNG.Tests/ScenerySyntaxTests.cs b/StarterNG.Tests/ScenerySyntaxTests.cs index 39fede4..6397a57 100644 --- a/StarterNG.Tests/ScenerySyntaxTests.cs +++ b/StarterNG.Tests/ScenerySyntaxTests.cs @@ -273,7 +273,7 @@ public class ScenerySyntaxTests include koniec.scm """; - var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn, _ => true); + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn, new SceneryFileLookup(_ => true)); Assert.Empty(scenery.Faults); Assert.Equal("td/rozklad", Assert.Single(scenery.Trainsets).Name); @@ -326,7 +326,7 @@ public class ScenerySyntaxTests endoptional """; - var faults = TestScenery.Parser().Parse("scenery/x.scn", scn, name => name is "tory.scm" or "budynek.inc").Faults; + var faults = TestScenery.Parser().Parse("scenery/x.scn", scn, new SceneryFileLookup(name => name is "tory.scm" or "budynek.inc")).Faults; Assert.Equal((SceneryFaultKind.MissingInclude, "x.scn", 2, null, "brak.scm"), Seen(Assert.Single(faults))); } @@ -378,4 +378,58 @@ public class ScenerySyntaxTests Assert.Equal(sbt, scenery.HasCompanionTimetable); Assert.Equal(sbt ? 0 : 1, scenery.Faults.Count); } + + private static IReadOnlyList TerrainFaults(string scn, bool sbt, params string[] onDisk) + { + var files = new InMemoryFileSystem(caseSensitive: true).WithLegacyFile("game/scenery/Mapa.scn", scn); + if (sbt) + files.WithFile("game/scenery/mapa.sbt", "EU07"); + foreach (string file in onDisk) + files.WithFile("game/scenery/" + file, ""); + return TestScenery.Repository(files).Load("game/scenery/Mapa.scn")!.Faults; + } + + [Theory] + [InlineData("Teren/Mapa.txtf")] + [InlineData("teren/mapa.btf")] + [InlineData("TEREN/MAPA.BTF")] + public void A_terrain_file_found_as_text_or_binary_is_no_fault(string onDisk) => + Assert.Empty(TerrainFaults("terrain teren/mapa.txtf endterrain\nFirstInit\n", sbt: false, onDisk)); + + [Fact] + public void A_terrain_file_missing_both_ways_is_reported_but_an_sbt_named_with_it_is_not() + { + var faults = TerrainFaults("// teren\nterrain teren/mapa.btf teren/stara.sbt\nendterrain\nFirstInit\n", sbt: false); + + Assert.Equal((SceneryFaultKind.MissingTerrainFile, "Mapa.scn", 2, null, "teren/mapa.btf"), + Seen(Assert.Single(faults))); + } + + [Fact] + public void A_terrain_file_takes_the_place_of_the_sbt_so_terrain_includes_are_read_after_all() + { + var faults = TerrainFaults("terrain teren/mapa.txtf endterrain\ninclude nmt/brak_ter.scm end\nFirstInit\n", + sbt: true, "teren/mapa.txtf"); + + Assert.Equal((SceneryFaultKind.MissingInclude, "Mapa.scn", 2, null, "nmt/brak_ter.scm"), + Seen(Assert.Single(faults))); + } + + [Theory] + [InlineData("include nmt/brak_ter.scm end\nterrain teren/mapa.txtf endterrain\nFirstInit\n", 2)] + [InlineData("node -1 0 t1 triangles trawa\n0 0 0 0 0 0 0 0 end\nendtri\nterrain teren/mapa.txtf endterrain\nFirstInit\n", 4)] + [InlineData("node -1 -1 t1 model 0 0 0 0 teren.t3d none endmodel\nterrain teren/mapa.txtf endterrain\nFirstInit\n", 2)] + [InlineData("FirstInit\nterrain teren/mapa.txtf endterrain\n", 2)] + public void A_terrain_file_behind_what_the_sbt_already_gave_is_passed_over(string scn, int line) + { + var faults = TerrainFaults(scn, sbt: true, "teren/mapa.txtf"); + + Assert.Equal((SceneryFaultKind.TerrainFileBehindSbt, "Mapa.scn", line, null, "teren/mapa.txtf"), + Seen(Assert.Single(faults))); + } + + [Fact] + public void A_model_not_of_the_terrain_leaves_the_sbt_free_to_give_way() => + Assert.Empty(TerrainFaults("node 500 0 m1 model 0 0 0 0 dom.t3d none endmodel\nterrain teren/mapa.txtf endterrain\nFirstInit\n", + sbt: true, "teren/mapa.txtf")); } diff --git a/StarterNG/Domain/Sceneries/SceneryFault.cs b/StarterNG/Domain/Sceneries/SceneryFault.cs index 1b4f5f7..6b607d5 100644 --- a/StarterNG/Domain/Sceneries/SceneryFault.cs +++ b/StarterNG/Domain/Sceneries/SceneryFault.cs @@ -61,6 +61,19 @@ public enum SceneryFaultKind /// An include whose file is not there; names it. MissingInclude, + /// + /// A terrain file named by a terrain directive whose text (.txtf) and binary + /// file (.btf) are both missing; names it. + /// + MissingTerrainFile, + + /// + /// A terrain file the simulator passes over: the scenery's terrain comes from its + /// .sbt, which something before the directive was already taken from; + /// names it. + /// + TerrainFileBehindSbt, + /// An include with no end, which swallows what follows it. IncludeNotClosed, diff --git a/StarterNG/Domain/Sceneries/ScenerySyntax.cs b/StarterNG/Domain/Sceneries/ScenerySyntax.cs index 87ee063..cdfe72e 100644 --- a/StarterNG/Domain/Sceneries/ScenerySyntax.cs +++ b/StarterNG/Domain/Sceneries/ScenerySyntax.cs @@ -5,6 +5,14 @@ using System.Linq; namespace StarterNG.Domain.Sceneries; +/// What the simulator finds of the files a scenery names. +/// Whether it opens a file of the scenery folder, named as the scenery names it. +/// +/// Whether the scenery has its own .sbt: while that is in use, its terrain includes +/// (_ter.scm) and terrain geometry are passed over, the .sbt holding them. +/// +public sealed record SceneryFileLookup(Func Opens, bool HasSbt = false); + /// /// Goes through a scenery's text for the parts the starter reads - trainsets, /// vehicles, includes - finding where each trainset and loose vehicle is, and @@ -29,17 +37,17 @@ public static class ScenerySyntax /// The scenery's text, its includes put in place. /// The scenery's file name, for faults in its own text. - /// - /// Whether the simulator opens an include, named as the include names it; null - /// leaves includes unchecked. + /// + /// What the simulator finds of the files the scenery names - includes and terrain + /// files; null leaves them unchecked. /// - public static Result Scan(string text, string file, Func? opensInclude = null) + public static Result Scan(string text, string file, SceneryFileLookup? files = 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, opensInclude, asSimulator: true); + var simulator = new Reading(tokens, lines, files, asSimulator: true); simulator.Run(); var author = new Reading(tokens, lines, null, asSimulator: false); author.Run(); @@ -110,8 +118,14 @@ public static class ScenerySyntax } private sealed class Reading( - List tokens, SceneryLines lines, Func? opensInclude, bool asSimulator) + List tokens, SceneryLines lines, SceneryFileLookup? files, bool asSimulator) { + // Whether the scenery's .sbt is in use, and whether the simulator passed over + // something of the text on its account already - or got past FirstInit - after + // which a terrain file can no longer take its place. + private bool _sbt = files?.HasSbt ?? false; + private bool _sbtTaken; + public readonly List<(int Start, int End, string Name)> Trainsets = new(); public readonly List<(int, int)> Loose = new(); public readonly List Faults = new(); @@ -156,12 +170,20 @@ public static class ScenerySyntax { // Not a vehicle: only its kind is stepped over, what follows is its own. if (i + 4 >= tokens.Count || !tokens[i + 4].Is("dynamic")) + { + if (_sbt && TerrainGeometry(i)) + _sbtTaken = true; i += 4; + } else i = Vehicle(i); } else if (token.Is("include")) i = Include(i); + else if (token.Is("terrain") && asSimulator) + i = Terrain(i); + else if (token.Is("firstinit")) + _sbtTaken |= _sbt; } if (_open is { } last) @@ -369,8 +391,13 @@ public static class ScenerySyntax return stop < tokens.Count && tokens[stop].Is("end") ? stop : stop - 1; } - if (opensInclude is not null && Plain(name) && !opensInclude(name)) - Fault(SceneryFaultKind.MissingInclude, token.Index, token: name); + if (files is not null && Plain(name)) + { + if (_sbt && SimulatorNames.Lower(name).Contains("_ter.scm")) + _sbtTaken = true; + else if (!files.Opens(name)) + Fault(SceneryFaultKind.MissingInclude, token.Index, token: name); + } // The simulator takes its parameters up to the word "end", written just so, // whatever comes before it. @@ -383,6 +410,52 @@ public static class ScenerySyntax return last; } + /// + /// A terrain directive: the files it names up to endterrain. Of a terrain + /// file (.txtf, .btf) the simulator takes the text or the binary file of the same + /// name, whichever is there; an .sbt it passes over quietly when it cannot use it. + /// + private int Terrain(int at) + { + int end = at + 1; + while (end < tokens.Count && !tokens[end].Is("endterrain")) + end++; + + for (int named = at + 1; named < end && files is not null; named++) + { + string name = tokens[named].Text; + string lower = SimulatorNames.Lower(name); + if (!Plain(name) || !(lower.EndsWith(".txtf") || lower.EndsWith(".btf"))) + continue; + + string stem = name[..name.LastIndexOf('.')]; + if (!files.Opens(stem + ".txtf") && !files.Opens(stem + ".btf")) + Fault(SceneryFaultKind.MissingTerrainFile, tokens[named].Index, token: name); + else if (_sbtTaken) + Fault(SceneryFaultKind.TerrainFileBehindSbt, tokens[named].Index, token: name); + else + // The .sbt gives way: the terrain includes from here on are read after all. + _sbt = false; + } + return Math.Min(end, tokens.Count - 1); + } + + /// + /// Whether the node starting at the given word is terrain geometry, which an .sbt + /// in use holds in its place: triangles or lines, or a model shown from any distance. + /// + private bool TerrainGeometry(int at) + { + if (at + 4 >= tokens.Count) + return false; + var kind = tokens[at + 4]; + if (kind.Is("model")) + return double.TryParse(tokens[at + 2].Text, NumberStyles.Float, CultureInfo.InvariantCulture, + out double nearest) && nearest < 0; + return kind.Is("triangles") || kind.Is("triangle_strip") || kind.Is("triangle_fan") || + kind.Is("lines") || kind.Is("line_strip") || kind.Is("line_loop"); + } + // 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) => diff --git a/StarterNG/Infrastructure/Sceneries/SceneryParser.cs b/StarterNG/Infrastructure/Sceneries/SceneryParser.cs index f8072ba..c663355 100644 --- a/StarterNG/Infrastructure/Sceneries/SceneryParser.cs +++ b/StarterNG/Infrastructure/Sceneries/SceneryParser.cs @@ -20,11 +20,11 @@ public sealed class SceneryParser _random = random; } - /// - /// Whether the simulator opens an include, as the include names it; null leaves - /// includes unchecked. + /// + /// What the simulator finds of the files the scenery names; null leaves them + /// unchecked. /// - public Scenery Parse(string path, string content, Func? opensInclude = null) + public Scenery Parse(string path, string content, SceneryFileLookup? files = null) { string original = content; @@ -38,7 +38,7 @@ public sealed class SceneryParser // 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), opensInclude); + var syntax = ScenerySyntax.Scan(content, System.IO.Path.GetFileName(path), files); var faults = syntax.Faults.ToList(); var inlined = SceneryIncludes.Ranges(content); diff --git a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs index 229d59a..a077dc6 100644 --- a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs +++ b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs @@ -7,7 +7,6 @@ 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; @@ -78,10 +77,9 @@ public sealed class SceneryRepository : ISceneryRepository var encodings = new Dictionary(StringComparer.Ordinal); // With its own .sbt the scenery's terrain comes from it, and the simulator - // passes its terrain includes over without opening them. + // passes its terrain includes over without opening them - unless a terrain + // file takes its place. bool binaryTerrain = Opens(Path.GetFileNameWithoutExtension(path) + ".sbt"); - bool OpensInclude(string name) => - Opens(name) || (binaryTerrain && SimulatorNames.Lower(name).Contains("_ter.scm")); (string File, string Text)? ReadInclude(string name) { @@ -98,7 +96,7 @@ public sealed class SceneryRepository : ISceneryRepository string own = LegacyText.Decode(_files.ReadAllBytes(path), out var ownEncoding); string text = SceneryIncludes.Inline(own, ReadInclude); - var scenery = _parser.Parse(path, text, OpensInclude); + var scenery = _parser.Parse(path, text, new SceneryFileLookup(Opens, binaryTerrain)); scenery.HasCompanionTimetable = binaryTerrain; scenery.Sources = sources; scenery.TextEncoding = ownEncoding; diff --git a/StarterNG/startercfg/lang/cz.xml b/StarterNG/startercfg/lang/cz.xml index 64df1b5..a9f8073 100644 --- a/StarterNG/startercfg/lang/cz.xml +++ b/StarterNG/startercfg/lang/cz.xml @@ -742,6 +742,8 @@ Vozidlo {0} stojí mimo soupravu: chybí záhlaví trainset? {0}: „{1}“ místo čísla Soubor {1} nenalezen (include) + Soubor terénu {1} nenalezen, ani .txtf, ani .btf (terrain) + Soubor terénu {1} ignorován: terén už pochází ze souboru .sbt scenérie include {1} bez end Soupravu {0} se nepodařilo načíst Vozidlo {0} se nepodařilo načíst diff --git a/StarterNG/startercfg/lang/en.xml b/StarterNG/startercfg/lang/en.xml index 3e1c383..df69ee9 100644 --- a/StarterNG/startercfg/lang/en.xml +++ b/StarterNG/startercfg/lang/en.xml @@ -772,6 +772,8 @@ Vehicle {0} stands outside a trainset: header missing? {0}: “{1}” instead of a number File {1} not found (include) + Terrain file {1} not found, neither .txtf nor .btf (terrain) + Terrain file {1} ignored: the terrain already comes from the .sbt of the scenery include {1} without end Trainset {0} could not be read Vehicle {0} could not be read diff --git a/StarterNG/startercfg/lang/hu.xml b/StarterNG/startercfg/lang/hu.xml index d7286f1..9fb85a4 100644 --- a/StarterNG/startercfg/lang/hu.xml +++ b/StarterNG/startercfg/lang/hu.xml @@ -742,6 +742,8 @@ A(z) {0} jármű szerelvényen kívül áll: hiányzik a trainset fejléc? {0}: „{1}” szám helyett A(z) {1} fájl nem található (include) + A(z) {1} terepfájl nem található, sem .txtf, sem .btf (terrain) + A(z) {1} terepfájl kimarad: a terep már a díszlet .sbt fájljából jön include {1} end nélkül A(z) {0} szerelvényt nem sikerült beolvasni A(z) {0} járművet nem sikerült beolvasni diff --git a/StarterNG/startercfg/lang/pl.xml b/StarterNG/startercfg/lang/pl.xml index 7e2df79..190a4f3 100644 --- a/StarterNG/startercfg/lang/pl.xml +++ b/StarterNG/startercfg/lang/pl.xml @@ -769,6 +769,8 @@ Pojazd {0} stoi poza składem: brak nagłówka trainset? {0}: „{1}” zamiast liczby Brak pliku {1} (include) + Brak pliku terenu {1}, ani .txtf, ani .btf (terrain) + Plik terenu {1} pominięty: teren pochodzi już z pliku .sbt scenerii include {1} bez end Nie udało się odczytać składu {0} Nie udało się odczytać pojazdu {0} diff --git a/StarterNG/startercfg/lang/ru.xml b/StarterNG/startercfg/lang/ru.xml index 280351e..ab65bd5 100644 --- a/StarterNG/startercfg/lang/ru.xml +++ b/StarterNG/startercfg/lang/ru.xml @@ -742,6 +742,8 @@ Транспорт {0} стоит вне состава: нет заголовка trainset? {0}: «{1}» вместо числа Файл {1} не найден (include) + Файл рельефа {1} не найден, ни .txtf, ни .btf (terrain) + Файл рельефа {1} пропущен: рельеф уже берётся из файла .sbt сценария include {1} без end Не удалось прочитать состав {0} Не удалось прочитать единицу {0}