diff --git a/.gitignore b/.gitignore index ff5aab4..e5e78b9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ /StarterNG/obj /.idea /.vs +starter/ + +# Build output of any project in the solution +[Bb]in/ +[Oo]bj/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..f17707b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "C#: Launch Startup Project", + "type": "dotnet", + "request": "launch", + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d333a83 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# StarterNG + +Launcher for the MaSzyna train simulator, written in C# with Avalonia. + +## Building a native (NativeAOT) build + +The project publishes as a self-contained NativeAOT binary — no .NET runtime is +needed on the target machine. The commands below are the same ones CI runs, see +[`.github/workflows/dotnet.yml`](.github/workflows/dotnet.yml). + +Requirements: + +- .NET SDK 9 +- Linux: a native toolchain and zlib headers — `sudo apt install clang zlib1g-dev` + (Debian/Ubuntu) or `sudo dnf install clang zlib-devel` (Fedora) +- Windows: the Visual Studio C++ build tools (Desktop development with C++) + +Restore for the target runtime first, then publish: + +```sh +dotnet restore StarterNG/StarterNG.csproj -r linux-x64 + +dotnet publish StarterNG/StarterNG.csproj \ + -c Release -r linux-x64 --self-contained true --no-restore \ + -p:PublishAot=true -p:PublishTrimmed=true \ + -o publish/linux-x64 +``` + +For a Windows build swap both `linux-x64` occurrences for `win-x64` (and the +line continuations for PowerShell backticks). NativeAOT does not cross-compile, +so the Windows package has to be built on Windows and the Linux one on Linux — +that is why CI uses a `windows-2022` and an `ubuntu-22.04` runner. + +The output directory is the whole distributable: the `Starter` executable, the +Skia/HarfBuzz native libraries and `startercfg/` with the translations and the +default key bindings. The `.dbg` (or `.pdb`) file next to it is debug symbols and +can be dropped from a release. Trim/AOT analysis warnings during the publish are +expected and do not fail the build. + +CI builds both runtimes on every push and pull request to `main` and `staging`, +and on manual dispatch. Pushes additionally replace a rolling pre-release with +the packaged artifacts — tagged `latest` for `main` and `staging` for `staging`. + +## Development build + +```sh +dotnet build StarterNG/StarterNG.csproj +``` diff --git a/StarterNG.Tests/ExecutableLocatorTests.cs b/StarterNG.Tests/ExecutableLocatorTests.cs new file mode 100644 index 0000000..bcaed3f --- /dev/null +++ b/StarterNG.Tests/ExecutableLocatorTests.cs @@ -0,0 +1,82 @@ +using StarterNG.Domain.Settings; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class ExecutableLocatorTests +{ + private const string Elf = "\u007fELF binary"; + private const string PortableExecutable = "MZ binary"; + + [Fact] + public void The_canonical_binary_is_preferred_over_other_candidates() + { + var files = new InMemoryFileSystem() + .WithExecutable(TestInstallation.At("eu07-dev"), Elf) + .WithExecutable(TestInstallation.At("eu07"), Elf); + var installation = new TestInstallation(files); + + string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem); + + Assert.Equal(ExeProblem.None, problem); + Assert.EndsWith("eu07", resolved); + } + + [Fact] + public void A_binary_for_the_other_platform_is_reported_as_such() + { + var files = new InMemoryFileSystem().WithExecutable(TestInstallation.At("eu07"), PortableExecutable); + var installation = new TestInstallation(files); + + installation.Executables.Resolve(new SimulatorSettings(), out var problem); + + Assert.Equal(ExeProblem.WrongPlatform, problem); + } + + [Fact] + public void A_binary_without_the_execute_bit_is_reported_as_such() + { + var files = new InMemoryFileSystem().WithFile(TestInstallation.At("eu07"), Elf); + var installation = new TestInstallation(files); + + installation.Executables.Resolve(new SimulatorSettings(), out var problem); + + Assert.Equal(ExeProblem.NotExecutable, problem); + } + + [Fact] + public void An_empty_installation_reports_the_canonical_name_as_missing() + { + var installation = new TestInstallation(); + + string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem); + + Assert.Equal(ExeProblem.NotFound, problem); + Assert.Equal("eu07", resolved); + } + + [Fact] + public void A_manually_chosen_path_is_used_as_given() + { + var files = new InMemoryFileSystem().WithExecutable(TestInstallation.At("eu07-custom"), Elf); + var installation = new TestInstallation(files); + var settings = new SimulatorSettings { SelectExeAutomatically = false, ExecutablePath = "eu07-custom" }; + + string resolved = installation.Executables.Resolve(settings, out var problem); + + Assert.Equal("eu07-custom", resolved); + Assert.Equal(ExeProblem.None, problem); + } + + [Fact] + public void Data_files_beside_the_binary_are_never_offered_as_candidates() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("eu07.ini"), "width 1280") + .WithFile(TestInstallation.At("eu07.log"), "log") + .WithExecutable(TestInstallation.At("eu07"), Elf); + var installation = new TestInstallation(files); + + Assert.Equal(new[] { "eu07" }, installation.Executables.ListCandidates()); + } +} diff --git a/StarterNG.Tests/Fakes/FakeEnvironment.cs b/StarterNG.Tests/Fakes/FakeEnvironment.cs new file mode 100644 index 0000000..78fc6fd --- /dev/null +++ b/StarterNG.Tests/Fakes/FakeEnvironment.cs @@ -0,0 +1,34 @@ +using StarterNG.Application.Abstractions; + +namespace StarterNG.Tests.Fakes; + +/// A host machine described inline: platform, home folder, env vars. +public sealed class FakeEnvironment : IEnvironment +{ + private readonly Dictionary _variables = new(StringComparer.Ordinal); + + public bool IsWindows { get; set; } + + public bool IsLinux { get; set; } = true; + + public bool IsMacOS { get; set; } + + public string BaseDirectory { get; set; } = Path.Combine("game"); + + public string? GetVariable(string name) => _variables.TryGetValue(name, out var value) ? value : null; + + public FakeEnvironment With(string name, string value) + { + _variables[name] = value; + return this; + } + + /// Switches the fake to Windows, with an APPDATA folder. + public FakeEnvironment AsWindows(string appData = "C:/Users/kolejarz/AppData/Roaming") + { + IsWindows = true; + IsLinux = false; + IsMacOS = false; + return With("APPDATA", appData); + } +} diff --git a/StarterNG.Tests/Fakes/FakeStrings.cs b/StarterNG.Tests/Fakes/FakeStrings.cs new file mode 100644 index 0000000..3097b94 --- /dev/null +++ b/StarterNG.Tests/Fakes/FakeStrings.cs @@ -0,0 +1,26 @@ +using StarterNG.Application.Abstractions; + +namespace StarterNG.Tests.Fakes; + +/// +/// Translation table that echoes keys back, so assertions can name the key that +/// a screen or check would have shown. +/// +public sealed class FakeStrings : ILocalizedStrings +{ + private readonly Dictionary _overrides = new(StringComparer.Ordinal); + + public string this[string key] => _overrides.TryGetValue(key, out var value) ? value : key; + + public string Opt(string key) => _overrides.TryGetValue(key, out var value) ? value : string.Empty; + + public string CurrentLanguage => "English"; + + public string CurrentLangCode => "en"; + + public FakeStrings With(string key, string value) + { + _overrides[key] = value; + return this; + } +} diff --git a/StarterNG.Tests/Fakes/FixedClock.cs b/StarterNG.Tests/Fakes/FixedClock.cs new file mode 100644 index 0000000..e4a2a0e --- /dev/null +++ b/StarterNG.Tests/Fakes/FixedClock.cs @@ -0,0 +1,16 @@ +using StarterNG.Application.Abstractions; + +namespace StarterNG.Tests.Fakes; + +/// A clock frozen at a chosen instant. +public sealed class FixedClock : IClock +{ + public FixedClock(DateTime now) + { + Now = now; + } + + public DateTime Now { get; set; } + + public DateTime UtcNow => Now.ToUniversalTime(); +} diff --git a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs new file mode 100644 index 0000000..2dddd26 --- /dev/null +++ b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs @@ -0,0 +1,168 @@ +using System.Text; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Tests.Fakes; + +/// +/// An over a dictionary, so a whole MaSzyna +/// installation can be described inline in a test. +/// +/// +/// Paths are normalised to forward slashes and compared case-insensitively, +/// matching how the parsers treat installation paths on both platforms. +/// +public sealed class InMemoryFileSystem : IFileSystem +{ + private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _directories = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _executable = new(StringComparer.OrdinalIgnoreCase); + + public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + static InMemoryFileSystem() + { + // The game's own files are code page 1250; a test that writes Polish text + // into one needs the provider registered just as the application does. + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + /// Adds a UTF-8 text file, creating its parent directories. + public InMemoryFileSystem WithFile(string path, string contents) => + WithFile(path, contents, Encoding.UTF8); + + /// Adds a text file in a given encoding, as the game's files are stored. + public InMemoryFileSystem WithFile(string path, string contents, Encoding encoding) + { + string key = Normalize(path); + _files[key] = encoding.GetBytes(contents); + AddParents(key); + return this; + } + + /// Adds a file in the code page the .scn and .fiz formats use. + public InMemoryFileSystem WithLegacyFile(string path, string contents) => + WithFile(path, contents, Encoding.GetEncoding(1250)); + + /// Adds an empty directory. + public InMemoryFileSystem WithDirectory(string path) + { + string key = Normalize(path); + _directories.Add(key); + AddParents(key); + return this; + } + + public bool FileExists(string path) => _files.ContainsKey(Normalize(path)); + + public bool DirectoryExists(string path) => _directories.Contains(Normalize(path)); + + public string ReadAllText(string path) => Encoding.UTF8.GetString(Bytes(path)); + + public string ReadAllText(string path, Encoding encoding) => encoding.GetString(Bytes(path)); + + public string[] ReadAllLines(string path) => + ReadAllText(path).Split('\n').Select(line => line.TrimEnd('\r')).ToArray(); + + public Stream OpenRead(string path) => new MemoryStream(Bytes(path), writable: false); + + public void WriteAllText(string path, string contents) => WithFile(path, contents); + + public void WriteAllText(string path, string contents, Encoding encoding) + { + string key = Normalize(path); + _files[key] = encoding.GetBytes(contents); + AddParents(key); + } + + public void WriteAllLines(string path, IEnumerable lines) => + WithFile(path, string.Join("\n", lines)); + + public void WriteAllBytes(string path, byte[] bytes) + { + string key = Normalize(path); + _files[key] = bytes; + AddParents(key); + } + + public void CreateDirectory(string path) => WithDirectory(path); + + public void DeleteFile(string path) => _files.Remove(Normalize(path)); + + public void CopyFile(string sourcePath, string destinationPath, bool overwrite) + { + string destination = Normalize(destinationPath); + if (!overwrite && _files.ContainsKey(destination)) + throw new IOException($"{destinationPath} already exists"); + WriteAllBytes(destinationPath, Bytes(sourcePath)); + } + + public IReadOnlyList GetFiles(string path, string searchPattern = "*") + { + string prefix = Normalize(path) + "/"; + var pattern = ToRegex(searchPattern); + return _files.Keys + .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Where(key => !key[prefix.Length..].Contains('/')) + .Where(key => pattern.IsMatch(key[prefix.Length..])) + .OrderBy(key => key, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public IReadOnlyList GetFilesRecursive(string path, string searchPattern) + { + string prefix = Normalize(path) + "/"; + var pattern = ToRegex(searchPattern); + return _files.Keys + .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Where(key => pattern.IsMatch(key[(key.LastIndexOf('/') + 1)..])) + .OrderBy(key => key, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public IReadOnlyList GetDirectories(string path) + { + string prefix = Normalize(path) + "/"; + return _directories + .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Where(key => !key[prefix.Length..].Contains('/')) + .OrderBy(key => key, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public DateTime GetLastWriteTimeUtc(string path) => LastWriteTimeUtc; + + /// Files added through carry the execute bit. + public bool IsExecutable(string path) => _executable.Contains(Normalize(path)); + + public InMemoryFileSystem WithExecutable(string path, string contents = "\u007fELF") + { + WithFile(path, contents); + _executable.Add(Normalize(path)); + return this; + } + + private byte[] Bytes(string path) => + _files.TryGetValue(Normalize(path), out var bytes) + ? bytes + : throw new FileNotFoundException(path); + + private void AddParents(string key) + { + int slash = key.LastIndexOf('/'); + while (slash > 0) + { + key = key[..slash]; + if (!_directories.Add(key)) + return; + slash = key.LastIndexOf('/'); + } + } + + private static string Normalize(string path) => + path.Replace('\\', '/').TrimEnd('/'); + + private static System.Text.RegularExpressions.Regex ToRegex(string searchPattern) => + new("^" + System.Text.RegularExpressions.Regex.Escape(searchPattern) + .Replace("\\*", ".*").Replace("\\?", ".") + "$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); +} diff --git a/StarterNG.Tests/Fakes/StubRandom.cs b/StarterNG.Tests/Fakes/StubRandom.cs new file mode 100644 index 0000000..cbb91e4 --- /dev/null +++ b/StarterNG.Tests/Fakes/StubRandom.cs @@ -0,0 +1,22 @@ +using StarterNG.Application.Abstractions; + +namespace StarterNG.Tests.Fakes; + +/// +/// Randomness pinned to the low end of every range, so weather that varies in +/// production is deterministic in tests. +/// +public sealed class StubRandom : IRandomSource +{ + private readonly int _fixedValue; + + public StubRandom(int fixedValue = 0) + { + _fixedValue = fixedValue; + } + + public int Next(int minInclusive, int maxExclusive) => + Math.Clamp(minInclusive + _fixedValue, minInclusive, Math.Max(minInclusive, maxExclusive - 1)); + + public double NextDouble() => 0.0; +} diff --git a/StarterNG.Tests/Fakes/TestInstallation.cs b/StarterNG.Tests/Fakes/TestInstallation.cs new file mode 100644 index 0000000..724a8d7 --- /dev/null +++ b/StarterNG.Tests/Fakes/TestInstallation.cs @@ -0,0 +1,58 @@ +using StarterNG.Application; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Infrastructure.Settings; +using StarterNG.Infrastructure.Vehicles; + +namespace StarterNG.Tests.Fakes; + +/// +/// The production object graph wired over an in-memory installation: the same +/// composition as AppServices, with disk, clock and randomness replaced. +/// +public sealed class TestInstallation +{ + public const string Root = "game"; + + public TestInstallation(InMemoryFileSystem? files = null, DateTime? now = null) + { + Files = files ?? new InMemoryFileSystem(); + Paths = new GamePaths(Root); + Clock = new FixedClock(now ?? new DateTime(2026, 1, 1, 12, 0, 0)); + Random = new StubRandom(); + Log = new FileDiagnosticsLog(Files, Clock, Paths); + Strings = new FakeStrings(); + + MiniTextures = new MiniTextureIndex(Files, Paths); + Physics = new FizPhysicsRepository(Files, Paths, Log); + Vehicles = new TexturesTxtVehicleRepository(Files, Paths, Log, new TexturesTxtParser()); + Sceneries = new SceneryRepository(Files, Paths, Log, new SceneryParser(Clock, Random)); + MissingAssets = new MissingAssetScanner(Files, Paths); + Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, Log); + + Environment = new FakeEnvironment(); + SettingsPaths = new SettingsPaths(Environment, Paths); + Executables = new ExecutableLocator(Files, Paths, Environment, Log); + SettingsStore = new SettingsStore(Files, Clock, Log, SettingsPaths, new SettingsSerializer(), Executables); + } + + public InMemoryFileSystem Files { get; } + public GamePaths Paths { get; } + public FixedClock Clock { get; } + public StubRandom Random { get; } + public FileDiagnosticsLog Log { get; } + public FakeStrings Strings { get; } + public MiniTextureIndex MiniTextures { get; } + public FizPhysicsRepository Physics { get; } + public TexturesTxtVehicleRepository Vehicles { get; } + public SceneryRepository Sceneries { get; } + public MissingAssetScanner MissingAssets { get; } + public GameLibrary Library { get; } + public FakeEnvironment Environment { get; } + public SettingsPaths SettingsPaths { get; } + public ExecutableLocator Executables { get; } + public SettingsStore SettingsStore { get; } + + /// Path of a file inside the fake installation. + public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray()); +} diff --git a/StarterNG.Tests/FileDiagnosticsLogTests.cs b/StarterNG.Tests/FileDiagnosticsLogTests.cs new file mode 100644 index 0000000..aa3f629 --- /dev/null +++ b/StarterNG.Tests/FileDiagnosticsLogTests.cs @@ -0,0 +1,45 @@ +using StarterNG.Infrastructure.Adapters; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class FileDiagnosticsLogTests +{ + private static FileDiagnosticsLog Build(out InMemoryFileSystem files) + { + files = new InMemoryFileSystem(); + return new FileDiagnosticsLog(files, new FixedClock(new DateTime(2026, 3, 7, 14, 5, 0)), + new GamePaths("game")); + } + + [Fact] + public void Log_timestamps_each_line_from_the_clock() + { + var log = Build(out _); + + log.Log("scenery loaded"); + + Assert.Equal("2026-03-07 14:05:00 scenery loaded", log.Text); + } + + [Fact] + public void Exceptions_are_logged_with_their_context_and_type() + { + var log = Build(out _); + + log.Log("scenery/foo.scn", new InvalidOperationException("bad node")); + + Assert.Contains("scenery/foo.scn: InvalidOperationException: bad node", log.Text); + } + + [Fact] + public void Clear_drops_everything_logged_so_far() + { + var log = Build(out _); + log.Log("first"); + + log.Clear(); + + Assert.Equal(string.Empty, log.Text); + } +} diff --git a/StarterNG.Tests/GameLibraryTests.cs b/StarterNG.Tests/GameLibraryTests.cs new file mode 100644 index 0000000..1efe8a5 --- /dev/null +++ b/StarterNG.Tests/GameLibraryTests.cs @@ -0,0 +1,75 @@ +using StarterNG.Application; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class GameLibraryTests +{ + private static TestInstallation Complete() => + new(new InMemoryFileSystem() + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"), "ep07-135.mat=ep07.e3d,ep07") + .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice") + .WithFile(TestInstallation.At("textures", "mini", "ep07.bmp"), "bitmap")); + + [Fact] + public void Loading_reads_both_the_rolling_stock_and_the_scenarios() + { + var library = Complete().Library; + + library.Load(); + + Assert.True(library.Loaded); + Assert.Single(library.Vehicles.Textures); + Assert.Equal("Katowice", Assert.Single(library.Sceneries).Name); + } + + [Fact] + public void Loading_reports_progress_through_to_done() + { + var phases = new List(); + var progress = new Progress(status => { lock (phases) phases.Add(status.Phase); }); + + Complete().Library.Load(progress); + + SpinWait.SpinUntil(() => { lock (phases) return phases.Contains(LoadPhase.Done); }, TimeSpan.FromSeconds(2)); + lock (phases) + { + Assert.Contains(LoadPhase.Vehicles, phases); + Assert.Contains(LoadPhase.Done, phases); + } + } + + [Fact] + public void Loading_twice_does_not_duplicate_the_scenarios() + { + var library = Complete().Library; + + library.Load(); + library.Load(); + + Assert.Single(library.Sceneries); + } + + [Fact] + public void Reloading_picks_up_a_scenery_added_after_startup() + { + var installation = Complete(); + var library = installation.Library; + library.Load(); + + installation.Files.WithFile(TestInstallation.At("scenery", "gdansk.scn"), "//$n Gdańsk"); + library.ReloadSceneries(); + + Assert.Equal(2, library.Sceneries.Count); + } + + [Fact] + public void An_installation_with_no_rolling_stock_is_logged() + { + var installation = new TestInstallation(); + + installation.Library.Load(); + + Assert.Contains("textures.txt", installation.Log.Text); + } +} diff --git a/StarterNG.Tests/GamePathsTests.cs b/StarterNG.Tests/GamePathsTests.cs new file mode 100644 index 0000000..ca0930a --- /dev/null +++ b/StarterNG.Tests/GamePathsTests.cs @@ -0,0 +1,26 @@ +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Tests; + +public class GamePathsTests +{ + [Fact] + public void Wellknown_folders_hang_off_the_installation_root() + { + var paths = new GamePaths(Path.Combine("opt", "maszyna")); + + Assert.Equal(Path.Combine("opt", "maszyna", "scenery"), paths.Scenery); + Assert.Equal(Path.Combine("opt", "maszyna", "dynamic"), paths.Dynamic); + Assert.Equal(Path.Combine("opt", "maszyna", "textures", "mini"), paths.MiniTextures); + Assert.Equal(Path.Combine("opt", "maszyna", "starter", "bledy.txt"), paths.DiagnosticsLog); + } + + [Fact] + public void FromRoot_joins_segments_below_the_root() + { + var paths = new GamePaths("game"); + + Assert.Equal(Path.Combine("game", "data", "load_weights.txt"), + paths.FromRoot("data", "load_weights.txt")); + } +} diff --git a/StarterNG.Tests/InstallationCheckTests.cs b/StarterNG.Tests/InstallationCheckTests.cs new file mode 100644 index 0000000..ffe1215 --- /dev/null +++ b/StarterNG.Tests/InstallationCheckTests.cs @@ -0,0 +1,43 @@ +using StarterNG.Infrastructure; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class InstallationCheckTests +{ + [Fact] + public void An_empty_folder_reports_every_missing_game_directory() + { + var installation = new TestInstallation(); + var strings = installation.Strings.With("FaultNoDir", "missing {0}"); + var check = new InstallationCheck(installation.Paths, installation.Files, strings, + installation.Physics, installation.Library, installation.SettingsStore); + + var faults = check.Run(); + + Assert.Contains("missing /dynamic", faults); + Assert.Contains("missing /scenery", faults); + Assert.Contains("FaultNoWeights", faults); + } + + [Fact] + public void A_complete_installation_reports_no_missing_directories() + { + var files = new InMemoryFileSystem() + .WithDirectory(Path.Combine("game", "dynamic")) + .WithDirectory(Path.Combine("game", "sounds")) + .WithDirectory(Path.Combine("game", "models")) + .WithDirectory(Path.Combine("game", "scenery")) + .WithDirectory(Path.Combine("game", "textures")) + .WithFile(Path.Combine("game", "data", "load_weights.txt"), ""); + var installation = new TestInstallation(files); + var strings = installation.Strings.With("FaultNoDir", "missing {0}"); + + var faults = new InstallationCheck(installation.Paths, files, strings, + installation.Physics, installation.Library, installation.SettingsStore).Run(); + + Assert.DoesNotContain(faults, fault => fault.StartsWith("missing ")); + Assert.DoesNotContain("FaultNoWeights", faults); + } +} diff --git a/StarterNG.Tests/LegacyTextTests.cs b/StarterNG.Tests/LegacyTextTests.cs new file mode 100644 index 0000000..9e48eee --- /dev/null +++ b/StarterNG.Tests/LegacyTextTests.cs @@ -0,0 +1,38 @@ +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Tests; + +public class LegacyTextTests +{ + [Fact] + public void The_game_code_page_resolves_without_anyone_registering_it_first() + { + // Regression guard: when this falls back, every Polish character in a + // scenery description comes out as Latin-1 mojibake ("Skład" as "Sk³ad"). + Assert.False(LegacyText.IsFallback); + Assert.Equal(1250, LegacyText.CodePage1250.CodePage); + } + + [Fact] + public void Polish_characters_survive_a_round_trip_through_it() + { + const string text = "Skład wygaszony pod wjazdowym. Strzebiń - Woźniki Śl."; + + byte[] bytes = LegacyText.CodePage1250.GetBytes(text); + + Assert.Equal(text, LegacyText.CodePage1250.GetString(bytes)); + } + + [Fact] + public void A_scenery_written_in_the_game_code_page_reads_back_intact() + { + var files = new StarterNG.Tests.Fakes.InMemoryFileSystem() + .WithLegacyFile(StarterNG.Tests.Fakes.TestInstallation.At("scenery", "td.scn"), + "//$n Strzebiń - Woźniki Śl. '92\n//$d Skład wygaszony pod wjazdowym."); + + var scenery = Assert.Single(new StarterNG.Tests.Fakes.TestInstallation(files).Sceneries.LoadAll()); + + Assert.Equal("Strzebiń - Woźniki Śl. '92", scenery.Name); + Assert.Equal("Skład wygaszony pod wjazdowym.", scenery.Description); + } +} diff --git a/StarterNG.Tests/LoadWeightsTests.cs b/StarterNG.Tests/LoadWeightsTests.cs new file mode 100644 index 0000000..ae6e520 --- /dev/null +++ b/StarterNG.Tests/LoadWeightsTests.cs @@ -0,0 +1,41 @@ +using StarterNG.Classes; +using StarterNG.Domain.Vehicles; +using StarterNG.Infrastructure.Vehicles; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class LoadWeightsTests +{ + private static LoadWeightsRepository Repository(InMemoryFileSystem files) + { + var installation = new TestInstallation(files); + return new LoadWeightsRepository(files, installation.Paths, installation.Log); + } + + [Fact] + public void Weights_are_read_from_the_pairs_in_the_file() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("data", "load_weights.txt"), + "{\n coal : 850 # węgiel\n sand : 1600\n}"); + + var table = Repository(files).Table; + + Assert.Equal(850, table.WeightOf("coal")); + Assert.Equal(1600, table.WeightOf("SAND")); + } + + [Fact] + public void An_unknown_cargo_falls_back_to_the_default_weight() + { + Assert.Equal(LoadWeightsTable.DefaultWeight, + Repository(new InMemoryFileSystem()).Table.WeightOf("nieznany")); + } + + [Fact] + public void The_pantograph_pseudo_load_weighs_nothing() + { + Assert.Equal(0, new LoadWeightsTable().WeightOf(Dynamic.PantState)); + } +} diff --git a/StarterNG.Tests/MiniTextureIndexTests.cs b/StarterNG.Tests/MiniTextureIndexTests.cs new file mode 100644 index 0000000..9133c66 --- /dev/null +++ b/StarterNG.Tests/MiniTextureIndexTests.cs @@ -0,0 +1,55 @@ +using StarterNG.Infrastructure.Adapters; +using StarterNG.Infrastructure.Vehicles; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class MiniTextureIndexTests +{ + private static MiniTextureIndex Build(params string[] thumbnails) + { + var files = new InMemoryFileSystem(); + foreach (string name in thumbnails) + files.WithFile(TestInstallation.At("textures", "mini", name + ".bmp"), "bitmap"); + + return new MiniTextureIndex(files, new GamePaths(TestInstallation.Root)); + } + + [Fact] + public void A_thumbnail_is_found_by_name_whatever_its_case() + { + var index = Build("ep07", "other"); + + Assert.True(index.Has("EP07")); + Assert.EndsWith("ep07.bmp", index.PathFor("ep07")); + } + + [Fact] + public void A_vehicle_naming_an_unknown_thumbnail_gets_other_bmp() + { + var index = Build("ep07", "other"); + + Assert.False(index.Has("nie-ma-takiej")); + Assert.EndsWith("other.bmp", index.PathFor("nie-ma-takiej")); + } + + [Fact] + public void Without_other_bmp_there_is_nothing_to_fall_back_to() + { + var index = Build("ep07"); + + // The views draw their own stand-in wagon from here on; see + // MiniTextures.MissingVisual. + Assert.Null(index.PathFor("nie-ma-takiej")); + Assert.Null(index.FallbackPath); + } + + [Fact] + public void An_installation_with_no_thumbnails_at_all_resolves_nothing() + { + var index = Build(); + + Assert.False(index.Has("ep07")); + Assert.Null(index.PathFor("ep07")); + } +} diff --git a/StarterNG.Tests/PhysicsAndAssetsTests.cs b/StarterNG.Tests/PhysicsAndAssetsTests.cs new file mode 100644 index 0000000..1d38874 --- /dev/null +++ b/StarterNG.Tests/PhysicsAndAssetsTests.cs @@ -0,0 +1,128 @@ +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class PhysicsAndAssetsTests +{ + private static string Fiz(string maker, string vehicle, string name) => + TestInstallation.At("dynamic", maker, vehicle, name + ".fiz"); + + [Fact] + public void A_fiz_yields_the_properties_the_starter_shows() + { + const string fiz = """ + Param: M=80000 Vmax=125 + Dimensions: L=15.9 + Load: LoadAccepted=Coal MaxLoad=40 + Engine: EngineType=DieselElectric + """; + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(Fiz("pkp", "sm42", "sm42"), fiz)); + + var physics = installation.Physics.For("pkp/sm42/", "sm42.e3d"); + + Assert.NotNull(physics); + Assert.Equal(80000, physics!.Mass); + Assert.Equal(125, physics.VMax); + Assert.Equal(15.9, physics.Length); + Assert.Equal("Coal", physics.LoadAccepted); + Assert.Equal(40, physics.MaxLoad); + Assert.True(physics.IsDieselEngine); + } + + [Fact] + public void A_vehicle_declaring_one_coupler_gets_the_same_at_both_ends() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(Fiz("pkp", "111a", "111a"), "BuffCoupl1: AllowedFlag=3 ControlType=screw")); + + var physics = installation.Physics.For("pkp/111a/", "111a")!; + + Assert.Equal(3, physics.AllowedFlagA); + Assert.Equal(3, physics.AllowedFlagB); + Assert.Equal("screw", physics.ControlTypeB); + } + + [Fact] + public void A_negative_coupler_flag_is_encoded_as_disabled() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(Fiz("pkp", "111a", "111a"), "BuffCoupl1: AllowedFlag=-3")); + + Assert.Equal(131, installation.Physics.For("pkp/111a/", "111a")!.AllowedFlagA); + } + + [Fact] + public void An_include_is_followed_and_its_parameters_substituted() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(Fiz("pkp", "ep07", "ep07"), "include common.fiz 84000 end") + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "common.fiz"), "Param: M=(1) Vmax=125")); + + var physics = installation.Physics.For("pkp/ep07/", "ep07")!; + + Assert.Equal(84000, physics.Mass); + Assert.Equal(125, physics.VMax); + } + + [Fact] + public void A_dumb_variant_stands_in_when_the_model_has_no_fiz_of_its_own() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(Fiz("pkp", "ep07", "ep07dumb"), "Param: M=1000")); + + Assert.NotNull(installation.Physics.For("pkp/ep07/", "ep07")); + } + + [Fact] + public void An_unknown_model_has_no_physics_and_an_empty_installation_indexes_nothing() + { + var installation = new TestInstallation(); + + Assert.Null(installation.Physics.For("pkp/x/", "nieznany")); + installation.Physics.Preload(); + Assert.Equal(0, installation.Physics.IndexedCount); + } + + [Fact] + public void Thumbnails_are_looked_up_by_name_with_a_fallback_to_other() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("textures", "mini", "ep07.bmp"), "bitmap") + .WithFile(TestInstallation.At("textures", "mini", "other.bmp"), "bitmap")); + + Assert.True(installation.MiniTextures.Has("EP07")); + Assert.False(installation.MiniTextures.Has("nieznany")); + Assert.EndsWith("ep07.bmp", installation.MiniTextures.PathFor("ep07")); + Assert.EndsWith("other.bmp", installation.MiniTextures.PathFor("nieznany")); + } + + [Fact] + public void A_livery_whose_skin_and_model_are_absent_is_reported() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"), + "ep07-135.mat=ep07.e3d,ep07"); + var installation = new TestInstallation(files); + installation.Vehicles.Load(installation.Library.Vehicles); + + var missing = installation.MissingAssets.Scan(installation.Library.Vehicles); + + Assert.Contains(missing, line => line.Contains("no file:") && line.Contains("ep07-135")); + Assert.Contains(missing, line => line.Contains("no model:") && line.Contains("ep07.e3d")); + } + + [Fact] + public void A_skin_present_under_an_accepted_extension_is_not_reported() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"), + "ep07-135.mat=ep07.e3d,ep07") + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "ep07-135.bmp"), "bitmap") + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "ep07.t3d"), "model"); + var installation = new TestInstallation(files); + installation.Vehicles.Load(installation.Library.Vehicles); + + Assert.Empty(installation.MissingAssets.Scan(installation.Library.Vehicles)); + } +} diff --git a/StarterNG.Tests/ScenarioPresentationTests.cs b/StarterNG.Tests/ScenarioPresentationTests.cs new file mode 100644 index 0000000..b3f9ce5 --- /dev/null +++ b/StarterNG.Tests/ScenarioPresentationTests.cs @@ -0,0 +1,167 @@ +using StarterNG.Domain.Sceneries; +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Presentation.Scenarios; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class FogScaleTests +{ + [Fact] + public void The_slider_ends_land_on_the_supported_visibility_range() + { + Assert.Equal(SceneryWeather.FogMin, FogScale.ToMetres(0)); + Assert.Equal(SceneryWeather.FogMax, FogScale.ToMetres(FogScale.SliderRange)); + } + + [Fact] + public void A_distance_survives_a_round_trip_through_the_slider() + { + foreach (int metres in new[] { 50, 200, 1500, 8000 }) + Assert.Equal(metres, FogScale.ToMetres(FogScale.ToSlider(metres))); + } + + [Fact] + public void Distances_snap_to_a_step_that_grows_with_the_range() + { + Assert.Equal(0, FogScale.ToMetres(200) % 5); + Assert.Equal(0, FogScale.ToMetres(700) % 100); + } + + [Fact] + public void Metres_below_a_kilometre_kilometres_above_it() + { + var culture = System.Globalization.CultureInfo.InvariantCulture; + + Assert.Equal("250 m", FogScale.Format(250, culture)); + Assert.Equal("2.5 km", FogScale.Format(2500, culture)); + } +} + +public class SeasonDatesTests +{ + private static SeasonDates On(DateTime now) => new(new FixedClock(now)); + + [Fact] + public void A_stored_day_of_year_becomes_that_day_in_the_current_year() + { + var date = On(new DateTime(2026, 7, 1)).DateOf(100); + + Assert.Equal(2026, date.Year); + Assert.Equal(100, date.DayOfYear); + } + + [Fact] + public void Day_zero_means_today() + { + var now = new DateTime(2026, 5, 4); + + Assert.Equal(now.DayOfYear, On(now).DateOf(0).DayOfYear); + } + + [Fact] + public void The_last_day_of_a_leap_year_stays_selectable() + { + Assert.Equal(366, On(new DateTime(2028, 1, 1)).DateOf(366).DayOfYear); + Assert.Equal(365, On(new DateTime(2026, 1, 1)).DateOf(400).DayOfYear); + } +} + +public class SceneryTreeBuilderTests +{ + private static (SceneryTreeBuilder Builder, List Sceneries) Build(params string[] contents) + { + var files = new InMemoryFileSystem(); + for (int i = 0; i < contents.Length; i++) + files.WithLegacyFile(TestInstallation.At("scenery", $"s{i}.scn"), contents[i]); + + var installation = new TestInstallation(files); + var sceneries = installation.Sceneries.LoadAll().ToList(); + return (new SceneryTreeBuilder(new SceneryTranslations(files, installation.Log)), sceneries); + } + + [Fact] + public void Scenarios_declaring_a_group_are_gathered_under_it() + { + var (builder, sceneries) = Build("//$l Śląsk", "//$l Śląsk", "//$n Bez grupy"); + + var nodes = builder.Build(sceneries, includeArchival: true, "en"); + + var group = Assert.Single(nodes, node => node.IsGroup); + Assert.Equal("Śląsk", group.Label); + Assert.Equal(2, group.Children.Count); + Assert.Single(nodes, node => !node.IsGroup); + } + + [Fact] + public void Archival_scenarios_are_left_out_unless_asked_for() + { + var (builder, sceneries) = Build("//$a", "//$n Zwykla"); + + Assert.Single(builder.Build(sceneries, includeArchival: false, "en")); + Assert.Equal(2, builder.Build(sceneries, includeArchival: true, "en").Count); + } + + [Fact] + public void Nodes_carry_the_index_of_the_scenery_they_stand_for() + { + var (builder, sceneries) = Build("//$n Pierwsza", "//$n Druga"); + + var nodes = builder.Build(sceneries, includeArchival: true, "en"); + + foreach (var node in nodes) + Assert.Equal(node.Label, sceneries[node.SceneryIndex].DisplayName); + } + + [Fact] + public void Both_levels_are_sorted_by_name() + { + var (builder, sceneries) = Build("//$l Zet", "//$l Alfa", "//$l Alfa"); + + var nodes = builder.Build(sceneries, includeArchival: true, "en"); + + Assert.Equal(new[] { "Alfa", "Zet" }, nodes.Select(node => node.Label)); + Assert.Equal(nodes[0].Children.Select(child => child.Label).OrderBy(label => label), + nodes[0].Children.Select(child => child.Label)); + } +} + +public class TimetableLocatorTests +{ + [Fact] + public void A_timetable_is_found_in_the_shared_folder_first() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice") + .WithFile(TestInstallation.At("timetables", "r1.txt"), "rozkład") + .WithFile(TestInstallation.At("scenery", "r1.txt"), "rozkład obok scenerii"); + var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll()); + + Assert.Equal(TestInstallation.At("timetables", "r1.txt"), + new TimetableLocator(files).Resolve(scenery, "r1")); + } + + [Fact] + public void A_name_already_carrying_its_extension_is_found_too() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice") + .WithFile(TestInstallation.At("timetables", "r1.txt"), "rozkład"); + var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll()); + + Assert.NotNull(new TimetableLocator(files).Resolve(scenery, "r1.txt")); + } + + [Fact] + public void The_formats_none_and_a_missing_file_both_resolve_to_nothing() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice"); + var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll()); + var locator = new TimetableLocator(files); + + Assert.Null(locator.Resolve(scenery, "none")); + Assert.Null(locator.Resolve(scenery, "nieistniejacy")); + Assert.Null(locator.Resolve(scenery, null)); + } +} diff --git a/StarterNG.Tests/SceneryExportTests.cs b/StarterNG.Tests/SceneryExportTests.cs new file mode 100644 index 0000000..4fdbb1c --- /dev/null +++ b/StarterNG.Tests/SceneryExportTests.cs @@ -0,0 +1,146 @@ +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SceneryExportTests +{ + private static SceneryParser Parser() => + new(new FixedClock(new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom()); + + [Fact] + public void An_untouched_scenery_exports_its_trainsets_back_verbatim() + { + const string scn = """ + //$n Test + trainset pociag tor_1 10 0 + node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0 + endtrainset + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + + string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); + + Assert.DoesNotContain("{{0}}", exported); + Assert.Contains("trainset pociag tor_1", exported); + Assert.Contains("endtrainset", exported); + } + + [Fact] + public void Selected_optional_includes_are_written_back_before_FirstInit() + { + const string scn = """ + include zima.inc end //$optional 1, Zima + x + endoptional + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + + string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); + + Assert.Contains("include zima.inc end", exported); + Assert.True(exported.IndexOf("include zima.inc", StringComparison.Ordinal) < + exported.IndexOf("FirstInit", StringComparison.Ordinal)); + } + + [Fact] + public void A_deselected_optional_include_stays_out() + { + const string scn = """ + include zima.inc end //$optional 1, Zima + x + endoptional + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + scenery.Includes[0].Selected = false; + + string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); + + Assert.DoesNotContain("include zima.inc", exported); + } + + [Fact] + public void A_kind_two_include_is_written_only_without_a_companion_timetable() + { + const string scn = """ + include rozklad.inc end //$optional 2, Rozkład + x + endoptional + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + + scenery.HasCompanionTimetable = false; + Assert.Contains("include rozklad.inc", + scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom())); + + scenery.HasCompanionTimetable = true; + Assert.DoesNotContain("include rozklad.inc", + scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom())); + } + + [Fact] + public void Edited_weather_replaces_the_authored_config_block() + { + const string scn = """ + config + movelight 100 + scenario.weather.temperature 5 + eventlauncher.range 200 + endconfig + atmo 0 0 0 10 2000 0 0 0 0 endatmo + time 8:30 0 0 endtime + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + + scenery.Weather.Day = 200; + scenery.Weather.Temperature = 25; + scenery.Weather.Dirty = true; + + string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); + + Assert.Contains("movelight 200", exported); + Assert.Contains("scenario.weather.temperature 25", exported); + Assert.DoesNotContain("movelight 100", exported); + // Config the scenery author wrote and the starter does not manage survives. + Assert.Contains("eventlauncher.range 200", exported); + } + + [Fact] + public void Decor_trainsets_can_be_dropped_from_the_export() + { + const string scn = """ + //$n Test + trainset ai_1 tor_1 10 0 //$decor + node -1 -1 ai-1 dynamic PKP/EP07 ep07 ep07.mmd 0 nobody 0 + endtrainset + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + + Assert.True(scenery.Trainsets[0].Decor); + Assert.DoesNotContain("trainset ai_1", + scenery.BuildExportContent(skipDecorTrainsets: true, new StubRandom())); + Assert.Contains("trainset ai_1", + scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom())); + } + + [Fact] + public void Loose_vehicles_are_appended_as_nodes_again() + { + const string scn = """ + node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + + string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); + + Assert.Contains("wagon_1", exported); + Assert.Contains("enddynamic", exported); + } +} diff --git a/StarterNG.Tests/SceneryParserTests.cs b/StarterNG.Tests/SceneryParserTests.cs new file mode 100644 index 0000000..5a5dd84 --- /dev/null +++ b/StarterNG.Tests/SceneryParserTests.cs @@ -0,0 +1,154 @@ +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SceneryParserTests +{ + private static SceneryParser Parser(DateTime? now = null) => + new(new FixedClock(now ?? new DateTime(2026, 5, 4, 9, 15, 0)), new StubRandom()); + + [Fact] + public void Directives_become_the_scenery_headline() + { + const string scn = """ + //$l Śląsk + //$n Osobowy do Katowic + //$d Pierwszy opis + //$d drugi wiersz + //$i images/kato.jpg + """; + + var scenery = Parser().Parse("scenery/kato.scn", scn); + + Assert.Equal("Śląsk", scenery.Group); + Assert.Equal("Osobowy do Katowic", scenery.Name); + Assert.Equal("Pierwszy opis\ndrugi wiersz", scenery.Description); + Assert.Equal("images/kato.jpg", scenery.ImageName); + Assert.False(scenery.Archival); + Assert.Equal("kato", scenery.DisplayName); + } + + [Fact] + public void The_a_directive_marks_a_scenery_archival() + { + var scenery = Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n"); + + Assert.True(scenery.Archival); + } + + [Fact] + public void Attachments_take_a_label_from_the_rest_of_the_line() + { + const string scn = """ + //$f 01 doc/rozklad.pdf Rozkład jazdy + //$f doc/mapa.png Mapa + //$f doc/notes.txt + """; + + var scenery = Parser().Parse("scenery/x.scn", scn); + + Assert.Collection(scenery.Attachments, + first => + { + Assert.Equal("doc/rozklad.pdf", first.FilePath); + Assert.Equal("Rozkład jazdy", first.Label); + }, + second => + { + Assert.Equal("doc/mapa.png", second.FilePath); + Assert.Equal("Mapa", second.Label); + }, + third => + { + Assert.Equal("doc/notes.txt", third.FilePath); + Assert.Equal("notes.txt", third.Label); + }); + } + + [Fact] + public void Optional_includes_are_lifted_out_of_the_template() + { + const string scn = """ + include zima.inc end //$optional 1, Sceneria zimowa + some content + endoptional + FirstInit + """; + + var scenery = Parser().Parse("scenery/x.scn", scn); + + var include = Assert.Single(scenery.Includes); + Assert.Equal("zima.inc", include.FilePath); + Assert.Equal("Sceneria zimowa", include.Desc); + Assert.Equal(1, include.Kind); + Assert.True(include.Selected); + Assert.DoesNotContain("zima.inc", scenery.Template); + } + + [Fact] + public void Includes_of_kind_two_are_off_until_export_decides() + { + const string scn = """ + include rozklad.inc end //$optional 2, Rozkład wbudowany + x + endoptional + """; + + var include = Assert.Single(Parser().Parse("scenery/x.scn", scn).Includes); + + Assert.Equal(2, include.Kind); + Assert.False(include.Selected); + } + + [Fact] + public void Trainsets_are_parsed_and_replaced_by_placeholders() + { + const string scn = """ + //$n Test + trainset pociag tor_1 10 0 + node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0 + endtrainset + FirstInit + """; + + var scenery = Parser().Parse("scenery/x.scn", scn); + + var trainset = Assert.Single(scenery.Trainsets); + Assert.True(trainset.Parsed); + Assert.Equal("pociag", trainset.Name); + Assert.Equal("tor_1", trainset.Track); + Assert.Contains("{{0}}", scenery.Template); + Assert.DoesNotContain("endtrainset", scenery.Template); + } + + [Fact] + public void Vehicles_outside_a_trainset_are_collected_as_loose() + { + const string scn = """ + node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic + FirstInit + """; + + var scenery = Parser().Parse("scenery/x.scn", scn); + + var vehicle = Assert.Single(scenery.LooseVehicles); + Assert.Equal("wagon_1", vehicle.Name); + Assert.Equal("PKP/111A", vehicle.DataFolder); + Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames); + Assert.DoesNotContain("enddynamic", scenery.Template); + } + + [Fact] + public void A_malformed_loose_vehicle_is_reported_and_left_in_place() + { + const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic\n"; + + var scenery = Parser().Parse("scenery/x.scn", scn); + + Assert.Empty(scenery.LooseVehicles); + Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames); + Assert.Contains(scenery.Faults, fault => fault.Contains("wagon_1")); + Assert.Contains("enddynamic", scenery.Template); + } +} diff --git a/StarterNG.Tests/SceneryRepositoryTests.cs b/StarterNG.Tests/SceneryRepositoryTests.cs new file mode 100644 index 0000000..facaa7e --- /dev/null +++ b/StarterNG.Tests/SceneryRepositoryTests.cs @@ -0,0 +1,101 @@ +using System.Text; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SceneryRepositoryTests +{ + private static SceneryRepository Build(InMemoryFileSystem files, out FileDiagnosticsLog log) + { + var paths = new GamePaths("game"); + var clock = new FixedClock(new DateTime(2026, 1, 1)); + log = new FileDiagnosticsLog(files, clock, paths); + return new SceneryRepository(files, paths, log, new SceneryParser(clock, new StubRandom())); + } + + [Fact] + public void Every_scn_in_the_scenery_folder_is_loaded() + { + var files = new InMemoryFileSystem() + .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice") + .WithFile(Path.Combine("game", "scenery", "gdansk.scn"), "//$n Gdańsk"); + + var loaded = Build(files, out _).LoadAll(); + + Assert.Equal(2, loaded.Count); + Assert.Contains(loaded, scenery => scenery.Name == "Katowice"); + } + + [Fact] + public void Authoring_scratch_files_and_other_extensions_are_skipped() + { + var files = new InMemoryFileSystem() + .WithFile(Path.Combine("game", "scenery", "$wip.scn"), "//$n Robocza") + .WithFile(Path.Combine("game", "scenery", "kato.inc"), "//$n Include") + .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice"); + + var loaded = Build(files, out _).LoadAll(); + + Assert.Equal("Katowice", Assert.Single(loaded).Name); + } + + [Fact] + public void A_companion_sbt_is_noticed_when_the_scenery_is_loaded() + { + var files = new InMemoryFileSystem() + .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice") + .WithFile(Path.Combine("game", "scenery", "kato.sbt"), "timetable"); + + var scenery = Assert.Single(Build(files, out _).LoadAll()); + + Assert.True(scenery.HasCompanionTimetable); + } + + [Fact] + public void A_missing_scenery_loads_as_null_rather_than_throwing() + { + Assert.Null(Build(new InMemoryFileSystem(), out _).Load(Path.Combine("game", "scenery", "gone.scn"))); + } + + [Fact] + public void Progress_is_reported_once_per_file() + { + var files = new InMemoryFileSystem() + .WithFile(Path.Combine("game", "scenery", "a.scn"), "//$n A") + .WithFile(Path.Combine("game", "scenery", "b.scn"), "//$n B"); + var steps = new List(); + + Build(files, out _).LoadAll(new Progress( + step => { lock (steps) steps.Add(step.Loaded); })); + + // Progress posts asynchronously; give it a moment to drain. + SpinWait.SpinUntil(() => { lock (steps) return steps.Count == 2; }, TimeSpan.FromSeconds(2)); + lock (steps) + Assert.Equal(new[] { 1, 2 }, steps.OrderBy(value => value)); + } + + [Fact] + public void The_preview_image_is_found_next_to_the_scenery() + { + var files = new InMemoryFileSystem() + .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$i kato.jpg") + .WithFile(Path.Combine("game", "scenery", "images", "kato.jpg"), "binary"); + var scenery = Assert.Single(Build(files, out _).LoadAll()); + + string? image = new SceneryImageLocator(files).Resolve(scenery); + + Assert.Equal(Path.Combine("game", "scenery", "images", "kato.jpg"), image); + } + + [Fact] + public void A_scenery_without_an_image_directive_resolves_to_nothing() + { + var files = new InMemoryFileSystem() + .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice"); + var scenery = Assert.Single(Build(files, out _).LoadAll()); + + Assert.Null(new SceneryImageLocator(files).Resolve(scenery)); + } +} diff --git a/StarterNG.Tests/SceneryTranslationsTests.cs b/StarterNG.Tests/SceneryTranslationsTests.cs new file mode 100644 index 0000000..a2d422b --- /dev/null +++ b/StarterNG.Tests/SceneryTranslationsTests.cs @@ -0,0 +1,52 @@ +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SceneryTranslationsTests +{ + private static string I18n(string file) => Path.Combine(Path.GetFullPath(TestInstallation.At("scenery")), + "i18n", file); + + [Fact] + public void An_at_reference_is_resolved_against_the_sceneries_own_table() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$d @mission.brief") + .WithFile(I18n("kato_en.json"), """{ "mission": { "brief": "Take the stopper to Katowice" } }"""); + var installation = new TestInstallation(files); + var scenery = Assert.Single(installation.Sceneries.LoadAll()); + var translations = new SceneryTranslations(files, installation.Log); + + translations.LoadFor(scenery, "en"); + + Assert.Equal("Take the stopper to Katowice", scenery.LocalizedDescription(translations)); + } + + [Fact] + public void The_users_language_is_layered_over_english() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$d @brief") + .WithFile(I18n("kato_en.json"), """{ "brief": "English" }""") + .WithFile(I18n("kato_pl.json"), """{ "brief": "Polski" }"""); + var installation = new TestInstallation(files); + var scenery = Assert.Single(installation.Sceneries.LoadAll()); + var translations = new SceneryTranslations(files, installation.Log); + + translations.LoadFor(scenery, "Polski"); + + Assert.Equal("Polski", scenery.LocalizedDescription(translations)); + } + + [Fact] + public void Literal_text_and_unknown_keys_are_left_alone() + { + var installation = new TestInstallation(); + var translations = new SceneryTranslations(installation.Files, installation.Log); + + Assert.Equal("zwykły opis", translations.Translate("zwykły opis")); + Assert.Equal("@nieznany.klucz", translations.Translate("@nieznany.klucz")); + Assert.Equal("", translations.Translate(null)); + } +} diff --git a/StarterNG.Tests/SceneryWeatherTests.cs b/StarterNG.Tests/SceneryWeatherTests.cs new file mode 100644 index 0000000..4e968c4 --- /dev/null +++ b/StarterNG.Tests/SceneryWeatherTests.cs @@ -0,0 +1,87 @@ +using StarterNG.Domain.Sceneries; +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SceneryWeatherTests +{ + private static SceneryParser Parser(DateTime now) => new(new FixedClock(now), new StubRandom()); + + [Fact] + public void Authored_weather_is_read_from_the_config_block() + { + const string scn = """ + config + movelight 172 + scenario.weather.temperature 21.5 + scenario.time.override 6:05 + endconfig + atmo 0 0 0 50 900 0 0 0 0.7 endatmo + time 8:30 0 0 endtime + """; + + var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather; + + Assert.True(weather.IsAuthored); + Assert.Equal(172, weather.Day); + Assert.Equal(21.5, weather.Temperature); + Assert.Equal("06:05", weather.ScenarioTimeOverride); + Assert.Equal("08:30", weather.Time); + Assert.Equal(50, weather.FogStart); + Assert.Equal(0.7, weather.Overcast); + } + + [Fact] + public void A_scenery_without_a_time_starts_at_the_players_clock() + { + var weather = Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather; + + Assert.Equal("17:42", weather.ScenarioTimeOverride); + Assert.False(weather.IsAuthored); + } + + [Fact] + public void Freshly_parsed_weather_counts_as_unchanged() + { + var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather; + + Assert.False(weather.Changed); + } + + [Fact] + public void Editing_then_restoring_returns_to_the_authored_values() + { + var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather; + + weather.Day = 5; + weather.Temperature = -12; + Assert.True(weather.Changed); + + weather.Restore(); + + Assert.False(weather.Changed); + Assert.Equal(100, weather.Day); + Assert.True(weather.Dirty); + } + + [Fact] + public void A_fog_range_is_clamped_into_the_supported_span() + { + var weather = new SceneryWeather(); + + weather.SetFogRange(start: 1, end: 999999, random: new StubRandom()); + + Assert.Equal(SceneryWeather.FogMin, weather.FogStart); + Assert.InRange(weather.FogEnd, SceneryWeather.FogMin, SceneryWeather.FogMax); + } + + [Fact] + public void Random_overcast_draws_from_the_presets_rather_than_the_stored_value() + { + var weather = new SceneryWeather { Overcast = 0.42, OvercastRandom = true }; + + Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom())); + Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.EffectiveOvercast(new StubRandom())); + } +} diff --git a/StarterNG.Tests/SettingsStoreTests.cs b/StarterNG.Tests/SettingsStoreTests.cs new file mode 100644 index 0000000..ba870a9 --- /dev/null +++ b/StarterNG.Tests/SettingsStoreTests.cs @@ -0,0 +1,208 @@ +using StarterNG.Domain.Settings; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SettingsStoreTests +{ + private const string UserIni = "/home/kolejarz/.config/MaSzyna/eu07.ini"; + + private static TestInstallation WithHome(InMemoryFileSystem? files = null) + { + var installation = new TestInstallation(files); + installation.Environment.With("HOME", "/home/kolejarz"); + return installation; + } + + [Fact] + public void Settings_are_read_from_the_users_ini() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "width 1920\nheight 1080\nfullscreen yes\nlang pl\n")); + + installation.SettingsStore.Load(); + + var settings = installation.SettingsStore.Settings; + Assert.Equal(1920, settings.Width); + Assert.Equal(1080, settings.Height); + Assert.True(settings.Fullscreen); + Assert.Equal("pl", settings.LanguageCode); + Assert.True(settings.LanguageWasSet); + Assert.Equal(UserIni, installation.SettingsStore.LoadedFrom); + } + + [Fact] + public void A_language_the_starter_does_not_ship_survives_a_round_trip() + { + // A user-supplied translation must reach the simulator as written; folding + // every unknown code into "en" is what forced eu07.exe into English. + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "lang zh\n")); + installation.SettingsStore.Load(); + + Assert.Equal("zh", installation.SettingsStore.Settings.LanguageCode); + + installation.SettingsStore.Save(); + + Assert.Contains("lang zh", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_language_chosen_in_the_picker_is_written_as_its_own_code() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.Language = "中文"; + installation.SettingsStore.Settings.LanguageCode = "zh"; + + installation.SettingsStore.Save(); + + Assert.Contains("lang zh", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void With_no_language_recorded_the_file_still_names_one() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains("lang en", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void The_language_code_is_written_in_lower_case() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.LanguageCode = "ZH"; + + installation.SettingsStore.Save(); + + Assert.Contains("lang zh", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void The_consist_state_override_keeps_the_key_the_old_starter_used() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "starter.batterydefault 2\n")); + + installation.SettingsStore.Load(); + Assert.Equal(ConsistReadyOverride.AlwaysReady, installation.SettingsStore.Settings.ReadyOverride); + + installation.SettingsStore.Settings.ReadyOverride = ConsistReadyOverride.AlwaysCold; + installation.SettingsStore.Save(); + + Assert.Contains("starter.batterydefault 1", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void An_out_of_range_override_falls_back_to_leaving_the_scenery_alone() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "starter.batterydefault 9\n")); + + installation.SettingsStore.Load(); + + Assert.Equal(ConsistReadyOverride.Scenery, installation.SettingsStore.Settings.ReadyOverride); + } + + [Fact] + public void A_first_run_falls_back_to_the_ini_shipped_with_the_installation() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(TestInstallation.At("eu07.ini"), "width 800\n")); + + installation.SettingsStore.Load(); + + Assert.Equal(800, installation.SettingsStore.Settings.Width); + Assert.Equal(TestInstallation.At("eu07.ini"), installation.SettingsStore.LoadedFrom); + // Saving still goes to the user's own file, never back into the installation. + Assert.Equal(UserIni, installation.SettingsStore.SavePath); + } + + [Fact] + public void With_no_ini_at_all_the_defaults_stand() + { + var installation = WithHome(); + + installation.SettingsStore.Load(); + + Assert.Equal(1280, installation.SettingsStore.Settings.Width); + Assert.False(installation.SettingsStore.Settings.LanguageWasSet); + } + + [Fact] + public void A_save_round_trips_the_settings() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.Width = 2560; + installation.SettingsStore.Settings.FieldOfView = 60; + installation.SettingsStore.Save(); + + var reopened = WithHome(installation.Files); + reopened.SettingsStore.Load(); + + Assert.Equal(2560, reopened.SettingsStore.Settings.Width); + Assert.Equal(60, reopened.SettingsStore.Settings.FieldOfView); + } + + [Fact] + public void Keys_the_starter_does_not_manage_survive_a_round_trip() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "width 1280\nsomethingexotic 42\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains("somethingexotic 42", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_registered_screen_is_asked_for_its_edits_before_a_save() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + installation.SettingsStore.RegisterCapture(new WidthCapture(3840)); + + installation.SettingsStore.CaptureAndSave(); + + Assert.Equal(3840, installation.SettingsStore.Settings.Width); + } + + [Fact] + public void An_ini_written_by_someone_else_is_noticed() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "width 1280\n")); + installation.SettingsStore.Load(); + + Assert.False(installation.SettingsStore.IsConfigNewerOnDisk()); + + installation.Files.LastWriteTimeUtc = installation.Files.LastWriteTimeUtc.AddMinutes(5); + + Assert.True(installation.SettingsStore.IsConfigNewerOnDisk()); + } + + [Fact] + public void On_windows_the_ini_lives_under_appdata() + { + var installation = new TestInstallation(); + installation.Environment.AsWindows(); + + Assert.Equal(Path.Combine("C:/Users/kolejarz/AppData/Roaming", "MaSzyna", "eu07.ini"), + installation.SettingsPaths.UserConfigPath()); + } + + /// Stands in for the settings screen holding an unsaved edit. + private sealed class WidthCapture : StarterNG.Application.ISettingsCapture + { + private readonly int _width; + + public WidthCapture(int width) => _width = width; + + public void CaptureInto(SimulatorSettings settings) => settings.Width = _width; + } +} diff --git a/StarterNG.Tests/StarterNG.Tests.csproj b/StarterNG.Tests/StarterNG.Tests.csproj new file mode 100644 index 0000000..165de2d --- /dev/null +++ b/StarterNG.Tests/StarterNG.Tests.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/StarterNG.Tests/TexturesTxtParserTests.cs b/StarterNG.Tests/TexturesTxtParserTests.cs new file mode 100644 index 0000000..70e74f0 --- /dev/null +++ b/StarterNG.Tests/TexturesTxtParserTests.cs @@ -0,0 +1,103 @@ +using StarterNG.Infrastructure.Vehicles; + +namespace StarterNG.Tests; + +public class TexturesTxtParserTests +{ + private static readonly TexturesTxtParser Parser = new(); + + [Fact] + public void A_livery_line_yields_a_texture_with_its_model_and_thumbnail() + { + var entry = Parser.Parse("pkp/ep07/", new[] { "ep07-135.mat=ep07.e3d,ep07" }); + + var texture = Assert.Single(entry!.Textures); + Assert.Equal("ep07-135", texture.Skinfile); + Assert.Equal("pkp/ep07/", texture.Directory); + Assert.Equal("ep07.e3d", texture.Model); + Assert.Equal("ep07", texture.MiniRef); + Assert.False(texture.Wreck); + } + + [Fact] + public void The_trailing_comment_is_read_as_the_credit_line() + { + var entry = Parser.Parse("pkp/ep07/", new[] + { + "ep07-135.mat=ep07.e3d,ep07 // 1.0,EP07-135,PKP Intercity,Warszawa,2019-04-01,Wrocław,Autor,Fotograf" + }); + + var meta = Assert.Single(entry!.Textures).Meta; + Assert.NotNull(meta); + Assert.Equal("EP07-135", meta!.Vehicle); + Assert.Equal("PKP Intercity", meta.Operator); + Assert.Equal("Warszawa", meta.Depot); + Assert.Equal("Autor", meta.TextureAuthor); + Assert.Equal("Fotograf", meta.PhotoAuthor); + } + + [Fact] + public void Wreck_liveries_are_marked_so_the_depot_can_hide_them() + { + var entry = Parser.Parse("pkp/ep07/", new[] { "ep07wreck.mat=ep07.e3d,ep07" }); + + Assert.True(Assert.Single(entry!.Textures).Wreck); + } + + [Fact] + public void The_dollar_a_marker_archives_everything_after_it() + { + var entry = Parser.Parse("pkp/ep07/", new[] + { + "ep07-135.mat=ep07.e3d,ep07", + "$a", + "ep07-old.mat=ep07.e3d,ep07old" + }); + + var groups = entry!.Groups; + Assert.Contains(groups, group => group is { Mini: "ep07", Archived: false }); + Assert.Contains(groups, group => group is { Mini: "ep07old", Archived: true }); + } + + [Fact] + public void A_caret_opens_a_fixed_set_of_the_following_vehicles() + { + var entry = Parser.Parse("pkp/en57/", new[] + { + "^3", + "en57-a.mat=en57.e3d,en57", + "en57-b.mat=en57.e3d,en57", + "en57-c.mat=en57.e3d,en57" + }); + + var set = Assert.Single(entry!.Sets); + Assert.Equal(3, set.Count); + Assert.Equal(3, set.TextureRefs.Count); + } + + [Fact] + public void Extra_equals_segments_become_alternative_models() + { + var entry = Parser.Parse("pkp/111a/", new[] { "111a.mat=111a.e3d,111a=111ab.e3d,111ab" }); + + var texture = Assert.Single(entry!.Textures); + Assert.Equal("111a.e3d", texture.Model); + Assert.Equal("111ab.e3d", Assert.Single(texture.Aliases).Model); + } + + [Fact] + public void Comments_headers_and_blank_lines_are_ignored() + { + var entry = Parser.Parse("pkp/ep07/", new[] { "", "# nagłówek", "@ coś", "// komentarz", "bez znaku równości" }); + + Assert.Null(entry); + } + + [Fact] + public void The_category_marker_applies_to_the_liveries_after_it() + { + var entry = Parser.Parse("pkp/ep07/", new[] { "!=e", "ep07-135.mat=ep07.e3d,ep07" }); + + Assert.Equal("e", Assert.Single(entry!.Groups).Category); + } +} diff --git a/StarterNG.Tests/TrainsetReadyTests.cs b/StarterNG.Tests/TrainsetReadyTests.cs new file mode 100644 index 0000000..f0fc00d --- /dev/null +++ b/StarterNG.Tests/TrainsetReadyTests.cs @@ -0,0 +1,56 @@ +using StarterNG.Classes; + +namespace StarterNG.Tests; + +public class TrainsetReadyTests +{ + private static Trainset Parse(string velocity) => + new($"trainset pociag tor_1 10 {velocity}\n" + + "node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0\n" + + "endtrainset"); + + [Fact] + public void A_consist_authored_standing_still_is_not_ready() + { + Assert.False(Parse("0").ReadyToGo); + } + + [Fact] + public void Marking_a_standing_consist_ready_gives_it_the_prepared_creep() + { + var trainset = Parse("0"); + + trainset.ReadyToGo = true; + + Assert.True(trainset.ReadyToGo); + Assert.Equal(0.1f, trainset.Velocity, 3); + } + + [Fact] + public void A_consist_authored_on_the_move_is_ready_and_keeps_its_speed() + { + var trainset = Parse("40"); + + Assert.True(trainset.ReadyToGo); + + trainset.ReadyToGo = false; + Assert.Equal(0f, trainset.Velocity); + + trainset.ReadyToGo = true; + Assert.Equal(40f, trainset.Velocity); + } + + [Fact] + public void A_consist_running_the_other_way_keeps_its_direction() + { + // The sign of the speed is what DynObj.cpp turns into the running + // direction, so restoring readiness must not flip it. + var trainset = Parse("-40"); + + trainset.ReadyToGo = false; + trainset.ReadyToGo = true; + + Assert.Equal(-40f, trainset.Velocity); + Assert.True(trainset.ReadyToGo); + } +} diff --git a/StarterNG.Tests/VehicleCatalogTests.cs b/StarterNG.Tests/VehicleCatalogTests.cs new file mode 100644 index 0000000..63901e2 --- /dev/null +++ b/StarterNG.Tests/VehicleCatalogTests.cs @@ -0,0 +1,120 @@ +using StarterNG.Domain.Vehicles; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class VehicleCatalogTests +{ + private static string TexturesFile(string maker, string vehicle) => + TestInstallation.At("dynamic", maker, vehicle, "textures.txt"); + + [Fact] + public void The_catalogue_is_filled_from_every_vehicle_folder() + { + var files = new InMemoryFileSystem() + .WithFile(TexturesFile("pkp", "ep07"), "ep07-135.mat=ep07.e3d,ep07") + .WithFile(TexturesFile("pkp", "111a"), "111a-1.mat=111a.e3d,111a"); + var installation = new TestInstallation(files); + + int liveries = installation.Vehicles.Load(installation.Library.Vehicles); + + Assert.Equal(2, liveries); + Assert.Equal(2, installation.Library.Vehicles.Textures.Count); + } + + [Fact] + public void The_data_folder_is_recorded_the_way_the_scenery_format_spells_it() + { + var files = new InMemoryFileSystem() + .WithFile(TexturesFile("pkp", "ep07"), "ep07-135.mat=ep07.e3d,ep07"); + var installation = new TestInstallation(files); + installation.Vehicles.Load(installation.Library.Vehicles); + + Assert.Equal("pkp/ep07/", Assert.Single(installation.Library.Vehicles.Textures).Directory); + } + + [Fact] + public void Wrecks_are_indexed_but_kept_out_of_the_offered_liveries() + { + var files = new InMemoryFileSystem() + .WithFile(TexturesFile("pkp", "ep07"), "ep07wrak.mat=ep07.e3d,ep07\nep07-135.mat=ep07.e3d,ep07"); + var installation = new TestInstallation(files); + installation.Vehicles.Load(installation.Library.Vehicles); + + var catalog = installation.Library.Vehicles; + Assert.Equal("ep07-135", Assert.Single(catalog.Textures).Skinfile); + Assert.NotNull(catalog.TextureForSkin("ep07wrak")); + } + + [Fact] + public void A_livery_is_found_by_its_skin_file_whatever_the_extension() + { + var files = new InMemoryFileSystem() + .WithFile(TexturesFile("pkp", "ep07"), "ep07-135.mat=ep07.e3d,ep07"); + var installation = new TestInstallation(files); + installation.Vehicles.Load(installation.Library.Vehicles); + + Assert.NotNull(installation.Library.Vehicles.TextureForSkin("EP07-135.bmp")); + Assert.Null(installation.Library.Vehicles.TextureForSkin("nieznany")); + } + + [Fact] + public void A_star_category_is_derived_from_the_first_letter_of_the_thumbnail() + { + var catalog = new VehicleCatalog(new MiniStub()); + catalog.BeginLoad(); + catalog.Ingest(ParsedEntry("pkp/ep07/", "ep07-135.mat=ep07.e3d,ep07")); + catalog.EndLoad(); + + Assert.Equal("E", Assert.Single(catalog.Textures).ResolvedCategory); + } + + [Fact] + public void Members_of_a_set_after_the_first_are_followers() + { + var files = new InMemoryFileSystem() + .WithFile(TexturesFile("pkp", "en57"), + "^2\nen57-a.mat=en57.e3d,en57\nen57-b.mat=en57.e3d,en57"); + var installation = new TestInstallation(files); + installation.Vehicles.Load(installation.Library.Vehicles); + var catalog = installation.Library.Vehicles; + + var lead = catalog.TextureForSkin("en57-a")!; + var follower = catalog.TextureForSkin("en57-b")!; + + Assert.False(catalog.IsSetFollower(lead)); + Assert.True(catalog.IsSetFollower(follower)); + Assert.Equal(2, catalog.ResolveSet(lead)!.Count); + } + + [Fact] + public void A_livery_falls_back_to_its_groups_thumbnail_when_its_own_is_missing() + { + var catalog = new VehicleCatalog(new MiniStub(known: "ep07")); + catalog.BeginLoad(); + catalog.Ingest(ParsedEntry("pkp/ep07/", "ep07-135.mat=ep07.e3d,ep07,brakujacy")); + catalog.EndLoad(); + + Assert.Equal("ep07", catalog.ResolveMiniName(Assert.Single(catalog.Textures))); + } + + /// Thumbnail index that knows about exactly one name. + private sealed class MiniStub : StarterNG.Application.Abstractions.IMiniTextureIndex + { + private readonly string? _known; + + public MiniStub(string? known = null) => _known = known; + + public void Preload() { } + + public bool Has(string? miniName) => miniName is not null && miniName == _known; + + public string? PathFor(string? miniName) => Has(miniName) ? miniName : FallbackPath; + + public string? FallbackPath => null; + } + + /// Builds an entry through the real parser, for catalogue-level tests. + private static VehicleEntry ParsedEntry(string directory, params string[] lines) => + new StarterNG.Infrastructure.Vehicles.TexturesTxtParser().Parse(directory, lines)!; +} diff --git a/StarterNG.sln b/StarterNG.sln index 7afd782..262289f 100644 --- a/StarterNG.sln +++ b/StarterNG.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 18.1.11312.151 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG", "StarterNG\StarterNG.csproj", "{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG.Tests", "StarterNG.Tests\StarterNG.Tests.csproj", "{00B068D7-2F49-4CAA-BC21-506503EBDFC8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,14 @@ Global {D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|Any CPU.Build.0 = Release|Any CPU {D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.ActiveCfg = Release|x64 {D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.Build.0 = Release|x64 + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.ActiveCfg = Debug|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.Build.0 = Debug|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.Build.0 = Release|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.ActiveCfg = Release|Any CPU + {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/StarterNG/App.axaml b/StarterNG/App.axaml index a347f53..99f30bc 100644 --- a/StarterNG/App.axaml +++ b/StarterNG/App.axaml @@ -1,22 +1,24 @@ - + + + - #177f00 #136B00 #0F5600 @@ -29,5 +31,13 @@ + + 6 + 6 + 24 + 12 + 12 + 2 + 4 diff --git a/StarterNG/App.axaml.cs b/StarterNG/App.axaml.cs index fa255b6..52a2cad 100644 --- a/StarterNG/App.axaml.cs +++ b/StarterNG/App.axaml.cs @@ -8,95 +8,185 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Threading; +using StarterNG.Application; using StarterNG.Classes; +using StarterNG.Infrastructure; using StarterNG.Services; namespace StarterNG; -public partial class App : Application +public partial class App : Avalonia.Application { - - public static LocalizationService Loc { get; } = new(); - + + /// + /// The translation table, as a static source for the XAML bindings. Code + /// should take + /// through its constructor rather than reading this. + /// + public static LocalizationService Loc => AppServices.Current.Localization; + public override void Initialize() { AvaloniaXamlLoader.Load(this); - // Load the persisted configuration up front so the saved language wins; - // fall back to the system culture only when the file has no lang entry. - Settings.Instance.Load(); + AppServices.Current.SettingsStore.Load(); - // Key bindings live in eu07_input-keyboard.ini next to eu07.ini. KeyboardConfig.Instance.Load(); CultureInfo ci = CultureInfo.InstalledUICulture ; - var langName = Settings.Instance.LanguageWasSet - ? Settings.Instance.Language + var langName = AppServices.Current.Settings.LanguageWasSet + ? AppServices.Current.Settings.Language : ci.Name switch { "pl-PL" => "Polski", _ => "English" }; - Settings.Instance.Language = langName; + AppServices.Current.Settings.Language = langName; + + // What eu07.ini asked for, before the starter picks a language it can + // actually display. + string requestedCode = AppServices.Current.Settings.LanguageCode; ApplyLanguage(langName); + + // The starter ships no translation for this code, so its own interface + // falls back - but the simulator has one and keeps it. Overwriting the key + // here would silently move eu07.exe to English on the next save. + if (!string.IsNullOrWhiteSpace(requestedCode) && + !string.Equals(requestedCode, Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase)) + AppServices.Current.Settings.LanguageCode = requestedCode; } - /// - /// Swaps the active language by loading the matching XML file from the - /// lang/ folder next to the executable (see ). - /// The translations are no longer compiled into the assembly, so they can be - /// edited or added without rebuilding the launcher. Accepts either a display - /// name ("English", "Polski") or a short code ("en", "pl"). - /// public static void ApplyLanguage(string langNameOrCode) { - // Loc.Load resolves the file and returns the canonical display name, - // which we mirror back into the settings so the stored value stays valid. + var resolvedName = Loc.Load(langNameOrCode); - Settings.Instance.Language = resolvedName; + + // The service is the authority on which language actually loaded: the code + // goes to eu07.ini for the simulator, the name to the picker. + AppServices.Current.Settings.Language = resolvedName; + AppServices.Current.Settings.LanguageCode = Loc.CurrentLangCode; + + ApplyCulture(Loc.CurrentLangCode); + } + + private static void ApplyCulture(string langCode) + { + CultureInfo culture; + try + { + culture = langCode switch + { + "" or "en" => CultureInfo.InvariantCulture, + "cz" => CultureInfo.GetCultureInfo("cs"), + _ => CultureInfo.GetCultureInfo(langCode) + }; + } + catch (CultureNotFoundException) + { + culture = CultureInfo.InvariantCulture; + } + + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; } public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - // Code page 1250 is needed to parse scenery files (done during load). + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - // Persist settings when the launcher is closing. Key bindings are only - // rewritten when actually edited, so an untouched file keeps its layout. desktop.ShutdownRequested += (_, _) => { - Settings.Instance.CaptureAndSave(); - if (KeyboardConfig.Instance.Dirty) - KeyboardConfig.Instance.Save(); + try { AppServices.Current.SettingsStore.CaptureAndSave(); } + catch (Exception ex) { Diagnostics.Log("Settings save", ex); } + + AppServices.Current.MissingVehicleLog.Dump(); + + try + { + if (KeyboardConfig.Instance.Dirty) + KeyboardConfig.Instance.Save(); + } + catch (Exception ex) { Diagnostics.Log("Keyboard config save", ex); } + + Diagnostics.Flush(); }; - var splash = new SplashWindow(); - desktop.MainWindow = splash; - splash.Show(); - - // Progress marshals callbacks back to this (UI) thread. - var progress = new Progress(splash.Report); + SplashWindow? splash = null; + bool mainReady = false; + var sw = System.Diagnostics.Stopwatch.StartNew(); Task.Run(async () => { - GameData.Instance.Load(progress); - await Task.Delay(400); // let the completed bar be visible briefly - }).ContinueWith(_ => - { - Dispatcher.UIThread.Post(() => + var progress = new Progress(status => { - var main = new MainWindow(); - desktop.MainWindow = main; // keep a main window before closing the splash + Dispatcher.UIThread.Post(() => + { + if (mainReady) + return; + + if (sw.ElapsedMilliseconds > 300 && splash == null) + { + splash = new SplashWindow(); + desktop.MainWindow = splash; + splash.Show(); + } + splash?.Report(status); + }); + }); + + AppServices.Current.Library.Load(progress); + }).ContinueWith(load => + { + Dispatcher.UIThread.Post(async () => + { + MainWindow main; + try + { + main = new MainWindow(); + } + catch (Exception ex) + { + Diagnostics.Log("Main window", ex); + Diagnostics.Flush(); + Console.Error.WriteLine(ex); + mainReady = true; + splash?.Close(); + desktop.Shutdown(1); + return; + } + + mainReady = true; + desktop.MainWindow = main; main.Show(); - splash.Close(); + splash?.Close(); + splash = null; + + if (load.IsFaulted && load.Exception is { } error) + { + var inner = error.GetBaseException(); + Diagnostics.Log("Game data load", inner); + await Diagnostics.ReportAsync( + $"{Loc["FaultLoadData"]}{Environment.NewLine}{Environment.NewLine}{Loc["FaultDetail"]} {inner.Message}"); + } + + var faults = Diagnostics.CheckInstallation(); + if (faults.Count > 0) + { + await Diagnostics.ReportAsync( + string.Join(Environment.NewLine, faults) + Environment.NewLine + Environment.NewLine + + Loc["FaultBadInstall"]); + } }); }); } base.OnFrameworkInitializationCompleted(); } -} \ No newline at end of file +} diff --git a/StarterNG/AppInfo.cs b/StarterNG/AppInfo.cs index 1fe417b..ddeb6f1 100644 --- a/StarterNG/AppInfo.cs +++ b/StarterNG/AppInfo.cs @@ -2,14 +2,9 @@ using System.Reflection; namespace StarterNG; -/// Static, display-ready information about the running launcher build. public static class AppInfo { - /// - /// The launcher's assembly version (e.g. "1.2.3.4"), with a " (DEBUG)" suffix - /// appended for Debug builds so debug and release binaries are easy to tell - /// apart. Computed once; safe to bind with {x:Static}. - /// + public static string Version { get; } = BuildVersion(); private static string BuildVersion() diff --git a/StarterNG/Application/Abstractions/IClock.cs b/StarterNG/Application/Abstractions/IClock.cs new file mode 100644 index 0000000..97f0c2c --- /dev/null +++ b/StarterNG/Application/Abstractions/IClock.cs @@ -0,0 +1,10 @@ +namespace StarterNG.Application.Abstractions; + +using System; + +public interface IClock +{ + DateTime Now { get; } + + DateTime UtcNow { get; } +} diff --git a/StarterNG/Application/Abstractions/IDiagnosticsLog.cs b/StarterNG/Application/Abstractions/IDiagnosticsLog.cs new file mode 100644 index 0000000..a5a0765 --- /dev/null +++ b/StarterNG/Application/Abstractions/IDiagnosticsLog.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace StarterNG.Application.Abstractions; + +public interface IDiagnosticsLog +{ + void Log(string message); + + void Log(string context, Exception exception); + + void Log(IEnumerable messages); + + string Text { get; } + + void Clear(); + + void Flush(); +} diff --git a/StarterNG/Application/Abstractions/IEnvironment.cs b/StarterNG/Application/Abstractions/IEnvironment.cs new file mode 100644 index 0000000..bc614ab --- /dev/null +++ b/StarterNG/Application/Abstractions/IEnvironment.cs @@ -0,0 +1,14 @@ +namespace StarterNG.Application.Abstractions; + +public interface IEnvironment +{ + bool IsWindows { get; } + + bool IsLinux { get; } + + bool IsMacOS { get; } + + string? GetVariable(string name); + + string BaseDirectory { get; } +} diff --git a/StarterNG/Application/Abstractions/IFileSystem.cs b/StarterNG/Application/Abstractions/IFileSystem.cs new file mode 100644 index 0000000..93c1083 --- /dev/null +++ b/StarterNG/Application/Abstractions/IFileSystem.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace StarterNG.Application.Abstractions; + +public interface IFileSystem +{ + bool FileExists(string path); + + bool DirectoryExists(string path); + + string ReadAllText(string path); + + string ReadAllText(string path, Encoding encoding); + + string[] ReadAllLines(string path); + + Stream OpenRead(string path); + + void WriteAllText(string path, string contents); + + void WriteAllText(string path, string contents, Encoding encoding); + + void WriteAllLines(string path, IEnumerable lines); + + void WriteAllBytes(string path, byte[] bytes); + + void CreateDirectory(string path); + + void DeleteFile(string path); + + void CopyFile(string sourcePath, string destinationPath, bool overwrite); + + IReadOnlyList GetFiles(string path, string searchPattern = "*"); + + IReadOnlyList GetDirectories(string path); + + IReadOnlyList GetFilesRecursive(string path, string searchPattern); + + DateTime GetLastWriteTimeUtc(string path); + + bool IsExecutable(string path); +} diff --git a/StarterNG/Application/Abstractions/IGamePaths.cs b/StarterNG/Application/Abstractions/IGamePaths.cs new file mode 100644 index 0000000..314647c --- /dev/null +++ b/StarterNG/Application/Abstractions/IGamePaths.cs @@ -0,0 +1,20 @@ +namespace StarterNG.Application.Abstractions; + +public interface IGamePaths +{ + string Root { get; } + + string Scenery { get; } + + string Dynamic { get; } + + string MiniTextures { get; } + + string Data { get; } + + string StarterConfig { get; } + + string DiagnosticsLog { get; } + + string FromRoot(params string[] segments); +} diff --git a/StarterNG/Application/Abstractions/ILocalizedStrings.cs b/StarterNG/Application/Abstractions/ILocalizedStrings.cs new file mode 100644 index 0000000..826d732 --- /dev/null +++ b/StarterNG/Application/Abstractions/ILocalizedStrings.cs @@ -0,0 +1,12 @@ +namespace StarterNG.Application.Abstractions; + +public interface ILocalizedStrings +{ + string this[string key] { get; } + + string Opt(string key); + + string CurrentLanguage { get; } + + string CurrentLangCode { get; } +} diff --git a/StarterNG/Application/Abstractions/IMiniTextureIndex.cs b/StarterNG/Application/Abstractions/IMiniTextureIndex.cs new file mode 100644 index 0000000..1edadd4 --- /dev/null +++ b/StarterNG/Application/Abstractions/IMiniTextureIndex.cs @@ -0,0 +1,12 @@ +namespace StarterNG.Application.Abstractions; + +public interface IMiniTextureIndex +{ + void Preload(); + + bool Has(string? miniName); + + string? PathFor(string? miniName); + + string? FallbackPath { get; } +} diff --git a/StarterNG/Application/Abstractions/IPhysicsRepository.cs b/StarterNG/Application/Abstractions/IPhysicsRepository.cs new file mode 100644 index 0000000..d479167 --- /dev/null +++ b/StarterNG/Application/Abstractions/IPhysicsRepository.cs @@ -0,0 +1,12 @@ +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Application.Abstractions; + +public interface IPhysicsRepository +{ + void Preload(); + + int IndexedCount { get; } + + VehiclePhysics? For(string? dataFolder, string? model); +} diff --git a/StarterNG/Application/Abstractions/IProcessLauncher.cs b/StarterNG/Application/Abstractions/IProcessLauncher.cs new file mode 100644 index 0000000..a8ecab1 --- /dev/null +++ b/StarterNG/Application/Abstractions/IProcessLauncher.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StarterNG.Application.Abstractions; + +public interface IProcessHandle +{ + bool HasExited { get; } + + Task WaitForExitAsync(CancellationToken cancellationToken = default); +} + +public interface IProcessLauncher +{ + IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory, + out string? error); + + bool OpenInShell(string pathOrUrl); + + IProcessHandle? FindRunning(string processName); +} diff --git a/StarterNG/Application/Abstractions/IRandomSource.cs b/StarterNG/Application/Abstractions/IRandomSource.cs new file mode 100644 index 0000000..b4403a2 --- /dev/null +++ b/StarterNG/Application/Abstractions/IRandomSource.cs @@ -0,0 +1,8 @@ +namespace StarterNG.Application.Abstractions; + +public interface IRandomSource +{ + int Next(int minInclusive, int maxExclusive); + + double NextDouble(); +} diff --git a/StarterNG/Application/Abstractions/ISceneryRepository.cs b/StarterNG/Application/Abstractions/ISceneryRepository.cs new file mode 100644 index 0000000..031b05b --- /dev/null +++ b/StarterNG/Application/Abstractions/ISceneryRepository.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Application.Abstractions; + +public readonly record struct SceneryLoadProgress(int Loaded, int Total, string FileName); + +public interface ISceneryRepository +{ + IReadOnlyList LoadAll(IProgress? progress = null); + + Scenery? Load(string path); +} diff --git a/StarterNG/Application/Abstractions/ISceneryTranslations.cs b/StarterNG/Application/Abstractions/ISceneryTranslations.cs new file mode 100644 index 0000000..de599ce --- /dev/null +++ b/StarterNG/Application/Abstractions/ISceneryTranslations.cs @@ -0,0 +1,10 @@ +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Application.Abstractions; + +public interface ISceneryTranslations +{ + void LoadFor(Scenery scenery, string langCode); + + string Translate(string? text); +} diff --git a/StarterNG/Application/Abstractions/IVehicleRepository.cs b/StarterNG/Application/Abstractions/IVehicleRepository.cs new file mode 100644 index 0000000..5e285f2 --- /dev/null +++ b/StarterNG/Application/Abstractions/IVehicleRepository.cs @@ -0,0 +1,8 @@ +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Application.Abstractions; + +public interface IVehicleRepository +{ + int Load(VehicleCatalog catalog); +} diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs new file mode 100644 index 0000000..e223746 --- /dev/null +++ b/StarterNG/Application/AppServices.cs @@ -0,0 +1,115 @@ +using System; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Settings; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Infrastructure.Settings; +using StarterNG.Infrastructure.Vehicles; +using StarterNG.Services; + +namespace StarterNG.Application; + +// Skladany recznie: PublishAot i PublishTrimmed wykluczaja kontener oparty na +// refleksji. Current jest service locatorem i wolno go czytac tylko z entry +// pointu i z konstruktorow widokow, ktorych Avalonia nie potrafi parametryzowac. +public sealed class AppServices +{ + private static AppServices? _current; + + private AppServices(IGamePaths paths, IFileSystem files, IClock clock, IEnvironment environment) + { + Paths = paths; + Files = files; + Clock = clock; + Environment = environment; + + var log = new FileDiagnosticsLog(files, clock, paths); + Log = log; + Random = new SystemRandomSource(); + Processes = new SystemProcessLauncher(log); + Localization = new LocalizationService(log); + Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random)); + SceneryImages = new SceneryImageLocator(files); + MiniTextures = new MiniTextureIndex(files, paths); + Vehicles = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser()); + Physics = new FizPhysicsRepository(files, paths, log); + MissingAssets = new MissingAssetScanner(files, paths); + SceneryTexts = new SceneryTranslations(files, log); + Timetables = new TimetableLocator(files); + LoadWeights = new LoadWeightsRepository(files, paths, log); + Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log); + State = new AppState(); + + var settingsPaths = new SettingsPaths(environment, paths); + Executables = new ExecutableLocator(files, paths, environment, log); + SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(), Executables); + SettingsPaths = settingsPaths; + MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, MissingAssets, Library, log); + StartSimulation = new StartSimulation(State, SettingsStore, files, Processes, Random, log); + } + + public static AppServices Current => + _current ?? throw new InvalidOperationException( + "AppServices.Current read before Initialize(); the composition root must be built first."); + + public static AppServices Initialize(IGamePaths? paths = null, IFileSystem? files = null, IClock? clock = null, + IEnvironment? environment = null) + { + _current = new AppServices( + paths ?? GamePaths.ForCurrentDirectory(), + files ?? new PhysicalFileSystem(), + clock ?? new SystemClock(), + environment ?? new SystemEnvironment()); + return _current; + } + + public IGamePaths Paths { get; } + + public IFileSystem Files { get; } + + public IClock Clock { get; } + + public IEnvironment Environment { get; } + + public IDiagnosticsLog Log { get; } + + public IProcessLauncher Processes { get; } + + public IRandomSource Random { get; } + + public ISceneryRepository Sceneries { get; } + + public SceneryImageLocator SceneryImages { get; } + + public IMiniTextureIndex MiniTextures { get; } + + public IVehicleRepository Vehicles { get; } + + public IPhysicsRepository Physics { get; } + + public MissingAssetScanner MissingAssets { get; } + + public ISceneryTranslations SceneryTexts { get; } + + public TimetableLocator Timetables { get; } + + public LoadWeightsRepository LoadWeights { get; } + + public GameLibrary Library { get; } + + public AppState State { get; } + + public SettingsStore SettingsStore { get; } + + public SimulatorSettings Settings => SettingsStore.Settings; + + public ExecutableLocator Executables { get; } + + public SettingsPaths SettingsPaths { get; } + + public MissingVehicleLog MissingVehicleLog { get; } + + public StartSimulation StartSimulation { get; } + + public LocalizationService Localization { get; } +} diff --git a/StarterNG/Application/AppState.cs b/StarterNG/Application/AppState.cs new file mode 100644 index 0000000..78ab716 --- /dev/null +++ b/StarterNG/Application/AppState.cs @@ -0,0 +1,50 @@ +using System; +using StarterNG.Domain.Sceneries; + +using StarterNG.Classes; + +namespace StarterNG.Application; + +public sealed class AppState +{ + public event Action? Changed; + + private Scenery? _currentScenery; + private Trainset? _currentTrainset; + private string? _startingVehicleName; + + public Scenery? CurrentScenery + { + get => _currentScenery; + set + { + if (ReferenceEquals(_currentScenery, value)) return; + _currentScenery = value; + Changed?.Invoke(); + } + } + + public Trainset? CurrentTrainset + { + get => _currentTrainset; + set + { + if (ReferenceEquals(_currentTrainset, value)) return; + _currentTrainset = value; + Changed?.Invoke(); + } + } + + public string? StartingVehicleName + { + get => _startingVehicleName; + set + { + if (_startingVehicleName == value) return; + _startingVehicleName = value; + Changed?.Invoke(); + } + } + + public void NotifyChanged() => Changed?.Invoke(); +} diff --git a/StarterNG/Application/GameLibrary.cs b/StarterNG/Application/GameLibrary.cs new file mode 100644 index 0000000..1f94b67 --- /dev/null +++ b/StarterNG/Application/GameLibrary.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Sceneries; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Application; + +public enum LoadPhase +{ + Vehicles, + Sceneries, + Done +} + +public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail); + +public sealed class GameLibrary +{ + private readonly IVehicleRepository _vehicles; + private readonly ISceneryRepository _sceneries; + private readonly IMiniTextureIndex _minis; + private readonly IPhysicsRepository _physics; + private readonly IDiagnosticsLog _log; + + public GameLibrary(IVehicleRepository vehicles, ISceneryRepository sceneries, IMiniTextureIndex minis, + IPhysicsRepository physics, IDiagnosticsLog log) + { + _vehicles = vehicles; + _sceneries = sceneries; + _minis = minis; + _physics = physics; + _log = log; + Vehicles = new VehicleCatalog(minis); + } + + public VehicleCatalog Vehicles { get; } + + public List Sceneries { get; } = new(); + + public bool Loaded { get; private set; } + + public void Load(IProgress? progress = null) + { + if (Loaded) + 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); + + ReloadSceneries(progress is null + ? null + : new Progress(step => progress.Report( + new LoadStatus((double)step.Loaded / step.Total, LoadPhase.Sceneries, step.FileName)))); + + progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null)); + Loaded = true; + } + + public void ReloadSceneries(IProgress? progress = null) + { + var reloaded = _sceneries.LoadAll(progress); + Sceneries.Clear(); + Sceneries.AddRange(reloaded); + } +} diff --git a/StarterNG/Application/MissingVehicleLog.cs b/StarterNG/Application/MissingVehicleLog.cs new file mode 100644 index 0000000..5d3ff7e --- /dev/null +++ b/StarterNG/Application/MissingVehicleLog.cs @@ -0,0 +1,40 @@ +using System; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Settings; +using StarterNG.Infrastructure.Vehicles; + +namespace StarterNG.Application; + +public sealed class MissingVehicleLog +{ + private readonly SimulatorSettings _settings; + private readonly MissingAssetScanner _scanner; + private readonly GameLibrary _library; + private readonly IDiagnosticsLog _log; + + public MissingVehicleLog(SimulatorSettings settings, MissingAssetScanner scanner, GameLibrary library, + IDiagnosticsLog log) + { + _settings = settings; + _scanner = scanner; + _library = library; + _log = log; + } + + public void Dump() + { + if (!_settings.LogMissingVehicleFiles) + return; + + try + { + var lines = _scanner.Scan(_library.Vehicles); + if (lines.Count > 0) + _log.Log(lines); + } + catch (Exception ex) + { + _log.Log("Missing vehicle log", ex); + } + } +} diff --git a/StarterNG/Application/SettingsStore.cs b/StarterNG/Application/SettingsStore.cs new file mode 100644 index 0000000..bb56352 --- /dev/null +++ b/StarterNG/Application/SettingsStore.cs @@ -0,0 +1,143 @@ +using System; +using System.IO; +using System.Text; +using StarterNG.Application.Abstractions; +using StarterNG.Classes; +using StarterNG.Domain.Settings; +using StarterNG.Infrastructure.Settings; +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Application; + +public interface ISettingsCapture +{ + void CaptureInto(SimulatorSettings settings); +} + +public sealed class SettingsStore +{ + private readonly IFileSystem _files; + private readonly IClock _clock; + private readonly IDiagnosticsLog _log; + private readonly SettingsPaths _paths; + private readonly SettingsSerializer _serializer; + private readonly ExecutableLocator _executables; + private readonly Encoding _encoding; + + private ConfigFile _config = new(); + private string _savePath = string.Empty; + private DateTime _configAgeUtc = DateTime.MinValue; + private ISettingsCapture? _capture; + + public SettingsStore(IFileSystem files, IClock clock, IDiagnosticsLog log, SettingsPaths paths, + SettingsSerializer serializer, ExecutableLocator executables) + { + _files = files; + _clock = clock; + _log = log; + _paths = paths; + _serializer = serializer; + _executables = executables; + _encoding = LegacyText.CodePage1250; + } + + public SimulatorSettings Settings { get; } = new(); + + public string LoadedFrom { get; private set; } = string.Empty; + + public string SavePath => _savePath; + + public void RegisterCapture(ISettingsCapture capture) => _capture = capture; + + public void Load() + { + _savePath = _paths.UserConfigPath(); + string source = _files.FileExists(_savePath) ? _savePath : _paths.InstallationConfigPath(); + LoadedFrom = source; + LoadFrom(source); + } + + public void LoadFrom(string path) + { + try + { + _config = _files.FileExists(path) + ? ConfigFile.Parse(_files.ReadAllText(path, _encoding)) + : new ConfigFile(); + } + catch (Exception ex) + { + _log.Log($"reading {path}", ex); + _config = new ConfigFile(); + } + + _serializer.Read(Settings, _config); + TouchConfigAge(); + } + + public void CaptureAndSave() + { + _capture?.CaptureInto(Settings); + Save(); + } + + public void Save() + { + if (string.IsNullOrEmpty(_savePath)) + _savePath = _paths.UserConfigPath(); + + SaveTo(_savePath); + } + + public void SaveTo(string path) + { + _serializer.Write(Settings, _config); + + try + { + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + _files.CreateDirectory(directory); + + _files.WriteAllText(path, _config.ToText(), _encoding); + TouchConfigAge(); + } + catch (Exception ex) + { + _log.Log($"saving {path}", ex); + } + } + + public void TouchConfigAge() + { + string path = string.IsNullOrEmpty(_savePath) ? _paths.UserConfigPath() : _savePath; + try + { + _configAgeUtc = _files.FileExists(path) ? _files.GetLastWriteTimeUtc(path) : _clock.UtcNow; + } + catch (Exception) + { + _configAgeUtc = _clock.UtcNow; + } + } + + public bool IsConfigNewerOnDisk() + { + string path = string.IsNullOrEmpty(_savePath) ? _paths.UserConfigPath() : _savePath; + try + { + if (!_files.FileExists(path) || _configAgeUtc == DateTime.MinValue) + return false; + + return _files.GetLastWriteTimeUtc(path) > _configAgeUtc + TimeSpan.FromSeconds(1); + } + catch (Exception) + { + return false; + } + } + + public string ResolveExecutable() => _executables.Resolve(Settings, out _); + + public string ResolveExecutable(out ExeProblem problem) => _executables.Resolve(Settings, out problem); +} diff --git a/StarterNG/Application/StartSimulation.cs b/StarterNG/Application/StartSimulation.cs new file mode 100644 index 0000000..8e05d29 --- /dev/null +++ b/StarterNG/Application/StartSimulation.cs @@ -0,0 +1,135 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using StarterNG.Application.Abstractions; +using StarterNG.Classes; +using StarterNG.Domain; +using StarterNG.Domain.Settings; +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Application; + +public enum SimulationStartOutcome +{ + Started, + + NothingSelected, + + ExportFailed, + + ExecutableProblem, + + LaunchFailed +} + +public readonly record struct SimulationStartResult( + SimulationStartOutcome Outcome, + IProcessHandle? Process = null, + string ExecutablePath = "", + ExeProblem Problem = ExeProblem.None, + string? Detail = null); + +public sealed class StartSimulation +{ + private const char ExportPrefix = '$'; + + + private readonly AppState _state; + private readonly SettingsStore _settings; + private readonly IFileSystem _files; + private readonly IProcessLauncher _processes; + private readonly IRandomSource _random; + private readonly IDiagnosticsLog _log; + + public StartSimulation(AppState state, SettingsStore settings, IFileSystem files, IProcessLauncher processes, + IRandomSource random, IDiagnosticsLog log) + { + _state = state; + _settings = settings; + _files = files; + _processes = processes; + _random = random; + _log = log; + } + + public static string? StartableVehicle(Trainset? trainset, string? preferred) + { + if (trainset is null || trainset.Vehicles.Count == 0) + return null; + + static bool CanStart(Dynamic vehicle) => + vehicle.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger; + + if (!string.IsNullOrEmpty(preferred)) + { + var picked = trainset.Vehicles.FirstOrDefault(vehicle => + string.Equals(vehicle.Name, preferred, StringComparison.OrdinalIgnoreCase)); + if (picked is not null && CanStart(picked)) + return picked.Name; + } + + return trainset.Vehicles.FirstOrDefault(CanStart)?.Name; + } + + public SimulationStartResult Execute(bool freeFly, bool saveSettings) + { + var scenery = _state.CurrentScenery; + var trainset = _state.CurrentTrainset; + if (scenery is null || trainset is null) + return new SimulationStartResult(SimulationStartOutcome.NothingSelected); + + // The depot checkbox is the consist's own state; this setting is a blanket + // override for people who always want the same thing, and it wins. + switch (_settings.Settings.ReadyOverride) + { + case ConsistReadyOverride.AlwaysCold: + trainset.ReadyToGo = false; + break; + case ConsistReadyOverride.AlwaysReady: + trainset.ReadyToGo = true; + break; + } + + string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName) + ?? StartableVehicle(trainset, _state.StartingVehicleName); + _state.StartingVehicleName = vehicle; + + scenery.Weather.Dirty = true; + + string exportName = ExportPrefix + Path.GetFileName(scenery.Path); + string exportPath = Path.Combine(Path.GetDirectoryName(scenery.Path) ?? "scenery", exportName); + try + { + _files.WriteAllText(exportPath, + scenery.BuildExportContent(_settings.Settings.IgnoreIrrelevantTrains, _random), + LegacyText.CodePage1250); + } + catch (Exception ex) + { + _log.Log($"writing {exportPath}", ex); + return new SimulationStartResult(SimulationStartOutcome.ExportFailed, Detail: ex.Message, + ExecutablePath: exportPath); + } + + if (saveSettings) + _settings.CaptureAndSave(); + + string executable = Path.GetFullPath(_settings.ResolveExecutable(out var problem)); + if (problem != ExeProblem.None) + return new SimulationStartResult(SimulationStartOutcome.ExecutableProblem, ExecutablePath: executable, + Problem: problem); + + string[] arguments = freeFly || string.IsNullOrEmpty(vehicle) + ? new[] { "-s", exportName } + : new[] { "-s", exportName, "-v", vehicle }; + + var process = _processes.Start(executable, arguments, Path.GetDirectoryName(executable), out string? error); + if (process is null) + return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable, + Detail: error); + + return new SimulationStartResult(SimulationStartOutcome.Started, process, executable); + } + +} diff --git a/StarterNG/Assets/starter.ico b/StarterNG/Assets/starter.ico new file mode 100644 index 0000000..2008b76 Binary files /dev/null and b/StarterNG/Assets/starter.ico differ diff --git a/StarterNG/Classes/AppState.cs b/StarterNG/Classes/AppState.cs deleted file mode 100644 index fbbdf0a..0000000 --- a/StarterNG/Classes/AppState.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace StarterNG.Classes; - -/// -/// Shared selection state across views: the scenery and trainset currently in -/// focus. The depot edits this trainset in place, so changes made in the depot -/// are reflected on the scenery (both views share the same Scenery/Trainset -/// objects from ). -/// -public sealed class AppState -{ - public static AppState Instance { get; } = new(); - - public Scenery? CurrentScenery { get; set; } - public Trainset? CurrentTrainset { get; set; } -} diff --git a/StarterNG/Classes/ConfigFile.cs b/StarterNG/Classes/ConfigFile.cs index f38895d..e551b2c 100644 --- a/StarterNG/Classes/ConfigFile.cs +++ b/StarterNG/Classes/ConfigFile.cs @@ -5,35 +5,17 @@ using System.Text; namespace StarterNG.Classes; -/// -/// A line-oriented reader/writer for the EU07 eu07.ini configuration -/// file. -/// -/// The file is not a classic INI: it is a flat list of key value [value…] -/// entries, one per line, with // comments (either trailing a line or -/// commenting the whole line out). The simulator parses it as a stream of -/// whitespace-separated tokens. -/// -/// This class keeps every line verbatim and only rewrites the value of a key -/// when is called. Comments, blank lines, commented-out -/// entries and — crucially — any keys the launcher does not understand are -/// preserved on save, so editing a handful of settings never drops the rest of -/// the user's configuration. -/// public sealed class ConfigFile { private readonly List _lines = new(); - // Active (uncommented) setting key -> index into _lines. Last occurrence wins, - // matching the simulator which lets later entries override earlier ones. private readonly Dictionary _index = new(StringComparer.OrdinalIgnoreCase); - /// Parses raw file text, preserving line breaks and content. public static ConfigFile Parse(string text) { var cfg = new ConfigFile(); - // Normalise CRLF/CR to LF for splitting; output uses Environment.NewLine. + text = text.Replace("\r\n", "\n").Replace('\r', '\n'); foreach (var line in text.Split('\n')) cfg.AddLine(line); @@ -45,13 +27,11 @@ public sealed class ConfigFile int idx = _lines.Count; _lines.Add(raw); if (TryParseKey(raw, out string key)) - _index[key] = idx; // later duplicates override earlier ones + _index[key] = idx; } - /// True if the key is present as an active (uncommented) entry. public bool Has(string key) => _index.ContainsKey(key); - /// Raw value (all tokens after the key, comment stripped), or null. public string? GetRaw(string key) { if (!_index.TryGetValue(key, out int i)) @@ -89,7 +69,6 @@ public sealed class ConfigFile CultureInfo.InvariantCulture, out double d) ? d : fallback; } - /// All whitespace-separated value tokens for a key (never null). public string[] GetTokens(string key) { var v = GetRaw(key); @@ -98,11 +77,6 @@ public sealed class ConfigFile : v.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); } - /// - /// Sets the value of . If the key already exists its - /// line is rewritten in place, preserving original indentation and any - /// trailing comment. Otherwise a new line is appended. - /// public void Set(string key, string value) { value ??= string.Empty; @@ -125,11 +99,6 @@ public sealed class ConfigFile } } - /// - /// Deactivates by turning its line into a comment, - /// preserving indentation, the key/value that was there and any trailing - /// comment. No-op if the key is not currently active. - /// public void Remove(string key) { if (!_index.TryGetValue(key, out int i)) @@ -153,28 +122,19 @@ public sealed class ConfigFile public void SetDouble(string key, double value) => Set(key, value.ToString("0.###", CultureInfo.InvariantCulture)); - /// Serialises back to text using the platform newline. public string ToText() => string.Join(Environment.NewLine, _lines); - // --- line parsing helpers ------------------------------------------------- - private static bool TryParseKey(string raw, out string key) { SplitLine(raw, out _, out key, out _, out _); return key.Length > 0; } - /// - /// Decomposes a raw line into leading whitespace, key, value (tokens after - /// the key) and trailing comment (including the //). For blank or - /// fully commented-out lines, key is empty. - /// private static void SplitLine(string raw, out string indent, out string key, out string value, out string comment) { indent = key = value = comment = string.Empty; - // leading whitespace int start = 0; while (start < raw.Length && char.IsWhiteSpace(raw[start])) start++; @@ -182,7 +142,6 @@ public sealed class ConfigFile string body = raw.Substring(start); - // A line whose first non-space content is "//" is a pure comment. if (body.StartsWith("//")) { comment = body; @@ -191,8 +150,6 @@ public sealed class ConfigFile if (body.Length == 0) return; - // Split off a trailing comment: the first "//" preceded by whitespace - // (so URLs such as tcp://… inside a value are not mistaken for one). int commentAt = FindCommentStart(body); string content = commentAt >= 0 ? body.Substring(0, commentAt) : body; if (commentAt >= 0) diff --git a/StarterNG/Classes/GameData.cs b/StarterNG/Classes/GameData.cs deleted file mode 100644 index ae17654..0000000 --- a/StarterNG/Classes/GameData.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; - -namespace StarterNG.Classes; - -public enum LoadPhase -{ - Vehicles, - Sceneries, - Done -} - -/// A single progress update emitted while the game data loads. -public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail); - -/// -/// Loads and holds the data shared across the launcher (vehicle database and -/// parsed sceneries) so it is parsed once, at startup, behind the splash — -/// instead of inside each view's constructor. -/// -public sealed class GameData -{ - public static GameData Instance { get; } = new(); - - public VehicleDatabase Vehicles { get; } = new(); - public List Sceneries { get; } = new(); - - public bool Loaded { get; private set; } - - /// - /// Parses the vehicle database and all sceneries, reporting progress. - /// Runs synchronously; call it from a background thread. Never throws. - /// - public void Load(IProgress? progress = null, - string vehiclesDir = "databases/vehicles/", - string sceneryDir = "scenery/", - string miniDir = "textures/mini/") - { - if (Loaded) - return; - - var vehicleFiles = VehicleDatabase.EnumerateFiles(vehiclesDir); - var scnFiles = EnumerateScenery(sceneryDir); - int total = Math.Max(1, vehicleFiles.Count + scnFiles.Count); - int done = 0; - - // Phase 1 - vehicle database - Vehicles.BeginLoad(); - foreach (string file in vehicleFiles) - { - progress?.Report(new LoadStatus((double)done / total, LoadPhase.Vehicles, - Path.GetFileName(file))); - try { Vehicles.LoadFile(file); } - catch { /* skip malformed file */ } - done++; - } - Vehicles.EndLoad(); - - // Preload the miniature .bmp index now (behind the splash) so the depot's - // first thumbnail render doesn't scan textures/mini/ on the UI thread. - VehicleDatabase.PreloadMiniIndex(miniDir); - - // Likewise index every .fiz under dynamic/ once, so the consist's physics - // (length / mass / couplings) resolve without a UI-thread directory scan. - Physics.PreloadIndex(); - - // Phase 2 - sceneries - foreach (string file in scnFiles) - { - progress?.Report(new LoadStatus((double)done / total, LoadPhase.Sceneries, - Path.GetFileName(file))); - try { Sceneries.Add(new Scenery(file)); } - catch { /* skip unreadable scenery */ } - done++; - } - - progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null)); - Loaded = true; - } - - private static List EnumerateScenery(string sceneryDir) - { - if (!Directory.Exists(sceneryDir)) - return new List(); - - return Directory.GetFiles(sceneryDir, "*.scn") - .Where(p => !Path.GetFileName(p).StartsWith("$")) - .ToList(); - } -} diff --git a/StarterNG/Classes/KeyMap.cs b/StarterNG/Classes/KeyMap.cs index 7fdba7b..741c606 100644 --- a/StarterNG/Classes/KeyMap.cs +++ b/StarterNG/Classes/KeyMap.cs @@ -4,16 +4,11 @@ using Avalonia.Input; namespace StarterNG.Classes; -/// -/// One cap on the on-screen keyboard. is the simulator key -/// token a binding would reference; when null the cap is a non-bindable filler -/// (Shift, Ctrl, Tab …) drawn only to keep the layout recognisable. -/// public sealed class KeyCap { public string Label = string.Empty; - public string? Token; // null => filler / not bindable - public double Width = 1.0; // relative width (1 == a standard key) + public string? Token; + public double Width = 1.0; public KeyCap(string label, string? token = null, double width = 1.0) { @@ -23,20 +18,12 @@ public sealed class KeyCap } } -/// -/// Translates between physical keyboard input, simulator key tokens and the -/// on-screen keyboard layout used by the controls visualisation. -/// public static class KeyMap { - /// - /// Resolves a pressed key to its simulator token, using the physical key to - /// disambiguate the numeric keypad. Returns null for keys with no token - /// (e.g. bare modifiers). - /// + public static string? FromInput(Key key, PhysicalKey physical) { - // Numeric keypad first - distinguished only by the physical key. + switch (physical) { case PhysicalKey.NumPad0: return "num_0"; @@ -57,15 +44,12 @@ public static class KeyMap case PhysicalKey.NumPadEnter: return "num_enter"; } - // Numeric keypad via the virtual key (fallback when PhysicalKey is Unknown). if (key >= Key.NumPad0 && key <= Key.NumPad9) return "num_" + (char)('0' + (key - Key.NumPad0)); - // Letters. if (key >= Key.A && key <= Key.Z) return ((char)('a' + (key - Key.A))).ToString(); - // Top-row digits. if (key >= Key.D0 && key <= Key.D9) return ((char)('0' + (key - Key.D0))).ToString(); @@ -98,14 +82,12 @@ public static class KeyMap }; } - /// True for keys that only act as modifiers and never form a binding alone. public static bool IsModifierKey(Key key) => key is Key.LeftShift or Key.RightShift or Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt or Key.LWin or Key.RWin; - /// Friendly, explicit name for a token, used in the binding list text. public static string DisplayName(string? token) { if (string.IsNullOrEmpty(token) || @@ -144,14 +126,9 @@ public static class KeyMap }; } - // ── On-screen keyboard layout ────────────────────────────────────────── - // Filler caps (Token == null) keep the shape familiar; only caps with a - // token are coloured by assignment state. - - /// Rows of the main (alphanumeric) keyboard block. public static readonly KeyCap[][] MainBlock = { - // F-row keys are reserved by the simulator, so they carry no token. + new[] { new KeyCap("Esc", null, 1.4), @@ -205,7 +182,6 @@ public static class KeyMap }, }; - /// Rows of the numeric-keypad block, drawn to the right of the main block. public static readonly KeyCap[][] NumpadBlock = { new[] @@ -234,10 +210,9 @@ public static class KeyMap }, }; - /// Navigation cluster (Insert/Home/PageUp · Delete/End/PageDown), drawn between the blocks. public static readonly KeyCap[][] NavBlock = { - // PgUp / PgDn are reserved by the simulator (no token), like [ ] and Enter. + new[] { new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp"), diff --git a/StarterNG/Classes/KeyboardConfig.cs b/StarterNG/Classes/KeyboardConfig.cs index 840f7b5..b717853 100644 --- a/StarterNG/Classes/KeyboardConfig.cs +++ b/StarterNG/Classes/KeyboardConfig.cs @@ -2,63 +2,37 @@ using System; using System.Collections.Generic; using System.IO; using System.Text; +using StarterNG.Infrastructure.Adapters; namespace StarterNG.Classes; -/// -/// A single keyboard command binding parsed from eu07_input-keyboard.ini. -/// A binding is a simulator command name, an optional pair of modifier flags -/// (shift / ctrl, either or both) and the key it is mapped to. -/// none (or an empty key) means the command is intentionally unbound. -/// public sealed class KeyBinding { - /// Simulator command token, e.g. mastercontrollerincrease. public string Command = string.Empty; - /// True when the binding requires the Shift modifier. public bool Shift; - /// True when the binding requires the Ctrl modifier. public bool Ctrl; - /// The key token (e.g. q, num_+) or none when unbound. public string Key = "none"; - /// Human description taken from the line's trailing comment (no //). public string Description = string.Empty; - /// Index into the backing line list, so the original line can be rewritten in place. internal int LineIndex = -1; - /// True when the command is mapped to an actual key. public bool IsAssigned => !string.IsNullOrEmpty(Key) && !Key.Equals("none", StringComparison.OrdinalIgnoreCase); } -/// -/// Reader/writer for the simulator's keyboard layout file, -/// eu07_input-keyboard.ini, which lives next to eu07.ini -/// (%APPDATA%\MaSzyna\ on Windows, ~/.config/MaSzyna/ elsewhere). -/// -/// The file is a flat list of command [shift] [ctrl] key // comment -/// lines. Modifiers are the literal tokens shift and ctrl appearing -/// in any order between the command and the key; the last value token is the key. -/// Blank lines, pure comments and any lines the launcher does not recognise are -/// preserved verbatim on save - only the binding lines the user actually edits -/// are rewritten. -/// public sealed class KeyboardConfig { public static KeyboardConfig Instance { get; } = new(); private readonly List _lines = new(); - /// All recognised bindings, in file order. public List Bindings { get; private set; } = new(); - /// True when a binding has been changed since the last load/save. public bool Dirty { get; set; } private string _savePath = string.Empty; @@ -67,11 +41,10 @@ public sealed class KeyboardConfig private static Encoding ResolveEncoding() { - // Comments use Polish diacritics in Windows-1250, like eu07.ini; preserve them. + try { - Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - return Encoding.GetEncoding(1250); + return LegacyText.CodePage1250; } catch { @@ -79,7 +52,6 @@ public sealed class KeyboardConfig } } - /// Per-user keyboard config path for the current OS (next to eu07.ini). public static string UserConfigPath() { if (OperatingSystem.IsWindows()) @@ -103,15 +75,12 @@ public sealed class KeyboardConfig return Path.Combine(AppContext.BaseDirectory, "eu07_input-keyboard.ini"); } - /// Default file shipped in the simulator's working directory. private static string WorkingDirDefaultPath() => Path.Combine(Directory.GetCurrentDirectory(), "eu07_input-keyboard.ini"); - /// Fallback template bundled with the launcher (startercfg/). private static string BundledDefaultPath() => Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini"); - /// Loads the per-user file, falling back to the working-dir or bundled default. public void Load() { _savePath = UserConfigPath(); @@ -120,7 +89,6 @@ public sealed class KeyboardConfig Dirty = false; } - /// Reloads the shipped defaults, discarding the user's current edits. public void LoadDefaults() { if (string.IsNullOrEmpty(_savePath)) @@ -151,7 +119,6 @@ public sealed class KeyboardConfig return paths.Length > 0 ? paths[^1] : string.Empty; } - /// Parses raw file text into , keeping every line verbatim. public void Parse(string text) { _lines.Clear(); @@ -170,10 +137,6 @@ public sealed class KeyboardConfig Bindings = bindings; } - /// - /// Parses a single line into a binding. Returns false for blank lines, pure - /// comments and anything without a command token. - /// private static bool TryParseBinding(string raw, int lineIndex, out KeyBinding binding) { binding = new KeyBinding { LineIndex = lineIndex }; @@ -181,7 +144,6 @@ public sealed class KeyboardConfig string body = raw; string description = string.Empty; - // Split off a trailing "// comment". int commentAt = body.IndexOf("//", StringComparison.Ordinal); if (commentAt >= 0) { @@ -191,7 +153,7 @@ public sealed class KeyboardConfig body = body.Trim(); if (body.Length == 0) - return false; // blank or pure-comment line + return false; var tokens = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); if (tokens.Length == 0) @@ -200,7 +162,6 @@ public sealed class KeyboardConfig binding.Command = tokens[0]; binding.Description = description; - // Everything after the command is modifiers + (at most) one key. bool shift = false, ctrl = false; string key = "none"; for (int i = 1; i < tokens.Length; i++) @@ -208,7 +169,7 @@ public sealed class KeyboardConfig string t = tokens[i]; if (t.Equals("shift", StringComparison.OrdinalIgnoreCase)) shift = true; else if (t.Equals("ctrl", StringComparison.OrdinalIgnoreCase)) ctrl = true; - else key = t; // last non-modifier token wins as the key + else key = t; } binding.Shift = shift; @@ -217,7 +178,6 @@ public sealed class KeyboardConfig return true; } - /// Rewrites each binding's line, then serialises the file to text. public string ToText() { foreach (var b in Bindings) @@ -229,7 +189,6 @@ public sealed class KeyboardConfig return string.Join(Environment.NewLine, _lines); } - /// Formats one binding as command [shift] [ctrl] key // comment. private static string FormatLine(KeyBinding b) { var sb = new StringBuilder(); @@ -243,7 +202,6 @@ public sealed class KeyboardConfig return sb.ToString(); } - /// Writes the bindings back to the per-user keyboard file. public void Save() { if (string.IsNullOrEmpty(_savePath)) @@ -257,9 +215,9 @@ public sealed class KeyboardConfig File.WriteAllText(_savePath, ToText(), FileEncoding); Dirty = false; } - catch + catch (Exception ex) { - // Could not write (permissions / path). Leave values in memory. + StarterNG.Infrastructure.Diagnostics.Log($"saving {_savePath}", ex); } } } diff --git a/StarterNG/Classes/Physics.cs b/StarterNG/Classes/Physics.cs deleted file mode 100644 index 3d65f63..0000000 --- a/StarterNG/Classes/Physics.cs +++ /dev/null @@ -1,230 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; - -namespace StarterNG.Classes; - -/// -/// Vehicle physics read from a .fiz file (dynamic/<dir>/<model>.fiz), mirroring -/// the original Starter's TLexParser.ParsePhysics. Provides mass, top speed, -/// length, accepted loads and the coupler AllowedFlag / ControlType used for the -/// automatic coupling calculation. -/// -public sealed class Physics -{ - public double Mass; // kg, as written in the .fiz - public double VMax; // km/h - public double Length; // metres - public string LoadAccepted = ""; - public int MaxLoad; - public int AllowedFlagA = 3; - public int AllowedFlagB = 3; - public string ControlTypeA = ""; - public string ControlTypeB = ""; - - private static readonly Dictionary Cache = new(StringComparer.OrdinalIgnoreCase); - - // Index of every .fiz under dynamic/, keyed by its file name without extension - // (e.g. "en57", "en57dumb"). Built once - the directory layout of the data - // folder vs the scenery's mmd token is unreliable, so we resolve by model name. - private static Dictionary? _fizIndex; - - /// Builds the .fiz index up front (call from the startup load). - public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot); - - private static Dictionary FizIndex(string dynamicRoot) - { - if (_fizIndex != null) - return _fizIndex; - - var index = new Dictionary(StringComparer.OrdinalIgnoreCase); - try - { - if (Directory.Exists(dynamicRoot)) - foreach (string f in Directory.EnumerateFiles(dynamicRoot, "*.fiz", SearchOption.AllDirectories)) - index[Path.GetFileNameWithoutExtension(f)] = f; - } - catch { /* unreadable dynamic/ - no physics */ } - - _fizIndex = index; - return index; - } - - /// - /// Loads (and caches) the physics for a model name (the .fiz is located by name - /// anywhere under dynamic/). The is unused now but - /// kept for call-site clarity. Returns null when nothing matches. Never throws. - /// - public static Physics? For(string? dataFolder, string? model, string dynamicRoot = "dynamic") - { - if (string.IsNullOrEmpty(model)) - return null; - - string m = Path.GetFileNameWithoutExtension(model!); // strip any .mmd/.t3d/etc. - if (m.Length == 0) - return null; - - if (Cache.TryGetValue(m, out var cached)) - return cached; - - var index = FizIndex(dynamicRoot); - string? path = null; - if (index.TryGetValue(m, out var p1)) path = p1; - else if (index.TryGetValue(m + "dumb", out var p2)) path = p2; - - Physics? phys = null; - if (path != null) - { - phys = new Physics { AllowedFlagB = -1 }; // -1 = "B end not declared yet" - try { Parse(phys, path, Path.GetDirectoryName(path) ?? "", null, 0); } - catch { /* partial data still useful */ } - - // The original copies the A-end coupler onto the B-end when only one - // BuffCoupl section is declared. - if (phys.AllowedFlagB == -1) - { - phys.AllowedFlagB = phys.AllowedFlagA; - if (string.IsNullOrEmpty(phys.ControlTypeB)) - phys.ControlTypeB = phys.ControlTypeA; - } - } - - Cache[m] = phys; - return phys; - } - - private static readonly HashSet KnownKeys = new(StringComparer.OrdinalIgnoreCase) - { - "M", "Vmax", "L", "LoadAccepted", "MaxLoad", "AllowedFlag", "ControlType" - }; - - private static void Parse(Physics p, string path, string dir, string[]? prms, int depth) - { - if (depth > 6 || !File.Exists(path)) - return; - - var tokens = Tokenize(File.ReadAllText(path, Encoding.GetEncoding(1250))); - - string section = ""; - string? pendingKey = null; - - for (int i = 0; i < tokens.Count; i++) - { - string tok = tokens[i]; - if (tok == "=") - continue; - - // include end -> parse the included physics - if (tok.Equals("include", StringComparison.OrdinalIgnoreCase)) - { - if (++i >= tokens.Count) break; - string incFile = Resolve(tokens[i], prms); - var incParams = new List(); - i++; - while (i < tokens.Count && !tokens[i].Equals("end", StringComparison.OrdinalIgnoreCase)) - incParams.Add(Resolve(tokens[i++], prms)); - Parse(p, Path.Combine(dir, incFile), dir, incParams.ToArray(), depth + 1); - pendingKey = null; - continue; - } - - int eq = tok.IndexOf('='); - if (eq >= 0) - { - string k = tok[..eq].TrimStart('.').Trim(); - string v = tok[(eq + 1)..].Trim(); - if (v.Length == 0) { pendingKey = k; continue; } - Apply(p, section, k, Resolve(v, prms)); - pendingKey = null; - continue; - } - - string t = tok.TrimStart('.').Trim(); - if (pendingKey != null) - { - Apply(p, section, pendingKey, Resolve(t, prms)); - pendingKey = null; - } - else if (KnownKeys.Contains(t)) - { - pendingKey = t; // value comes in a following token (spaced "Key = value") - } - else - { - // any other bare identifier is a section header (Param, Load, - // Dimensions, BuffCoupl/1/2, or one we don't read). Keep only - // letters/digits so "Param:" or "Param," still match. - section = new string(t.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); - } - } - } - - private static void Apply(Physics p, string section, string key, string value) - { - switch (section) - { - case "param": - if (key.Equals("M", StringComparison.OrdinalIgnoreCase)) p.Mass = ParseD(value, p.Mass); - else if (key.Equals("Vmax", StringComparison.OrdinalIgnoreCase)) p.VMax = ParseD(value, p.VMax); - break; - case "load": - if (key.Equals("LoadAccepted", StringComparison.OrdinalIgnoreCase)) p.LoadAccepted = value; - else if (key.Equals("MaxLoad", StringComparison.OrdinalIgnoreCase)) p.MaxLoad = (int)ParseD(value, p.MaxLoad); - break; - case "dimensions": - if (key.Equals("L", StringComparison.OrdinalIgnoreCase)) p.Length = ParseD(value, p.Length); - break; - case "buffcoupl": - case "buffcoupl1": - if (key.Equals("AllowedFlag", StringComparison.OrdinalIgnoreCase)) p.AllowedFlagA = Flag(value, p.AllowedFlagA); - else if (key.Equals("ControlType", StringComparison.OrdinalIgnoreCase)) p.ControlTypeA = value; - break; - case "buffcoupl2": - if (key.Equals("AllowedFlag", StringComparison.OrdinalIgnoreCase)) p.AllowedFlagB = Flag(value, p.AllowedFlagB); - else if (key.Equals("ControlType", StringComparison.OrdinalIgnoreCase)) p.ControlTypeB = value; - break; - } - } - - // A negative AllowedFlag means a permanent coupling: abs + 128 (workshop-lock bit). - private static int Flag(string v, int fallback) - => int.TryParse(v, System.Globalization.NumberStyles.Integer, - System.Globalization.CultureInfo.InvariantCulture, out int f) - ? (f < 0 ? -f + 128 : f) - : fallback; - - private static double ParseD(string v, double fallback) - => double.TryParse(v, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out double d) - ? d - : fallback; - - // Substitutes an include placeholder like "(c1)" with the matching include - // parameter (1-based), otherwise returns the token unchanged. - private static string Resolve(string token, string[]? prms) - { - if (prms == null || token.Length < 3 || token[0] != '(' || token[^1] != ')') - return token; - string digits = new string(token.Where(char.IsDigit).ToArray()); - return int.TryParse(digits, out int idx) && idx >= 1 && idx <= prms.Length - ? prms[idx - 1] - : token; - } - - private static List Tokenize(string text) - { - var sb = new StringBuilder(); - foreach (var line in text.Split('\n')) - { - string l = line; - int hash = l.IndexOf('#'); - if (hash >= 0) l = l[..hash]; - sb.Append(l).Append(' '); - } - return sb.ToString() - .Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .ToList(); - } -} diff --git a/StarterNG/Classes/PresetStore.cs b/StarterNG/Classes/PresetStore.cs index 9d9ce65..b03a748 100644 --- a/StarterNG/Classes/PresetStore.cs +++ b/StarterNG/Classes/PresetStore.cs @@ -1,37 +1,35 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using System.Text.RegularExpressions; +using StarterNG.Infrastructure.Adapters; namespace StarterNG.Classes; -/// One saved consist in the user's vehicle warehouse. +public enum PresetSort +{ + Name, + Track +} + public sealed class TrainsetPreset { - /// Display name shown in the "load from warehouse" menu. public string Name { get; set; } = ""; - /// Complete "trainset … endtrainset" scenery entry (self-contained). public string Entry { get; set; } = ""; } -/// Root object persisted to userpresets.json. public sealed class PresetCollection { public List Presets { get; set; } = new(); } -/// -/// The user's vehicle/consist "warehouse": named trainset presets persisted as JSON. -/// Stored under the per-user config directory so it survives reinstalls: -/// Windows: %APPDATA%\MaSzyna\starter\userpresets.json -/// macOS: ~/Library/Application Support/MaSzyna/starter/userpresets.json -/// Linux: ~/.config/MaSzyna/starter/userpresets.json -/// All operations are best-effort and never throw. -/// public static class PresetStore { private static string ResolvePath() @@ -54,8 +52,8 @@ public static class PresetStore public static string FilePath { get; } = ResolvePath(); - // Source-generated metadata keeps (de)serialisation AOT/trim-safe, matching the - // approach used for the vehicle database. + public static PresetSort SortMode { get; set; } = PresetSort.Name; + private static readonly JsonSerializerOptions Options = new() { WriteIndented = true, @@ -65,13 +63,16 @@ public static class PresetStore private static JsonTypeInfo TypeInfo => (JsonTypeInfo)Options.GetTypeInfo(typeof(PresetCollection)); - /// All saved presets, sorted by name. Empty when none / unreadable. - public static IReadOnlyList All() => Load().Presets; + public static IReadOnlyList All() + { + var list = Load().Presets.ToList(); + if (SortMode == PresetSort.Track) + list.Sort((a, b) => string.Compare(TrackOf(a), TrackOf(b), StringComparison.OrdinalIgnoreCase)); + else + list.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + return list; + } - /// - /// Saves (or overwrites, by name) a consist preset. No-op on a blank name or - /// empty entry. - /// public static void Save(string? name, string? entry) { name = name?.Trim() ?? ""; @@ -85,7 +86,6 @@ public static class PresetStore Persist(col); } - /// Removes a preset by name (case-insensitive). public static void Delete(string name) { var col = Load(); @@ -93,6 +93,76 @@ public static class PresetStore Persist(col); } + public static int ImportMagazyn(string? path = null) + { + path ??= FindMagazynPath(); + if (path is null || !File.Exists(path)) return 0; + + int added = 0; + try + { + string text = File.ReadAllText(path, LegacyText.CodePage1250); + var section = Regex.Matches(text, + @"\[TRAINSET\d+\s*=\s*([^\]]*)\](.*?)(?=\[TRAINSET|\z)", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + + foreach (Match m in section) + { + string name = m.Groups[1].Value.Trim(); + if (name.Length == 0) name = $"import_{added + 1}"; + + var nodes = new List(); + foreach (Match line in Regex.Matches(m.Groups[2].Value, @"(?m)^\s*\d+\s*=\s*(.+)$")) + { + string body = line.Groups[1].Value.Trim(); + if (!body.Contains("enddynamic", StringComparison.OrdinalIgnoreCase)) + body += " enddynamic"; + nodes.Add(body); + } + if (nodes.Count == 0) continue; + + var sb = new StringBuilder(); + sb.Append("trainset ").Append(Sanitize(name)).Append(" none 0 0\n"); + foreach (string n in nodes) + sb.Append(n).Append('\n'); + sb.Append("endtrainset\n"); + + Save(name, sb.ToString()); + added++; + } + } + catch (Exception ex) + { + Infrastructure.Diagnostics.Log($"Import {path}", ex); + } + return added; + } + + private static string? FindMagazynPath() + { + string cwd = Directory.GetCurrentDirectory(); + foreach (string rel in new[] + { + Path.Combine("starter", "magazyn.ini"), + "starter.ini", + "RAINSTED.INI" + }) + { + string p = Path.Combine(cwd, rel); + if (File.Exists(p)) return p; + } + return null; + } + + private static string TrackOf(TrainsetPreset p) + { + var m = Regex.Match(p.Entry ?? "", @"^\s*trainset\s+\S+\s+(\S+)", RegexOptions.Multiline | RegexOptions.IgnoreCase); + return m.Success ? m.Groups[1].Value : ""; + } + + private static string Sanitize(string name) => + string.IsNullOrWhiteSpace(name) ? "preset" : name.Replace(' ', '_'); + private static PresetCollection Load() { try @@ -104,9 +174,9 @@ public static class PresetStore return col; } } - catch + catch (Exception ex) { - // unreadable / malformed - start from an empty warehouse + StarterNG.Infrastructure.Diagnostics.Log($"reading {FilePath}", ex); } return new PresetCollection(); } @@ -118,26 +188,17 @@ public static class PresetStore Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); File.WriteAllText(FilePath, JsonSerializer.Serialize(col, TypeInfo)); } - catch + catch (Exception ex) { - // best-effort: a failed save just means the warehouse isn't updated + StarterNG.Infrastructure.Diagnostics.Log($"saving {FilePath}", ex); } } } -/// -/// Helpers to convert a consist to/from its canonical scenery text - the full -/// "trainset … endtrainset" block, exactly what is placed on the clipboard. -/// public static class ConsistText { - /// The complete scenery entry for a trainset (incl. endtrainset). public static string Serialize(Trainset trainset) => trainset.ToSceneryEntry(); - /// - /// Parses the vehicles out of a complete trainset entry. Returns null when the - /// text holds no usable node::dynamic vehicles. - /// public static List? VehiclesFrom(string? text) { if (string.IsNullOrWhiteSpace(text)) @@ -154,7 +215,6 @@ public static class ConsistText } } -// Source-generated JSON metadata (AOT/trim-safe; no runtime reflection). [JsonSerializable(typeof(PresetCollection))] [JsonSerializable(typeof(TrainsetPreset))] internal partial class PresetJsonContext : JsonSerializerContext diff --git a/StarterNG/Classes/Scenery.cs b/StarterNG/Classes/Scenery.cs deleted file mode 100644 index bcf3583..0000000 --- a/StarterNG/Classes/Scenery.cs +++ /dev/null @@ -1,283 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; - -namespace StarterNG.Classes; - -public class Scenery -{ - public List Lines; - public List Trainsets; - public string Group; - public string Path; - - // Starter directives (comment-syntax metadata, see wiki "Plik scenerii"). - // They do not affect the simulation, only how a starter presents the scenery. - public string Name; // //$n - scenery name - public string Description; // //$d - scenery description - public string ImageName; // //$i - main-window image (scenery thumbnail) - - /// True when the scenery declares the //$a "archival" flag. - public bool Archival; - - // Weather / environment. Like the original Starter, these are editable and are - // written into the scenery's "config" block on launch (see RewriteWeather). - // Defaults mirror the original (15 °C, day 0, 10:30, clear sky). - public string WeatherTime = "10:30"; // h:mm -> "time"/"scenario.time.override" - public int Day = 0; // "movelight " (day of year / season) - public double Temperature = 15; // "scenario.weather.temperature" - public int FogEnd = 2000; // visibility in metres (atmo fog range) - public double Overcast = 0; // atmo overcast factor (-1.5 .. 1.5) - - /// True when the scenery actually declared any weather command. - public bool HasWeather; - - /// Set once the user edits the weather, so export rewrites the config. - public bool WeatherDirty; - - // The file content with each trainset block replaced by a {{i}} placeholder, - // used to rebuild the .scn on export. - private readonly string _template; - - // Lazily resolved, cached path to the //$i image on disk (null if not found). - private string _imagePath; - private bool _imagePathResolved; - - public Scenery(string path) - { - this.Path = path; - Trainsets = new List(); - if (!File.Exists(path)) - throw new FileNotFoundException(path); - var encoding = Encoding.GetEncoding(1250); // Windows-1250 - string content = File.ReadAllText(path, encoding); - - // property scanning - starter directives written as // comments. - // \b after the letter keeps //$d from matching //$decor, //$i from - // matching //$it, etc. - this.Group = MatchDirective(content, "l"); - this.Name = MatchDirective(content, "n"); - // //$d may appear on several lines; each is one line of the description. - this.Description = MatchAllDirectives(content, "d"); - this.ImageName = MatchDirective(content, "i"); - // //$a marks an archival scenery (present = archival, regardless of value). - this.Archival = MatchDirective(content, "a") != null; - - // weather/environment is read from the raw text before trainset blocks - // are stripped out below (the atmosphere commands live outside trainsets) - ParseWeather(content); - - - // parsing trainsets - List trainsetEntries = new List(); - Regex regex = new Regex( - @"trainset\b[\s\S]*?\bendtrainset\b", - RegexOptions.IgnoreCase - ); - int idx = 0; - content = regex.Replace(content, match => - { - trainsetEntries.Add(match.Value); - return $"{{{{{idx++}}}}}"; - }); - _template = content; - - // 1:1 with placeholders - the Trainset ctor never throws (unparsable - // blocks are kept verbatim), so indices stay aligned for export. - foreach (string trainsetEntry in trainsetEntries) - Trainsets.Add(new Trainset(trainsetEntry)); - } - - /// - /// Rebuilds the full .scn content with the (possibly modified) trainsets - /// substituted back into their original positions. - /// - public string BuildExportContent(bool skipDecorTrainsets = false) - { - string result = _template; - - // Inject the (edited) weather into the config block before substituting - // trainsets, so the {{i}} placeholders shield the trainset text from the - // weather rewrite. Only done when the user actually changed the weather. - if (WeatherDirty) - result = RewriteWeather(result); - - for (int i = 0; i < Trainsets.Count; i++) - { - string entry = skipDecorTrainsets && Trainsets[i].Decor - ? string.Empty - : Trainsets[i].ToSceneryEntry(); - result = result.Replace("{{" + i + "}}", entry); - } - return result; - } - - /// - /// Reads the scenery's environment commands into the editable weather fields. - /// Mirrors the original Starter (config: movelight / scenario.weather.temperature - /// / scenario.time.override, plus top-level time and the atmo fog/overcast block). - /// - private void ParseWeather(string content) - { - // start time: "scenario.time.override h:mm" wins, else top-level "time h:mm" - var ovr = Regex.Match(content, @"(?i)scenario\.time\.override\s+(\d{1,2})[:.](\d{2})"); - var time = ovr.Success - ? ovr - : Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b"); - if (time.Success) - { - WeatherTime = $"{time.Groups[1].Value.PadLeft(2, '0')}:{time.Groups[2].Value}"; - HasWeather = true; - } - - // "movelight " - day of the year (sun elevation / season) - var move = Regex.Match(content, @"(?im)\bmovelight\s+(-?\d+)"); - if (move.Success && int.TryParse(move.Groups[1].Value, out int day)) - { - Day = day; - HasWeather = true; - } - - // "scenario.weather.temperature <°C>" - var temp = Regex.Match(content, @"(?i)scenario\.weather\.temperature\s+(-?\d+(?:\.\d+)?)"); - if (temp.Success && - double.TryParse(temp.Groups[1].Value, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out double t)) - { - Temperature = t; - HasWeather = true; - } - - // "atmo R G B fogStart fogEnd R G B overcast endatmo" - var atmo = Regex.Match(content, @"(?is)\batmo\b(.*?)\bendatmo\b"); - if (atmo.Success) - { - var nums = Regex.Matches(atmo.Groups[1].Value, @"-?\d+(?:\.\d+)?") - .Select(m => m.Value).ToList(); - if (nums.Count >= 5 && int.TryParse(nums[4], out int fog)) - { - FogEnd = fog; - HasWeather = true; - } - if (nums.Count >= 6 && - double.TryParse(nums[^1], System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out double oc)) - Overcast = oc; - } - } - - /// - /// Replaces the scenery's environment commands with a fresh config block built - /// from the current weather fields (the C# equivalent of the original Starter's - /// TLexParser.ChangeConfig). On any failure the original text is returned - /// unchanged so a launch is never blocked by a bad rewrite. - /// - private string RewriteWeather(string text) - { - try - { - // strip the existing weather commands - string s = text; - s = Regex.Replace(s, @"(?is)\bconfig\b.*?\bendconfig\b", " "); - s = Regex.Replace(s, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " "); - s = Regex.Replace(s, @"(?is)\batmo\b.*?\bendatmo\b", " "); - s = Regex.Replace(s, @"(?im)^[ \t]*movelight\s+\S+", " "); - s = Regex.Replace(s, @"(?i)scenario\.weather\.temperature\s+\S+", " "); - s = Regex.Replace(s, @"(?i)scenario\.time\.override\s+\S+", " "); - - var inv = System.Globalization.CultureInfo.InvariantCulture; - string config = - "config\r\n" + - $"movelight {Day}\r\n" + - $"scenario.weather.temperature {Temperature.ToString(inv)}\r\n" + - $"scenario.time.override {WeatherTime}\r\n" + - $"time {WeatherTime} 0 0 endtime\r\n" + - $"atmo 0 0 0 {FogEnd} {FogEnd} 0 0 0 {Overcast.ToString(inv)} endatmo\r\n" + - "endconfig\r\n"; - - return config + s; - } - catch - { - return text; - } - } - - /// - /// Collects every //$<letter> line (e.g. all //$d lines) and joins them - /// into one multi-line string. Returns null when none are present. - /// - private static string MatchAllDirectives(string content, string letter) - { - var matches = Regex.Matches( - content, - @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)", - RegexOptions.Multiline - ); - if (matches.Count == 0) - return null; - return string.Join("\n", matches.Select(m => m.Groups[1].Value.TrimEnd())); - } - - /// - /// Reads a single starter directive value (the text after //$<letter>). - /// Returns null when the directive is absent. The trailing whitespace - /// requirement separates e.g. //$d from //$decor and //$i from //$it. - /// - private static string MatchDirective(string content, string letter) - { - var match = Regex.Match( - content, - @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)", - RegexOptions.Multiline - ); - return match.Success ? match.Groups[1].Value.Trim() : null; - } - - /// - /// Resolved on-disk path of the //$i scenery image, or null if none is - /// declared or the file cannot be found. The value of //$i may be a bare - /// file name or a path; common locations are probed and the result cached. - /// - public string ImagePath - { - get - { - if (_imagePathResolved) - return _imagePath; - _imagePathResolved = true; - _imagePath = ResolveImagePath(); - return _imagePath; - } - } - - private string ResolveImagePath() - { - if (string.IsNullOrWhiteSpace(ImageName)) - return null; - - // normalise legacy back-slashes so paths work cross-platform - string name = ImageName.Replace('\\', '/').Trim(); - string scnDir = System.IO.Path.GetDirectoryName(Path) ?? "."; // e.g. scenery/ - string root = System.IO.Path.GetDirectoryName(scnDir) ?? "."; // MaSzyna root - - // probe the usual places, first hit wins - var candidates = new List - { - name, // as given (cwd / absolute) - System.IO.Path.Combine(root, name), // relative to MaSzyna root - System.IO.Path.Combine(scnDir, name), // next to the .scn - System.IO.Path.Combine(scnDir, "images", name), // scenery/images/ - System.IO.Path.Combine(root, "scenery", "images", name), // scenery/images/ from root - }; - - foreach (string candidate in candidates) - { - if (File.Exists(candidate)) - return candidate; - } - return null; - } -} diff --git a/StarterNG/Classes/Settings.cs b/StarterNG/Classes/Settings.cs deleted file mode 100644 index 1275cb9..0000000 --- a/StarterNG/Classes/Settings.cs +++ /dev/null @@ -1,608 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; - -namespace StarterNG.Classes; - -public enum BatteryDefault { Default = 0, AlwaysOff = 1, AlwaysOn = 2 } - -/// -/// Application settings backing the Settings view, persisted to the simulator's -/// eu07.ini. -/// -/// Where it loads/saves. The active file lives next to the -/// simulator's own config: %APPDATA%\MaSzyna\eu07.ini on Windows or -/// ~/.config/MaSzyna/eu07.ini elsewhere. If that file does not exist yet, -/// defaults are read from an eu07.ini in the launcher's working directory -/// (the same fallback the simulator uses). Saving always writes to the per-user -/// path. -/// -/// Unknown keys are kept. The whole file is round-tripped through -/// ; only the keys the UI actually edits are rewritten, -/// so any extra settings the launcher does not model survive a save. -/// -/// Some mappings are best-effort where the wiki does not document an exact -/// key; those are marked with NOTE and can be adjusted against the running exe. -/// -public sealed class Settings -{ - public static Settings Instance { get; } = new(); - - /// - /// Pulls the current values out of the Settings view into this instance. - /// The view assigns this when it is constructed; null if the view was never - /// opened (in which case Save just re-writes the loaded values). - /// - public Action? CaptureFromUi { get; set; } - - /// True if the loaded config explicitly contained a lang entry. - public bool LanguageWasSet { get; private set; } - - // ── General ─────────────────────────────────────────────────────────── - public string Language = "English"; // lang (pl/en) - public bool Fullscreen; // fullscreen - public bool PauseWhenInactive = true; // inactivepause - public bool PauseOnStart; // pause - public int CursorSensitivity = 2; // |mousescale x| - public bool InvertMouseHorizontal; // mousescale x < 0 - public bool InvertMouseVertical; // mousescale y < 0 - - // ── Communication ───────────────────────────────────────────────────── - public bool IgnoreGamepad; // input.gamepad (inverted) - public int FeedbackMode = 1; // feedbackmode (0-5) - public int FeedbackPort = 888; // feedbackport - public bool UartEnabled; // uart presence - public string UartPort = ""; // uart - public string UartTune = ""; // uarttune - public bool UartDebug; // uartdebug - public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel; // uartfeature tokens - - // ── Other ───────────────────────────────────────────────────────────── - public bool SelectExeAutomatically = true; // starter.exe.auto (launcher-only) - public string ExecutablePath = "eu07.exe"; // starter.exe.path (launcher-only) - public bool DebugMode; // debugmode - public bool VirtualShunting = true; // ai.trainman - public bool LogMissingVehicleFiles; // starter.logmissingvehicles (launcher-only; not yet wired to any load/export codepath) - - // ── Graphics ────────────────────────────────────────────────────────── - public int RenderEngine; // gfxrenderer (index, see RenderEngines) - public int Width = 1280; // width - public int Height = 720; // height - public int BufferScalePercent = 100; // starter.bufferscale (NOTE: launcher-only) - public int MaxTextureSize = 4096; // maxtexturesize - public int MaxCabTextureSize = 4096; // maxcabtexturesize - public int TextureFiltering = 8; // anisotropicfiltering (1/2/4/8/16) - public int Multisampling = 2; // multisampling (0-3) - public int DynamicLights = 7; // dynamiclights (0-7) - public double DrawRangeFactor = 3.0; // gfx.drawrange.factor.max - public bool VSync; // vsync - public bool Smoke = true; // gfx.smoke - public int SmokeFidelity = 1; // gfx.smoke.fidelity (1-4) - public bool ChromaticAberration = true; // gfx.postfx.chromaticaberration.enabled - public bool MotionBlur = true; // gfx.postfx.motionblur.enabled - public bool ExtraEffects = true; // gfx.extraeffects - public bool EnvMap = true; // gfx.envmap.enabled - public bool UseVbo = true; // usevbo - public bool RenderShadows = true; // shadows + gfx.shadowmap.enabled - public int ReflectionsFramerate = 60; // gfx.reflections.framerate - public int ShadowMapResolution = 2048; // shadowtune[0] - public int ShadowProjectionRange = 250; // shadowtune[1] - public int CabShadowsRange; // gfx.shadows.cab.range - public int ShadowRankCutoff = 3; // gfx.shadow.rank.cutoff (NOTE) - public int ReflectionsFidelity; // gfx.reflections.fidelity (0-2) - public int FieldOfView = 45; // fieldofview (15-75) NOTE: 'fovSlider' in XAML - public bool PythonScreens = true; // python.enabled - public bool PythonThreadedUpload = true; // python.threadedupload - public bool FpsLimitEnabled; // fpslimit (presence) - public int FpsLimit = 30; // fpslimit (1-100) - public double ShadowAngleLimit = 0.2; // gfx.shadow.angle.min (0.2-1.0) - public bool FullscreenWindowed; // fullscreenwindowed - public bool RenderAngleVulkan; // gfx.angleplatform ("vulkan") - - // ── Physics ─────────────────────────────────────────────────────────── - public int SplineFidelity = 1; // splinefidelity (1-4) - public bool FullPhysics = true; // fullphysics - public bool EnableTraction = true; // enabletraction (pantograph can break) - public bool LiveTraction = true; // livetraction - public bool PhysicsLog; // physicslog (speedometer tapes) - public bool DebugLog = true; // debuglog (3 / 0) - public bool MultipleLogs; // multiplelogs - public bool DisplaySimulation = true; // gfx.skiprendering (inverted) - public bool CrashDamage = true; // crashdamage - public double Friction = 1.0; // friction (0.1-3.0) - public double BrakeStep = 1.0; // brakestep (0.4-3.0) - public double BrakeSpeed = 3.0; // brakespeed (0.4-3.0) - - // ── Sound ───────────────────────────────────────────────────────────── - public bool SoundEnabled = true; // soundenabled - public int Volume = 50; // sound.volume (0-2 → 0-100) - public int RadioVolume = 75; // sound.volume.radio (0-1 → 0-100) - public int VehiclesVolume = 100; // sound.volume.vehicle - public int PositionalVolume = 100; // sound.volume.positional - public int AmbientVolume = 100; // sound.volume.ambient - - // ── Advanced ────────────────────────────────────────────────────────── - public bool CompressTextures = true; // compresstex - public bool ScaleSpeculars = true; // scalespeculars - public bool UseGLES; // gfx.usegles - public bool ShaderGamma; // gfx.shadergamma - public bool ScreenMipmaps = true; // python.mipmaps - public bool ExtendedModelConversion; // convertmodels ("143"/"0") - public bool GfxResourceMove; // gfx.resource.move - public bool GfxResourceSweep = true; // gfx.resource.sweep - public int TrainPhysicsThreads; // async.trainThreads - public bool IgnoreIrrelevantTrains; // starter.ignoreirrelevant (launcher-only) - - // ── Starter (launcher-only, stored under starter.* keys) ─────────────── - public bool AutoCloseStarter; // starter.autoclose - public bool LargeThumbnails; // starter.largethumbnails - public bool AutoExpandSceneryTree; // starter.expandtree - public BatteryDefault BatteryDefault = BatteryDefault.Default; // starter.batterydefault (launcher-only) - - // List filters (launcher-only). Default on. - public bool ShowAiVehicles = true; // starter.show.ai (show //$o "-" consists) - public bool DrivableOnly = true; // starter.drivableonly (hide //$decor consists) - public bool ShowArchivalSceneries = true; // starter.show.archival - - /// gfxrenderer tokens in the order shown by the render-engine combo. - public static readonly string[] RenderEngines = - { "full", "legacy", "simpleshader", "simple", "off", "experimental" }; - - /// Anisotropic-filtering steps for the texture-quality slider (1-5). - public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 }; - - // Tokens of mousescale / shadowtune that the UI does not edit but must keep. - private double _mouseScaleYMagnitude = 0.2; - private string[] _shadowTuneExtra = { "400", "300" }; // tokens [2], [3] - - private ConfigFile _config = new(); - private string _savePath = string.Empty; - - /// Resolves the per-user eu07.ini path for the current OS. - public static string UserConfigPath() - { - if (OperatingSystem.IsWindows()) - { - string? appData = Environment.GetEnvironmentVariable("APPDATA"); - if (!string.IsNullOrEmpty(appData)) - return Path.Combine(appData, "MaSzyna", "eu07.ini"); - } - else if (OperatingSystem.IsMacOS()) - { - string? home = Environment.GetEnvironmentVariable("HOME"); - if (!string.IsNullOrEmpty(home)) - return Path.Combine(home, "Library", "Application Support", "MaSzyna", "eu07.ini"); - } - else - { - string? home = Environment.GetEnvironmentVariable("HOME"); - if (!string.IsNullOrEmpty(home)) - return Path.Combine(home, ".config", "MaSzyna", "eu07.ini"); - } - // Last resort: alongside the launcher. - return Path.Combine(AppContext.BaseDirectory, "eu07.ini"); - } - - /// Default config shipped in the launcher's working directory. - private static string DefaultConfigPath() => - Path.Combine(Directory.GetCurrentDirectory(), "eu07.ini"); - - /// Directory holding the per-user eu07.ini, for locating launcher-only data alongside it. - public static string UserConfigDirectory() => - Path.GetDirectoryName(UserConfigPath()) ?? AppContext.BaseDirectory; - - /// - /// The simulator executable to launch. With auto-selection on, the working - /// directory is scanned for an eu07* binary - eu07 on Linux/macOS, - /// eu07.exe on Windows - so the same procedure works on every OS. With it - /// off, the explicitly chosen path is used. - /// - public string ResolveExecutable() - { - if (!SelectExeAutomatically && !string.IsNullOrWhiteSpace(ExecutablePath)) - return ExecutablePath; - - string canonical = OperatingSystem.IsWindows() ? "eu07.exe" : "eu07"; - try - { - string? best = null; - foreach (string path in Directory.GetFiles(Directory.GetCurrentDirectory(), "eu07*")) - { - if (!IsExecutableCandidate(path)) - continue; - string file = Path.GetFileName(path); - if (string.Equals(file, canonical, StringComparison.OrdinalIgnoreCase)) - return path; // exact canonical name wins - if (best == null || file.Length < Path.GetFileName(best).Length) - best = path; // otherwise prefer the shortest eu07* name - } - if (best != null) - return best; - } - catch { /* fall through to the canonical name */ } - - return canonical; - } - - /// - /// Scans (defaults to the working directory) for - /// plausible eu07* launcher binaries, newest first, for populating the - /// manual-selection dropdown. Does not affect . - /// - public static List ListCandidateExecutables(string? directory = null) - { - directory ??= Directory.GetCurrentDirectory(); - try - { - return Directory.GetFiles(directory, "eu07*") - .Where(IsExecutableCandidate) - .OrderByDescending(File.GetLastWriteTimeUtc) - .Select(Path.GetFileName) - .ToList()!; - } - catch { return new List(); } - } - - // Filters the eu07* matches down to plausible launcher binaries, skipping data - // and config files (eu07.ini, eu07.log, libraries, …). - private static bool IsExecutableCandidate(string path) - { - string ext = Path.GetExtension(path).ToLowerInvariant(); - if (ext is ".ini" or ".log" or ".txt" or ".cfg" or ".config" or ".json" - or ".dll" or ".so" or ".dat" or ".bak" or ".csv" or ".xml") - return false; - if (OperatingSystem.IsWindows()) - return ext == ".exe"; - // Linux / macOS: no extension (eu07), a known binary suffix, or .exe - // (some builds - notably this project's own build-macos.sh - ship the - // macOS binary under the historical "eu07.exe" name). - return ext.Length == 0 || ext is ".exe" or ".x86_64" or ".run" or ".appimage"; - } - - private static readonly Encoding FileEncoding = ResolveEncoding(); - - private static Encoding ResolveEncoding() - { - // eu07.ini comments use Polish diacritics in Windows-1250; preserve them. - try - { - Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - return Encoding.GetEncoding(1250); - } - catch - { - return Encoding.UTF8; - } - } - - /// Loads settings from the per-user file, or the working-dir default. - public void Load() - { - _savePath = UserConfigPath(); - string source = File.Exists(_savePath) ? _savePath : DefaultConfigPath(); - LoadFrom(source); - } - - /// Captures the latest UI values (if the view is open) and saves. - public void CaptureAndSave() - { - CaptureFromUi?.Invoke(); - Save(); - } - - /// Loads settings from an arbitrary path without changing where writes. - public void LoadFrom(string path) - { - try - { - _config = File.Exists(path) - ? ConfigFile.Parse(File.ReadAllText(path, FileEncoding)) - : new ConfigFile(); - } - catch - { - _config = new ConfigFile(); - } - - ReadFromConfig(); - } - - /// Persists settings to the per-user eu07.ini, keeping unknown keys. - public void Save() - { - if (string.IsNullOrEmpty(_savePath)) - _savePath = UserConfigPath(); - - SaveTo(_savePath); - } - - /// Writes settings to an arbitrary path without changing where writes. - public void SaveTo(string path) - { - WriteToConfig(); - - try - { - string? dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir)) - Directory.CreateDirectory(dir); - File.WriteAllText(path, _config.ToText(), FileEncoding); - } - catch - { - // Could not write (permissions / path). Leave values in memory. - } - } - - // ── config → fields ─────────────────────────────────────────────────── - private void ReadFromConfig() - { - var c = _config; - - // General - LanguageWasSet = c.Has("lang"); - Language = c.GetString("lang", "en").ToLowerInvariant() == "pl" ? "Polski" : "English"; - Fullscreen = c.GetBool("fullscreen", false); - PauseWhenInactive = c.GetBool("inactivepause", true); - PauseOnStart = c.GetBool("pause", false); - - var mouse = c.GetTokens("mousescale"); - if (mouse.Length >= 1 && double.TryParse(mouse[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double mx)) - { - InvertMouseHorizontal = mx < 0; - CursorSensitivity = Clamp((int)Math.Round(Math.Abs(mx)), 1, 5); - } - if (mouse.Length >= 2 && double.TryParse(mouse[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double my)) - { - InvertMouseVertical = my < 0; - _mouseScaleYMagnitude = Math.Abs(my); - } - - // Communication - IgnoreGamepad = !c.GetBool("input.gamepad", true); - FeedbackMode = Clamp(c.GetInt("feedbackmode", 1), 0, 5); - FeedbackPort = c.GetInt("feedbackport", 888); - UartEnabled = c.Has("uart"); - UartPort = c.GetString("uart", ""); - UartTune = c.GetString("uarttune", ""); - UartDebug = c.GetBool("uartdebug", false); - if (c.Has("uartfeature")) - { - var uartTokens = (c.GetRaw("uartfeature") ?? "").Split('|', StringSplitOptions.RemoveEmptyEntries); - var uf = new HashSet(uartTokens, StringComparer.OrdinalIgnoreCase); - UartMain = uf.Contains("main"); UartScnd = uf.Contains("scnd"); UartTrain = uf.Contains("train"); - UartLocal = uf.Contains("local"); UartRadioVolume = uf.Contains("radiovolume"); UartRadioChannel = uf.Contains("radiochannel"); - } - else - { - UartMain = UartScnd = UartTrain = UartLocal = true; - UartRadioVolume = UartRadioChannel = false; - } - - // Other - SelectExeAutomatically = c.GetBool("starter.exe.auto", true); - ExecutablePath = c.GetString("starter.exe.path", "eu07.exe"); - DebugMode = c.GetBool("debugmode", false); - VirtualShunting = c.GetBool("ai.trainman", true); - LogMissingVehicleFiles = c.GetBool("starter.logmissingvehicles", false); - - // Graphics - RenderEngine = IndexOf(RenderEngines, c.GetString("gfxrenderer", "full"), 0); - Width = c.GetInt("width", 1280); - Height = c.GetInt("height", 720); - BufferScalePercent = Clamp(c.GetInt("starter.bufferscale", 100), 50, 200); - MaxTextureSize = c.GetInt("maxtexturesize", 4096); - MaxCabTextureSize = c.GetInt("maxcabtexturesize", 4096); - TextureFiltering = c.GetInt("anisotropicfiltering", 8); - Multisampling = Clamp(c.GetInt("multisampling", 2), 0, 3); - DynamicLights = Clamp(c.GetInt("dynamiclights", 7), 0, 7); - DrawRangeFactor = c.GetDouble("gfx.drawrange.factor.max", 3.0); - VSync = c.GetBool("vsync", false); - Smoke = c.GetBool("gfx.smoke", true); - SmokeFidelity = Clamp(c.GetInt("gfx.smoke.fidelity", 1), 1, 4); - ChromaticAberration = c.GetBool("gfx.postfx.chromaticaberration.enabled", true); - MotionBlur = c.GetBool("gfx.postfx.motionblur.enabled", true); - ExtraEffects = c.GetBool("gfx.extraeffects", true); - EnvMap = c.GetBool("gfx.envmap.enabled", true); - UseVbo = c.GetBool("usevbo", true); - RenderShadows = c.GetBool("shadows", true); - ReflectionsFramerate = c.GetInt("gfx.reflections.framerate", 60); - - var shadowTune = c.GetTokens("shadowtune"); - if (shadowTune.Length >= 1) ShadowMapResolution = ParseInt(shadowTune[0], 2048); - if (shadowTune.Length >= 2) ShadowProjectionRange = ParseInt(shadowTune[1], 250); - if (shadowTune.Length >= 4) _shadowTuneExtra = new[] { shadowTune[2], shadowTune[3] }; - - CabShadowsRange = (int)Math.Round(c.GetDouble("gfx.shadows.cab.range", 0)); - ShadowRankCutoff = c.GetInt("gfx.shadow.rank.cutoff", 3); - ReflectionsFidelity = Clamp(c.GetInt("gfx.reflections.fidelity", 0), 0, 2); - FieldOfView = Clamp((int)Math.Round(c.GetDouble("fieldofview", 45)), 15, 75); - PythonScreens = c.GetBool("python.enabled", true); - PythonThreadedUpload = c.GetBool("python.threadedupload", true); - FpsLimitEnabled = c.Has("fpslimit"); - FpsLimit = Clamp(c.GetInt("fpslimit", 30), 1, 100); - ShadowAngleLimit = c.GetDouble("gfx.shadow.angle.min", 0.2); - FullscreenWindowed = c.GetBool("fullscreenwindowed", false); - RenderAngleVulkan = string.Equals(c.GetString("gfx.angleplatform", ""), "vulkan", StringComparison.OrdinalIgnoreCase); - - // Physics - SplineFidelity = Clamp(c.GetInt("splinefidelity", 1), 1, 4); - FullPhysics = c.GetBool("fullphysics", true); - EnableTraction = c.GetBool("enabletraction", true); - LiveTraction = c.GetBool("livetraction", true); - PhysicsLog = c.GetBool("physicslog", false); - DebugLog = c.GetInt("debuglog", 3) != 0; - MultipleLogs = c.GetBool("multiplelogs", false); - DisplaySimulation = !c.GetBool("gfx.skiprendering", false); - CrashDamage = c.GetBool("crashdamage", true); - Friction = c.GetDouble("friction", 1.0); - BrakeStep = c.GetDouble("brakestep", 1.0); - BrakeSpeed = c.GetDouble("brakespeed", 3.0); - - // Sound - SoundEnabled = c.GetBool("soundenabled", true); - Volume = Clamp((int)Math.Round(c.GetDouble("sound.volume", 1.0) * 50), 1, 100); - RadioVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.radio", 0.75) * 100), 1, 100); - VehiclesVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.vehicle", 1.0) * 100), 1, 100); - PositionalVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.positional", 1.0) * 100), 1, 100); - AmbientVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.ambient", 1.0) * 100), 1, 100); - - // Advanced - CompressTextures = c.GetBool("compresstex", true); - ScaleSpeculars = c.GetBool("scalespeculars", true); - UseGLES = c.GetBool("gfx.usegles", false); - ShaderGamma = c.GetBool("gfx.shadergamma", false); - ScreenMipmaps = c.GetBool("python.mipmaps", true); - ExtendedModelConversion = c.GetInt("convertmodels", 0) != 0; - GfxResourceMove = c.GetBool("gfx.resource.move", false); - GfxResourceSweep = c.GetBool("gfx.resource.sweep", true); - TrainPhysicsThreads = Clamp(c.GetInt("async.trainThreads", 0), 0, Environment.ProcessorCount); - IgnoreIrrelevantTrains = c.GetBool("starter.ignoreirrelevant", false); - - // Starter - AutoCloseStarter = c.GetBool("starter.autoclose", false); - LargeThumbnails = c.GetBool("starter.largethumbnails", false); - AutoExpandSceneryTree = c.GetBool("starter.expandtree", false); - BatteryDefault = (BatteryDefault)Clamp(c.GetInt("starter.batterydefault", 0), 0, 2); - ShowAiVehicles = c.GetBool("starter.show.ai", true); - DrivableOnly = c.GetBool("starter.drivableonly", true); - ShowArchivalSceneries = c.GetBool("starter.show.archival", true); - } - - // ── fields → config ─────────────────────────────────────────────────── - private void WriteToConfig() - { - var c = _config; - - // General - c.Set("lang", Language == "Polski" ? "pl" : "en"); - c.SetBool("fullscreen", Fullscreen); - c.SetBool("inactivepause", PauseWhenInactive); - c.SetBool("pause", PauseOnStart); - - double mx = CursorSensitivity * (InvertMouseHorizontal ? -1 : 1); - double my = _mouseScaleYMagnitude * (InvertMouseVertical ? -1 : 1); - c.Set("mousescale", - $"{mx.ToString("0.###", CultureInfo.InvariantCulture)} " + - $"{my.ToString("0.###", CultureInfo.InvariantCulture)}"); - - // Communication - c.SetBool("input.gamepad", !IgnoreGamepad); - c.SetInt("feedbackmode", FeedbackMode); - c.SetInt("feedbackport", FeedbackPort); - if (UartEnabled && !string.IsNullOrWhiteSpace(UartPort)) c.Set("uart", UartPort); else c.Remove("uart"); - c.Set("uarttune", UartTune); - c.SetBool("uartdebug", UartDebug); - var sb = new StringBuilder(); - if (UartMain) sb.Append("main|"); - if (UartScnd) sb.Append("scnd|"); - if (UartTrain) sb.Append("train|"); - if (UartLocal) sb.Append("local|"); - if (UartRadioVolume) sb.Append("radiovolume|"); - if (UartRadioChannel) sb.Append("radiochannel|"); - c.Set("uartfeature", sb.ToString()); - - // Other - c.SetBool("starter.exe.auto", SelectExeAutomatically); - c.Set("starter.exe.path", ExecutablePath); - c.SetBool("debugmode", DebugMode); - c.SetBool("ai.trainman", VirtualShunting); - c.SetBool("starter.logmissingvehicles", LogMissingVehicleFiles); - - // Graphics - c.Set("gfxrenderer", RenderEngines[Clamp(RenderEngine, 0, RenderEngines.Length - 1)]); - c.SetInt("width", Width); - c.SetInt("height", Height); - c.SetInt("starter.bufferscale", BufferScalePercent); - c.SetInt("maxtexturesize", MaxTextureSize); - c.SetInt("maxcabtexturesize", MaxCabTextureSize); - c.SetInt("anisotropicfiltering", TextureFiltering); - c.SetInt("multisampling", Multisampling); - c.SetInt("dynamiclights", DynamicLights); - c.SetDouble("gfx.drawrange.factor.max", DrawRangeFactor); - c.SetBool("vsync", VSync); - c.SetBool("gfx.smoke", Smoke); - c.SetInt("gfx.smoke.fidelity", SmokeFidelity); - c.SetBool("gfx.postfx.chromaticaberration.enabled", ChromaticAberration); - c.SetBool("gfx.postfx.motionblur.enabled", MotionBlur); - c.SetBool("gfx.extraeffects", ExtraEffects); - c.SetBool("gfx.envmap.enabled", EnvMap); - c.SetBool("usevbo", UseVbo); - c.SetBool("shadows", RenderShadows); - c.SetBool("gfx.shadowmap.enabled", RenderShadows); - c.SetInt("gfx.reflections.framerate", ReflectionsFramerate); - c.Set("shadowtune", - $"{ShadowMapResolution} {ShadowProjectionRange} " + - $"{_shadowTuneExtra[0]} {_shadowTuneExtra[1]}"); - c.SetInt("gfx.shadows.cab.range", CabShadowsRange); - c.SetInt("gfx.shadow.rank.cutoff", ShadowRankCutoff); - c.SetInt("gfx.reflections.fidelity", ReflectionsFidelity); - c.SetInt("fieldofview", FieldOfView); - c.SetBool("python.enabled", PythonScreens); - c.SetBool("python.threadedupload", PythonThreadedUpload); - if (FpsLimitEnabled) c.SetInt("fpslimit", FpsLimit); else c.Remove("fpslimit"); - c.SetDouble("gfx.shadow.angle.min", ShadowAngleLimit); - c.SetBool("fullscreenwindowed", FullscreenWindowed); - if (RenderAngleVulkan) c.Set("gfx.angleplatform", "vulkan"); else c.Remove("gfx.angleplatform"); - - // Physics - c.SetInt("splinefidelity", SplineFidelity); - c.SetBool("fullphysics", FullPhysics); - c.SetBool("enabletraction", EnableTraction); - c.SetBool("livetraction", LiveTraction); - c.SetBool("physicslog", PhysicsLog); - c.SetInt("debuglog", DebugLog ? 3 : 0); - c.SetBool("multiplelogs", MultipleLogs); - c.SetBool("gfx.skiprendering", !DisplaySimulation); - c.SetBool("crashdamage", CrashDamage); - c.SetDouble("friction", Friction); - c.SetDouble("brakestep", BrakeStep); - c.SetDouble("brakespeed", BrakeSpeed); - - // Sound - c.SetBool("soundenabled", SoundEnabled); - c.SetDouble("sound.volume", Volume / 50.0); - c.SetDouble("sound.volume.radio", RadioVolume / 100.0); - c.SetDouble("sound.volume.vehicle", VehiclesVolume / 100.0); - c.SetDouble("sound.volume.positional", PositionalVolume / 100.0); - c.SetDouble("sound.volume.ambient", AmbientVolume / 100.0); - - // Advanced - c.SetBool("compresstex", CompressTextures); - c.SetBool("scalespeculars", ScaleSpeculars); - c.SetBool("gfx.usegles", UseGLES); - c.SetBool("gfx.shadergamma", ShaderGamma); - c.SetBool("python.mipmaps", ScreenMipmaps); - c.Set("convertmodels", ExtendedModelConversion ? "143" : "0"); - c.SetBool("gfx.resource.move", GfxResourceMove); - c.SetBool("gfx.resource.sweep", GfxResourceSweep); - c.SetInt("async.trainThreads", TrainPhysicsThreads); - c.SetBool("starter.ignoreirrelevant", IgnoreIrrelevantTrains); - - // Starter - c.SetBool("starter.autoclose", AutoCloseStarter); - c.SetBool("starter.largethumbnails", LargeThumbnails); - c.SetBool("starter.expandtree", AutoExpandSceneryTree); - c.SetInt("starter.batterydefault", (int)BatteryDefault); - c.SetBool("starter.show.ai", ShowAiVehicles); - c.SetBool("starter.drivableonly", DrivableOnly); - c.SetBool("starter.show.archival", ShowArchivalSceneries); - } - - // ── small helpers ────────────────────────────────────────────────────── - private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v); - - private static int ParseInt(string s, int fallback) => - int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) ? n : fallback; - - private static int IndexOf(string[] values, string token, int fallback) - { - for (int i = 0; i < values.Length; i++) - if (string.Equals(values[i], token, StringComparison.OrdinalIgnoreCase)) - return i; - return fallback; - } -} diff --git a/StarterNG/Classes/SettingsProfileStore.cs b/StarterNG/Classes/SettingsProfileStore.cs index 78858f2..1ea7ec5 100644 --- a/StarterNG/Classes/SettingsProfileStore.cs +++ b/StarterNG/Classes/SettingsProfileStore.cs @@ -2,13 +2,14 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using StarterNG.Application; namespace StarterNG.Classes; public static class SettingsProfileStore { public static string DirectoryPath { get; } = - Path.Combine(Settings.UserConfigDirectory(), "starter", "profiles"); + Path.Combine(AppServices.Current.SettingsPaths.UserConfigDirectory(), "starter", "profiles"); public static IReadOnlyList ListProfiles() { diff --git a/StarterNG/Classes/Trainset.cs b/StarterNG/Classes/Trainset.cs index f857168..47ed387 100644 --- a/StarterNG/Classes/Trainset.cs +++ b/StarterNG/Classes/Trainset.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -10,7 +10,7 @@ namespace StarterNG.Classes; public enum eDriverType { Headdriver, - Reardriver, + Reardriver, Passenger, Nobody } @@ -22,18 +22,42 @@ public class Trainset public float Offset; public float Velocity; public float OriginalVelocity; + + // The scenery format has no battery field: a trainset with a non-zero speed is + // the prepared one. DynObj.cpp derives ReadyFlag from it, and that branch of + // CheckLocomotiveParameters is what charges the pipes, sets the brake handle + // and closes the battery contactor. The sign carries the direction, so a + // consist authored on the move keeps its own speed. + private const float StandingStill = 0.01f; + private const float PreparedCreep = 0.1f; + + public bool ReadyToGo + { + get => MathF.Abs(Velocity) >= StandingStill; + set + { + if (!value) + { + Velocity = 0f; + return; + } + + Velocity = MathF.Abs(OriginalVelocity) < StandingStill ? PreparedCreep : OriginalVelocity; + } + } public string Description; public List Vehicles; - /// Original .scn text of this trainset block (incl. endtrainset). public string RawEntry; - /// True if the block parsed cleanly; false blocks are exported verbatim. public bool Parsed; - /// True when the block carries the //$decor flag (decoration, not drivable). public bool Decor; + public string? Logo; + + public string? Mini; + public Trainset(string trainsetEntry) { RawEntry = trainsetEntry; @@ -59,8 +83,6 @@ public class Trainset .Where(t => !t.StartsWith("//") && !t.StartsWith("/*")) .ToList(); - // handle specific descriptions - // //$o var match = Regex.Match( trainsetEntry, @"^\s*//\$o\s*(.*)$", @@ -68,15 +90,20 @@ public class Trainset ); if (match.Success) this.Description = match.Groups[1].Value.Trim(); - - // parse trainset - + + var logo = Regex.Match(trainsetEntry, @"^\s*//\$il\b[ \t]*([^\r\n]*)", RegexOptions.Multiline); + if (logo.Success) + Logo = logo.Groups[1].Value.Trim(); + + var mini = Regex.Match(trainsetEntry, @"^\s*//\$it\b[ \t]*([^\r\n]*)", RegexOptions.Multiline); + if (mini.Success) + Mini = mini.Groups[1].Value.Trim(); + for (int i = 0; i < tokens.Count; i++) { if (tokens[i] == "endtrainset") break; - - // trainset properties + if (tokens[i] == "trainset") { this.Name = tokens[++i]; @@ -86,26 +113,24 @@ public class Trainset this.OriginalVelocity = this.Velocity; continue; } - - // skip entire assignments block + if (tokens[i] == "assignments") { while (i < tokens.Count && tokens[i] != "endassignment") i++; - i++; // jump over endassignment + i++; continue; } - - // load vehicles + if (tokens[i] == "node") { Dynamic nodeDynamic = new Dynamic(); nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture); nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture); nodeDynamic.Name = tokens[++i]; - i++; // dynamic keyword + i++; nodeDynamic.DataFolder = tokens[++i]; - nodeDynamic.SkinFile = tokens[++i]; + nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i]); nodeDynamic.MmdFile = tokens[++i]; nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture); @@ -117,69 +142,47 @@ public class Trainset _ => eDriverType.Nobody }; - // couplingdata: numeric bit-mask plus optional ".B/.W/.T" parameter - // codes (brake / wheel / thermo settings) kept verbatim. nodeDynamic.Coupling = Coupling.Parse(tokens[++i]); - // Everything before enddynamic is the optional, positional tail - // documented for node::dynamic: - // [Lx] velocity [loadcount [loadtype]] [destination [destination]] var trailing = new List(); while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic") trailing.Add(tokens[++i]); nodeDynamic.ReadTrailing(trailing); - i++; // jump over enddynamic + i++; Vehicles.Add(nodeDynamic); } } } - - /// - /// The .scn text for this trainset for export. Unparsed blocks are written - /// back verbatim; parsed blocks are regenerated from the current vehicles. - /// + public string ToSceneryEntry() => Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry; public string GetTrainsetEntry() { - string entry = ""; - entry += "trainset "; - entry += this.Name + " "; - entry += this.Track + " "; - entry += this.Offset.ToString(CultureInfo.InvariantCulture) + " "; - entry += this.Velocity.ToString(CultureInfo.InvariantCulture) + " "; - entry += "\n"; + var sb = new StringBuilder(); + if (Decor) + sb.Append("//$decor\n"); + if (!string.IsNullOrEmpty(Description)) + sb.Append("//$o ").Append(Description).Append('\n'); + if (!string.IsNullOrEmpty(Logo)) + sb.Append("//$il ").Append(Logo).Append('\n'); + if (!string.IsNullOrEmpty(Mini)) + sb.Append("//$it ").Append(Mini).Append('\n'); + + sb.Append("trainset "); + sb.Append(Name).Append(' '); + sb.Append(Track).Append(' '); + sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' '); + sb.Append(Velocity.ToString(CultureInfo.InvariantCulture)); + sb.Append('\n'); + + string entry = sb.ToString(); foreach (Dynamic vehicle in Vehicles) - { - string driverType = ""; - switch (vehicle.DriverType) - { - case eDriverType.Headdriver: - driverType = "headdriver"; - break; - case eDriverType.Reardriver: - driverType = "reardriver"; - break; - case eDriverType.Passenger: - driverType = "passenger"; - break; - default: - driverType = "nobody"; - break; - } + entry += vehicle.ToTrainsetNode(); - entry += - $"node {vehicle.RangeMax} {vehicle.RangeMin} {vehicle.Name} dynamic " + - $"{vehicle.DataFolder} {vehicle.SkinFile} {vehicle.MmdFile} " + - $"{vehicle.Offset.ToString(CultureInfo.InvariantCulture)} " + - $"{driverType} {vehicle.Coupling}{vehicle.WriteTrailing()} enddynamic\n"; - } - - return entry; } } @@ -196,112 +199,92 @@ public class Dynamic public float Offset; public eDriverType DriverType; - /// - /// Coupling with the next vehicle (the couplingdata field): the bit-mask - /// plus any ".B/.W/.T" parameter codes (brake / wheel / thermo settings). - /// public Coupling Coupling = new(); - /// - /// Low byte of the coupling mask, exposed for the depot's per-bit editor. - /// Backed by so the two never diverge. - /// public byte couplingData { get => (byte)(Coupling.AbsFlags & 0xFF); set => Coupling.Flags = value; } - // --- optional node::dynamic trailing parameters (all positional, all optional) --- + public const string PantState = "pantstate"; - /// "Lx" MaxLoad override token (e.g. "L0"), or null when absent. - public string? MaxLoad; - - /// Whether a per-vehicle velocity token is present / should be written. - public bool HasVelocity; - - /// Per-vehicle starting velocity. - public float Velocity; - - /// Cargo amount (loadcount). - public int LoadCount; - - /// Cargo type (loadtype) — only meaningful when > 0. - public string? LoadType; - - /// Optional cargo destination tokens (rarely used; preserved verbatim). - public List Destinations = new(); - - // --- depot / consist-builder extras (not part of the .scn syntax) --- - - /// Explicit miniature name (from the vehicle database). Falls back to SkinFile. - public string? MiniName; - - /// Whether the vehicle is visually reversed in the consist. - public bool Flipped; + public const int PantStateMax = 3; /// - /// Parses the optional positional parameters that follow couplingdata: - /// [Lx] velocity [loadcount [loadtype]] [destination [destination]]. - /// See https://wiki.eu07.pl/index.php?title=Obiekt_node::dynamic + /// Lives in the coupling token as ".L<n>" - that is where the simulator reads + /// it from (DynObj.cpp, the MoreParams loop). The older space-separated "L<n>" + /// token in front of the load count is still read for legacy files, but never + /// written back: the current scenario parser reads that slot as the load count. /// + public int MaxLoad + { + get => Coupling.MaxLoad; + set => Coupling.MaxLoad = value; + } + + public bool HasVelocity; + + public float Velocity; + + public int LoadCount; + + public string? LoadType; + + public string? MiniName; + + public bool Flipped; + + public static bool IsPantStateType(string? type) => + string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase); + + public bool IsPantState => IsPantStateType(LoadType); + internal void ReadTrailing(List t) { int p = 0; - // Lx — MaxLoad override (e.g. "L0"); only when it genuinely looks like one. - if (p < t.Count && (t[p][0] == 'L' || t[p][0] == 'l') - && t[p].Length > 1 && char.IsDigit(t[p][1])) - MaxLoad = t[p++]; - - // velocity — the first plain number after the coupling - if (p < t.Count && float.TryParse(t[p], NumberStyles.Float, - CultureInfo.InvariantCulture, out float v)) + // Legacy form: max load as its own token in front of the load count. The + // current scenario parser reads that slot as an int, so this is read only - + // the setter moves the value into the coupling token, where it is written. + if (p < t.Count && t[p].Length > 0 && t[p][0] == 'L') { - Velocity = v; - HasVelocity = true; + if (int.TryParse(t[p].AsSpan(1), NumberStyles.Integer, + CultureInfo.InvariantCulture, out int ml)) + MaxLoad = ml; p++; } - // loadcount [loadtype] — loadtype is present only when loadcount > 0 if (p < t.Count && int.TryParse(t[p], NumberStyles.Integer, CultureInfo.InvariantCulture, out int lc)) { LoadCount = lc; p++; - if (lc > 0 && p < t.Count) + if (p < t.Count && (lc > 0 || IsPantStateType(t[p]))) LoadType = t[p++]; } - // remaining tokens: optional destinations, kept verbatim - while (p < t.Count) - Destinations.Add(t[p++]); } - /// - /// Regenerates the trailing parameter string (each present token prefixed with - /// a space), the inverse of . A loadcount of 0 is - /// omitted, matching the canonical trainset entries the simulator expects. - /// + internal static string StripSkinExtension(string token) + { + int dot = token.LastIndexOf('.'); + return dot > 0 ? token[..dot] : token; + } + internal string WriteTrailing() { var sb = new StringBuilder(); - if (MaxLoad != null) - sb.Append(' ').Append(MaxLoad); - if (HasVelocity) - sb.Append(' ').Append(Velocity.ToString(CultureInfo.InvariantCulture)); - if (LoadCount > 0) - { - sb.Append(' ').Append(LoadCount.ToString(CultureInfo.InvariantCulture)); - if (!string.IsNullOrEmpty(LoadType)) - sb.Append(' ').Append(LoadType); - } - foreach (string d in Destinations) - sb.Append(' ').Append(d); + + bool loaded = !string.IsNullOrEmpty(LoadType); + sb.Append(' ').Append((loaded ? LoadCount : 0).ToString(CultureInfo.InvariantCulture)); + + if (loaded && (LoadCount > 0 || IsPantState)) + sb.Append(' ').Append(LoadType); + return sb.ToString(); } - /// Deep copy, used when importing a trainset into the editable consist. public Dynamic Clone() => new Dynamic { RangeMax = RangeMax, @@ -314,50 +297,77 @@ public class Dynamic Offset = Offset, DriverType = DriverType, Coupling = Coupling.Clone(), - MaxLoad = MaxLoad, HasVelocity = HasVelocity, Velocity = Velocity, LoadCount = LoadCount, LoadType = LoadType, - Destinations = new List(Destinations), MiniName = MiniName, Flipped = Flipped }; + + public string ToTrainsetNode() + { + string driver = DriverType switch + { + eDriverType.Headdriver => "headdriver", + eDriverType.Reardriver => "reardriver", + eDriverType.Passenger => "passenger", + _ => "nobody" + }; + + return + $"node {RangeMax} {RangeMin} {Name} dynamic " + + $"{DataFolder} {SkinFile} {MmdFile} " + + $"{Offset.ToString(CultureInfo.InvariantCulture)} " + + $"{driver} {Coupling}{WriteTrailing()} enddynamic\n"; + } + + public string ToLooseNode() + { + string driver = DriverType switch + { + eDriverType.Headdriver => "headdriver", + eDriverType.Reardriver => "reardriver", + eDriverType.Passenger => "passenger", + _ => "nobody" + }; + string path = string.IsNullOrEmpty(PathName) ? "none" : PathName; + string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture); + return + $"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " + + $"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " + + $"{DataFolder} {SkinFile} {MmdFile} {path} " + + $"{Offset.ToString(CultureInfo.InvariantCulture)} {driver} {vel}" + + $"{WriteTrailing()} enddynamic\n"; + } +} + +public sealed class ConsistItem +{ + public List Cars { get; set; } = new(); + public bool Grouped { get; set; } + public bool Flipped { get; set; } + public eDriverType Driver { get; set; } = eDriverType.Nobody; } -/// -/// The couplingdata value joining a vehicle to the next one: a bit-mask of -/// the active coupling types, optionally followed by ".B/.W/.T" parameter codes. -/// A negative mask means the coupling is locked (cannot be uncoupled in-sim); the -/// simulator takes its absolute value before testing the individual bits. -/// See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow -/// public sealed class Coupling { - // Mask bit values, per the wiki. - public const int Mechanical = 1; // mechanical link - public const int BrakePipe = 2; // pneumatic 5 atm (brakes) - public const int ControlMU = 4; // multiple-unit control - public const int HighVoltage = 8; // high voltage - public const int Gangway = 16; // passage between vehicles - public const int AuxPneumatic = 32; // auxiliary pneumatic 8 atm - public const int Heating = 64; // heating - public const int WorkshopLock = 128; // workshop coupling (uncouple lock) - /// Signed mask exactly as written; negative = locked. + public const int Mechanical = 1; + public const int BrakePipe = 2; + public const int ControlMU = 4; + public const int HighVoltage = 8; + public const int Gangway = 16; + public const int AuxPneumatic = 32; + public const int Heating = 64; + public const int WorkshopLock = 128; + public int Flags; - /// - /// Textual parameter codes that follow the mask after a '.', in order - /// (e.g. "BR", "WH25F5"). Preserved verbatim so brake / wheel / thermo - /// settings survive a round-trip even when the launcher does not edit them. - /// public List Parameters = new(); - /// Mask with the lock sign removed (what the simulator tests bits on). public int AbsFlags => Flags < 0 ? -Flags : Flags; - /// True when the coupling is marked permanent (negative mask). public bool Locked { get => Flags < 0; @@ -366,7 +376,6 @@ public sealed class Coupling public bool Has(int bit) => (AbsFlags & bit) != 0; - /// Sets or clears a mask bit, preserving the lock sign. public void Set(int bit, bool on) { int abs = on ? (AbsFlags | bit) : (AbsFlags & ~bit); @@ -388,25 +397,21 @@ public sealed class Coupling return c; } - /// The brake-rack setting carried in the "B" parameter, or null. public BrakeSetting? GetBrake() => BrakeSetting.FromParameter( Parameters.FirstOrDefault(p => p.StartsWith("B", StringComparison.Ordinal))); - /// Replaces (or clears, when null) the "B" brake parameter. public void SetBrake(BrakeSetting? brake) { Parameters.RemoveAll(p => p.StartsWith("B", StringComparison.Ordinal)); - if (brake != null) + if (brake is { IsEmpty: false }) Parameters.Insert(0, brake.ToParameter()); } - /// The wheel/damage setting carried in the "W" parameter, or null. public WheelSettings? GetWheels() => WheelSettings.FromParameter( Parameters.FirstOrDefault(p => p.StartsWith("W", StringComparison.Ordinal))); - /// Replaces (or clears, when empty/null) the "W" wheel parameter. public void SetWheels(WheelSettings? wheels) { Parameters.RemoveAll(p => p.StartsWith("W", StringComparison.Ordinal)); @@ -414,13 +419,44 @@ public sealed class Coupling Parameters.Add(wheels.ToParameter()); } + /// + /// Max load override, the ".L<n>" sub-parameter. -1 means "not set", so the + /// vehicle keeps the MaxLoad from its .fiz. + /// + public int MaxLoad + { + get => Parameters.Find(IsMaxLoad) is { } p + && int.TryParse(p.AsSpan(1), NumberStyles.Integer, + CultureInfo.InvariantCulture, out int v) ? v : -1; + set + { + Parameters.RemoveAll(IsMaxLoad); + if (value >= 0) + Parameters.Add("L" + value.ToString(CultureInfo.InvariantCulture)); + } + } + + private static bool IsMaxLoad(string param) => + param.Length > 1 + && char.ToUpperInvariant(param[0]) == 'L' + && param.Skip(1).All(char.IsDigit); + + public bool ThermoDynamic + { + get => Parameters.Exists(p => p.Equals("TA", StringComparison.OrdinalIgnoreCase)); + set + { + Parameters.RemoveAll(p => p.Equals("TA", StringComparison.OrdinalIgnoreCase)); + if (value) Parameters.Add("TA"); + } + } + public Coupling Clone() => new() { Flags = Flags, Parameters = new List(Parameters) }; - /// Serialises back to "<flags>[.param[.param…]]". public override string ToString() { var sb = new StringBuilder(); @@ -431,67 +467,72 @@ public sealed class Coupling } } -/// -/// A vehicle's brake-rack setting, carried in the "B" parameter of its coupling -/// field: B<mode>[<load>][<switch>] (e.g. "BP", "BRA", "BG1"). -/// See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow -/// public sealed class BrakeSetting { - /// Brake modes, longest first so "R+Mg" wins over "R" while parsing. - public static readonly string[] Modes = { "R+Mg", "G", "P", "R", "Q", "O", "A" }; + public static readonly string[] Modes = { "G", "P", "R", "M" }; - /// Brake mode: G (freight), P (passenger), R (express), R+Mg, Q, O (off), A (auto). - public string Mode = "P"; + public static readonly string[] Loads = { "T", "H", "F" }; + + // The simulator tests every letter of the B parameter independently, so the + // "off" states are a separate axis from the G/P/R/M delay position. They stay + // one combo because they contradict each other, but they no longer block a + // delay position from being picked alongside them (e.g. "BRQ"). + public static readonly string[] Switches = { "<>", "0", "1", "X", "E", "Q" }; + + public string? Mode; - /// Load adaptation: T (empty), H (medium), F (loaded), A (auto), or null. public string? Load; - /// Brake on/off switch: 0 (off), 1 (off ~10%), A (on), or null. public string? Switch; + // A brake switch or a load adaptation with no delay position is still a + // parameter worth writing, so all three axes count towards emptiness. + public bool IsEmpty => Mode is null && Load is null && Switch is null; + public static BrakeSetting? FromParameter(string? param) { - if (string.IsNullOrEmpty(param) || param![0] != 'B' || param.Length < 2) + if (string.IsNullOrEmpty(param) || char.ToUpperInvariant(param![0]) != 'B') return null; - string body = param.Substring(1); - string? mode = Modes.FirstOrDefault(m => body.StartsWith(m, StringComparison.Ordinal)); - if (mode is null) - return null; + string body = param.Substring(1).ToUpperInvariant(); + var b = new BrakeSetting + { + // "M" first: the simulator reads it as R plus the magnetic rail brake, + // so a bare "R" must not swallow it. Switches are matched in the + // declared order too, so "<>" wins over the digits it cannot contain. + Mode = First(body, "M", "G", "P", "R"), + Load = First(body, Loads), + Switch = First(body, Switches) + }; + return b.Mode is null && b.Load is null && b.Switch is null ? null : b; - var b = new BrakeSetting { Mode = mode }; - int p = mode.Length; - if (p < body.Length && "THFA".IndexOf(body[p]) >= 0) - b.Load = body[p++].ToString(); - if (p < body.Length && "01A".IndexOf(body[p]) >= 0) - b.Switch = body[p].ToString(); - return b; + static string? First(string body, params string[] codes) => + Array.Find(codes, c => body.Contains(c, StringComparison.Ordinal)); } - public string ToParameter() => "B" + Mode + (Load ?? "") + (Switch ?? ""); + public string ToParameter() => "B" + Mode + Switch + Load; } -/// -/// Wheel-damage settings carried in the "W" parameter of a vehicle's coupling -/// field: W[H<sway%>][F<flat mm>][R<random flat mm>][P<flat prob%>] -/// (e.g. "WH25F5R10P8"). See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow -/// public sealed class WheelSettings { - /// H — sway ("wężykowanie") probability, %. public int Sway; - /// F — flat spot ("podkucie") of this size, mm. public int Flatness; - /// R — additional random flat spot, 0..x mm. public int FlatnessRand; - /// P — flat-spot probability, % (guaranteed when omitted). - public int FlatnessProb; + /// + /// Chance (%) that the flat spot is applied at all. The simulator defaults it + /// to 100 when the P sub-parameter is missing, so that is the default here too + /// - otherwise "0" would be written as "absent" and mean the exact opposite. + /// + public int FlatnessProb = DefaultFlatnessProb; - public bool IsEmpty => Sway <= 0 && Flatness <= 0 && FlatnessRand <= 0 && FlatnessProb <= 0; + public const int DefaultFlatnessProb = 100; + + // A probability on its own does nothing: without a flat size there is nothing + // to roll for. + public bool IsEmpty => Sway <= 0 && Flatness <= 0 && FlatnessRand <= 0; public static WheelSettings? FromParameter(string? param) { @@ -524,7 +565,7 @@ public sealed class WheelSettings if (Sway > 0) sb.Append('H').Append(Sway); if (Flatness > 0) sb.Append('F').Append(Flatness); if (FlatnessRand > 0) sb.Append('R').Append(FlatnessRand); - if (FlatnessProb > 0) sb.Append('P').Append(FlatnessProb); + if (FlatnessProb != DefaultFlatnessProb) sb.Append('P').Append(FlatnessProb); return sb.ToString(); } -} \ No newline at end of file +} diff --git a/StarterNG/Classes/VehicleDatabase.cs b/StarterNG/Classes/VehicleDatabase.cs deleted file mode 100644 index e9dd68b..0000000 --- a/StarterNG/Classes/VehicleDatabase.cs +++ /dev/null @@ -1,397 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; - -namespace StarterNG.Classes; - -// ===================================================================== -// Vehicle JSON database (databases/vehicles/*.json) -// Format documented in vehicleEntryDoc.md (schema_version 1). -// ===================================================================== - -/// Root object of a single vehicle JSON file. -public class VehicleEntry -{ - [JsonPropertyName("uuid")] public string? Uuid { get; set; } - [JsonPropertyName("schema_version")] public int SchemaVersion { get; set; } - [JsonPropertyName("groups")] public List Groups { get; set; } = new(); - [JsonPropertyName("textures")] public List Textures { get; set; } = new(); - [JsonPropertyName("sets")] public List Sets { get; set; } = new(); - [JsonPropertyName("unknown")] public List Unknown { get; set; } = new(); -} - -/// Merged database root (databases/vehicles/vehicles.json). -public class VehicleEntryCollection -{ - [JsonPropertyName("vehicles")] public List Vehicles { get; set; } = new(); -} - -/// Legacy header / vehicle category group. -public class VehicleGroup -{ - [JsonPropertyName("id")] public string Id { get; set; } = ""; - [JsonPropertyName("category")] public string? Category { get; set; } - [JsonPropertyName("mini")] public string? Mini { get; set; } - [JsonPropertyName("archived")] public bool Archived { get; set; } - [JsonPropertyName("implicit")] public bool Implicit { get; set; } -} - -/// A single .mat skin entry. -public class VehicleTexture -{ - [JsonPropertyName("uuid")] public string? Uuid { get; set; } - [JsonPropertyName("directory")] public string Directory { get; set; } = ""; - [JsonPropertyName("skinfile")] public string Skinfile { get; set; } = ""; - [JsonPropertyName("model")] public string? Model { get; set; } - [JsonPropertyName("group")] public string? Group { get; set; } - [JsonPropertyName("mini_ref")] public string? MiniRef { get; set; } - [JsonPropertyName("texture_mini")] public string? TextureMini { get; set; } - [JsonPropertyName("wreck")] public bool Wreck { get; set; } - [JsonPropertyName("aliases")] public List Aliases { get; set; } = new(); - [JsonPropertyName("meta")] public VehicleMeta? Meta { get; set; } - - /// Full skin path = directory + skinfile. - [JsonIgnore] public string FullPath => Directory + Skinfile; - - // ── cached lookups, populated once after load (VehicleDatabase.BuildTextureIndex) ── - // These mirror the depot's ClassOf / CategoryOf so the search/filter hot path - // never recomputes them (no per-keystroke dictionary lookups over every texture). - - /// Group's "mini" (or this texture's mini_ref) — the vehicle class. Never null. - [JsonIgnore] public string ResolvedClass { get; internal set; } = ""; - - /// Group's "category" letter, or null when the group is unknown. - [JsonIgnore] public string? ResolvedCategory { get; internal set; } -} - -/// Alternate mapping from a malformed multi-= legacy line. -public class VehicleAlias -{ - [JsonPropertyName("model")] public string? Model { get; set; } - [JsonPropertyName("group")] public string? Group { get; set; } - [JsonPropertyName("mini_ref")] public string? MiniRef { get; set; } - [JsonPropertyName("texture_mini")] public string? TextureMini { get; set; } -} - -/// Parsed metadata from the legacy // comment section. -public class VehicleMeta -{ - [JsonPropertyName("raw")] public string? Raw { get; set; } - [JsonPropertyName("version")] public string? Version { get; set; } - [JsonPropertyName("vehicle")] public string? Vehicle { get; set; } - [JsonPropertyName("operator")] public string? Operator { get; set; } - [JsonPropertyName("depot")] public string? Depot { get; set; } - [JsonPropertyName("revision_date")] public string? RevisionDate { get; set; } - [JsonPropertyName("revision_place")] public string? RevisionPlace { get; set; } - [JsonPropertyName("texture_author")] public string? TextureAuthor { get; set; } - [JsonPropertyName("photo_author")] public string? PhotoAuthor { get; set; } - [JsonPropertyName("extra")] public List Extra { get; set; } = new(); -} - -/// -/// Aggregated, in-memory view over every vehicle JSON file found in -/// databases/vehicles/. Provides lookup helpers for the depot UI. -/// -public class VehicleDatabase -{ - public List Textures { get; } = new(); - public Dictionary GroupsById { get; } = new(); - public List Sets { get; } = new(); - - /// Texture-uuid -> automatic-consist set that contains it. - public Dictionary SetByTextureUuid { get; } = new(); - - /// Texture-uuid -> texture (includes wrecks, so set refs resolve). - public Dictionary TextureByUuid { get; } = new(); - - /// skinfile (without extension, lower-case) -> texture. - public Dictionary TextureBySkin { get; } = new(); - - // System.Text.Json options matching the previous Newtonsoft leniency: property - // names matched case-insensitively, // and /* */ comments skipped, and trailing - // commas allowed. Unknown members are ignored by default. The source-generated - // VehicleJsonContext resolver keeps deserialization AOT/trim-safe (no reflection). - private static readonly JsonSerializerOptions JsonOpts = new() - { - PropertyNameCaseInsensitive = true, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true, - TypeInfoResolver = VehicleJsonContext.Default, - }; - - /// - /// Loads the database in one call. When a merged vehicles.json is present - /// it is used, otherwise every per-vehicle *.json file is read. - /// Never throws: unreadable files are skipped. - /// - public void Load(string directory = "databases/vehicles/") - { - BeginLoad(); - foreach (string file in EnumerateFiles(directory)) - LoadFile(file); - EndLoad(); - } - - /// Resets the aggregate before an incremental load. - public void BeginLoad() - { - Textures.Clear(); - GroupsById.Clear(); - Sets.Clear(); - SetByTextureUuid.Clear(); - TextureByUuid.Clear(); - TextureBySkin.Clear(); - } - - /// Finalises an incremental load (builds lookup indexes). - public void EndLoad() - { - BuildSetIndex(); - BuildTextureIndex(); - } - - /// - /// Files that make up the database: the merged vehicles.json if present, - /// otherwise every per-vehicle *.json. Used to drive load progress. - /// - public static List EnumerateFiles(string directory = "databases/vehicles/") - { - if (!Directory.Exists(directory)) - return new List(); - - string merged = Path.Combine(directory, "vehicles.json"); - if (File.Exists(merged)) - return new List { merged }; - - return Directory.GetFiles(directory, "*.json").ToList(); - } - - /// Ingests a single database file (merged or per-vehicle). - public void LoadFile(string file) - { - if (string.Equals(Path.GetFileName(file), "vehicles.json", StringComparison.OrdinalIgnoreCase)) - LoadMerged(file); - else - LoadEntryFile(file); - } - - private void LoadMerged(string file) - { - try - { - var collection = JsonSerializer.Deserialize( - File.ReadAllText(file), - (JsonTypeInfo)JsonOpts.GetTypeInfo(typeof(VehicleEntryCollection))); - if (collection?.Vehicles is null) return; - foreach (var entry in collection.Vehicles) - Ingest(entry); - } - catch - { - // ignore malformed merged database - } - } - - private void LoadEntryFile(string file) - { - try - { - var entry = JsonSerializer.Deserialize( - File.ReadAllText(file), - (JsonTypeInfo)JsonOpts.GetTypeInfo(typeof(VehicleEntry))); - if (entry is not null) - Ingest(entry); - } - catch - { - // ignore malformed single-vehicle file - } - } - - private void Ingest(VehicleEntry entry) - { - foreach (var group in entry.Groups) - { - if (!string.IsNullOrEmpty(group.Id)) - GroupsById.TryAdd(group.Id, group); - } - - foreach (var texture in entry.Textures) - { - // index every texture (wrecks too) so set / skin references resolve - if (!string.IsNullOrEmpty(texture.Uuid)) - TextureByUuid[texture.Uuid!] = texture; - if (!string.IsNullOrEmpty(texture.Skinfile)) - TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture); - - // skip wrecks from the standard browser list - if (texture.Wreck) continue; - Textures.Add(texture); - } - - Sets.AddRange(entry.Sets); - } - - /// - /// If the texture belongs to an automatic-consist set, returns all of that - /// set's textures in their defined order; otherwise null. - /// - public List? ResolveSet(VehicleTexture texture) - { - if (string.IsNullOrEmpty(texture.Uuid)) return null; - if (!SetByTextureUuid.TryGetValue(texture.Uuid!, out var set) || set.TextureRefs is null) - return null; - - var cars = new List(); - foreach (string uuid in set.TextureRefs) - { - if (!string.IsNullOrEmpty(uuid) && TextureByUuid.TryGetValue(uuid, out var tex)) - cars.Add(tex); - } - return cars.Count > 0 ? cars : null; - } - - private void BuildSetIndex() - { - foreach (var set in Sets) - { - if (set.TextureRefs is null) continue; - foreach (string uuid in set.TextureRefs) - { - if (!string.IsNullOrEmpty(uuid)) - SetByTextureUuid[uuid] = set; - } - } - } - - // Resolves each texture's class (group mini / mini_ref) and category letter - // once, after all groups are loaded, so the depot reads cached fields instead - // of doing a GroupsById lookup per texture on every search keystroke. - private void BuildTextureIndex() - { - foreach (var t in Textures) - { - VehicleGroup? g = null; - if (t.Group != null) - GroupsById.TryGetValue(t.Group, out g); - - string mini = g != null && !string.IsNullOrEmpty(g.Mini) - ? g.Mini! - : t.MiniRef ?? ""; - - string? category = g?.Category; - - // A "*" category is a wildcard: the actual category is the first letter - // of the mini (upper-cased, like the original Starter's UpCase(mini[1]) - // wagon-typing), e.g. a "Bdhpumn" mini -> category "B". - if (category == "*") - { - string source = !string.IsNullOrEmpty(mini) ? mini : (t.TextureMini ?? ""); - if (source.Length > 0) - category = char.ToUpperInvariant(source[0]).ToString(); - } - - t.ResolvedCategory = category; - t.ResolvedClass = mini; - } - } - - /// - /// Miniature name for a texture: the texture_mini property, but if no .bmp - /// for it exists, falls back to the mini of the group it belongs to. - /// - public string? ResolveMiniName(VehicleTexture texture) - { - if (!string.IsNullOrEmpty(texture.TextureMini) && MiniPath(texture.TextureMini) != null) - return texture.TextureMini; - - if (texture.Group != null && GroupsById.TryGetValue(texture.Group, out var grp) - && !string.IsNullOrEmpty(grp.Mini)) - return grp.Mini; - - return texture.TextureMini; - } - - /// Resolved mini for a skin file (matched case-insensitively), or null. - public string? MiniForSkin(string? skinFile) - { - if (string.IsNullOrEmpty(skinFile)) return null; - string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant(); - return TextureBySkin.TryGetValue(key, out var tex) ? ResolveMiniName(tex) : null; - } - - /// The texture for a skin file (matched case-insensitively), or null. - public VehicleTexture? TextureForSkin(string? skinFile) - { - if (string.IsNullOrEmpty(skinFile)) return null; - string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant(); - return TextureBySkin.TryGetValue(key, out var tex) ? tex : null; - } - - /// Header label for the group a texture belongs to. - public string GroupHeader(string? groupId) - { - if (!string.IsNullOrEmpty(groupId) && GroupsById.TryGetValue(groupId, out var grp)) - { - string mini = string.IsNullOrEmpty(grp.Mini) ? grp.Id : grp.Mini!; - return string.IsNullOrEmpty(grp.Category) ? mini : $"{mini} ({grp.Category})"; - } - return groupId ?? ""; - } - - // Case-insensitive index of mini .bmp files (built once). - private static Dictionary? _miniIndex; - - /// - /// Builds the miniature .bmp index up front (called from the startup load, - /// behind the splash) so the first thumbnail render never stalls the UI - /// thread scanning textures/mini/. No-op if it was already built. - /// - public static void PreloadMiniIndex(string miniDir = "textures/mini/") => - _miniIndex ??= BuildMiniIndex(miniDir); - - /// - /// Resolves a miniature .bmp path under textures/mini/ case-insensitively, - /// or null if missing. The directory is indexed once and reused. - /// - public static string? MiniPath(string? miniName, string miniDir = "textures/mini/") - { - if (string.IsNullOrEmpty(miniName)) return null; - - var index = _miniIndex ??= BuildMiniIndex(miniDir); - return index.TryGetValue(miniName!.ToLowerInvariant(), out var path) ? path : null; - } - - private static Dictionary BuildMiniIndex(string miniDir) - { - var index = new Dictionary(); - if (!Directory.Exists(miniDir)) - return index; - - foreach (string file in Directory.GetFiles(miniDir, "*.bmp")) - index[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = file; - return index; - } -} - -/// Automatic consist definition (legacy ^x grouping). -public class VehicleSet -{ - [JsonPropertyName("uuid")] public string? Uuid { get; set; } - [JsonPropertyName("mode")] public string? Mode { get; set; } - [JsonPropertyName("count")] public int Count { get; set; } - [JsonPropertyName("texture_refs")] public List TextureRefs { get; set; } = new(); -} - -// Source-generated JSON metadata — makes deserialization AOT/trim-safe by avoiding -// runtime reflection over the model types. Nested types (VehicleGroup, VehicleTexture, -// VehicleSet, VehicleAlias, VehicleMeta) are pulled in automatically by the generator. -[JsonSerializable(typeof(VehicleEntry))] -[JsonSerializable(typeof(VehicleEntryCollection))] -internal partial class VehicleJsonContext : JsonSerializerContext -{ -} diff --git a/StarterNG/Controls/DetailRows.cs b/StarterNG/Controls/DetailRows.cs new file mode 100644 index 0000000..706a895 --- /dev/null +++ b/StarterNG/Controls/DetailRows.cs @@ -0,0 +1,33 @@ +using Avalonia.Controls; + +namespace StarterNG.Controls; + +public static class DetailRows +{ + public const string NoValue = "–"; + + public static SettingRow Row(string? label = null, Control? content = null, + string? value = null, string? description = null, + string? tip = null) + { + var row = new SettingRow + { + Label = label, + Description = description, + ValueText = value, + Content = content + }; + row.Classes.Add("Compact"); + + if (!string.IsNullOrWhiteSpace(tip)) + ToolTip.SetTip(row, tip); + + return row; + } + + public static SettingRow Reading(string label, string? value) + { + bool empty = string.IsNullOrWhiteSpace(value); + return Row(label, value: empty ? NoValue : value, tip: empty ? null : value); + } +} diff --git a/StarterNG/Controls/SettingRow.cs b/StarterNG/Controls/SettingRow.cs new file mode 100644 index 0000000..bee10d4 --- /dev/null +++ b/StarterNG/Controls/SettingRow.cs @@ -0,0 +1,69 @@ +using Avalonia; +using Avalonia.Controls; + +namespace StarterNG.Controls; + +public class SettingRow : ContentControl +{ + public static readonly StyledProperty LabelProperty = + AvaloniaProperty.Register(nameof(Label)); + + public static readonly StyledProperty DescriptionProperty = + AvaloniaProperty.Register(nameof(Description)); + + public static readonly StyledProperty ConfigKeyProperty = + AvaloniaProperty.Register(nameof(ConfigKey)); + + public static readonly StyledProperty ValueTextProperty = + AvaloniaProperty.Register(nameof(ValueText)); + + public string? Label + { + get => GetValue(LabelProperty); + set => SetValue(LabelProperty, value); + } + + public string? Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public string? ConfigKey + { + get => GetValue(ConfigKeyProperty); + set => SetValue(ConfigKeyProperty, value); + } + + public string? ValueText + { + get => GetValue(ValueTextProperty); + set => SetValue(ValueTextProperty, value); + } + + public SettingRow() + { + UpdatePseudoClasses(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == LabelProperty || + change.Property == DescriptionProperty || + change.Property == ConfigKeyProperty || + change.Property == ValueTextProperty || + change.Property == ContentProperty) + UpdatePseudoClasses(); + } + + private void UpdatePseudoClasses() + { + PseudoClasses.Set(":no-label", string.IsNullOrEmpty(Label)); + PseudoClasses.Set(":no-description", string.IsNullOrEmpty(Description)); + PseudoClasses.Set(":no-key", string.IsNullOrEmpty(ConfigKey)); + PseudoClasses.Set(":no-value", string.IsNullOrEmpty(ValueText)); + PseudoClasses.Set(":no-content", Content is null); + } +} diff --git a/StarterNG/Controls/SettingsSection.cs b/StarterNG/Controls/SettingsSection.cs new file mode 100644 index 0000000..7cb092d --- /dev/null +++ b/StarterNG/Controls/SettingsSection.cs @@ -0,0 +1,16 @@ +using Avalonia; +using Avalonia.Controls.Primitives; + +namespace StarterNG.Controls; + +public class SettingsSection : HeaderedContentControl +{ + public static readonly StyledProperty ToggleProperty = + AvaloniaProperty.Register(nameof(Toggle)); + + public object? Toggle + { + get => GetValue(ToggleProperty); + set => SetValue(ToggleProperty, value); + } +} diff --git a/StarterNG/Domain/Cargo.cs b/StarterNG/Domain/Cargo.cs new file mode 100644 index 0000000..4584bc5 --- /dev/null +++ b/StarterNG/Domain/Cargo.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using StarterNG.Classes; + +namespace StarterNG.Domain; + +public sealed class Cargo +{ + private readonly VehicleInfo _info; + private readonly Random _rng; + + public Cargo(VehicleInfo info, Random rng) + { + _info = info; + _rng = rng; + } + + public bool IsLoadable(ConsistItem item) => IsLoadable(item.Cars[0]); + + public bool IsLoadable(Dynamic car) + { + if (_info.CategoryOf(car) is "e" or "s" or "p") + return false; + return !string.IsNullOrWhiteSpace(_info.PhysicsFor(car)?.LoadAccepted); + } + + public int MaxFor(Dynamic car, string? type = null) + { + if (!IsLoadable(car)) return 0; + + if (Dynamic.IsPantStateType(type ?? car.LoadType)) return Dynamic.PantStateMax; + + if (car.MaxLoad >= 0) return car.MaxLoad; + return _info.PhysicsFor(car)?.MaxLoad ?? 0; + } + + public int LimitFor(Dynamic car, string? type = null) + { + int max = MaxFor(car, type); + return max > 0 ? max : (IsLoadable(car) ? int.MaxValue : 0); + } + + public List AcceptedTypes(Dynamic car) + { + var p = _info.PhysicsFor(car); + if (!IsLoadable(car) || p == null) + return new List(); + + return p.LoadAccepted.Split(',', ';') + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public bool Accepts(Dynamic car, string? type) => + !string.IsNullOrEmpty(type) && + AcceptedTypes(car).Contains(type!, StringComparer.OrdinalIgnoreCase); + + public void FillToMax(Dynamic car) + { + int max = MaxFor(car); + if (max <= 0) return; + if (string.IsNullOrEmpty(car.LoadType)) + car.LoadType = AcceptedTypes(car).FirstOrDefault(); + if (string.IsNullOrEmpty(car.LoadType)) return; + car.LoadCount = max; + } + + public void CopyFromPrevious(IReadOnlyList consist, ConsistItem item) + { + int idx = IndexOf(consist, item); + if (idx <= 0) return; + + var source = consist[idx - 1].Cars[0]; + foreach (var c in item.Cars) + { + if (!Accepts(c, source.LoadType)) continue; + c.LoadType = source.LoadType; + c.LoadCount = Math.Min(source.LoadCount, LimitFor(c)); + } + } + + public void FillUnit(ConsistItem item) + { + foreach (var c in item.Cars) + FillToMax(c); + } + + public void CopyToFollowing(IReadOnlyList consist, ConsistItem item) + { + int idx = IndexOf(consist, item); + if (idx < 0) return; + + var lead = item.Cars[0]; + string? type = lead.LoadType; + if (string.IsNullOrEmpty(type)) return; + + foreach (var c in consist.Skip(idx + 1).SelectMany(u => u.Cars)) + { + if (!AcceptedTypes(c).Contains(type!, StringComparer.OrdinalIgnoreCase)) + continue; + c.LoadType = type; + c.LoadCount = Math.Min(lead.LoadCount, MaxFor(c)); + } + } + + public void RandomTypes(IReadOnlyList consist) + { + foreach (var c in consist.SelectMany(u => u.Cars)) + { + var types = AcceptedTypes(c); + if (types.Count == 0) continue; + c.LoadType = types[_rng.Next(types.Count)]; + } + } + + public void RandomAmounts(IReadOnlyList consist) + { + foreach (var c in consist.SelectMany(u => u.Cars)) + { + int max = MaxFor(c); + if (max <= 0) continue; + if (string.IsNullOrEmpty(c.LoadType)) + c.LoadType = AcceptedTypes(c).FirstOrDefault(); + if (string.IsNullOrEmpty(c.LoadType)) continue; + int min = Math.Max(1, max / 10); + c.LoadCount = _rng.Next(min, max + 1); + } + } + + private static int IndexOf(IReadOnlyList consist, ConsistItem item) + { + for (int i = 0; i < consist.Count; i++) + if (ReferenceEquals(consist[i], item)) + return i; + return -1; + } +} diff --git a/StarterNG/Domain/Consist.cs b/StarterNG/Domain/Consist.cs new file mode 100644 index 0000000..a41d0d0 --- /dev/null +++ b/StarterNG/Domain/Consist.cs @@ -0,0 +1,585 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +using StarterNG.Classes; +using StarterNG.Domain.Sceneries; +using StarterNG.Domain.Vehicles; +using StarterNG.Application; + +namespace StarterNG.Domain; + +public sealed class Consist : IReadOnlyList +{ + private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" }; + + private readonly VehicleCatalog _db; + private readonly VehicleInfo _info; + private readonly List _items = new(); + private int _nameCounter; + + public Consist(VehicleCatalog db, VehicleInfo info) + { + _db = db; + _info = info; + } + + public event Action? Changed; + + public Trainset? EditingTrainset { get; set; } + + public ConsistItem? Selected { get; set; } + + public Dynamic? SelectedCar { get; set; } + + public int Count => _items.Count; + + public ConsistItem this[int index] + { + get => _items[index]; + set => _items[index] = value; + } + + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public int IndexOf(ConsistItem item) => _items.IndexOf(item); + + public void Raise() => Changed?.Invoke(); + + public void LoadFrom(Trainset trainset) + { + EditingTrainset = trainset; + _items.Clear(); + + var cars = trainset.Vehicles.Select(v => + { + var c = v.Clone(); + c.MiniName = _db.MiniForSkin(c.SkinFile) ?? c.MiniName; + return c; + }).ToList(); + + int i = 0; + while (i < cars.Count) + { + if (HoldsNext(cars[i])) + { + var locked = new List { cars[i] }; + int j = i; + while (j < cars.Count - 1 && HoldsNext(cars[j])) + locked.Add(cars[++j]); + + _items.Add(new ConsistItem + { + Cars = locked, + Grouped = true, + Flipped = locked[0].Offset < 0, + Driver = UnitDriver(locked) + }); + i = j + 1; + continue; + } + + VehicleSet? set = null; + if (_db.TextureForSkin(cars[i].SkinFile)?.Uuid is { } uuid) + _db.SetByTextureUuid.TryGetValue(uuid, out set); + + if (set?.TextureRefs is { Count: > 1 }) + { + var members = new HashSet(set.TextureRefs.Where(r => !string.IsNullOrEmpty(r))); + var group = new List(); + + int j = i; + while (j < cars.Count && group.Count < set.TextureRefs.Count) + { + if (_db.TextureForSkin(cars[j].SkinFile)?.Uuid is { } u + && members.Contains(u)) + group.Add(cars[j++]); + else + break; + } + + if (group.Count >= 2) + { + _items.Add(new ConsistItem + { + Cars = group, + Grouped = true, + Flipped = group[0].Offset < 0, + Driver = UnitDriver(group) + }); + i = j; + continue; + } + } + + if (IsUnitCar(cars[i])) + { + string key = UnitKey(cars[i]); + var group = new List { cars[i] }; + int j = i + 1; + while (j < cars.Count && IsUnitCar(cars[j]) && UnitKey(cars[j]) == key) + group.Add(cars[j++]); + + if (group.Count >= 2) + { + _items.Add(new ConsistItem + { + Cars = group, + Grouped = true, + Flipped = group[0].Offset < 0, + Driver = UnitDriver(group) + }); + i = j; + continue; + } + } + + _items.Add(new ConsistItem + { + Cars = new List { cars[i] }, + Grouped = false, + Flipped = cars[i].Offset < 0, + Driver = cars[i].DriverType + }); + i++; + } + + Selected = _items.FirstOrDefault(); + SyncStartingVehicle(); + Raise(); + } + + public List Flatten() + { + var flat = new List(); + foreach (var item in _items) + { + var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse().ToList() : item.Cars; + foreach (var car in cars) + flat.Add(car); + } + return flat; + } + + public void SyncStartingVehicle() + { + if (Selected is not { Cars.Count: > 0 } item) + return; + + var driven = item.Cars.FirstOrDefault(c => + c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver); + AppServices.Current.State.StartingVehicleName = (driven ?? item.Cars[0]).Name; + } + + public Dynamic MakeDynamic(VehicleTexture texture, Scenery? scenery) + { + string skin = Base(texture.Skinfile); + return new Dynamic + { + RangeMax = -1, + RangeMin = 0, + Name = MakeUniqueName(skin, scenery), + DataFolder = StripDynamicPrefix(texture.Directory), + SkinFile = skin, + MmdFile = string.IsNullOrEmpty(texture.Model) ? skin : texture.Model!, + Offset = 0f, + DriverType = eDriverType.Nobody, + Coupling = new Coupling { Flags = Coupling.Mechanical | Coupling.BrakePipe }, + HasVelocity = true, + MiniName = _db.ResolveMiniName(texture) + }; + } + + private string MakeUniqueName(string baseName, Scenery? scenery) + { + if (string.IsNullOrWhiteSpace(baseName)) baseName = "vehicle"; + baseName = baseName.Replace(' ', '_'); + + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in _items) + foreach (var c in item.Cars) + if (!string.IsNullOrEmpty(c.Name)) + used.Add(c.Name!); + + if (scenery != null) + { + foreach (string n in scenery.LooseVehicleNames) + used.Add(n); + foreach (var ts in scenery.Trainsets) + { + if (ReferenceEquals(ts, EditingTrainset)) continue; + foreach (var v in ts.Vehicles) + if (!string.IsNullOrEmpty(v.Name)) + used.Add(v.Name!); + } + } + + string candidate; + do + candidate = $"{baseName}_{++_nameCounter}"; + while (used.Contains(candidate)); + return candidate; + } + + private static string StripDynamicPrefix(string directory) + { + string d = directory.Replace('\\', '/').TrimEnd('/'); + const string prefix = "dynamic/"; + if (d.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + d = d[prefix.Length..]; + return d; + } + + public void Insert(int at, ConsistItem item) => + _items.Insert(Math.Clamp(at, 0, _items.Count), item); + + public void MoveLeft(ConsistItem item) + { + int i = _items.IndexOf(item); + if (i > 0) + { + (_items[i - 1], _items[i]) = (_items[i], _items[i - 1]); + Raise(); + } + } + + public void MoveRight(ConsistItem item) + { + int i = _items.IndexOf(item); + if (i >= 0 && i < _items.Count - 1) + { + (_items[i + 1], _items[i]) = (_items[i], _items[i + 1]); + Raise(); + } + } + + public void Move(int from, int to) + { + if (from < 0 || from >= _items.Count) return; + + var item = _items[from]; + _items.RemoveAt(from); + to = Math.Clamp(to, 0, _items.Count); + _items.Insert(to, item); + + Selected = item; + AutoConnectAll(); + Raise(); + } + + public void Remove(ConsistItem item) + { + int gap = _items.IndexOf(item); + _items.Remove(item); + + if (ReferenceEquals(Selected, item)) + Selected = _items.Count == 0 + ? null + : _items[Math.Clamp(gap, 0, _items.Count - 1)]; + + Raise(); + } + + public void Flip(ConsistItem item) + { + int index = _items.IndexOf(item); + if (index < 0) return; + + int first = index; + while (first > 0 && HoldsTail(_items[first - 1])) + first--; + + int last = index; + while (last + 1 < _items.Count && HoldsTail(_items[last])) + last++; + + for (int k = first; k <= last; k++) + { + var card = _items[k]; + card.Flipped = !card.Flipped; + foreach (var car in card.Cars) + car.Offset = car.Offset >= 0 ? -1f : 0f; + } + + if (last > first) + _items.Reverse(first, last - first + 1); + + AutoConnectAll(); + Raise(); + } + + public void CycleDriver(ConsistItem item) + { + var car = ActiveCar(item); + car.DriverType = car.DriverType switch + { + eDriverType.Nobody => eDriverType.Headdriver, + eDriverType.Headdriver => eDriverType.Reardriver, + eDriverType.Reardriver => eDriverType.Passenger, + _ => eDriverType.Nobody + }; + item.Driver = UnitDriver(item.Cars); + if (ReferenceEquals(Selected, item)) + SyncStartingVehicle(); + Raise(); + AppServices.Current.State.NotifyChanged(); + } + + public void Split(ConsistItem item) + { + int i = _items.IndexOf(item); + if (i < 0) return; + + var order = item.Flipped + ? Enumerable.Reverse(item.Cars).ToList() + : item.Cars; + + _items.RemoveAt(i); + for (int c = 0; c < order.Count; c++) + { + _items.Insert(i + c, new ConsistItem + { + Cars = new List { order[c] }, + Grouped = false, + Flipped = item.Flipped, + Driver = order[c].DriverType + }); + } + Selected = _items[i]; + Raise(); + } + + public void Join(ConsistItem item) + { + int index = _items.IndexOf(item); + if (index < 0) return; + + int first = index; + while (first > 0 && HoldsTail(_items[first - 1])) + first--; + + int last = index; + + bool inUnit = first < index || HoldsTail(_items[index]); + if (!inUnit && last + 1 < _items.Count) + { + var card = _items[last]; + if (card.Cars.Count > 0) + TailCar(card).Coupling.Set(Coupling.WorkshopLock, true); + last++; + } + + while (last + 1 < _items.Count && HoldsTail(_items[last])) + last++; + + if (last == first) return; + + var cars = new List(); + var driver = eDriverType.Nobody; + for (int k = first; k <= last; k++) + { + cars.AddRange(_items[k].Cars); + if (driver == eDriverType.Nobody) + driver = _items[k].Driver; + } + + bool flipped = _items[first].Flipped; + if (flipped) + cars.Reverse(); + + var joined = new ConsistItem + { + Cars = cars, + Grouped = true, + Flipped = flipped, + Driver = driver + }; + + _items.RemoveRange(first, last - first + 1); + _items.Insert(first, joined); + Selected = joined; + + AutoConnectAll(); + Raise(); + } + + public void RandomizeOrder(Random rng) + { + int start = Selected != null ? _items.IndexOf(Selected) : 0; + if (start < 0) start = 0; + + var indices = new List(); + for (int i = start; i < _items.Count; i++) + { + if (_items[i].Grouped) continue; + string? cat = _info.CategoryOf(_items[i].Cars[0]); + if (cat is { Length: 1 } && char.IsUpper(cat[0])) + indices.Add(i); + } + + if (indices.Count < 2) return; + + var items = indices.Select(i => _items[i]).ToList(); + for (int i = items.Count - 1; i > 0; i--) + { + int j = rng.Next(i + 1); + (items[i], items[j]) = (items[j], items[i]); + } + for (int k = 0; k < indices.Count; k++) + _items[indices[k]] = items[k]; + + AutoConnectAll(); + Raise(); + } + + public void RandomizeOrientation(Random rng) + { + int start = Selected != null ? _items.IndexOf(Selected) : 0; + if (start < 0) start = 0; + if (start >= _items.Count) return; + + for (int i = start; i < _items.Count; i++) + if (rng.Next(2) == 0) + _items[i].Flipped = !_items[i].Flipped; + + AutoConnectAll(); + Raise(); + } + + public void AutoConnectAll() + { + var flat = new List<(Dynamic car, bool flipped)>(); + foreach (var item in _items) + { + var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse() : item.Cars; + foreach (var c in cars) + flat.Add((c, item.Flipped)); + } + + for (int i = 0; i < flat.Count - 1; i++) + { + var (left, lf) = flat[i]; + var (right, rf) = flat[i + 1]; + + var lp = _info.PhysicsFor(left); + var rp = _info.PhysicsFor(right); + + int leftMax = lp == null ? 3 : (lf ? lp.AllowedFlagA : lp.AllowedFlagB); + int rightMax = rp == null ? 3 : (rf ? rp.AllowedFlagB : rp.AllowedFlagA); + int common = leftMax & rightMax; + + string lct = lp == null ? "" : (lf ? lp.ControlTypeA : lp.ControlTypeB); + string rct = rp == null ? "" : (rf ? rp.ControlTypeB : rp.ControlTypeA); + if ((common & Coupling.ControlMU) != 0 && + !string.Equals(lct, rct, StringComparison.OrdinalIgnoreCase)) + common &= ~Coupling.ControlMU; + + left.Coupling.Flags = left.Coupling.Locked ? -common : common; + } + } + + public bool CanFormUnit(ConsistItem left, ConsistItem right) + { + if (left.Cars.Count == 0 || right.Cars.Count == 0) + return false; + + var tail = TailCar(left); + var head = HeadCar(right); + + var lp = _info.PhysicsFor(tail); + var rp = _info.PhysicsFor(head); + int leftMax = lp == null ? 3 : (left.Flipped ? lp.AllowedFlagA : lp.AllowedFlagB); + int rightMax = rp == null ? 3 : (right.Flipped ? rp.AllowedFlagB : rp.AllowedFlagA); + + return (leftMax & rightMax & Coupling.WorkshopLock) != 0 || SameSet(tail, head); + } + + public bool SameSet(Dynamic a, Dynamic b) + { + if (IsUnitCar(a) && IsUnitCar(b) && UnitKey(a) == UnitKey(b)) + return true; + + string? ua = _db.TextureForSkin(a.SkinFile)?.Uuid; + string? ub = _db.TextureForSkin(b.SkinFile)?.Uuid; + if (string.IsNullOrEmpty(ua) || string.IsNullOrEmpty(ub)) + return false; + + return _db.SetByTextureUuid.TryGetValue(ua!, out var sa) && + _db.SetByTextureUuid.TryGetValue(ub!, out var sb) && + ReferenceEquals(sa, sb) && sa.TextureRefs.Count > 1; + } + + public Dynamic ActiveCar(ConsistItem item) => + SelectedCar != null && item.Cars.Contains(SelectedCar) ? SelectedCar : item.Cars[0]; + + public static IEnumerable UnitTargets(ConsistItem item) => item.Cars; + + public IEnumerable MemberTargets(ConsistItem item) => + item.Cars.Count == 1 ? item.Cars : new[] { ActiveCar(item) }; + + public static Dynamic TailCar(ConsistItem item) => + item.Flipped ? item.Cars[0] : item.Cars[^1]; + + public static Dynamic HeadCar(ConsistItem item) => + item.Flipped ? item.Cars[^1] : item.Cars[0]; + + public static bool HoldsTail(ConsistItem item) => + item.Cars.Count > 0 && HoldsNext(TailCar(item)); + + public static bool HoldsNext(Dynamic car) => + car.Coupling.Has(Coupling.WorkshopLock) || car.Coupling.Locked; + + public static eDriverType UnitDriver(IReadOnlyList group) => + group.Select(c => c.DriverType) + .Where(d => d != eDriverType.Nobody) + .DefaultIfEmpty(eDriverType.Nobody) + .First(); + + public static string Base(string skinOrName) => System.IO.Path.GetFileNameWithoutExtension(skinOrName); + + public static string UnitKey(Dynamic d) => StripCarSuffix(Base(d.SkinFile)); + + public static bool IsUnitCar(Dynamic d) => UnitKey(d) != Base(d.SkinFile); + + private static string StripCarSuffix(string name) + { + foreach (string suf in CarSuffixes) + { + if (name.Length > suf.Length && + name.EndsWith(suf, StringComparison.OrdinalIgnoreCase) && + char.IsDigit(name[name.Length - suf.Length - 1])) + return name[..^suf.Length]; + } + return name; + } + + public static string UnitLabel(ConsistItem item) + { + string key = UnitKey(item.Cars[0]); + return item.Cars.Count > 1 ? $"{key} [{item.Cars.Count}]" : key; + } + + public static int GetWagonNumber(Dynamic car) + { + string name = car.Name ?? ""; + int hash = name.LastIndexOf('#'); + if (hash >= 0 && hash + 1 < name.Length && name[(hash + 1)..].All(char.IsDigit) + && int.TryParse(name[(hash + 1)..], out int n)) + return n; + return 0; + } + + public void SetWagonNumber(Dynamic car, int number) + { + string name = car.Name ?? ""; + int hash = name.LastIndexOf('#'); + string baseName = (hash >= 0 && hash + 1 < name.Length && name[(hash + 1)..].All(char.IsDigit)) + ? name[..hash] + : name; + car.Name = number > 0 ? $"{baseName}#{number}" : baseName; + Raise(); + } +} diff --git a/StarterNG/Domain/Sceneries/Scenery.cs b/StarterNG/Domain/Sceneries/Scenery.cs new file mode 100644 index 0000000..243ab2f --- /dev/null +++ b/StarterNG/Domain/Sceneries/Scenery.cs @@ -0,0 +1,139 @@ +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; + +public sealed class SceneryAttachment +{ + public string FilePath = ""; + public string Label = ""; +} + +public sealed class SceneryInclude +{ + public string FilePath = ""; + public string Desc = ""; + + public int Kind; + + public bool Selected; +} + +public sealed class Scenery +{ + public Scenery(string path, string template) + { + Path = path; + Template = template; + } + + public string Path { get; } + + public string Template { get; } + + public bool HasCompanionTimetable { get; set; } + + public string? Group { get; set; } + + public string? Name { get; set; } + + public string? Description { get; set; } + + public string? ImageName { get; set; } + + public bool Archival { get; set; } + + public List Trainsets { get; } = new(); + + public List Attachments { get; } = new(); + + public List Includes { get; } = new(); + + public List LooseVehicles { get; } = new(); + + public List LooseVehicleNames { get; } = new(); + + public List Faults { get; } = new(); + + public SceneryWeather Weather { get; } = new(); + + public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path); + + public string LocalizedDescription(ISceneryTranslations translations) => + string.IsNullOrEmpty(Description) + ? "" + : string.Join("\n", Description.Split('\n').Select(translations.Translate)); + + public string BuildExportContent(bool skipDecorTrainsets, IRandomSource random) + { + string result = Template; + + if (Weather.Dirty) + result = SceneryWeatherWriter.Rewrite(result, Weather, random); + + result = InjectIncludes(result); + + for (int i = 0; i < Trainsets.Count; i++) + { + string entry = skipDecorTrainsets && Trainsets[i].Decor + ? string.Empty + : Trainsets[i].ToSceneryEntry(); + result = result.Replace("{{" + i + "}}", entry); + } + + if (LooseVehicles.Count > 0) + result = AppendLooseVehicles(result); + + return result; + } + + private string AppendLooseVehicles(string text) + { + var sb = new StringBuilder(); + foreach (var vehicle in LooseVehicles) + sb.Append(vehicle.ToLooseNode()); + if (sb.Length == 0) + return text; + + return InsertBeforeFirstInit(text, sb.ToString(), fallbackAtEnd: true); + } + + private string InjectIncludes(string text) + { + if (Includes.Count == 0) + return text; + + var sb = new StringBuilder(); + foreach (var include in Includes) + { + if (include.Kind == 2) + { + if (!HasCompanionTimetable) + sb.Append("include ").Append(include.FilePath).Append(" end\r\n"); + continue; + } + if (include.Selected) + sb.Append("include ").Append(include.FilePath).Append(" end\r\n"); + } + + if (sb.Length == 0) + return text; + + return InsertBeforeFirstInit(text, sb.ToString(), fallbackAtEnd: false); + } + + private static string InsertBeforeFirstInit(string text, string addition, bool fallbackAtEnd) + { + var firstInit = Regex.Match(text, @"\bFirstInit\b", RegexOptions.IgnoreCase); + if (firstInit.Success) + return text[..firstInit.Index] + addition + text[firstInit.Index..]; + + return fallbackAtEnd ? text + addition : addition + text; + } +} diff --git a/StarterNG/Domain/Sceneries/SceneryWeather.cs b/StarterNG/Domain/Sceneries/SceneryWeather.cs new file mode 100644 index 0000000..ebe9b78 --- /dev/null +++ b/StarterNG/Domain/Sceneries/SceneryWeather.cs @@ -0,0 +1,81 @@ +using System; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Domain.Sceneries; + +public sealed class SceneryWeather +{ + public const int FogMin = 10; + public const int FogMax = 10000; + + private static readonly double[] OvercastPresets = { -1.5, 0, 0.3, 0.7, 1, 1.1, 1.5 }; + + private string _authoredTime = "10:30"; + private string _authoredTimeOverride = "10:30"; + private int _authoredDay; + private double _authoredTemperature = 15; + private int _authoredFogEnd = 2000; + private double _authoredOvercast; + private bool _authoredOvercastRandom; + + public string Time { get; set; } = "10:30"; + + public string ScenarioTimeOverride { get; set; } = "10:30"; + + public int Day { get; set; } + + public double Temperature { get; set; } = 15; + + public int FogStart { get; set; } = 10; + + public int FogEnd { get; set; } = 2000; + + public double Overcast { get; set; } + + public bool OvercastRandom { get; set; } + + public bool IsAuthored { get; set; } + + public bool Dirty { get; set; } + + public void AcceptAsAuthored() + { + _authoredTime = Time; + _authoredTimeOverride = ScenarioTimeOverride; + _authoredDay = Day; + _authoredTemperature = Temperature; + _authoredFogEnd = FogEnd; + _authoredOvercast = Overcast; + _authoredOvercastRandom = OvercastRandom; + } + + public bool Changed => + Day != _authoredDay || + ScenarioTimeOverride != _authoredTimeOverride || + Math.Abs(Temperature - _authoredTemperature) > 0.001 || + FogEnd != _authoredFogEnd || + OvercastRandom != _authoredOvercastRandom || + (!OvercastRandom && Math.Abs(Overcast - _authoredOvercast) > 0.001); + + public void Restore() + { + Time = _authoredTime; + ScenarioTimeOverride = _authoredTimeOverride; + Day = _authoredDay; + Temperature = _authoredTemperature; + FogEnd = _authoredFogEnd; + Overcast = _authoredOvercast; + OvercastRandom = _authoredOvercastRandom; + Dirty = true; + } + + public void SetFogRange(int start, int end, IRandomSource random) + { + FogStart = Math.Clamp(start, FogMin, FogMax); + end = Math.Clamp(end, FogStart, FogMax); + FogEnd = end > FogStart ? random.Next(FogStart, end + 1) : end; + } + + public double EffectiveOvercast(IRandomSource random) => + OvercastRandom ? OvercastPresets[random.Next(0, OvercastPresets.Length)] : Overcast; +} diff --git a/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs b/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs new file mode 100644 index 0000000..bf4270d --- /dev/null +++ b/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs @@ -0,0 +1,76 @@ +using System; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using StarterNG.Application.Abstractions; + +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); + + public static string Rewrite(string text, SceneryWeather weather, IRandomSource random) + { + try + { + 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+", " "); + + var inv = CultureInfo.InvariantCulture; + string config = + "config\r\n" + + $"movelight {weather.Day}\r\n" + + $"scenario.weather.temperature {weather.Temperature.ToString(inv)}\r\n" + + $"scenario.time.override {DelphiHourMinute(weather.ScenarioTimeOverride)}\r\n" + + carried + + "endconfig\r\n" + + $"atmo 0 0 0 {weather.FogEnd} {weather.FogEnd} 0 0 0 " + + $"{weather.EffectiveOvercast(random).ToString(inv)} endatmo\r\n" + + $"time {DelphiHourMinute(weather.Time)} 0 0 endtime\r\n"; + + return config + stripped; + } + catch + { + return text; + } + } + + private static string DelphiHourMinute(string hhmm) + { + int colon = hhmm.IndexOf(':'); + if (colon <= 0) + return hhmm; + + return hhmm[..colon].TrimStart('0') is { Length: > 0 } hour + ? hour + hhmm[colon..] + : "0" + hhmm[colon..]; + } + + 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"); + } + return sb.ToString(); + } +} diff --git a/StarterNG/Domain/Settings/SimulatorSettings.cs b/StarterNG/Domain/Settings/SimulatorSettings.cs new file mode 100644 index 0000000..a93169e --- /dev/null +++ b/StarterNG/Domain/Settings/SimulatorSettings.cs @@ -0,0 +1,130 @@ +using System; + +namespace StarterNG.Domain.Settings; + +// Values match the key eu07.ini has carried since the old starter. +public enum ConsistReadyOverride { Scenery = 0, AlwaysCold = 1, AlwaysReady = 2 } + +public enum ExeProblem { None, NotFound, NotExecutable, WrongPlatform } + +public sealed class SimulatorSettings +{ + public bool LanguageWasSet { get; internal set; } + + public string Language = "English"; + + public string LanguageCode = ""; + public bool Fullscreen; + public bool PauseWhenInactive = true; + public bool PauseOnStart; + public int CursorSensitivity = 2; + public bool InvertMouseHorizontal; + public bool InvertMouseVertical; + + public bool IgnoreGamepad; + public int FeedbackMode = 1; + public int FeedbackPort = 888; + public bool UartEnabled; + public string UartPort = ""; + public string UartTune = ""; + public bool UartDebug; + public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel; + + public bool SelectExeAutomatically = true; + public string ExecutablePath = "eu07.exe"; + public bool DebugMode; + public bool VirtualShunting = true; + public bool LogMissingVehicleFiles; + + public int RenderEngine; + public int Width = 1280; + public int Height = 720; + public int BufferScalePercent = 100; + public int MaxTextureSize = 4096; + public int MaxCabTextureSize = 4096; + public int TextureFiltering = 8; + public int Multisampling = 2; + public int DynamicLights = 7; + public double DrawRangeFactor = 3.0; + public bool VSync; + public bool Smoke = true; + public int SmokeFidelity = 1; + public bool ChromaticAberration = true; + public bool MotionBlur = true; + public bool ExtraEffects = true; + public bool EnvMap = true; + public bool UseVbo = true; + public bool RenderShadows = true; + public int ReflectionsFramerate = 60; + public int ShadowMapResolution = 2048; + public int ShadowProjectionRange = 250; + public int CabShadowsRange; + public int ShadowRankCutoff = 3; + public int ReflectionsFidelity; + public int FieldOfView = 45; + public bool PythonScreens = true; + public bool PythonThreadedUpload = true; + public bool FpsLimitEnabled; + public int FpsLimit = 30; + public double ShadowAngleLimit = 0.2; + public bool FullscreenWindowed; + public bool RenderAngleVulkan; + + public int SplineFidelity = 1; + public bool FullPhysics = true; + public bool EnableTraction = true; + public bool LiveTraction = true; + public bool PhysicsLog; + public bool DebugLog = true; + public bool MultipleLogs; + public bool DisplaySimulation = true; + public bool CrashDamage = true; + public double Friction = 1.0; + public double BrakeStep = 1.0; + public double BrakeSpeed = 3.0; + + public bool SoundEnabled = true; + public int Volume = 50; + public int RadioVolume = 75; + public int VehiclesVolume = 100; + public int PositionalVolume = 100; + public int AmbientVolume = 100; + public bool SkipPipeline; + public int PythonScreenUpdateRate = 200; + public bool DebugLogVisible; + internal int DebugLogExtraBits { get; set; } + + public bool CompressTextures = true; + public bool ScaleSpeculars = true; + public bool UseGLES; + public bool ShaderGamma; + public bool ScreenMipmaps = true; + public bool ExtendedModelConversion; + public bool GfxResourceMove; + public bool GfxResourceSweep = true; + public int TrainPhysicsThreads; + public bool IgnoreIrrelevantTrains; + + public bool AutoCloseStarter; + public bool LargeThumbnails; + public bool AutoExpandSceneryTree; + + public ConsistReadyOverride ReadyOverride = ConsistReadyOverride.Scenery; + + public bool ShowAiVehicles = true; + public bool DrivableOnly = true; + public bool ShowArchivalSceneries = true; + public bool HideArchivalVehicles = true; + public string LastScenery = ""; + + public bool WindowMaximized; + + public static readonly string[] RenderEngines = + { "full", "legacy", "simpleshader", "simple", "off", "experimental" }; + + public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 }; + + internal double MouseScaleYMagnitude { get; set; } = 0.2; + + internal string[] ShadowTuneExtra { get; set; } = { "400", "300" }; +} diff --git a/StarterNG/Domain/TexRandomizer.cs b/StarterNG/Domain/TexRandomizer.cs new file mode 100644 index 0000000..450afd9 --- /dev/null +++ b/StarterNG/Domain/TexRandomizer.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using StarterNG.Classes; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Domain; + +public sealed class TexRandomizer +{ + private readonly VehicleCatalog _db; + private readonly Random _rng; + private readonly List _stock = new(); + + public int RevisionTolerance { get; set; } = -1; + public int RevYear { get; set; } = -1; + public bool WithoutArchival { get; set; } = true; + + public static string RulesPath + { + get + { + string cwd = Directory.GetCurrentDirectory(); + string primary = Path.Combine(cwd, "starter", "reguly.txt"); + if (File.Exists(primary)) return primary; + string bundled = Path.Combine(AppContext.BaseDirectory, "startercfg", "reguly.txt"); + if (File.Exists(bundled)) return bundled; + return primary; + } + } + + public TexRandomizer(VehicleCatalog db, Random? rng = null) + { + _db = db; + _rng = rng ?? Random.Shared; + LoadRules(); + } + + public void LoadRules() + { + _stock.Clear(); + string path = RulesPath; + if (!File.Exists(path)) return; + + try + { + foreach (string line in File.ReadAllLines(path)) + { + string trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed[0] == '#') continue; + string[] parts = trimmed.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length > 0) + _stock.Add(parts); + } + } + catch (Exception ex) + { + StarterNG.Infrastructure.Diagnostics.Log("texture randomizer stock list", ex); + } + } + + public int Apply(Consist consist, Func makeCar) + { + int changed = 0; + for (int i = 0; i < consist.Count; i++) + { + var item = consist[i]; + if (item.Cars.Count == 0) continue; + + var current = _db.TextureForSkin(item.Cars[0].SkinFile); + if (current != null && _db.IsSetFollower(current)) + continue; + + string? mini = ResolveFilterMini(item, i, consist, current); + if (string.IsNullOrEmpty(mini)) continue; + + int? revYear = null; + if (RevisionTolerance > 0) + { + revYear = RevYear > 0 ? RevYear : ParseRevisionYear(current?.Meta?.RevisionDate); + if (revYear is null) continue; + } + + string? owner = null; + string? cat = current?.ResolvedCategory; + + if (current != null && cat is not ("z" or "a")) + owner = current.Meta?.Operator; + + var aliases = ExpandMini(mini); + var pool = FindCandidates(aliases, owner, revYear, current); + if (pool.Count < 2) continue; + + var pick = pool[_rng.Next(pool.Count)]; + if (ReferenceEquals(pick, current) || + string.Equals(pick.Skinfile, current?.Skinfile, StringComparison.OrdinalIgnoreCase)) + continue; + + var set = _db.ResolveSet(pick); + var unit = set ?? new List { pick }; + consist[i] = new ConsistItem + { + Cars = unit.Select(makeCar).ToList(), + Grouped = unit.Count > 1, + Driver = item.Driver, + Flipped = item.Flipped + }; + changed++; + } + return changed; + } + + private string? ResolveFilterMini(ConsistItem item, int index, Consist consist, VehicleTexture? current) + { + if (current != null) + { + string skin = current.Skinfile ?? ""; + if (skin.Contains("112E", StringComparison.OrdinalIgnoreCase)) + return null; + if (current.Wreck) return null; + + string mini = current.ResolvedClass; + if (string.IsNullOrEmpty(mini)) + mini = _db.ResolveMiniName(current) ?? ""; + return string.IsNullOrEmpty(mini) ? null : mini; + } + + if (index > 0) + { + var prev = _db.TextureForSkin(consist[index - 1].Cars[0].SkinFile); + if (prev != null && !string.IsNullOrEmpty(prev.ResolvedClass)) + return prev.ResolvedClass; + } + if (index + 1 < consist.Count) + { + var next = _db.TextureForSkin(consist[index + 1].Cars[0].SkinFile); + if (next != null && !string.IsNullOrEmpty(next.ResolvedClass)) + return next.ResolvedClass; + } + + bool driven = item.Driver is eDriverType.Headdriver or eDriverType.Reardriver; + return driven ? "st44" : "A_112A"; + } + + private List ExpandMini(string mini) + { + var list = new List { mini }; + string upper = mini.ToUpperInvariant(); + foreach (var stock in _stock) + { + bool hit = stock.Any(s => s.ToUpperInvariant().Contains(upper) || upper.Contains(s.ToUpperInvariant())); + if (!hit) continue; + foreach (string s in stock) + { + if (!list.Contains(s, StringComparer.OrdinalIgnoreCase)) + list.Add(s); + } + break; + } + return list; + } + + private List FindCandidates( + IReadOnlyList minis, + string? owner, + int? revYear, + VehicleTexture? current) + { + var result = new List(); + foreach (var t in _db.Textures) + { + if (t.Wreck) continue; + if (WithoutArchival && t.ResolvedArchived) continue; + if (_db.IsSetFollower(t)) continue; + + string candidateMini = t.ResolvedClass; + if (string.IsNullOrEmpty(candidateMini)) + candidateMini = _db.ResolveMiniName(t) ?? ""; + if (string.IsNullOrEmpty(candidateMini)) continue; + + bool miniOk = minis.Any(m => + string.Equals(m, candidateMini, StringComparison.OrdinalIgnoreCase) || + string.Equals(m, t.TextureMini, StringComparison.OrdinalIgnoreCase) || + string.Equals(m, t.MiniRef, StringComparison.OrdinalIgnoreCase)); + if (!miniOk) continue; + + if (!string.IsNullOrEmpty(owner) && + !string.Equals(owner, t.Meta?.Operator, StringComparison.OrdinalIgnoreCase)) + continue; + + if (revYear is int year && RevisionTolerance > 0) + { + int? y = ParseRevisionYear(t.Meta?.RevisionDate); + if (y is null || Math.Abs(y.Value - year) > RevisionTolerance) + continue; + } + + result.Add(t); + } + + if (current != null && !result.Contains(current)) + result.Add(current); + + return result; + } + + public static int? ParseRevisionYear(string? revision) + { + if (string.IsNullOrWhiteSpace(revision)) return null; + + if (revision.Length >= 10 && revision[2] == '.' && revision[5] == '.' && + int.TryParse(revision.AsSpan(6, 4), out int yDot) && yDot is >= 1900 and <= 2100) + return yDot; + + for (int i = 0; i + 3 < revision.Length; i++) + { + if (char.IsDigit(revision[i]) && char.IsDigit(revision[i + 1]) && + char.IsDigit(revision[i + 2]) && char.IsDigit(revision[i + 3])) + { + int y = (revision[i] - '0') * 1000 + (revision[i + 1] - '0') * 100 + + (revision[i + 2] - '0') * 10 + (revision[i + 3] - '0'); + if (y is >= 1900 and <= 2100) return y; + } + } + return null; + } +} diff --git a/StarterNG/Domain/TrainsetDisplay.cs b/StarterNG/Domain/TrainsetDisplay.cs new file mode 100644 index 0000000..10037af --- /dev/null +++ b/StarterNG/Domain/TrainsetDisplay.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using StarterNG.Classes; +using StarterNG.Domain.Sceneries; +using StarterNG.Domain.Vehicles; +using StarterNG.Application; + +namespace StarterNG.Domain; + +public static class TrainsetDisplay +{ + public static bool IsEmpty(Trainset trainset) => + trainset.Vehicles.Count == 0 || + trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name) && string.IsNullOrWhiteSpace(v.SkinFile)); + + public static string? CompositionText(Trainset trainset) + { + if (IsEmpty(trainset)) + return null; + + var sb = new StringBuilder(); + string? prevKey = null; + int run = 0; + + void Flush() + { + if (prevKey is null || run == 0) return; + if (sb.Length > 0) sb.Append(" + "); + sb.Append(run > 1 ? $"{prevKey}({run})" : prevKey); + prevKey = null; + run = 0; + } + + for (int i = 0; i < trainset.Vehicles.Count; i++) + { + var v = trainset.Vehicles[i]; + + if (i == 0 && IsPowered(v)) + { + sb.Append(VehicleLabel(v, head: true)); + prevKey = null; + run = 0; + continue; + } + + string key = VehicleLabel(v); + + if (string.Equals(key, prevKey, StringComparison.OrdinalIgnoreCase)) + { + run++; + } + else + { + Flush(); + prevKey = key; + run = 1; + } + } + Flush(); + return sb.Length > 0 ? sb.ToString() : string.Join(" + ", trainset.Vehicles.Select(v => + !string.IsNullOrWhiteSpace(v.Name) ? v.Name! : BaseSkin(v))); + } + + public static string VehicleLabel(Dynamic v, bool head = false) + { + var tex = AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile); + string? cls = string.IsNullOrEmpty(tex?.ResolvedClass) ? null : tex!.ResolvedClass; + string? name = string.IsNullOrWhiteSpace(v.Name) ? null : v.Name; + return (head ? name ?? cls : cls ?? name) ?? BaseSkin(v); + } + + public static bool IsPowered(Dynamic v) => + AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile)?.ResolvedCategory + is "e" or "s" or "p" or "z" or "a"; + + private static string BaseSkin(Dynamic v) => + string.IsNullOrWhiteSpace(v.SkinFile) ? "?" : + System.IO.Path.GetFileNameWithoutExtension(v.SkinFile); + + public static string? DefaultStartingVehicle(Trainset trainset) + { + if (trainset.Vehicles.Count == 0) + return null; + + var last = trainset.Vehicles[^1]; + if (last.DriverType is eDriverType.Headdriver or eDriverType.Reardriver) + return last.Name; + + var staffed = trainset.Vehicles.FirstOrDefault(v => + v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger); + return staffed?.Name ?? trainset.Vehicles[0].Name; + } + + public static List<(string Label, string Value)> StatsFields( + Trainset? trainset, Func physicsFor, + Func? categoryOf = null, + Func? loadWeightKg = null) + { + var fields = new List<(string, string)>(); + if (trainset is null) + return fields; + + var (lengthM, massKg, loadKg) = RecalcParams(trainset, physicsFor, categoryOf, loadWeightKg, allVehicles: false); + if (massKg < 200_000) + (lengthM, massKg, loadKg) = RecalcParams(trainset, physicsFor, categoryOf, loadWeightKg, allVehicles: true); + + var inv = CultureInfo.InvariantCulture; + + string track = trainset.Track ?? ""; + if (trainset.Velocity > 0) + track = $"{track} ({trainset.Velocity.ToString("0.#", inv)} km/h)"; + + fields.Add(($"{App.Loc["Mass"]} [t]:", (massKg / 1000.0).ToString("0.#", inv))); + fields.Add(($"{App.Loc["MassBrutto"]} [t]:", ((massKg + loadKg) / 1000.0).ToString("0.#", inv))); + fields.Add(($"{App.Loc["Length"]} [m]:", lengthM.ToString("0.#", inv))); + fields.Add(($"{App.Loc["VehicleCount"]}:", trainset.Vehicles.Count.ToString(inv))); + fields.Add(($"{App.Loc["Track"]}:", track)); + return fields; + } + + public static string FormatStats(Trainset? trainset, Func physicsFor, + string lengthLabel, string massLabel, string vehiclesLabel, string trackLabel, + Func? categoryOf = null, + Func? loadWeightKg = null) + { + if (trainset is null) + return ""; + + var (lengthM, massKg, loadKg) = RecalcParams(trainset, physicsFor, categoryOf, loadWeightKg, allVehicles: false); + if (massKg < 200_000) + (lengthM, massKg, loadKg) = RecalcParams(trainset, physicsFor, categoryOf, loadWeightKg, allVehicles: true); + + var inv = CultureInfo.InvariantCulture; + string track = trainset.Track ?? ""; + if (trainset.Velocity > 0) + track = $"{track} ({trainset.Velocity.ToString("0.#", inv)} km/h)"; + + string massPart = $"{massLabel}: {(massKg / 1000.0).ToString("0.#", inv)} t"; + if (loadKg > 0) + massPart += $" / {((massKg + loadKg) / 1000.0).ToString("0.#", inv)} t"; + + return $"{lengthLabel}: {lengthM.ToString("0.#", inv)} m · " + + $"{massPart} · " + + $"{vehiclesLabel}: {trainset.Vehicles.Count} · " + + $"{trackLabel}: {track}"; + } + + private static (double lengthM, double massKg, double loadKg) RecalcParams( + Trainset trainset, + Func physicsFor, + Func? categoryOf, + Func? loadWeightKg, + bool allVehicles) + { + double lengthM = 0, massKg = 0, loadKg = 0; + foreach (var car in trainset.Vehicles) + { + var p = physicsFor(car); + if (p != null) + lengthM += p.Length; + + if (!IncludeVehicleToMass(car, p, categoryOf?.Invoke(car), allVehicles)) + continue; + + if (p != null) + massKg += p.Mass; + + if (loadWeightKg != null && car.LoadCount > 0 && !string.IsNullOrEmpty(car.LoadType)) + { + if (LoadAccepted(p, car.LoadType)) + loadKg += (double)car.LoadCount * loadWeightKg(car.LoadType); + else + // The scenery loads something this vehicle's .fiz does not + // accept. It is left out of the gross mass, and saying so beats + // leaving the reading quietly short. + AppServices.Current.Log.Log( + $"{car.Name}: nieobslugiwany ladunek \"{car.LoadType}\""); + } + } + return (lengthM, massKg, loadKg); + } + + private static bool IncludeVehicleToMass(Dynamic car, VehiclePhysics? p, string? category, bool allVehicles) + { + if (p == null) return false; + if (allVehicles) return true; + if (category is "z" or "a") return true; + return car.DriverType is eDriverType.Passenger or eDriverType.Nobody; + } + + private static bool LoadAccepted(VehiclePhysics? p, string loadType) + { + if (p == null || string.IsNullOrWhiteSpace(p.LoadAccepted)) + return false; + return p.LoadAccepted.Contains(loadType, StringComparison.OrdinalIgnoreCase); + } + + public static string? UniquifyForLaunch(Trainset trainset, Scenery scenery, string? startName) + { + var used = CollectNames(scenery); + var db = AppServices.Current.Library.Vehicles; + + bool mu = trainset.Vehicles.Any(v => + { + string? cat = db.TextureForSkin(v.SkinFile)?.ResolvedCategory; + return cat is "z" or "a"; + }); + + Dynamic? target = null; + if (!string.IsNullOrEmpty(startName)) + target = trainset.Vehicles.FirstOrDefault(v => + string.Equals(v.Name, startName, StringComparison.OrdinalIgnoreCase)); + target ??= trainset.Vehicles.FirstOrDefault(v => + v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger); + target ??= trainset.Vehicles.FirstOrDefault(); + + if (mu) + { + foreach (var v in trainset.Vehicles) + Rename(v, used); + return target?.Name; + } + + if (target != null) + Rename(target, used); + return target?.Name; + } + + public static string PreferredBaseName(Dynamic v) + { + var tex = AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile); + if (tex != null) + { + + if (!string.IsNullOrEmpty(tex.TextureMini) && + !string.Equals(tex.TextureMini, tex.ResolvedClass, StringComparison.OrdinalIgnoreCase)) + return SanitizeName(tex.TextureMini!); + if (!string.IsNullOrEmpty(tex.Skinfile)) + return SanitizeName(Path.GetFileNameWithoutExtension(tex.Skinfile)); + } + if (!string.IsNullOrWhiteSpace(v.SkinFile)) + return SanitizeName(Path.GetFileNameWithoutExtension(v.SkinFile)); + return SanitizeName(string.IsNullOrWhiteSpace(v.Name) ? "vehicle" : v.Name!); + } + + public static string EnsureUnique(string baseName, HashSet used) + { + baseName = SanitizeName(baseName); + if (!used.Contains(baseName)) + { + used.Add(baseName); + return baseName; + } + int n = 1; + string candidate; + do + candidate = $"{baseName}_{n++}"; + while (used.Contains(candidate)); + used.Add(candidate); + return candidate; + } + + public static HashSet CollectNames(Scenery scenery, Trainset? except = null) + { + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string n in scenery.LooseVehicleNames) + used.Add(n); + foreach (var d in scenery.LooseVehicles) + if (!string.IsNullOrEmpty(d.Name)) + used.Add(d.Name!); + foreach (var ts in scenery.Trainsets) + { + if (ReferenceEquals(ts, except)) continue; + foreach (var v in ts.Vehicles) + if (!string.IsNullOrEmpty(v.Name)) + used.Add(v.Name!); + } + return used; + } + + private static string SanitizeName(string name) => + string.IsNullOrWhiteSpace(name) ? "vehicle" : name.Replace(' ', '_'); + + public static (string Base, string Suffix) SplitWagonNumber(string? name) + { + string n = name ?? ""; + int hash = n.LastIndexOf('#'); + if (hash >= 0 && hash + 1 < n.Length && n[(hash + 1)..].All(char.IsDigit)) + return (n[..hash], n[hash..]); + return (n, ""); + } + + private static void Rename(Dynamic v, HashSet used) + { + var (_, number) = SplitWagonNumber(v.Name); + if (!string.IsNullOrEmpty(v.Name)) + used.Remove(v.Name!); + v.Name = EnsureUnique(PreferredBaseName(v), used) + number; + } +} diff --git a/StarterNG/Domain/UData.cs b/StarterNG/Domain/UData.cs new file mode 100644 index 0000000..16c3038 --- /dev/null +++ b/StarterNG/Domain/UData.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StarterNG.Classes; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Domain; + +public static class UData +{ + public readonly record struct TrainParams(double Length, double Mass, double LoadMass, int CountVehicles); + + public static int GetMaxCoupler(Dynamic v, Func physicsFor, bool leftCoupler = true) + { + var fiz = physicsFor(v); + if (fiz == null) return 3; + return leftCoupler + ? (v.Offset >= 0 ? fiz.AllowedFlagA : fiz.AllowedFlagB) + : (v.Offset >= 0 ? fiz.AllowedFlagB : fiz.AllowedFlagA); + } + + public static string GetControlType(Dynamic v, Func physicsFor, bool leftCoupler = true) + { + var fiz = physicsFor(v); + if (fiz == null) return ""; + return leftCoupler + ? (v.Offset >= 0 ? fiz.ControlTypeA : fiz.ControlTypeB) + : (v.Offset >= 0 ? fiz.ControlTypeB : fiz.ControlTypeA); + } + + public static List CheckFlag(int flag) + { + var result = new List(); + for (int f = 128; f >= 1; f >>= 1) + if (flag >= f) { result.Add(f); flag -= f; } + return result; + } + + public static int CommonCoupler(int c1, int c2) + { + var f1 = CheckFlag(c1); + var f2 = CheckFlag(c2); + return f1.Where(f2.Contains).Sum(); + } + + public static void AutoConnect(IList vehicles, int leftVehicle, + Func physicsFor) + { + if (leftVehicle < 0 || vehicles.Count - 1 <= leftVehicle) return; + + int leftMax = GetMaxCoupler(vehicles[leftVehicle], physicsFor, leftCoupler: false); + string controlType1 = GetControlType(vehicles[leftVehicle], physicsFor, leftCoupler: false); + + int rightMax = GetMaxCoupler(vehicles[leftVehicle + 1], physicsFor); + string controlType2 = GetControlType(vehicles[leftVehicle + 1], physicsFor); + + int coupler = CommonCoupler(leftMax, rightMax); + + const int F4 = 4; + if (CheckFlag(coupler).Contains(F4) && + !string.Equals(controlType1, controlType2, StringComparison.OrdinalIgnoreCase)) + coupler -= F4; + + vehicles[leftVehicle].Coupling.Flags = coupler; + } + + public static List GetMultiple(IList vehicles, int index, + Func setIdOf) + { + var result = new List { index }; + + bool Same(int a, int b) + { + string? x = setIdOf(vehicles[a]); + return x != null && x == setIdOf(vehicles[b]); + } + + int i = 0; + while (index - i > 0 && Same(index - i, index - i - 1)) + { + result.Add(index - i - 1); + i++; + } + + i = 0; + while (vehicles.Count - 1 > index + i && Same(index + i, index + i + 1)) + { + result.Add(index + i + 1); + i++; + } + return result; + } + + public static bool StaffedTrain(Trainset train) => + train.Vehicles.Count > 0 && + (IsDriver(train.Vehicles[0].DriverType) || IsDriver(train.Vehicles[^1].DriverType)); + + private static bool IsDriver(eDriverType d) => + d is eDriverType.Headdriver or eDriverType.Reardriver; + + public static bool IncludeVehicleToMass(Dynamic v, bool allVehicles, + Func physicsFor, Func categoryOf) + { + if (physicsFor(v) == null) return false; + if (allVehicles) return true; + + bool notDriver = v.DriverType is eDriverType.Passenger or eDriverType.Nobody; + string? cat = categoryOf(v); + return notDriver || cat is "z" or "a"; + } + + public static TrainParams RecalcTrainParams(Trainset? train, bool allVehicles, + Func physicsFor, Func categoryOf, + Func loadWeight) + { + double length = 0, mass = 0, loadMass = 0; + if (train == null) return new TrainParams(0, 0, 0, 0); + + for (int i = train.Vehicles.Count - 1; i >= 0; i--) + { + var v = train.Vehicles[i]; + var fiz = physicsFor(v); + + if (IncludeVehicleToMass(v, allVehicles, physicsFor, categoryOf) && fiz != null) + { + mass += fiz.Mass; + + if (!string.IsNullOrEmpty(v.LoadType) && + fiz.LoadAccepted.Contains(v.LoadType!, StringComparison.OrdinalIgnoreCase)) + loadMass += v.LoadCount * loadWeight(v.LoadType); + else if (!string.IsNullOrEmpty(v.LoadType)) + Infrastructure.Diagnostics.Log($"{v.Name} - nieobsługiwany ładunek pojazdu"); + } + + if (fiz != null) + length += fiz.Length; + } + return new TrainParams(length, mass, loadMass, train.Vehicles.Count); + } + + public static void RandomLoad(IEnumerable vehicles, + Func physicsFor, Random rng) + { + foreach (var v in vehicles) + { + var fiz = physicsFor(v); + if (fiz == null) continue; + + var cargo = fiz.LoadAccepted + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Where(x => x.Length > 0) + .ToList(); + + if (cargo.Count > 0) + v.LoadType = cargo[rng.Next(cargo.Count)]; + } + } + + public static void ReverseMultiple(IList vehicles, int position, + Func setIdOf, Func physicsFor) + { + var indexes = GetMultiple(vehicles, position, setIdOf); + + foreach (int i in indexes) + vehicles[i].Offset = vehicles[i].Offset >= 0 ? -1f : 0f; + + indexes.Sort(); + + int first = indexes[0], last = indexes[^1]; + for (int i = 0; i < indexes.Count / 2; i++) + (vehicles[first + i], vehicles[last - i]) = (vehicles[last - i], vehicles[first + i]); + + foreach (int i in indexes) + AutoConnect(vehicles, i, physicsFor); + } + + public static bool CheckMoveVehicle(IList vehicles, int fromPos, int toPos, + Func setIdOf) + { + bool Same(int a, int b) + { + if (a < 0 || b < 0 || a >= vehicles.Count || b >= vehicles.Count) return false; + string? x = setIdOf(vehicles[a]); + return x != null && x == setIdOf(vehicles[b]); + } + + if (fromPos < toPos) + { + if (toPos > 0 && toPos < vehicles.Count - 1 && Same(toPos, toPos + 1)) + return false; + } + else if (toPos > 0 && Same(toPos, toPos - 1)) + { + return false; + } + return true; + } +} diff --git a/StarterNG/Domain/UDepot.cs b/StarterNG/Domain/UDepot.cs new file mode 100644 index 0000000..36ee242 --- /dev/null +++ b/StarterNG/Domain/UDepot.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using StarterNG.Classes; +using StarterNG.Infrastructure; +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Domain; + +public static class UDepot +{ + public sealed class DepotTrain + { + public string TrainName = ""; + public List Vehicles = new(); + } + + public static string? FindDepotFile(string? path = null) + { + if (!string.IsNullOrEmpty(path)) + return File.Exists(path) ? path : null; + + string root = Directory.GetCurrentDirectory(); + foreach (string rel in new[] { Path.Combine("starter", "magazyn.ini"), "starter.ini", "RAINSTED.INI" }) + { + string full = Path.Combine(root, rel); + if (File.Exists(full)) return full; + } + return null; + } + + public static List ReadDepot(string? path = null) + { + var result = new List(); + string? file = FindDepotFile(path); + if (file == null) return result; + + string text; + try + { + text = File.ReadAllText(file, LegacyText.CodePage1250); + } + catch (Exception ex) + { + Diagnostics.Log($"Błąd parsowania magazynu {file}", ex); + return result; + } + + DepotTrain? current = null; + int line = 0; + + foreach (string raw in text.Replace("\r\n", "\n").Split('\n')) + { + line++; + string s = raw.Trim(); + if (s.Length == 0) continue; + + var header = Regex.Match(s, @"^\[\s*TRAINSET\d+\s*(?:=\s*([^\]]*))?\]", RegexOptions.IgnoreCase); + if (header.Success) + { + current = new DepotTrain { TrainName = header.Groups[1].Value.Trim() }; + result.Add(current); + continue; + } + + if (current == null) continue; + + var entry = Regex.Match(s, @"^\d+\s*=\s*(.*)$"); + string body = entry.Success ? entry.Groups[1].Value.Trim() : s; + if (!body.StartsWith("node", StringComparison.OrdinalIgnoreCase)) continue; + + try + { + var v = ParseNode(body); + if (v != null) current.Vehicles.Add(v); + } + catch (Exception ex) + { + Diagnostics.Log($"Błąd parsowania magazynu. Linia: {line}", ex); + } + } + + result.RemoveAll(t => t.Vehicles.Count == 0); + return result; + } + + private static Dynamic? ParseNode(string body) + { + var t = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (t.Length < 10) return null; + + var inv = CultureInfo.InvariantCulture; + int p = 1; + + var d = new Dynamic + { + RangeMax = float.Parse(t[p++], inv), + RangeMin = float.Parse(t[p++], inv), + Name = t[p++] + }; + p++; + d.DataFolder = t[p++]; + d.SkinFile = Dynamic.StripSkinExtension(t[p++]); + d.MmdFile = t[p++]; + d.Offset = float.Parse(t[p++], inv); + d.DriverType = t[p++].ToLowerInvariant() switch + { + "headdriver" => eDriverType.Headdriver, + "reardriver" => eDriverType.Reardriver, + "passenger" => eDriverType.Passenger, + _ => eDriverType.Nobody + }; + d.Coupling = Coupling.Parse(t[p++]); + + var trailing = new List(); + while (p < t.Length && !string.Equals(t[p], "enddynamic", StringComparison.OrdinalIgnoreCase)) + trailing.Add(t[p++]); + d.ReadTrailing(trailing); + return d; + } + + public static bool SaveDepot(IReadOnlyList depot, string? path = null) + { + path ??= Path.Combine(Directory.GetCurrentDirectory(), "starter", "magazyn.ini"); + try + { + var sb = new StringBuilder(); + for (int i = 0; i < depot.Count; i++) + { + if (depot[i].Vehicles.Count == 0) continue; + + sb.Append('[').Append("TRAINSET").Append(i).Append('=') + .Append(depot[i].TrainName).Append("]\n"); + + for (int y = 0; y < depot[i].Vehicles.Count; y++) + sb.Append(y.ToString("00", CultureInfo.InvariantCulture)).Append('=') + .Append(depot[i].Vehicles[y].ToTrainsetNode()); + } + + string? dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(path, sb.ToString(), LegacyText.CodePage1250); + return true; + } + catch (Exception ex) + { + Diagnostics.Log("Błąd zapisu magazynu", ex); + return false; + } + } +} diff --git a/StarterNG/Domain/VehicleInfo.cs b/StarterNG/Domain/VehicleInfo.cs new file mode 100644 index 0000000..1193e58 --- /dev/null +++ b/StarterNG/Domain/VehicleInfo.cs @@ -0,0 +1,36 @@ +using StarterNG.Classes; +using StarterNG.Domain.Vehicles; +using StarterNG.Application; + +namespace StarterNG.Domain; + +public sealed class VehicleInfo +{ + private readonly VehicleCatalog _db; + + public VehicleInfo(VehicleCatalog db) => _db = db; + + public static string ClassOf(VehicleTexture t) => t.ResolvedClass; + + public static string? CategoryOf(VehicleTexture t) => t.ResolvedCategory; + + public static bool IsPoweredCategory(string? c) => + c is "e" or "s" or "p" or "z" or "a"; + + public VehicleTexture? TextureFor(Dynamic car) => _db.TextureForSkin(car.SkinFile); + + public string? CategoryOf(Dynamic car) => + TextureFor(car) is { } t ? CategoryOf(t) : null; + + public VehiclePhysics? PhysicsFor(VehicleTexture texture) => + AppServices.Current.Physics.For(texture.Directory, texture.Model) + ?? AppServices.Current.Physics.For(texture.Directory, texture.Skinfile); + + public VehiclePhysics? PhysicsFor(Dynamic car) + { + string? dbModel = TextureFor(car)?.Model; + return AppServices.Current.Physics.For(car.DataFolder, dbModel) + ?? AppServices.Current.Physics.For(car.DataFolder, car.MmdFile) + ?? AppServices.Current.Physics.For(car.DataFolder, car.SkinFile); + } +} diff --git a/StarterNG/Domain/Vehicles/LoadWeightsTable.cs b/StarterNG/Domain/Vehicles/LoadWeightsTable.cs new file mode 100644 index 0000000..2422545 --- /dev/null +++ b/StarterNG/Domain/Vehicles/LoadWeightsTable.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using StarterNG.Classes; + +namespace StarterNG.Domain.Vehicles; + +public sealed class LoadWeightsTable +{ + public const int DefaultWeight = 1000; + + private readonly Dictionary _weights; + + public LoadWeightsTable(IReadOnlyDictionary? weights = null) + { + _weights = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [Dynamic.PantState] = 0 + }; + + if (weights is null) + return; + + foreach (var (name, weight) in weights) + _weights[name] = weight; + } + + public int WeightOf(string? name) => + !string.IsNullOrEmpty(name) && _weights.TryGetValue(name, out int weight) ? weight : DefaultWeight; +} diff --git a/StarterNG/Domain/Vehicles/VehicleCatalog.cs b/StarterNG/Domain/Vehicles/VehicleCatalog.cs new file mode 100644 index 0000000..095837e --- /dev/null +++ b/StarterNG/Domain/Vehicles/VehicleCatalog.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Domain.Vehicles; + +public sealed class VehicleCatalog +{ + private readonly IMiniTextureIndex _minis; + + public VehicleCatalog(IMiniTextureIndex minis) + { + _minis = minis; + } + + public List Textures { get; } = new(); + + public Dictionary GroupsById { get; } = new(); + + public List Sets { get; } = new(); + + public Dictionary SetByTextureUuid { get; } = new(); + + public Dictionary TextureByUuid { get; } = new(); + + public Dictionary TextureBySkin { get; } = new(); + + public void BeginLoad() + { + Textures.Clear(); + GroupsById.Clear(); + Sets.Clear(); + SetByTextureUuid.Clear(); + TextureByUuid.Clear(); + TextureBySkin.Clear(); + } + + public void Ingest(VehicleEntry entry) + { + foreach (var group in entry.Groups) + if (!string.IsNullOrEmpty(group.Id)) + GroupsById.TryAdd(group.Id, group); + + foreach (var texture in entry.Textures) + { + if (!string.IsNullOrEmpty(texture.Uuid)) + TextureByUuid[texture.Uuid] = texture; + + if (!string.IsNullOrEmpty(texture.Skinfile)) + TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture); + + if (texture.Wreck) + continue; + + Textures.Add(texture); + } + + Sets.AddRange(entry.Sets); + } + + public void EndLoad() + { + BuildSetIndex(); + ResolveGroupInheritance(); + } + + public List? ResolveSet(VehicleTexture texture) + { + if (string.IsNullOrEmpty(texture.Uuid)) + return null; + if (!SetByTextureUuid.TryGetValue(texture.Uuid, out var set) || set.TextureRefs is null) + return null; + + var cars = new List(); + foreach (string uuid in set.TextureRefs) + if (!string.IsNullOrEmpty(uuid) && TextureByUuid.TryGetValue(uuid, out var car)) + cars.Add(car); + + return cars.Count > 0 ? cars : null; + } + + public bool IsSetFollower(VehicleTexture texture) + { + if (string.IsNullOrEmpty(texture.Uuid)) + return false; + if (!SetByTextureUuid.TryGetValue(texture.Uuid, out var set) || + set.TextureRefs is null || set.TextureRefs.Count < 2) + return false; + + string? lead = set.TextureRefs.FirstOrDefault(reference => !string.IsNullOrEmpty(reference)); + return lead is not null && !string.Equals(lead, texture.Uuid, StringComparison.OrdinalIgnoreCase); + } + + public string? ResolveMiniName(VehicleTexture texture) + { + if (_minis.Has(texture.TextureMini)) + return texture.TextureMini; + + if (texture.Group is not null && GroupsById.TryGetValue(texture.Group, out var group) && + !string.IsNullOrEmpty(group.Mini)) + return group.Mini; + + return texture.TextureMini; + } + + public string? MiniForSkin(string? skinFile) => + TextureForSkin(skinFile) is { } texture ? ResolveMiniName(texture) : null; + + public VehicleTexture? TextureForSkin(string? skinFile) + { + if (string.IsNullOrEmpty(skinFile)) + return null; + + string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant(); + return TextureBySkin.TryGetValue(key, out var texture) ? texture : null; + } + + public string GroupHeader(string? groupId) + { + if (string.IsNullOrEmpty(groupId) || !GroupsById.TryGetValue(groupId, out var group)) + return groupId ?? ""; + + string mini = string.IsNullOrEmpty(group.Mini) ? group.Id : group.Mini; + return string.IsNullOrEmpty(group.Category) ? mini : $"{mini} ({group.Category})"; + } + + private void BuildSetIndex() + { + foreach (var set in Sets) + { + if (set.TextureRefs is null) + continue; + + foreach (string uuid in set.TextureRefs) + if (!string.IsNullOrEmpty(uuid)) + SetByTextureUuid[uuid] = set; + } + } + + private void ResolveGroupInheritance() + { + foreach (var texture in Textures) + { + VehicleGroup? group = null; + if (texture.Group is not null) + GroupsById.TryGetValue(texture.Group, out group); + + string mini = group is not null && !string.IsNullOrEmpty(group.Mini) + ? group.Mini + : texture.MiniRef ?? ""; + + string? category = group?.Category; + + if (category == "*") + { + string source = !string.IsNullOrEmpty(mini) ? mini : texture.TextureMini ?? ""; + if (source.Length > 0) + category = char.ToUpperInvariant(source[0]).ToString(); + } + + texture.ResolvedCategory = category; + texture.ResolvedClass = mini; + texture.ResolvedArchived = group?.Archived ?? false; + } + } +} diff --git a/StarterNG/Domain/Vehicles/VehiclePhysics.cs b/StarterNG/Domain/Vehicles/VehiclePhysics.cs new file mode 100644 index 0000000..742bed8 --- /dev/null +++ b/StarterNG/Domain/Vehicles/VehiclePhysics.cs @@ -0,0 +1,40 @@ +using System; + +namespace StarterNG.Domain.Vehicles; + +public sealed class VehiclePhysics +{ + public double Mass { get; set; } + + public double VMax { get; set; } + + public double Length { get; set; } + + public string LoadAccepted { get; set; } = ""; + + public string LoadQ { get; set; } = ""; + + public int MaxLoad { get; set; } + + public int AllowedFlagA { get; set; } = 3; + + public int AllowedFlagB { get; set; } = 3; + + public string ControlTypeA { get; set; } = ""; + + public string ControlTypeB { get; set; } = ""; + + public string EngineType { get; set; } = ""; + + /// + /// True for the engine types the simulator actually runs its diesel heat model for. + /// Mover.cpp calls dizel_Update - and through it dizel_Heat - only for DieselEngine + /// and DieselElectric; LoadFIZ_EngineDecode maps the legacy "DumbDE" onto + /// DieselElectric. On anything else the coolant temperature struct is written and + /// never read. + /// + public bool IsDieselEngine => + EngineType.Equals("DieselEngine", StringComparison.OrdinalIgnoreCase) + || EngineType.Equals("DieselElectric", StringComparison.OrdinalIgnoreCase) + || EngineType.Equals("DumbDE", StringComparison.OrdinalIgnoreCase); +} diff --git a/StarterNG/Domain/Vehicles/VehicleTexture.cs b/StarterNG/Domain/Vehicles/VehicleTexture.cs new file mode 100644 index 0000000..820b08a --- /dev/null +++ b/StarterNG/Domain/Vehicles/VehicleTexture.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; + +namespace StarterNG.Domain.Vehicles; + +public sealed class VehicleEntry +{ + public string? Uuid { get; set; } + public List Groups { get; } = new(); + public List Textures { get; } = new(); + public List Sets { get; } = new(); +} + +public sealed class VehicleGroup +{ + public string Id { get; set; } = ""; + public string? Category { get; set; } + public string? Mini { get; set; } + + public bool Archived { get; set; } +} + +public sealed class VehicleTexture +{ + public string? Uuid { get; set; } + public string Directory { get; set; } = ""; + public string Skinfile { get; set; } = ""; + public string? Model { get; set; } + public string? Group { get; set; } + public string? MiniRef { get; set; } + public string? TextureMini { get; set; } + + public bool Wreck { get; set; } + + public List Aliases { get; } = new(); + public VehicleMeta? Meta { get; set; } + + public string FullPath => Directory + Skinfile; + + public string ResolvedClass { get; internal set; } = ""; + + public string? ResolvedCategory { get; internal set; } + + public bool ResolvedArchived { get; internal set; } +} + +public sealed class VehicleAlias +{ + public string? Model { get; set; } + public string? Group { get; set; } + public string? MiniRef { get; set; } + public string? TextureMini { get; set; } +} + +public sealed class VehicleMeta +{ + public string? Raw { get; set; } + public string? Version { get; set; } + public string? Vehicle { get; set; } + public string? Operator { get; set; } + public string? Depot { get; set; } + public string? RevisionDate { get; set; } + public string? RevisionPlace { get; set; } + public string? TextureAuthor { get; set; } + public string? PhotoAuthor { get; set; } + public List Extra { get; } = new(); +} + +public sealed class VehicleSet +{ + public string? Uuid { get; set; } + public string? Mode { get; set; } + public int Count { get; set; } + public List TextureRefs { get; set; } = new(); +} diff --git a/StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs b/StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs new file mode 100644 index 0000000..e1c8e5c --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Adapters; + +public sealed class FileDiagnosticsLog : IDiagnosticsLog +{ + private readonly IFileSystem _files; + private readonly IClock _clock; + private readonly string _logPath; + + private readonly object _gate = new(); + private readonly List _lines = new(); + + private StreamWriter? _writer; + private bool _writerFailed; + + public FileDiagnosticsLog(IFileSystem files, IClock clock, IGamePaths paths) + { + _files = files; + _clock = clock; + _logPath = paths.DiagnosticsLog; + } + + public string LogPath => _logPath; + + public string Text + { + get { lock (_gate) return string.Join(Environment.NewLine, _lines); } + } + + public void Log(string message) + { + string line = $"{_clock.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)} {message}"; + lock (_gate) + { + _lines.Add(line); + Append(line); + } + } + + public void Log(string context, Exception exception) => + Log($"{context}: {exception.GetType().Name}: {exception.Message}"); + + public void Log(IEnumerable messages) + { + foreach (string message in messages) + Log(message); + } + + public void Clear() + { + lock (_gate) + { + _lines.Clear(); + CloseWriter(); + try + { + if (_files.FileExists(_logPath)) + _files.DeleteFile(_logPath); + } + catch + { + } + } + } + + public void Flush() + { + lock (_gate) + { + if (_lines.Count == 0) + return; + + try + { + _writer?.Flush(); + } + catch (Exception ex) + { + _writerFailed = true; + _lines.Add($"log flush failed: {ex.GetType().Name}: {ex.Message}"); + } + + if (!_writerFailed && _writer is not null) + return; + + try + { + _files.CreateDirectory(Path.GetDirectoryName(_logPath)!); + _files.WriteAllLines(_logPath, _lines); + } + catch + { + } + } + } + + private void Append(string line) + { + if (_writerFailed) + return; + + try + { + if (_writer is null) + { + _files.CreateDirectory(Path.GetDirectoryName(_logPath)!); + _writer = new StreamWriter(_logPath, append: false) { AutoFlush = true }; + } + _writer.WriteLine(line); + } + catch + { + _writerFailed = true; + CloseWriter(); + } + } + + private void CloseWriter() + { + try + { + _writer?.Dispose(); + } + catch + { + } + _writer = null; + } +} diff --git a/StarterNG/Infrastructure/Adapters/GamePaths.cs b/StarterNG/Infrastructure/Adapters/GamePaths.cs new file mode 100644 index 0000000..3dd5c67 --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/GamePaths.cs @@ -0,0 +1,36 @@ +using System.IO; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Adapters; + +public sealed class GamePaths : IGamePaths +{ + public GamePaths(string root) + { + Root = root; + } + + public static GamePaths ForCurrentDirectory() => new(Directory.GetCurrentDirectory()); + + public string Root { get; } + + public string Scenery => Path.Combine(Root, "scenery"); + + public string Dynamic => Path.Combine(Root, "dynamic"); + + public string MiniTextures => Path.Combine(Root, "textures", "mini"); + + public string Data => Path.Combine(Root, "data"); + + public string StarterConfig => Path.Combine(Root, "starter"); + + public string DiagnosticsLog => Path.Combine(StarterConfig, "bledy.txt"); + + public string FromRoot(params string[] segments) + { + string result = Root; + foreach (string segment in segments) + result = Path.Combine(result, segment); + return result; + } +} diff --git a/StarterNG/Infrastructure/Adapters/LegacyText.cs b/StarterNG/Infrastructure/Adapters/LegacyText.cs new file mode 100644 index 0000000..991b023 --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/LegacyText.cs @@ -0,0 +1,37 @@ +using System; +using System.Text; + +namespace StarterNG.Infrastructure.Adapters; + +/// +/// The code page every file the simulator owns is written in: sceneries, +/// textures.txt, .fiz, timetables, eu07.ini. +/// +/// +/// Code page 1250 is not built into .NET; it arrives with +/// , which has to be registered before the +/// first GetEncoding call. Doing that registration at each call site is how +/// Polish characters end up as Latin-1 mojibake ("Skład" as "Sk³ad"): whichever +/// site runs first wins, and the composition root builds its repositories before +/// any of them. One property, resolved once, removes the ordering question. +/// +public static class LegacyText +{ + public static Encoding CodePage1250 { get; } = Resolve(); + + public static bool IsFallback { get; private set; } + + private static Encoding Resolve() + { + try + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding(1250); + } + catch (Exception) + { + IsFallback = true; + return Encoding.Latin1; + } + } +} diff --git a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs new file mode 100644 index 0000000..3c3effe --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Adapters; + +public sealed class PhysicalFileSystem : IFileSystem +{ + public bool FileExists(string path) => File.Exists(path); + + public bool DirectoryExists(string path) => Directory.Exists(path); + + public string ReadAllText(string path) => File.ReadAllText(path); + + public string ReadAllText(string path, Encoding encoding) => File.ReadAllText(path, encoding); + + public string[] ReadAllLines(string path) => File.ReadAllLines(path); + + public Stream OpenRead(string path) => File.OpenRead(path); + + public void WriteAllText(string path, string contents) => File.WriteAllText(path, contents); + + public void WriteAllText(string path, string contents, Encoding encoding) => + File.WriteAllText(path, contents, encoding); + + public void WriteAllLines(string path, IEnumerable lines) => File.WriteAllLines(path, lines); + + public void WriteAllBytes(string path, byte[] bytes) => File.WriteAllBytes(path, bytes); + + public void CreateDirectory(string path) => Directory.CreateDirectory(path); + + public void DeleteFile(string path) => File.Delete(path); + + public void CopyFile(string sourcePath, string destinationPath, bool overwrite) => + File.Copy(sourcePath, destinationPath, overwrite); + + public IReadOnlyList GetFiles(string path, string searchPattern = "*") => + Directory.Exists(path) ? Directory.GetFiles(path, searchPattern) : Array.Empty(); + + public IReadOnlyList GetFilesRecursive(string path, string searchPattern) => + Directory.Exists(path) + ? Directory.GetFiles(path, searchPattern, SearchOption.AllDirectories) + : Array.Empty(); + + public IReadOnlyList GetDirectories(string path) => + Directory.Exists(path) ? Directory.GetDirectories(path) : Array.Empty(); + + public DateTime GetLastWriteTimeUtc(string path) => File.GetLastWriteTimeUtc(path); + + public bool IsExecutable(string path) + { + if (OperatingSystem.IsWindows()) + return true; + + const UnixFileMode anyExecute = + UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + return (File.GetUnixFileMode(path) & anyExecute) != 0; + } +} diff --git a/StarterNG/Infrastructure/Adapters/SystemClock.cs b/StarterNG/Infrastructure/Adapters/SystemClock.cs new file mode 100644 index 0000000..225e73b --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/SystemClock.cs @@ -0,0 +1,11 @@ +using System; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Adapters; + +public sealed class SystemClock : IClock +{ + public DateTime Now => DateTime.Now; + + public DateTime UtcNow => DateTime.UtcNow; +} diff --git a/StarterNG/Infrastructure/Adapters/SystemEnvironment.cs b/StarterNG/Infrastructure/Adapters/SystemEnvironment.cs new file mode 100644 index 0000000..24e9ded --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/SystemEnvironment.cs @@ -0,0 +1,17 @@ +using System; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Adapters; + +public sealed class SystemEnvironment : IEnvironment +{ + public bool IsWindows => OperatingSystem.IsWindows(); + + public bool IsLinux => OperatingSystem.IsLinux(); + + public bool IsMacOS => OperatingSystem.IsMacOS(); + + public string? GetVariable(string name) => Environment.GetEnvironmentVariable(name); + + public string BaseDirectory => AppContext.BaseDirectory; +} diff --git a/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs new file mode 100644 index 0000000..c72eae0 --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Adapters; + +public sealed class SystemProcessLauncher : IProcessLauncher +{ + private readonly IDiagnosticsLog _log; + + public SystemProcessLauncher(IDiagnosticsLog log) + { + _log = log; + } + + public IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory, + out string? error) + { + var info = new ProcessStartInfo + { + FileName = executablePath, + WorkingDirectory = workingDirectory ?? string.Empty, + UseShellExecute = false + }; + foreach (string argument in arguments) + info.ArgumentList.Add(argument); + + try + { + var process = Process.Start(info); + if (process is null) + { + error = executablePath; + _log.Log($"start {executablePath}: process not created"); + return null; + } + + error = null; + return new ProcessHandle(process); + } + catch (Exception ex) + { + error = ex.Message; + _log.Log($"start {executablePath}", ex); + return null; + } + } + + public bool OpenInShell(string pathOrUrl) + { + try + { + Process.Start(new ProcessStartInfo(pathOrUrl) { UseShellExecute = true }); + return true; + } + catch (Exception ex) + { + _log.Log($"open {pathOrUrl}", ex); + return false; + } + } + + public IProcessHandle? FindRunning(string processName) + { + try + { + var process = Process.GetProcessesByName(processName).FirstOrDefault(); + return process is null ? null : new ProcessHandle(process); + } + catch (Exception ex) + { + _log.Log($"lookup {processName}", ex); + return null; + } + } + + private sealed class ProcessHandle : IProcessHandle + { + private readonly Process _process; + + public ProcessHandle(Process process) + { + _process = process; + } + + public bool HasExited + { + get + { + try { return _process.HasExited; } + catch { return true; } + } + } + + public Task WaitForExitAsync(CancellationToken cancellationToken = default) => + _process.WaitForExitAsync(cancellationToken); + } +} diff --git a/StarterNG/Infrastructure/Adapters/SystemRandomSource.cs b/StarterNG/Infrastructure/Adapters/SystemRandomSource.cs new file mode 100644 index 0000000..b5300dd --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/SystemRandomSource.cs @@ -0,0 +1,11 @@ +using System; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Adapters; + +public sealed class SystemRandomSource : IRandomSource +{ + public int Next(int minInclusive, int maxExclusive) => Random.Shared.Next(minInclusive, maxExclusive); + + public double NextDouble() => Random.Shared.NextDouble(); +} diff --git a/StarterNG/Infrastructure/Diagnostics.cs b/StarterNG/Infrastructure/Diagnostics.cs new file mode 100644 index 0000000..2696797 --- /dev/null +++ b/StarterNG/Infrastructure/Diagnostics.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using StarterNG.Application; +using StarterNG.Views; + +namespace StarterNG.Infrastructure; + +public static class Diagnostics +{ + private static Application.Abstractions.IDiagnosticsLog Log_ => AppServices.Current.Log; + + public static string LogPath => AppServices.Current.Paths.DiagnosticsLog; + + public static string Text => Log_.Text; + + public static void Clear() => Log_.Clear(); + + public static void Log(string message) => Log_.Log(message); + + public static void Log(IEnumerable messages) => Log_.Log(messages); + + public static void Log(string context, Exception ex) => Log_.Log(context, ex); + + public static void Flush() => Log_.Flush(); + + private static Window? Owner => + (Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime) + ?.MainWindow; + + public static Task ReportAsync(string message, string? title = null) + { + Log_.Log(message.Replace(Environment.NewLine, " | ")); + + var owner = Owner; + if (owner is null) + return Task.CompletedTask; + + return MessageBox.Show(owner, message, title ?? App.Loc["FaultTitle"], MessageBoxButtons.Ok); + } + + public static Task ReportAsync(string context, Exception ex, string? title = null) => + ReportAsync($"{context}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {ex.Message}", title); + + public static void ReportOnUiThread(string message, string? title = null) + { + if (Dispatcher.UIThread.CheckAccess()) + _ = ReportAsync(message, title); + else + Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title)); + } + + public static List CheckInstallation() => + new InstallationCheck( + AppServices.Current.Paths, + AppServices.Current.Files, + AppServices.Current.Localization, + AppServices.Current.Physics, + AppServices.Current.Library, + AppServices.Current.SettingsStore) + .Run(); +} diff --git a/StarterNG/Infrastructure/DiagnosticsLogSink.cs b/StarterNG/Infrastructure/DiagnosticsLogSink.cs new file mode 100644 index 0000000..3d629a5 --- /dev/null +++ b/StarterNG/Infrastructure/DiagnosticsLogSink.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Avalonia.Logging; + +namespace StarterNG.Infrastructure; + +public sealed class DiagnosticsLogSink : ILogSink +{ + private const int MaxDistinctMessages = 200; + + private readonly LogEventLevel _minimumLevel; + private readonly HashSet _seen = new(StringComparer.Ordinal); + private readonly object _gate = new(); + private bool _capped; + + private DiagnosticsLogSink(LogEventLevel minimumLevel) => _minimumLevel = minimumLevel; + + public static void Install(LogEventLevel minimumLevel = LogEventLevel.Warning) => + Logger.Sink = new DiagnosticsLogSink(minimumLevel); + + public bool IsEnabled(LogEventLevel level, string area) => level >= _minimumLevel; + + public void Log(LogEventLevel level, string area, object? source, string messageTemplate) => + Write(level, area, source, messageTemplate, null); + + public void Log(LogEventLevel level, string area, object? source, string messageTemplate, + params object?[] propertyValues) => + Write(level, area, source, messageTemplate, propertyValues); + + private void Write(LogEventLevel level, string area, object? source, string messageTemplate, + object?[]? propertyValues) + { + if (!IsEnabled(level, area)) + return; + + string line = $"[Avalonia/{area}/{level}] {Format(messageTemplate, propertyValues)}"; + if (source is not null) + line += $" (source: {source.GetType().Name})"; + + lock (_gate) + { + if (_capped || !_seen.Add(line)) + return; + if (_seen.Count >= MaxDistinctMessages) + { + _capped = true; + Diagnostics.Log(line); + Diagnostics.Log($"[Avalonia] {MaxDistinctMessages} distinct messages logged, muting the rest"); + return; + } + } + + Diagnostics.Log(line); + } + + private static string Format(string template, object?[]? values) + { + if (values is null || values.Length == 0 || template.IndexOf('{') < 0) + return template; + + var sb = new StringBuilder(template.Length + 32); + int next = 0; + for (int i = 0; i < template.Length; i++) + { + if (template[i] != '{') + { + sb.Append(template[i]); + continue; + } + + int close = template.IndexOf('}', i); + if (close < 0) + { + sb.Append(template, i, template.Length - i); + break; + } + + sb.Append(next < values.Length ? values[next]?.ToString() ?? "null" : template[i..(close + 1)]); + next++; + i = close; + } + return sb.ToString(); + } +} diff --git a/StarterNG/Infrastructure/DropGap.cs b/StarterNG/Infrastructure/DropGap.cs new file mode 100644 index 0000000..3c5bc82 --- /dev/null +++ b/StarterNG/Infrastructure/DropGap.cs @@ -0,0 +1,67 @@ +using System; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace StarterNG.Infrastructure; + +public sealed class DropGap +{ + private readonly Border _gap; + private int _index = -1; + + public DropGap(int milliseconds = 130) + { + _gap = new Border + { + Width = 0, + IsHitTestVisible = false, + Transitions = new Transitions + { + new DoubleTransition + { + Property = Layoutable.WidthProperty, + Duration = TimeSpan.FromMilliseconds(milliseconds), + Easing = new CubicEaseOut() + } + } + }; + } + + public bool IsGap(Control control) => ReferenceEquals(control, _gap); + + public void Show(Panel panel, int childIndex, double width) + { + int target = Math.Clamp(childIndex, 0, ChildCountWithoutGap(panel)); + + if (_index != target) + { + panel.Children.Remove(_gap); + panel.Children.Insert(Math.Clamp(target, 0, panel.Children.Count), _gap); + _index = target; + } + + _gap.Width = Math.Max(width, 8); + } + + public void Hide(Panel panel) + { + if (_index < 0) return; + + _gap.Width = 0; + panel.Children.Remove(_gap); + _index = -1; + } + + public void Forget() => _index = -1; + + private int ChildCountWithoutGap(Panel panel) + { + int count = panel.Children.Count; + foreach (var child in panel.Children) + if (ReferenceEquals(child, _gap)) + return count - 1; + return count; + } +} diff --git a/StarterNG/Infrastructure/DropIndexTracker.cs b/StarterNG/Infrastructure/DropIndexTracker.cs new file mode 100644 index 0000000..2d0ff54 --- /dev/null +++ b/StarterNG/Infrastructure/DropIndexTracker.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; + +namespace StarterNG.Infrastructure; + +public sealed class DropIndexTracker +{ + private const double Hysteresis = 14; + private static readonly TimeSpan Quiet = TimeSpan.FromMilliseconds(60); + + private readonly List _midpoints = new(); + private DateTime _changedAt = DateTime.MinValue; + + public int Index { get; private set; } = -1; + + public void Capture(IEnumerable midpoints) + { + _midpoints.Clear(); + _midpoints.AddRange(midpoints); + Index = -1; + _changedAt = DateTime.MinValue; + } + + public void Reset() + { + _midpoints.Clear(); + Index = -1; + } + + public bool Update(double x) + { + int candidate = Index; + + if (candidate < 0) + { + candidate = 0; + while (candidate < _midpoints.Count && x > _midpoints[candidate]) + candidate++; + } + else + { + while (candidate < _midpoints.Count && x > _midpoints[candidate] + Hysteresis) + candidate++; + while (candidate > 0 && x < _midpoints[candidate - 1] - Hysteresis) + candidate--; + } + + if (candidate == Index) + return false; + + var now = DateTime.UtcNow; + if (Index >= 0 && now - _changedAt < Quiet) + return false; + + _changedAt = now; + Index = candidate; + return true; + } +} diff --git a/StarterNG/Infrastructure/EdgeScroller.cs b/StarterNG/Infrastructure/EdgeScroller.cs new file mode 100644 index 0000000..8f662d5 --- /dev/null +++ b/StarterNG/Infrastructure/EdgeScroller.cs @@ -0,0 +1,63 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Threading; + +namespace StarterNG.Infrastructure; + +public sealed class EdgeScroller +{ + private const double Zone = 56; + private const double MaxStep = 18; + + private readonly DispatcherTimer _timer; + private ScrollViewer? _target; + private double _step; + + public EdgeScroller() + { + _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + _timer.Tick += (_, _) => Step(); + } + + public void Update(ScrollViewer scroll, double pointerX) + { + double width = scroll.Viewport.Width; + + _step = pointerX < Zone + ? -MaxStep * Math.Clamp((Zone - pointerX) / Zone, 0, 1) + : pointerX > width - Zone + ? MaxStep * Math.Clamp((pointerX - (width - Zone)) / Zone, 0, 1) + : 0; + + _target = scroll; + + if (_step == 0) + _timer.Stop(); + else if (!_timer.IsEnabled) + _timer.Start(); + } + + public void Stop() + { + _timer.Stop(); + _target = null; + _step = 0; + } + + private void Step() + { + if (_target is null || _step == 0) + { + _timer.Stop(); + return; + } + + double max = Math.Max(0, _target.Extent.Width - _target.Viewport.Width); + double x = Math.Clamp(_target.Offset.X + _step, 0, max); + if (Math.Abs(x - _target.Offset.X) < 0.01) + return; + + _target.Offset = new Vector(x, _target.Offset.Y); + } +} diff --git a/StarterNG/Infrastructure/InstallationCheck.cs b/StarterNG/Infrastructure/InstallationCheck.cs new file mode 100644 index 0000000..0c0bb0c --- /dev/null +++ b/StarterNG/Infrastructure/InstallationCheck.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.IO; +using StarterNG.Application.Abstractions; +using StarterNG.Classes; +using StarterNG.Application; +using StarterNG.Domain.Settings; + +namespace StarterNG.Infrastructure; + +public sealed class InstallationCheck +{ + private static readonly string[] RequiredFolders = { "dynamic", "sounds", "models", "scenery", "textures" }; + + private readonly IGamePaths _paths; + private readonly IFileSystem _files; + private readonly ILocalizedStrings _strings; + private readonly IPhysicsRepository _physics; + private readonly GameLibrary _library; + private readonly SettingsStore _settings; + + public InstallationCheck(IGamePaths paths, IFileSystem files, ILocalizedStrings strings, + IPhysicsRepository physics, GameLibrary library, SettingsStore settings) + { + _paths = paths; + _files = files; + _strings = strings; + _physics = physics; + _library = library; + _settings = settings; + } + + public List Run() + { + var faults = new List(); + + foreach (string folder in RequiredFolders) + if (!_files.DirectoryExists(_paths.FromRoot(folder))) + faults.Add(string.Format(_strings["FaultNoDir"], "/" + folder)); + + if (!_files.FileExists(Path.Combine(_paths.Data, "load_weights.txt"))) + faults.Add(_strings["FaultNoWeights"]); + + if (_library.Sceneries.Count == 0) + faults.Add(_strings["FaultNoScenery"]); + + if (_library.Vehicles.Textures.Count == 0) + faults.Add(_strings["FaultNoVehicles"]); + + if (_physics.IndexedCount == 0) + faults.Add(_strings["FaultNoPhysics"]); + + _settings.ResolveExecutable(out var problem); + if (problem == ExeProblem.NotFound) + faults.Add(_strings["FaultNoExe"]); + + return faults; + } +} diff --git a/StarterNG/Infrastructure/LoadingScreen.cs b/StarterNG/Infrastructure/LoadingScreen.cs new file mode 100644 index 0000000..97ea515 --- /dev/null +++ b/StarterNG/Infrastructure/LoadingScreen.cs @@ -0,0 +1,123 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace StarterNG.Infrastructure; + +public static class LoadingScreen +{ + public static void Prepare(string? logoKey, string? sceneryName = null) + { + try + { + string? src = ResolveSource(logoKey) ?? ResolveSource(sceneryName) ?? PickRandom(); + if (src is null) return; + + string destDir = Path.Combine(Directory.GetCurrentDirectory(), "textures"); + Directory.CreateDirectory(destDir); + string dest = Path.Combine(destDir, "logo.bmp"); + + if (src.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) + { + File.Copy(src, dest, overwrite: true); + return; + } + + WriteBmp(src, dest); + } + catch (Exception ex) + { + Diagnostics.Log("Loading screen logo", ex); + } + } + + private static string? ResolveSource(string? key) + { + if (string.IsNullOrWhiteSpace(key)) return null; + key = Path.GetFileNameWithoutExtension(key.Trim()); + string dir = Path.Combine(Directory.GetCurrentDirectory(), "textures", "logo"); + foreach (string ext in new[] { ".bmp", ".jpg", ".jpeg", ".png" }) + { + string path = Path.Combine(dir, key + ext); + if (File.Exists(path)) return path; + } + return null; + } + + private static string? PickRandom() + { + string dir = Path.Combine(Directory.GetCurrentDirectory(), "textures", "logo"); + if (!Directory.Exists(dir)) return null; + var files = Directory.GetFiles(dir, "logo*.*") + .Where(f => + { + string e = Path.GetExtension(f).ToLowerInvariant(); + return e is ".bmp" or ".jpg" or ".jpeg" or ".png"; + }) + .ToArray(); + if (files.Length == 0) return null; + return files[Random.Shared.Next(files.Length)]; + } + + private static void WriteBmp(string sourcePath, string destPath) + { + using var src = new Bitmap(sourcePath); + int w = src.PixelSize.Width; + int h = src.PixelSize.Height; + if (w <= 0 || h <= 0) return; + + var wb = new WriteableBitmap(src.PixelSize, src.Dpi, PixelFormat.Bgra8888, AlphaFormat.Opaque); + using var fb = wb.Lock(); + src.CopyPixels(new PixelRect(0, 0, w, h), fb.Address, fb.RowBytes * h, fb.RowBytes); + + int srcStride = fb.RowBytes; + var pixels = new byte[srcStride * h]; + Marshal.Copy(fb.Address, pixels, 0, pixels.Length); + + int rowStride = ((w * 3 + 3) / 4) * 4; + int pixelBytes = rowStride * h; + var file = new byte[54 + pixelBytes]; + + file[0] = (byte)'B'; file[1] = (byte)'M'; + WriteInt32(file, 2, file.Length); + WriteInt32(file, 10, 54); + WriteInt32(file, 14, 40); + WriteInt32(file, 18, w); + WriteInt32(file, 22, h); + WriteInt16(file, 26, 1); + WriteInt16(file, 28, 24); + WriteInt32(file, 34, pixelBytes); + + for (int y = 0; y < h; y++) + { + int destY = h - 1 - y; + int destOff = 54 + destY * rowStride; + int srcOff = y * srcStride; + for (int x = 0; x < w; x++) + { + int si = srcOff + x * 4; + int di = destOff + x * 3; + file[di] = pixels[si]; + file[di + 1] = pixels[si + 1]; + file[di + 2] = pixels[si + 2]; + } + } + + File.WriteAllBytes(destPath, file); + } + + private static void WriteInt32(byte[] b, int o, int v) + { + b[o] = (byte)v; b[o + 1] = (byte)(v >> 8); + b[o + 2] = (byte)(v >> 16); b[o + 3] = (byte)(v >> 24); + } + + private static void WriteInt16(byte[] b, int o, int v) + { + b[o] = (byte)v; b[o + 1] = (byte)(v >> 8); + } +} diff --git a/StarterNG/Infrastructure/MonoFont.cs b/StarterNG/Infrastructure/MonoFont.cs new file mode 100644 index 0000000..a3dfdda --- /dev/null +++ b/StarterNG/Infrastructure/MonoFont.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Media; + +namespace StarterNG.Infrastructure; + +public static class MonoFont +{ + private static readonly string[] Preferred = + { + "Consolas", + "Menlo", + "SF Mono", + "DejaVu Sans Mono", + "Liberation Mono", + "Noto Sans Mono", + "Ubuntu Mono", + "Courier New" + }; + + public static FontFamily Family { get; } = Resolve(); + + private static FontFamily Resolve() + { + try + { + var installed = new HashSet( + FontManager.Current.SystemFonts.Select(f => f.Name), + StringComparer.OrdinalIgnoreCase); + + foreach (string name in Preferred) + if (installed.Contains(name)) + return new FontFamily(name); + + string? anyMono = installed.FirstOrDefault( + n => n.Contains("Mono", StringComparison.OrdinalIgnoreCase)); + if (anyMono is not null) + return new FontFamily(anyMono); + } + catch + { + } + + return FontFamily.Default; + } +} diff --git a/StarterNG/Infrastructure/Sceneries/SceneryImageLocator.cs b/StarterNG/Infrastructure/Sceneries/SceneryImageLocator.cs new file mode 100644 index 0000000..dba90fe --- /dev/null +++ b/StarterNG/Infrastructure/Sceneries/SceneryImageLocator.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.IO; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Infrastructure.Sceneries; + +public sealed class SceneryImageLocator +{ + private readonly IFileSystem _files; + private readonly Dictionary _cache = new(); + + public SceneryImageLocator(IFileSystem files) + { + _files = files; + } + + public string? Resolve(Scenery scenery) + { + lock (_cache) + { + if (_cache.TryGetValue(scenery.Path, out string? cached)) + return cached; + + string? resolved = Search(scenery); + _cache[scenery.Path] = resolved; + return resolved; + } + } + + private string? Search(Scenery scenery) + { + if (string.IsNullOrWhiteSpace(scenery.ImageName)) + return null; + + string name = scenery.ImageName.Replace('\\', '/').Trim(); + string sceneryDir = Path.GetDirectoryName(scenery.Path) ?? "."; + string root = Path.GetDirectoryName(sceneryDir) ?? "."; + + string[] candidates = + { + name, + Path.Combine(root, name), + Path.Combine(sceneryDir, name), + Path.Combine(sceneryDir, "images", name), + Path.Combine(root, "scenery", "images", name) + }; + + foreach (string candidate in candidates) + if (_files.FileExists(candidate)) + return candidate; + + return null; + } +} diff --git a/StarterNG/Infrastructure/Sceneries/SceneryParser.cs b/StarterNG/Infrastructure/Sceneries/SceneryParser.cs new file mode 100644 index 0000000..788062a --- /dev/null +++ b/StarterNG/Infrastructure/Sceneries/SceneryParser.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +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; + +public sealed class SceneryParser +{ + private readonly IClock _clock; + private readonly IRandomSource _random; + + public SceneryParser(IClock clock, IRandomSource random) + { + _clock = clock; + _random = random; + } + + public Scenery Parse(string path, string content) + { + string original = content; + + var faults = new List(); + var attachments = ParseAttachments(original); + + var includes = new List(); + content = StripOptionalIncludes(content, includes); + + var trainsetEntries = new List(); + content = Regex.Replace(content, @"trainset\b[\s\S]*?\bendtrainset\b", + match => + { + trainsetEntries.Add(match.Value); + return $"{{{{{trainsetEntries.Count - 1}}}}}"; + }, + RegexOptions.IgnoreCase); + + var looseVehicles = new List(); + var looseNames = new List(); + content = ExtractLooseVehicles(content, looseVehicles, looseNames, faults); + + var scenery = new Scenery(path, content) + { + Group = MatchDirective(original, "l"), + Name = MatchDirective(original, "n"), + Description = MatchAllDirectives(original, "d"), + ImageName = MatchDirective(original, "i"), + Archival = MatchDirective(original, "a") != null + }; + + scenery.Attachments.AddRange(attachments); + scenery.Includes.AddRange(includes); + scenery.LooseVehicles.AddRange(looseVehicles); + scenery.LooseVehicleNames.AddRange(looseNames); + scenery.Faults.AddRange(faults); + + ParseWeather(original, 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 List ParseAttachments(string content) + { + var attachments = new List(); + + foreach (Match m in Regex.Matches(content, @"^//\$f\b[ \t]*([^\r\n]*)", RegexOptions.Multiline)) + { + string rest = m.Groups[1].Value.Trim(); + if (rest.Length == 0) + continue; + + string[] parts = rest.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length >= 3 && parts[0].Length <= 2) + attachments.Add(new SceneryAttachment + { + FilePath = parts[1], + Label = string.Join(" ", parts.Skip(2)) + }); + else if (parts.Length >= 2) + attachments.Add(new SceneryAttachment + { + FilePath = parts[0], + Label = string.Join(" ", parts.Skip(1)) + }); + else + attachments.Add(new SceneryAttachment + { + FilePath = parts[0], + Label = System.IO.Path.GetFileName(parts[0]) + }); + } + + return attachments; + } + + private static string StripOptionalIncludes(string content, List includes) + { + if (content.IndexOf("$optional", StringComparison.OrdinalIgnoreCase) < 0) + return content; + + return Regex.Replace( + content, + @"include\s+(\S+(?:\s+\S+)*?)\s+end\s*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b", + m => + { + string includePath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " "); + string raw = m.Groups[2].Value.Trim().TrimStart(',', ' '); + string[] parameters = raw.Split(new[] { ',', '|', ';' }, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + int kind = 1; + string description = includePath; + if (parameters.Length >= 1 && int.TryParse(parameters[0], out int parsedKind)) + kind = parsedKind; + if (parameters.Length >= 2) + description = parameters[1]; + + includes.Add(new SceneryInclude + { + FilePath = includePath, + Desc = description, + Kind = kind, + Selected = kind == 1 + }); + return " "; + }, + RegexOptions.IgnoreCase); + } + + private static string ExtractLooseVehicles(string content, List vehicles, List names, + List faults) + { + var inv = CultureInfo.InvariantCulture; + + return Regex.Replace( + content, + @"(?is)\bnode\s+(\S+)\s+(\S+)\s+(\S+)\s+dynamic\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)((?:\s+(?!enddynamic)\S+)*)\s*enddynamic\b", + m => + { + string name = m.Groups[3].Value; + + if (name.StartsWith("{{", StringComparison.Ordinal)) + return m.Value; + + try + { + var 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; + } + }); + } + + private void ParseWeather(string content, SceneryWeather weather) + { + var time = Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b"); + if (time.Success) + { + weather.Time = $"{time.Groups[1].Value.PadLeft(2, '0')}:{time.Groups[2].Value}"; + weather.ScenarioTimeOverride = weather.Time; + } + + var over = Regex.Match(content, @"(?i)scenario\.time\.override\s+(\d{1,2})[:.](\d{2})"); + if (over.Success) + { + weather.ScenarioTimeOverride = $"{over.Groups[1].Value.PadLeft(2, '0')}:{over.Groups[2].Value}"; + weather.IsAuthored = true; + } + else if (!time.Success) + { + var now = _clock.Now; + weather.ScenarioTimeOverride = $"{now.Hour:D2}:{now.Minute:D2}"; + } + + var move = Regex.Match(content, @"(?im)\bmovelight\s+(-?\d+)"); + if (move.Success && int.TryParse(move.Groups[1].Value, out int day)) + { + weather.Day = day; + weather.IsAuthored = true; + } + + var temperature = Regex.Match(content, @"(?i)scenario\.weather\.temperature\s+(-?\d+(?:\.\d+)?)"); + if (temperature.Success && + double.TryParse(temperature.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, + out double celsius)) + { + weather.Temperature = celsius; + weather.IsAuthored = true; + } + + var atmo = Regex.Match(content, @"(?is)\batmo\b(.*?)\bendatmo\b"); + if (!atmo.Success) + return; + + var numbers = Regex.Matches(atmo.Groups[1].Value, @"-?\d+(?:\.\d+)?") + .Select(m => m.Value) + .ToList(); + + if (numbers.Count >= 5 && + int.TryParse(numbers[3], out int fogStart) && + int.TryParse(numbers[4], out int fogEnd)) + { + weather.SetFogRange(fogStart, fogEnd, _random); + weather.IsAuthored = true; + } + + if (numbers.Count >= 6 && + double.TryParse(numbers[^1], NumberStyles.Float, CultureInfo.InvariantCulture, out double overcast)) + weather.Overcast = overcast; + } + + private static string? MatchAllDirectives(string content, string letter) + { + var matches = Regex.Matches(content, @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)", RegexOptions.Multiline); + return matches.Count == 0 + ? null + : string.Join("\n", matches.Select(m => m.Groups[1].Value.TrimEnd())); + } + + private static string? MatchDirective(string content, string letter) + { + var match = Regex.Match(content, @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)", RegexOptions.Multiline); + return match.Success ? match.Groups[1].Value.Trim() : null; + } +} diff --git a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs new file mode 100644 index 0000000..5da5dee --- /dev/null +++ b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs @@ -0,0 +1,77 @@ +using System; +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.Sceneries; +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Infrastructure.Sceneries; + +public sealed class SceneryRepository : ISceneryRepository +{ + private const char ExcludedPrefix = '$'; + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + private readonly IDiagnosticsLog _log; + private readonly SceneryParser _parser; + private readonly Encoding _encoding; + + public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser) + { + _files = files; + _paths = paths; + _log = log; + _parser = parser; + _encoding = LegacyText.CodePage1250; + } + + public IReadOnlyList LoadAll(IProgress? progress = null) + { + var files = SceneryFiles().ToList(); + var loaded = new ConcurrentBag(); + int total = Math.Max(1, files.Count); + int done = 0; + + Parallel.ForEach(files, + new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, + file => + { + var scenery = Load(file); + if (scenery is not null) + loaded.Add(scenery); + + progress?.Report(new SceneryLoadProgress(Interlocked.Increment(ref done), total, + Path.GetFileName(file))); + }); + + return loaded.ToList(); + } + + public Scenery? Load(string path) + { + try + { + if (!_files.FileExists(path)) + return null; + + var scenery = _parser.Parse(path, _files.ReadAllText(path, _encoding)); + scenery.HasCompanionTimetable = _files.FileExists(Path.ChangeExtension(path, ".sbt")); + return scenery; + } + catch (Exception ex) + { + _log.Log($"scenery/{Path.GetFileName(path)}", ex); + return null; + } + } + + private IEnumerable SceneryFiles() => + _files.GetFiles(_paths.Scenery, "*.scn") + .Where(path => Path.GetFileName(path).FirstOrDefault() != ExcludedPrefix); +} diff --git a/StarterNG/Infrastructure/Sceneries/SceneryTranslations.cs b/StarterNG/Infrastructure/Sceneries/SceneryTranslations.cs new file mode 100644 index 0000000..efcc73d --- /dev/null +++ b/StarterNG/Infrastructure/Sceneries/SceneryTranslations.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Infrastructure.Sceneries; + +public sealed class SceneryTranslations : ISceneryTranslations +{ + private const char ReferencePrefix = '@'; + + private const string BaseLanguage = "en"; + + private readonly IFileSystem _files; + private readonly IDiagnosticsLog _log; + private readonly Dictionary _map = new(StringComparer.OrdinalIgnoreCase); + + public SceneryTranslations(IFileSystem files, IDiagnosticsLog log) + { + _files = files; + _log = log; + } + + public void LoadFor(Scenery scenery, string langCode) + { + _map.Clear(); + + string sceneryDir = Path.GetDirectoryName(Path.GetFullPath(scenery.Path)) ?? "scenery"; + string id = Path.GetFileNameWithoutExtension(scenery.Path); + string i18nDir = Path.Combine(sceneryDir, "i18n"); + string language = NormalizeLanguage(langCode); + + TryLoad(Path.Combine(i18nDir, $"{id}_{BaseLanguage}.json")); + if (!string.Equals(language, BaseLanguage, StringComparison.OrdinalIgnoreCase)) + TryLoad(Path.Combine(i18nDir, $"{id}_{language}.json")); + } + + public string Translate(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return text ?? ""; + + string trimmed = text.TrimStart(); + if (trimmed.Length == 0 || trimmed[0] != ReferencePrefix) + return text; + + string key = trimmed[1..].Trim(); + return _map.TryGetValue(key, out string? translated) ? translated : text; + } + + private void TryLoad(string path) + { + if (!_files.FileExists(path)) + return; + + try + { + using var document = JsonDocument.Parse(_files.ReadAllText(path)); + if (document.RootElement.ValueKind == JsonValueKind.Object) + Flatten("", document.RootElement); + } + catch (Exception ex) + { + _log.Log($"scenery i18n/{Path.GetFileName(path)}", ex); + } + } + + private void Flatten(string prefix, JsonElement element) + { + foreach (var property in element.EnumerateObject()) + { + string key = string.IsNullOrEmpty(prefix) ? property.Name : prefix + "." + property.Name; + switch (property.Value.ValueKind) + { + case JsonValueKind.String: + _map[key] = property.Value.GetString() ?? ""; + break; + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + _map[key] = property.Value.ToString(); + break; + case JsonValueKind.Object: + Flatten(key, property.Value); + break; + } + } + } + + private static string NormalizeLanguage(string language) + { + string lower = language.Trim().ToLowerInvariant(); + return lower switch + { + "polski" or "pl" => "pl", + "english" or "en" => "en", + "čeština" or "cesky" or "czech" or "cs" or "cz" => "cz", + "magyar" or "hungarian" or "hu" => "hu", + "русский" or "russian" or "ru" => "ru", + _ => lower.Length >= 2 ? lower[..2] : "en" + }; + } +} diff --git a/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs b/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs new file mode 100644 index 0000000..75496d4 --- /dev/null +++ b/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Linq; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Infrastructure.Sceneries; + +public sealed class TimetableLocator +{ + private const string NoTimetable = "none"; + + private readonly IFileSystem _files; + + public TimetableLocator(IFileSystem files) + { + _files = files; + } + + public string? Resolve(Scenery scenery, string? name) + { + if (string.IsNullOrWhiteSpace(name) || name.Equals(NoTimetable, StringComparison.OrdinalIgnoreCase)) + return null; + + string sceneryDir = Path.GetDirectoryName(scenery.Path) ?? "."; + string root = Path.GetDirectoryName(sceneryDir) ?? "."; + + string[] candidates = + { + Path.Combine(root, "timetables", name + ".txt"), + Path.Combine(sceneryDir, name + ".txt"), + Path.Combine(root, "scenario", name + ".txt"), + Path.Combine(root, "timetables", name), + Path.Combine(sceneryDir, name) + }; + + return candidates.FirstOrDefault(_files.FileExists); + } +} diff --git a/StarterNG/Infrastructure/Settings/ExecutableLocator.cs b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs new file mode 100644 index 0000000..2283eee --- /dev/null +++ b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Settings; + +namespace StarterNG.Infrastructure.Settings; + +public sealed class ExecutableLocator +{ + private static readonly string[] NeverExecutable = + { + ".ini", ".log", ".txt", ".cfg", ".config", ".json", ".dll", ".so", ".dat", ".bak", ".csv", ".xml" + }; + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + private readonly IEnvironment _environment; + private readonly IDiagnosticsLog _log; + + public ExecutableLocator(IFileSystem files, IGamePaths paths, IEnvironment environment, IDiagnosticsLog log) + { + _files = files; + _paths = paths; + _environment = environment; + _log = log; + } + + public string CanonicalName => _environment.IsWindows ? "eu07.exe" : "eu07"; + + public string Resolve(SimulatorSettings settings, out ExeProblem problem) + { + if (!settings.SelectExeAutomatically && !string.IsNullOrWhiteSpace(settings.ExecutablePath)) + { + problem = Validate(FullPath(settings.ExecutablePath)); + return settings.ExecutablePath; + } + + string? fallback = null; + try + { + var candidates = _files.GetFiles(_paths.Root, "eu07*") + .Where(IsCandidate) + .OrderBy(path => string.Equals(Path.GetFileName(path), CanonicalName, + StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(path => Path.GetFileName(path).Length); + + foreach (string path in candidates) + { + if (Validate(path) == ExeProblem.None) + { + problem = ExeProblem.None; + return path; + } + fallback ??= path; + } + } + catch (Exception ex) + { + _log.Log("looking for the simulator executable", ex); + } + + if (fallback is not null) + { + problem = Validate(fallback); + return fallback; + } + + problem = ExeProblem.NotFound; + return CanonicalName; + } + + public List ListCandidates(string? directory = null) + { + try + { + return _files.GetFiles(directory ?? _paths.Root, "eu07*") + .Where(IsCandidate) + .OrderByDescending(_files.GetLastWriteTimeUtc) + .Select(Path.GetFileName) + .ToList()!; + } + catch (Exception) + { + return new List(); + } + } + + public ExeProblem Validate(string path) + { + try + { + if (!_files.FileExists(path)) + return ExeProblem.NotFound; + + if (!_environment.IsWindows && !_files.IsExecutable(path)) + return ExeProblem.NotExecutable; + + return MatchesPlatform(path) ? ExeProblem.None : ExeProblem.WrongPlatform; + } + catch (Exception) + { + return ExeProblem.NotFound; + } + } + + private bool MatchesPlatform(string path) + { + byte[] head = new byte[4]; + using var stream = _files.OpenRead(path); + if (stream.Read(head, 0, 4) != 4) + return true; + + bool portableExecutable = head[0] == 0x4D && head[1] == 0x5A; + bool elf = head[0] == 0x7F && head[1] == 0x45 && head[2] == 0x4C && head[3] == 0x46; + + if (_environment.IsWindows && elf) + return false; + if (_environment.IsLinux && portableExecutable) + return false; + + return true; + } + + private bool IsCandidate(string path) + { + string extension = Path.GetExtension(path).ToLowerInvariant(); + + if (NeverExecutable.Contains(extension)) + return false; + + if (_environment.IsWindows) + return extension == ".exe"; + + if (_environment.IsLinux) + return extension.Length == 0 || extension is ".x86_64" or ".run" or ".appimage"; + + return extension.Length == 0 || extension is ".exe" or ".x86_64" or ".run" or ".appimage"; + } + + private string FullPath(string path) => + Path.IsPathRooted(path) ? path : Path.Combine(_paths.Root, path); +} diff --git a/StarterNG/Infrastructure/Settings/SettingsPaths.cs b/StarterNG/Infrastructure/Settings/SettingsPaths.cs new file mode 100644 index 0000000..d9eba18 --- /dev/null +++ b/StarterNG/Infrastructure/Settings/SettingsPaths.cs @@ -0,0 +1,48 @@ +using System.IO; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Settings; + +public sealed class SettingsPaths +{ + private const string ConfigFileName = "eu07.ini"; + private const string VendorFolder = "MaSzyna"; + + private readonly IEnvironment _environment; + private readonly IGamePaths _paths; + + public SettingsPaths(IEnvironment environment, IGamePaths paths) + { + _environment = environment; + _paths = paths; + } + + public string UserConfigPath() + { + if (_environment.IsWindows) + { + string? appData = _environment.GetVariable("APPDATA"); + if (!string.IsNullOrEmpty(appData)) + return Path.Combine(appData, VendorFolder, ConfigFileName); + } + else if (_environment.IsMacOS) + { + string? home = _environment.GetVariable("HOME"); + if (!string.IsNullOrEmpty(home)) + return Path.Combine(home, "Library", "Application Support", VendorFolder, ConfigFileName); + } + else + { + string? home = _environment.GetVariable("HOME"); + if (!string.IsNullOrEmpty(home)) + return Path.Combine(home, ".config", VendorFolder, ConfigFileName); + } + + return Path.Combine(_environment.BaseDirectory, ConfigFileName); + } + + public string InstallationConfigPath() => _paths.FromRoot(ConfigFileName); + + public string UserConfigDirectory() => + Path.GetDirectoryName(UserConfigPath()) ?? _environment.BaseDirectory; +} diff --git a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs new file mode 100644 index 0000000..498a50f --- /dev/null +++ b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using StarterNG.Classes; +using StarterNG.Domain.Settings; + +namespace StarterNG.Infrastructure.Settings; + +public sealed class SettingsSerializer +{ + public void Read(SimulatorSettings s, ConfigFile c) + { + s.LanguageWasSet = c.Has("lang"); + s.LanguageCode = c.GetString("lang", ""); + s.Language = s.LanguageCode; + s.Fullscreen = c.GetBool("fullscreen", false); + s.PauseWhenInactive = c.GetBool("inactivepause", true); + s.PauseOnStart = c.GetBool("pause", false); + + var mouse = c.GetTokens("mousescale"); + if (mouse.Length >= 1 && double.TryParse(mouse[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double mx)) + { + s.InvertMouseHorizontal = mx < 0; + s.CursorSensitivity = Clamp((int)Math.Round(Math.Abs(mx)), 1, 5); + } + if (mouse.Length >= 2 && double.TryParse(mouse[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double my)) + { + s.InvertMouseVertical = my < 0; + s.MouseScaleYMagnitude = Math.Abs(my); + } + + s.IgnoreGamepad = !c.GetBool("input.gamepad", true); + s.FeedbackMode = Clamp(c.GetInt("feedbackmode", 1), 0, 5); + s.FeedbackPort = c.GetInt("feedbackport", 888); + s.UartEnabled = c.Has("uart"); + s.UartPort = c.GetString("uart", ""); + s.UartTune = c.GetString("uarttune", ""); + s.UartDebug = c.GetBool("uartdebug", false); + if (c.Has("uartfeature")) + { + var uartTokens = (c.GetRaw("uartfeature") ?? "").Split('|', StringSplitOptions.RemoveEmptyEntries); + var uf = new HashSet(uartTokens, StringComparer.OrdinalIgnoreCase); + s.UartMain = uf.Contains("main"); s.UartScnd = uf.Contains("scnd"); s.UartTrain = uf.Contains("train"); + s.UartLocal = uf.Contains("local"); s.UartRadioVolume = uf.Contains("radiovolume"); s.UartRadioChannel = uf.Contains("radiochannel"); + } + else + { + s.UartMain = s.UartScnd = s.UartTrain = s.UartLocal = true; + s.UartRadioVolume = s.UartRadioChannel = false; + } + + s.SelectExeAutomatically = c.GetBool("starter.exe.auto", true); + s.ExecutablePath = c.GetString("starter.exe.path", "eu07.exe"); + s.DebugMode = c.GetBool("debugmode", false); + s.VirtualShunting = c.GetBool("ai.trainman", true); + s.LogMissingVehicleFiles = c.GetBool("starter.logmissingvehicles", false); + + s.RenderEngine = IndexOf(SimulatorSettings.RenderEngines, c.GetString("gfxrenderer", "full"), 0); + s.Width = c.GetInt("width", 1280); + s.Height = c.GetInt("height", 720); + s.BufferScalePercent = Clamp(c.GetInt("starter.bufferscale", 100), 50, 200); + s.MaxTextureSize = c.GetInt("maxtexturesize", 4096); + s.MaxCabTextureSize = c.GetInt("maxcabtexturesize", 4096); + s.TextureFiltering = c.GetInt("anisotropicfiltering", 8); + s.Multisampling = Clamp(c.GetInt("multisampling", 2), 0, 3); + s.DynamicLights = Clamp(c.GetInt("dynamiclights", 7), 0, 7); + s.DrawRangeFactor = c.GetDouble("gfx.drawrange.factor.max", 3.0); + s.VSync = c.GetBool("vsync", false); + s.Smoke = c.GetBool("gfx.smoke", true); + s.SmokeFidelity = Clamp(c.GetInt("gfx.smoke.fidelity", 1), 1, 4); + s.ChromaticAberration = c.GetBool("gfx.postfx.chromaticaberration.enabled", true); + s.MotionBlur = c.GetBool("gfx.postfx.motionblur.enabled", true); + s.ExtraEffects = c.GetBool("gfx.extraeffects", true); + s.EnvMap = c.GetBool("gfx.envmap.enabled", true); + s.UseVbo = c.GetBool("usevbo", true); + s.RenderShadows = c.GetBool("shadows", true); + s.ReflectionsFramerate = c.GetInt("gfx.reflections.framerate", 60); + + var shadowTune = c.GetTokens("shadowtune"); + if (shadowTune.Length >= 1) s.ShadowMapResolution = ParseInt(shadowTune[0], 2048); + if (shadowTune.Length >= 2) s.ShadowProjectionRange = ParseInt(shadowTune[1], 250); + if (shadowTune.Length >= 4) s.ShadowTuneExtra = new[] { shadowTune[2], shadowTune[3] }; + + s.CabShadowsRange = (int)Math.Round(c.GetDouble("gfx.shadows.cab.range", 0)); + s.ShadowRankCutoff = c.GetInt("gfx.shadow.rank.cutoff", 3); + s.ReflectionsFidelity = Clamp(c.GetInt("gfx.reflections.fidelity", 0), 0, 2); + s.FieldOfView = Clamp((int)Math.Round(c.GetDouble("fieldofview", 45)), 15, 75); + s.PythonScreens = c.GetBool("python.enabled", true); + s.PythonThreadedUpload = c.GetBool("python.threadedupload", true); + s.FpsLimitEnabled = c.Has("fpslimit"); + s.FpsLimit = Clamp(c.GetInt("fpslimit", 30), 1, 100); + s.ShadowAngleLimit = c.GetDouble("gfx.shadow.angle.min", 0.2); + s.FullscreenWindowed = c.GetBool("fullscreenwindowed", false); + s.RenderAngleVulkan = string.Equals(c.GetString("gfx.angleplatform", ""), "vulkan", StringComparison.OrdinalIgnoreCase); + + s.SplineFidelity = Clamp(c.GetInt("splinefidelity", 1), 1, 4); + s.FullPhysics = c.GetBool("fullphysics", true); + s.EnableTraction = c.GetBool("enabletraction", true); + s.LiveTraction = c.GetBool("livetraction", true); + s.PhysicsLog = c.GetBool("physicslog", false); + int debugLog = c.GetInt("debuglog", 3); + s.DebugLog = (debugLog & 1) != 0; + s.DebugLogVisible = (debugLog & 2) != 0; + s.DebugLogExtraBits = debugLog & ~3; + s.MultipleLogs = c.GetBool("multiplelogs", false); + s.DisplaySimulation = !c.GetBool("gfx.skiprendering", false); + s.CrashDamage = c.GetBool("crashdamage", true); + s.Friction = c.GetDouble("friction", 1.0); + s.BrakeStep = c.GetDouble("brakestep", 1.0); + s.BrakeSpeed = c.GetDouble("brakespeed", 3.0); + + s.SoundEnabled = c.GetBool("soundenabled", true); + s.Volume = Clamp((int)Math.Round(c.GetDouble("sound.volume", 1.0) * 50), 1, 100); + s.RadioVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.radio", 0.75) * 100), 1, 100); + s.VehiclesVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.vehicle", 1.0) * 100), 1, 100); + s.PositionalVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.positional", 1.0) * 100), 1, 100); + s.AmbientVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.ambient", 1.0) * 100), 1, 100); + s.SkipPipeline = c.GetBool("gfx.skippipeline", false); + s.PythonScreenUpdateRate = c.Has("python.updatetime") + ? Clamp(c.GetInt("python.updatetime", 200), 0, 10000) + : MapOldPyScreenPriority(c.GetString("pyscreenrendererpriority", "normal")); + + s.CompressTextures = c.GetBool("compresstex", true); + s.ScaleSpeculars = c.GetBool("scalespeculars", true); + s.UseGLES = c.GetBool("gfx.usegles", false); + s.ShaderGamma = c.GetBool("gfx.shadergamma", false); + s.ScreenMipmaps = c.GetBool("python.mipmaps", true); + s.ExtendedModelConversion = c.GetInt("convertmodels", 0) != 0; + s.GfxResourceMove = c.GetBool("gfx.resource.move", false); + s.GfxResourceSweep = c.GetBool("gfx.resource.sweep", true); + s.TrainPhysicsThreads = Clamp(c.GetInt("async.trainThreads", 0), 0, Environment.ProcessorCount); + s.IgnoreIrrelevantTrains = c.GetBool("starter.ignoreirrelevant", false); + + s.AutoCloseStarter = c.GetBool("starter.autoclose", false); + s.LargeThumbnails = c.GetBool("starter.largethumbnails", false); + s.AutoExpandSceneryTree = c.GetBool("starter.expandtree", false); + // An unrecognised value means "do not interfere" rather than the nearest + // state: clamping upwards would silently force every consist ready. + s.ReadyOverride = c.GetInt("starter.batterydefault", 0) switch + { + 1 => ConsistReadyOverride.AlwaysCold, + 2 => ConsistReadyOverride.AlwaysReady, + _ => ConsistReadyOverride.Scenery + }; + s.ShowAiVehicles = c.GetBool("starter.show.ai", true); + s.DrivableOnly = c.GetBool("starter.drivableonly", true); + s.ShowArchivalSceneries = c.GetBool("starter.show.archival", true); + s.HideArchivalVehicles = c.GetBool("starter.hide.archivalvehicles", true); + s.LastScenery = c.GetString("starter.last.scenery", ""); + s.WindowMaximized = c.GetBool("starter.window.maximized", false); + } + + private static readonly string[] ObsoleteKeys = + { + "gfx.postfx.tonemapping", + }; + + public void Write(SimulatorSettings s, ConfigFile c) + { + foreach (string key in ObsoleteKeys) + c.Remove(key); + + c.Set("lang", string.IsNullOrWhiteSpace(s.LanguageCode) + ? "en" + : s.LanguageCode.Trim().ToLowerInvariant()); + c.SetBool("fullscreen", s.Fullscreen); + c.SetBool("inactivepause", s.PauseWhenInactive); + c.SetBool("pause", s.PauseOnStart); + + double mx = s.CursorSensitivity * (s.InvertMouseHorizontal ? -1 : 1); + double my = s.MouseScaleYMagnitude * (s.InvertMouseVertical ? -1 : 1); + c.Set("mousescale", + $"{mx.ToString("0.###", CultureInfo.InvariantCulture)} " + + $"{my.ToString("0.###", CultureInfo.InvariantCulture)}"); + + c.SetBool("input.gamepad", !s.IgnoreGamepad); + c.SetInt("feedbackmode", s.FeedbackMode); + c.SetInt("feedbackport", s.FeedbackPort); + if (s.UartEnabled && !string.IsNullOrWhiteSpace(s.UartPort)) c.Set("uart", s.UartPort); else c.Remove("uart"); + c.Set("uarttune", s.UartTune); + c.SetBool("uartdebug", s.UartDebug); + var sb = new StringBuilder(); + if (s.UartMain) sb.Append("main|"); + if (s.UartScnd) sb.Append("scnd|"); + if (s.UartTrain) sb.Append("train|"); + if (s.UartLocal) sb.Append("local|"); + if (s.UartRadioVolume) sb.Append("radiovolume|"); + if (s.UartRadioChannel) sb.Append("radiochannel|"); + c.Set("uartfeature", sb.ToString()); + + c.SetBool("starter.exe.auto", s.SelectExeAutomatically); + c.Set("starter.exe.path", s.ExecutablePath); + c.SetBool("debugmode", s.DebugMode); + c.SetBool("ai.trainman", s.VirtualShunting); + c.SetBool("starter.logmissingvehicles", s.LogMissingVehicleFiles); + + c.Set("gfxrenderer", SimulatorSettings.RenderEngines[Clamp(s.RenderEngine, 0, SimulatorSettings.RenderEngines.Length - 1)]); + c.SetInt("width", s.Width); + c.SetInt("height", s.Height); + c.SetInt("starter.bufferscale", s.BufferScalePercent); + int fbw = (int)Math.Round(s.Width * s.BufferScalePercent / 100.0); + int fbh = (int)Math.Round(s.Height * s.BufferScalePercent / 100.0); + c.SetInt("gfx.framebuffer.width", fbw); + c.SetInt("gfx.framebuffer.height", fbh); + c.SetInt("maxtexturesize", s.MaxTextureSize); + c.SetInt("maxcabtexturesize", s.MaxCabTextureSize); + c.SetInt("anisotropicfiltering", s.TextureFiltering); + c.SetInt("multisampling", s.Multisampling); + c.SetInt("dynamiclights", s.DynamicLights); + c.SetDouble("gfx.drawrange.factor.max", s.DrawRangeFactor); + c.SetBool("vsync", s.VSync); + c.SetBool("gfx.smoke", s.Smoke); + c.SetInt("gfx.smoke.fidelity", s.SmokeFidelity); + c.SetBool("gfx.postfx.chromaticaberration.enabled", s.ChromaticAberration); + c.SetBool("gfx.postfx.motionblur.enabled", s.MotionBlur); + c.SetBool("gfx.extraeffects", s.ExtraEffects); + c.SetBool("gfx.envmap.enabled", s.EnvMap); + c.SetBool("usevbo", s.UseVbo); + c.SetBool("shadows", s.RenderShadows); + c.SetBool("gfx.shadowmap.enabled", s.RenderShadows); + c.SetInt("gfx.reflections.framerate", s.ReflectionsFramerate); + c.Set("shadowtune", + $"{s.ShadowMapResolution} {s.ShadowProjectionRange} " + + $"{s.ShadowTuneExtra[0]} {s.ShadowTuneExtra[1]}"); + c.SetInt("gfx.shadows.cab.range", s.CabShadowsRange); + c.SetInt("gfx.shadow.rank.cutoff", s.ShadowRankCutoff); + c.SetInt("gfx.reflections.fidelity", s.ReflectionsFidelity); + c.SetInt("fieldofview", s.FieldOfView); + c.SetBool("python.enabled", s.PythonScreens); + c.SetBool("python.threadedupload", s.PythonThreadedUpload); + if (s.FpsLimitEnabled) c.SetInt("fpslimit", s.FpsLimit); else c.Remove("fpslimit"); + c.SetDouble("gfx.shadow.angle.min", s.ShadowAngleLimit); + c.SetBool("fullscreenwindowed", s.FullscreenWindowed); + if (s.RenderAngleVulkan) c.Set("gfx.angleplatform", "vulkan"); else c.Remove("gfx.angleplatform"); + + c.SetInt("splinefidelity", s.SplineFidelity); + c.SetBool("fullphysics", s.FullPhysics); + c.SetBool("enabletraction", s.EnableTraction); + c.SetBool("livetraction", s.LiveTraction); + c.SetBool("physicslog", s.PhysicsLog); + c.SetInt("debuglog", + (s.DebugLog ? 1 : 0) | (s.DebugLogVisible ? 2 : 0) | s.DebugLogExtraBits); + c.SetBool("multiplelogs", s.MultipleLogs); + c.SetBool("gfx.skiprendering", !s.DisplaySimulation); + c.SetBool("crashdamage", s.CrashDamage); + c.SetDouble("friction", s.Friction); + c.SetDouble("brakestep", s.BrakeStep); + c.SetDouble("brakespeed", s.BrakeSpeed); + + c.SetBool("soundenabled", s.SoundEnabled); + c.SetDouble("sound.volume", s.Volume / 50.0); + c.SetDouble("sound.volume.radio", s.RadioVolume / 100.0); + c.SetDouble("sound.volume.vehicle", s.VehiclesVolume / 100.0); + c.SetDouble("sound.volume.positional", s.PositionalVolume / 100.0); + c.SetDouble("sound.volume.ambient", s.AmbientVolume / 100.0); + c.SetBool("gfx.skippipeline", s.SkipPipeline); + c.SetInt("python.updatetime", s.PythonScreenUpdateRate); + + c.SetBool("compresstex", s.CompressTextures); + c.SetBool("scalespeculars", s.ScaleSpeculars); + c.SetBool("gfx.usegles", s.UseGLES); + c.SetBool("gfx.shadergamma", s.ShaderGamma); + c.SetBool("python.mipmaps", s.ScreenMipmaps); + c.Set("convertmodels", s.ExtendedModelConversion ? "143" : "0"); + c.SetBool("gfx.resource.move", s.GfxResourceMove); + c.SetBool("gfx.resource.sweep", s.GfxResourceSweep); + c.SetInt("async.trainThreads", s.TrainPhysicsThreads); + c.SetBool("starter.ignoreirrelevant", s.IgnoreIrrelevantTrains); + + c.SetBool("starter.autoclose", s.AutoCloseStarter); + c.SetBool("starter.largethumbnails", s.LargeThumbnails); + c.SetBool("starter.expandtree", s.AutoExpandSceneryTree); + c.SetInt("starter.batterydefault", (int)s.ReadyOverride); + c.SetBool("starter.show.ai", s.ShowAiVehicles); + c.SetBool("starter.drivableonly", s.DrivableOnly); + c.SetBool("starter.show.archival", s.ShowArchivalSceneries); + c.SetBool("starter.hide.archivalvehicles", s.HideArchivalVehicles); + if (!string.IsNullOrWhiteSpace(s.LastScenery)) + c.Set("starter.last.scenery", s.LastScenery); + else + c.Remove("starter.last.scenery"); + c.SetBool("starter.window.maximized", s.WindowMaximized); + } + + private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v); + + private static int ParseInt(string s, int fallback) => + int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) ? n : fallback; + + private static int IndexOf(string[] values, string token, int fallback) + { + for (int i = 0; i < values.Length; i++) + if (string.Equals(values[i], token, StringComparison.OrdinalIgnoreCase)) + return i; + return fallback; + } + + private static int MapOldPyScreenPriority(string priority) => + priority.ToLowerInvariant() switch + { + "lower" => 400, + "lowest" => 800, + "idle" => 1600, + "off" => 0, + _ => 200 + }; +} diff --git a/StarterNG/Infrastructure/Shortcuts.cs b/StarterNG/Infrastructure/Shortcuts.cs new file mode 100644 index 0000000..2791347 --- /dev/null +++ b/StarterNG/Infrastructure/Shortcuts.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Input; + +namespace StarterNG.Infrastructure; + +public enum ShortcutScope +{ + Global, + + Depot, + + Consist +} + +public sealed record Shortcut( + string Id, + string Display, + string LabelKey, + ShortcutScope Scope, + Key Key = Key.None, + KeyModifiers Modifiers = KeyModifiers.None) +{ + public bool IsDispatched => Key != Key.None; + + public bool Matches(Key key, KeyModifiers modifiers) => + IsDispatched && Key == key && Modifiers == modifiers; +} + +public static class Shortcuts +{ + public const string Start = "start"; + public const string PageScenarios = "page.scenarios"; + public const string PageDepot = "page.depot"; + public const string PageSettings = "page.settings"; + public const string ShowHelp = "help"; + public const string AddVehicle = "vehicle.add"; + public const string RemoveVehicle = "vehicle.remove"; + public const string ClearConsist = "consist.clear"; + public const string SetNumber = "vehicle.number"; + public const string FocusSearch = "search"; + + public static readonly IReadOnlyList All = new[] + { + new Shortcut(Start, "Ctrl+Enter", "Start", ShortcutScope.Global, + Key.Enter, KeyModifiers.Control), + new Shortcut(PageScenarios, "Ctrl+1", "NavScenarios", ShortcutScope.Global, + Key.D1, KeyModifiers.Control), + new Shortcut(PageDepot, "Ctrl+2", "NavDepot", ShortcutScope.Global, + Key.D2, KeyModifiers.Control), + new Shortcut(PageSettings, "Ctrl+3", "NavSettings", ShortcutScope.Global, + Key.D3, KeyModifiers.Control), + new Shortcut(ShowHelp, "F1", "ShortcutShowHelp", ShortcutScope.Global, + Key.F1), + + new Shortcut(FocusSearch, "Ctrl+F", "ShortcutFocusSearch", ShortcutScope.Depot, + Key.F, KeyModifiers.Control), + new Shortcut(AddVehicle, "Insert", "ShortcutAddVehicle", ShortcutScope.Depot, + Key.Insert), + new Shortcut(RemoveVehicle, "Delete", "RemoveVehicle", ShortcutScope.Depot, + Key.Delete), + new Shortcut(ClearConsist, "Ctrl+Delete", "ConsistRemoveAll", ShortcutScope.Depot, + Key.Delete, KeyModifiers.Control), + new Shortcut(SetNumber, "Ctrl+N", "ShortcutSetNumber", ShortcutScope.Depot, + Key.N, KeyModifiers.Control), + + new Shortcut("consist.prevnext", "← →", "ShortcutPrevNext", ShortcutScope.Consist), + new Shortcut("consist.firstlast", "Home / End", "ShortcutFirstLast", ShortcutScope.Consist), + new Shortcut("consist.member", "↑ ↓", "ShortcutMember", ShortcutScope.Consist), + new Shortcut("consist.move", "Ctrl+← Ctrl+→", "ShortcutMoveVehicle", ShortcutScope.Consist), + new Shortcut("consist.flip", "Space", "TipFlip", ShortcutScope.Consist), + new Shortcut("consist.menu", "Enter", "ShortcutCardMenu", ShortcutScope.Consist) + }; + + public static string? Match(Key key, KeyModifiers modifiers) => + All.FirstOrDefault(s => s.Matches(key, modifiers))?.Id; + + public static IEnumerable InScope(ShortcutScope scope) => + All.Where(s => s.Scope == scope); + + public static string ScopeLabel(ShortcutScope scope) => scope switch + { + ShortcutScope.Depot => App.Loc["NavDepot"], + ShortcutScope.Consist => App.Loc["Consist"], + _ => App.Loc["ShortcutsGlobal"] + }; +} diff --git a/StarterNG/Infrastructure/StatsBar.cs b/StarterNG/Infrastructure/StatsBar.cs new file mode 100644 index 0000000..cbc7048 --- /dev/null +++ b/StarterNG/Infrastructure/StatsBar.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace StarterNG.Infrastructure; + +public static class StatsBar +{ + public static void Fill(Panel host, IReadOnlyList<(string Label, string Value)> fields) + { + host.Children.Clear(); + for (int i = 0; i < fields.Count; i++) + { + var (label, value) = fields[i]; + var pair = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 5, + // no trailing gap on the last pair - these sit in a panel title, + // flush with its right edge + Margin = new Avalonia.Thickness(0, 0, i == fields.Count - 1 ? 0 : 22, 0), + VerticalAlignment = VerticalAlignment.Center + }; + pair.Children.Add(new TextBlock + { + Text = label, + FontSize = 12, + Opacity = 0.65, + VerticalAlignment = VerticalAlignment.Center + }); + pair.Children.Add(new TextBlock + { + Text = value, + FontSize = 12, + FontWeight = FontWeight.SemiBold, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis + }); + host.Children.Add(pair); + } + } +} diff --git a/StarterNG/Infrastructure/TextMatch.cs b/StarterNG/Infrastructure/TextMatch.cs new file mode 100644 index 0000000..ffb0651 --- /dev/null +++ b/StarterNG/Infrastructure/TextMatch.cs @@ -0,0 +1,36 @@ +using System; +using System.Globalization; +using System.Text; + +namespace StarterNG.Infrastructure; + +public static class TextMatch +{ + public static bool Contains(string? haystack, string? needle) + { + if (string.IsNullOrEmpty(needle)) + return true; + if (string.IsNullOrEmpty(haystack)) + return false; + + return Fold(haystack).Contains(Fold(needle), StringComparison.Ordinal); + } + + public static string Fold(string text) + { + if (string.IsNullOrEmpty(text)) + return string.Empty; + + var decomposed = text.ToLowerInvariant().Normalize(NormalizationForm.FormD); + var sb = new StringBuilder(decomposed.Length); + + foreach (char c in decomposed) + { + if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) + continue; + sb.Append(c == 'ł' ? 'l' : c); + } + + return sb.ToString().Normalize(NormalizationForm.FormC); + } +} diff --git a/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs b/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs new file mode 100644 index 0000000..52f52eb --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Vehicles; +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Infrastructure.Vehicles; + +public sealed class FizPhysicsRepository : IPhysicsRepository +{ + private const int MaxIncludeDepth = 6; + + private const int UndeclaredCoupler = -1; + + private static readonly HashSet KnownKeys = new(StringComparer.OrdinalIgnoreCase) + { + "M", "Vmax", "L", "LoadAccepted", "LoadQ", "MaxLoad", "AllowedFlag", "ControlType", "EngineType" + }; + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + private readonly IDiagnosticsLog _log; + private readonly Encoding _encoding; + + private readonly ConcurrentDictionary _cache = + new(StringComparer.OrdinalIgnoreCase); + + private readonly object _indexGate = new(); + private Dictionary? _index; + + public FizPhysicsRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log) + { + _files = files; + _paths = paths; + _log = log; + _encoding = LegacyText.CodePage1250; + } + + public void Preload() => Index(); + + public int IndexedCount => _index?.Count ?? 0; + + public VehiclePhysics? For(string? dataFolder, string? model) + { + if (string.IsNullOrEmpty(model)) + return null; + + string name = Path.GetFileNameWithoutExtension(model); + if (name.Length == 0) + return null; + + if (_cache.TryGetValue(name, out var cached)) + return cached; + + var physics = Read(name); + _cache[name] = physics; + return physics; + } + + private VehiclePhysics? Read(string name) + { + var index = Index(); + + if (!index.TryGetValue(name, out string? path) && + !index.TryGetValue(name + "dumb", out path)) + return null; + + var physics = new VehiclePhysics { AllowedFlagB = UndeclaredCoupler }; + try + { + Parse(physics, path, Path.GetDirectoryName(path) ?? "", parameters: null, depth: 0); + } + catch (Exception ex) + { + _log.Log($"physics/{Path.GetFileName(path)}", ex); + } + + if (physics.AllowedFlagB == UndeclaredCoupler) + { + physics.AllowedFlagB = physics.AllowedFlagA; + if (string.IsNullOrEmpty(physics.ControlTypeB)) + physics.ControlTypeB = physics.ControlTypeA; + } + + return physics; + } + + private Dictionary Index() + { + lock (_indexGate) + { + if (_index is not null) + return _index; + + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + foreach (string file in _files.GetFilesRecursive(_paths.Dynamic, "*.fiz")) + index[Path.GetFileNameWithoutExtension(file)] = file; + } + catch (Exception ex) + { + _log.Log($"indexing {_paths.Dynamic}", ex); + } + + _index = index; + return index; + } + } + + private void Parse(VehiclePhysics physics, string path, string directory, string[]? parameters, int depth) + { + if (depth > MaxIncludeDepth || !_files.FileExists(path)) + return; + + var tokens = Tokenize(_files.ReadAllText(path, _encoding)); + + string section = ""; + string? pendingKey = null; + + for (int i = 0; i < tokens.Count; i++) + { + string token = tokens[i]; + if (token == "=") + continue; + + if (token.Equals("include", StringComparison.OrdinalIgnoreCase)) + { + if (++i >= tokens.Count) + break; + + string includeFile = Resolve(tokens[i], parameters); + var includeParameters = new List(); + i++; + while (i < tokens.Count && !tokens[i].Equals("end", StringComparison.OrdinalIgnoreCase)) + includeParameters.Add(Resolve(tokens[i++], parameters)); + + Parse(physics, Path.Combine(directory, includeFile), directory, includeParameters.ToArray(), + depth + 1); + pendingKey = null; + continue; + } + + int equals = token.IndexOf('='); + if (equals >= 0) + { + string key = token[..equals].TrimStart('.').Trim(); + string value = token[(equals + 1)..].Trim(); + if (value.Length == 0) + { + pendingKey = key; + continue; + } + Apply(physics, section, key, Resolve(value, parameters)); + pendingKey = null; + continue; + } + + string bare = token.TrimStart('.').Trim(); + if (pendingKey is not null) + { + Apply(physics, section, pendingKey, Resolve(bare, parameters)); + pendingKey = null; + } + else if (KnownKeys.Contains(bare)) + { + pendingKey = bare; + } + else + { + section = new string(bare.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); + } + } + } + + private static void Apply(VehiclePhysics physics, string section, string key, string value) + { + switch (section) + { + case "param": + if (Is(key, "M")) physics.Mass = ParseDouble(value, physics.Mass); + else if (Is(key, "Vmax")) physics.VMax = ParseDouble(value, physics.VMax); + break; + case "load": + if (Is(key, "LoadAccepted")) physics.LoadAccepted = value; + else if (Is(key, "LoadQ")) physics.LoadQ = value; + else if (Is(key, "MaxLoad")) physics.MaxLoad = (int)ParseDouble(value, physics.MaxLoad); + break; + case "engine": + if (Is(key, "EngineType")) physics.EngineType = value; + break; + case "dimensions": + if (Is(key, "L")) physics.Length = ParseDouble(value, physics.Length); + break; + case "buffcoupl": + case "buffcoupl1": + if (Is(key, "AllowedFlag")) physics.AllowedFlagA = ParseFlag(value, physics.AllowedFlagA); + else if (Is(key, "ControlType")) physics.ControlTypeA = value; + break; + case "buffcoupl2": + if (Is(key, "AllowedFlag")) physics.AllowedFlagB = ParseFlag(value, physics.AllowedFlagB); + else if (Is(key, "ControlType")) physics.ControlTypeB = value; + break; + } + } + + private static bool Is(string key, string name) => key.Equals(name, StringComparison.OrdinalIgnoreCase); + + private static int ParseFlag(string value, int fallback) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int flag) + ? flag < 0 ? -flag + 128 : flag + : fallback; + + private static double ParseDouble(string value, double fallback) => + double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) + ? parsed + : fallback; + + private static string Resolve(string token, string[]? parameters) + { + if (parameters is null || token.Length < 3 || token[0] != '(' || token[^1] != ')') + return token; + + string digits = new(token.Where(char.IsDigit).ToArray()); + return int.TryParse(digits, out int index) && index >= 1 && index <= parameters.Length + ? parameters[index - 1] + : token; + } + + private static List Tokenize(string text) + { + var sb = new StringBuilder(); + foreach (string line in text.Split('\n')) + { + string stripped = line; + int comment = stripped.IndexOf('#'); + if (comment >= 0) + stripped = stripped[..comment]; + sb.Append(stripped).Append(' '); + } + + return sb.ToString() + .Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .ToList(); + } +} diff --git a/StarterNG/Infrastructure/Vehicles/LoadWeightsRepository.cs b/StarterNG/Infrastructure/Vehicles/LoadWeightsRepository.cs new file mode 100644 index 0000000..53a4f1f --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/LoadWeightsRepository.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Vehicles; +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Infrastructure.Vehicles; + +public sealed class LoadWeightsRepository +{ + private const string FileName = "load_weights.txt"; + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + private readonly IDiagnosticsLog _log; + private readonly object _gate = new(); + + private LoadWeightsTable? _table; + + public LoadWeightsRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log) + { + _files = files; + _paths = paths; + _log = log; + } + + public LoadWeightsTable Table + { + get + { + lock (_gate) + return _table ??= Read(); + } + } + + private LoadWeightsTable Read() + { + string path = Path.Combine(_paths.Data, FileName); + if (!_files.FileExists(path)) + return new LoadWeightsTable(); + + try + { + return new LoadWeightsTable(ParsePairs(_files.ReadAllText(path, LegacyText.CodePage1250))); + } + catch (Exception ex) + { + _log.Log($"data/{FileName}", ex); + return new LoadWeightsTable(); + } + } + + private static Dictionary ParsePairs(string text) + { + var body = new StringBuilder(); + foreach (string raw in text.Split('\n')) + { + string line = raw; + + int comment = line.IndexOfAny(new[] { '#', ';' }); + if (comment >= 0) + line = line[..comment]; + + int slashes = line.IndexOf("//", StringComparison.Ordinal); + if (slashes >= 0) + line = line[..slashes]; + + body.Append(line).Append(' '); + } + + string[] tokens = body.Replace(":", " : ").ToString() + .Split(new[] { ' ', '\t', '\r' }, StringSplitOptions.RemoveEmptyEntries); + + var weights = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 1; i + 1 < tokens.Length; i++) + { + if (tokens[i] != ":") + continue; + + string name = tokens[i - 1]; + if (name is "{" or "}") + continue; + + if (int.TryParse(tokens[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int weight)) + weights[name] = weight; + } + + return weights; + } +} diff --git a/StarterNG/Infrastructure/Vehicles/MiniTextureIndex.cs b/StarterNG/Infrastructure/Vehicles/MiniTextureIndex.cs new file mode 100644 index 0000000..c42293c --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/MiniTextureIndex.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.IO; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Vehicles; + +public sealed class MiniTextureIndex : IMiniTextureIndex +{ + private const string FallbackName = "other"; + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + private readonly object _gate = new(); + + private Dictionary? _index; + + public MiniTextureIndex(IFileSystem files, IGamePaths paths) + { + _files = files; + _paths = paths; + } + + public void Preload() => Index(); + + public bool Has(string? miniName) => + !string.IsNullOrEmpty(miniName) && Index().ContainsKey(miniName.ToLowerInvariant()); + + public string? PathFor(string? miniName) + { + if (!string.IsNullOrEmpty(miniName) && + Index().TryGetValue(miniName.ToLowerInvariant(), out string? path)) + return path; + + return FallbackPath; + } + + public string? FallbackPath => + Index().TryGetValue(FallbackName, out string? path) ? path : null; + + private Dictionary Index() + { + lock (_gate) + { + if (_index is not null) + return _index; + + var index = new Dictionary(StringComparer.Ordinal); + foreach (string file in _files.GetFiles(_paths.MiniTextures, "*.bmp")) + index[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = file; + + _index = index; + return index; + } + } +} diff --git a/StarterNG/Infrastructure/Vehicles/MissingAssetScanner.cs b/StarterNG/Infrastructure/Vehicles/MissingAssetScanner.cs new file mode 100644 index 0000000..d303e69 --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/MissingAssetScanner.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.IO; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Infrastructure.Vehicles; + +public sealed class MissingAssetScanner +{ + private static readonly string[] TextureExtensions = { ".mat", ".bmp" }; + private static readonly string[] ModelExtensions = { ".t3d", ".e3d" }; + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + + public MissingAssetScanner(IFileSystem files, IGamePaths paths) + { + _files = files; + _paths = paths; + } + + public List Scan(VehicleCatalog catalog) + { + var lines = new List(); + + foreach (var texture in catalog.Textures) + { + string directory = Path.Combine( + _paths.Dynamic, + texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/')); + + if (!string.IsNullOrEmpty(texture.Skinfile) && + !ExistsWithAnyExtension(Path.Combine(directory, texture.Skinfile), TextureExtensions)) + lines.Add($"# no file: {texture.Directory}{texture.Skinfile}"); + + if (!string.IsNullOrEmpty(texture.Model) && + !ExistsWithAnyExtension(Path.Combine(directory, texture.Model), ModelExtensions)) + lines.Add($"# no model: {texture.Directory}{texture.Model}"); + } + + return lines; + } + + private bool ExistsWithAnyExtension(string path, string[] extensions) + { + if (_files.FileExists(path)) + return true; + + foreach (string extension in extensions) + if (_files.FileExists(Path.ChangeExtension(path, extension))) + return true; + + return false; + } +} diff --git a/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs b/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs new file mode 100644 index 0000000..c99a88e --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Infrastructure.Vehicles; + +public sealed class TexturesTxtParser +{ + private const string DeriveCategory = "*"; + + public VehicleEntry? Parse(string directory, IReadOnlyList lines) + { + var entry = new VehicleEntry { Uuid = "legacy:" + directory }; + + bool archived = false; + string categorySign = DeriveCategory; + int setSize = 0; + var pendingSet = new List(); + + foreach (string raw in lines) + { + string line = raw.Trim(); + if (line.Length == 0) + continue; + + char first = line[0]; + if (first is '#' or '@' or '*') + continue; + + if (line.StartsWith("$a", StringComparison.OrdinalIgnoreCase)) + { + archived = true; + continue; + } + + if (first == '^') + { + FlushSet(entry, pendingSet, setSize); + setSize = line.Length > 1 && char.IsDigit(line[1]) ? line[1] - '0' : 0; + continue; + } + + if (line.StartsWith("!=", StringComparison.Ordinal)) + { + categorySign = line.Length > 2 ? line[2].ToString() : DeriveCategory; + continue; + } + + if (line.IndexOf('=') < 0 || line.StartsWith("//", StringComparison.Ordinal)) + continue; + + var texture = ParseLivery(line, directory, categorySign, archived, entry); + if (texture is null) + continue; + + entry.Textures.Add(texture); + + if (setSize > 1) + { + pendingSet.Add(texture.Uuid!); + if (pendingSet.Count == setSize) + FlushSet(entry, pendingSet, setSize); + } + } + + FlushSet(entry, pendingSet, setSize); + return entry.Textures.Count > 0 ? entry : null; + } + + private static void FlushSet(VehicleEntry entry, List pending, int size) + { + if (pending.Count > 1) + entry.Sets.Add(new VehicleSet + { + Uuid = "legacy-set:" + pending[0], + Mode = "sequence", + Count = size > 0 ? size : pending.Count, + TextureRefs = new List(pending) + }); + + pending.Clear(); + } + + private static VehicleTexture? ParseLivery(string line, string directory, string categorySign, bool archived, + VehicleEntry entry) + { + string description = ""; + int comment = line.IndexOf("//", StringComparison.Ordinal); + if (comment >= 0) + { + description = line[(comment + 2)..].Trim(); + line = line[..comment].Trim(); + } + + string[] parts = line.Split('='); + if (parts.Length < 2) + return null; + + string skin = StripExtension(parts[0].Trim()); + if (skin.Length == 0) + return null; + + var specs = new List<(string Model, string Mini, string TextureMini)>(); + for (int i = 1; i < parts.Length; i++) + { + string[] fields = parts[i].Split(','); + if (fields.Length < 2) + continue; + specs.Add((fields[0].Trim(), fields[1].Trim(), fields.Length > 2 ? fields[2].Trim() : "")); + } + if (specs.Count == 0) + return null; + + var lead = specs[0]; + string groupId = $"legacy:{directory}:{lead.Mini}:{categorySign}:{(archived ? 1 : 0)}"; + + if (!entry.Groups.Exists(group => group.Id == groupId)) + entry.Groups.Add(new VehicleGroup + { + Id = groupId, + Category = categorySign, + Mini = lead.Mini, + Archived = archived + }); + + var texture = new VehicleTexture + { + Uuid = $"legacy:{directory}{skin}", + Directory = directory, + Skinfile = skin, + Wreck = skin.Contains("wreck", StringComparison.OrdinalIgnoreCase) || + skin.Contains("wrak", StringComparison.OrdinalIgnoreCase), + Model = lead.Model, + Group = groupId, + MiniRef = lead.Mini, + TextureMini = string.IsNullOrEmpty(lead.TextureMini) ? null : lead.TextureMini, + Meta = ParseDescription(description) + }; + + for (int i = 1; i < specs.Count; i++) + texture.Aliases.Add(new VehicleAlias + { + Model = specs[i].Model, + MiniRef = specs[i].Mini, + TextureMini = string.IsNullOrEmpty(specs[i].TextureMini) ? null : specs[i].TextureMini + }); + + return texture; + } + + private static VehicleMeta? ParseDescription(string description) + { + if (string.IsNullOrWhiteSpace(description)) + return null; + + string[] fields = description.Replace('_', ' ').Split(','); + string At(int index) => index < fields.Length ? fields[index].Trim() : ""; + + return new VehicleMeta + { + Raw = description, + Version = At(0), + Vehicle = At(1), + Operator = At(2), + Depot = At(3), + RevisionDate = At(4), + RevisionPlace = At(5), + TextureAuthor = At(6), + PhotoAuthor = At(7) + }; + } + + private static string StripExtension(string name) + { + int dot = name.LastIndexOf('.'); + return dot > 0 ? name[..dot] : name; + } +} diff --git a/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs b/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs new file mode 100644 index 0000000..4a9a30d --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Vehicles; +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Infrastructure.Vehicles; + +public sealed class TexturesTxtVehicleRepository : IVehicleRepository +{ + private const string TexturesFileName = "textures.txt"; + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + private readonly IDiagnosticsLog _log; + private readonly TexturesTxtParser _parser; + private readonly Encoding _encoding; + + public TexturesTxtVehicleRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, + TexturesTxtParser parser) + { + _files = files; + _paths = paths; + _log = log; + _parser = parser; + _encoding = LegacyText.CodePage1250; + } + + public int Load(VehicleCatalog catalog) + { + catalog.BeginLoad(); + + int liveries = 0; + foreach (string file in TexturesFiles()) + { + var entry = ReadEntry(file); + if (entry is null) + continue; + + liveries += entry.Textures.Count; + catalog.Ingest(entry); + } + + catalog.EndLoad(); + return liveries; + } + + private VehicleEntry? ReadEntry(string path) + { + try + { + string[] lines = _files.ReadAllText(path, _encoding).Split('\n'); + return _parser.Parse(RelativeDirectory(path), lines); + } + catch (Exception ex) + { + _log.Log($"textures.txt {path}", ex); + return null; + } + } + + private IEnumerable TexturesFiles() + { + foreach (string maker in _files.GetDirectories(_paths.Dynamic)) + foreach (string vehicle in _files.GetDirectories(maker)) + { + string path = Path.Combine(vehicle, TexturesFileName); + if (_files.FileExists(path)) + yield return path; + } + } + + private string RelativeDirectory(string texturesPath) + { + string directory = Path.GetDirectoryName(texturesPath) ?? ""; + string full = Path.GetFullPath(directory); + string root = Path.GetFullPath(_paths.Dynamic); + + string relative = full.StartsWith(root, StringComparison.OrdinalIgnoreCase) + ? full[root.Length..].TrimStart(Path.DirectorySeparatorChar, '/') + : directory; + + return relative.Replace('\\', '/').TrimEnd('/') + "/"; + } +} diff --git a/StarterNG/MainWindow.axaml b/StarterNG/MainWindow.axaml index 8cbddeb..9d2b8bc 100644 --- a/StarterNG/MainWindow.axaml +++ b/StarterNG/MainWindow.axaml @@ -9,55 +9,22 @@ xmlns:views="clr-namespace:StarterNG.Views" xmlns:starterNg="clr-namespace:StarterNG" Title="Starter MaSzyna" - Width="1200" Height="768" MinWidth="1200" MinHeight="666" + Icon="/Assets/starter.ico" + Width="1010" Height="768" MinWidth="1010" MinHeight="666" WindowStartupLocation="CenterScreen"> - - - - + BorderThickness="0,0,0,1" + PointerPressed="TitleBar_OnPointerPressed"> + - - - - - + - - - - - - - - - - - - + - + @@ -109,18 +73,23 @@ + + - - + + + - - -