diff --git a/.gitignore b/.gitignore
index 1774014..e5e78b9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,7 @@
/.idea
/.vs
starter/
+
+# Build output of any project in the solution
+[Bb]in/
+[Oo]bj/
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..84c67ce
--- /dev/null
+++ b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs
@@ -0,0 +1,131 @@
+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);
+
+ public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ /// Adds a text file, creating its parent directories.
+ public InMemoryFileSystem WithFile(string path, string contents)
+ {
+ string key = Normalize(path);
+ _files[key] = Encoding.UTF8.GetBytes(contents);
+ AddParents(key);
+ return this;
+ }
+
+ /// 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 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;
+
+ 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/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/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..ee4c139
--- /dev/null
+++ b/StarterNG.Tests/InstallationCheckTests.cs
@@ -0,0 +1,39 @@
+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 strings = new FakeStrings().With("FaultNoDir", "missing {0}");
+ var check = new InstallationCheck(new GamePaths("game"), new InMemoryFileSystem(), strings);
+
+ 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 strings = new FakeStrings().With("FaultNoDir", "missing {0}");
+
+ var faults = new InstallationCheck(new GamePaths("game"), files, strings).Run();
+
+ Assert.DoesNotContain(faults, fault => fault.StartsWith("missing "));
+ Assert.DoesNotContain("FaultNoWeights", faults);
+ }
+}
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.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.cs b/StarterNG/App.axaml.cs
index 6053a20..1417935 100644
--- a/StarterNG/App.axaml.cs
+++ b/StarterNG/App.axaml.cs
@@ -8,16 +8,22 @@ 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()
{
diff --git a/StarterNG/Application/Abstractions/IClock.cs b/StarterNG/Application/Abstractions/IClock.cs
new file mode 100644
index 0000000..b3fa40d
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IClock.cs
@@ -0,0 +1,15 @@
+namespace StarterNG.Application.Abstractions;
+
+using System;
+
+///
+/// Port over the system clock. Scenery weather defaults, timetable rendering and
+/// log timestamps all depend on "now"; routing them through a clock keeps those
+/// rules deterministic under test.
+///
+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..aa049b4
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IDiagnosticsLog.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+
+namespace StarterNG.Application.Abstractions;
+
+///
+/// Port over the starter's error log. Domain and application code depends on
+/// this interface only; the file-backed implementation and the message box that
+/// surfaces faults to the user live in Infrastructure and Presentation.
+///
+public interface IDiagnosticsLog
+{
+ void Log(string message);
+
+ void Log(string context, Exception exception);
+
+ void Log(IEnumerable messages);
+
+ /// Everything logged so far, newline separated.
+ string Text { get; }
+
+ void Clear();
+
+ void Flush();
+}
diff --git a/StarterNG/Application/Abstractions/IFileSystem.cs b/StarterNG/Application/Abstractions/IFileSystem.cs
new file mode 100644
index 0000000..2d04926
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IFileSystem.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+
+namespace StarterNG.Application.Abstractions;
+
+///
+/// Port over the file system. Everything that reads or writes the MaSzyna
+/// installation goes through this interface instead of and
+/// , so parsers and use cases can be exercised against an
+/// in-memory installation in tests.
+///
+///
+/// Kept deliberately narrow: only the operations the application actually
+/// performs are exposed. Paths are passed through verbatim to the adapter, which
+/// resolves them relative to the process working directory just like the
+/// framework methods it replaces.
+///
+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);
+
+ /// Top-level files, empty when the directory does not exist.
+ IReadOnlyList GetFiles(string path, string searchPattern = "*");
+
+ /// Top-level directories, empty when the directory does not exist.
+ IReadOnlyList GetDirectories(string path);
+
+ DateTime GetLastWriteTimeUtc(string path);
+}
diff --git a/StarterNG/Application/Abstractions/IGamePaths.cs b/StarterNG/Application/Abstractions/IGamePaths.cs
new file mode 100644
index 0000000..464cf37
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IGamePaths.cs
@@ -0,0 +1,34 @@
+namespace StarterNG.Application.Abstractions;
+
+///
+/// The layout of a MaSzyna installation. Replaces the
+/// Directory.GetCurrentDirectory() calls and hard-coded "scenery/",
+/// "dynamic", "textures/mini/" literals that were previously spread across
+/// parsers, views and the loader.
+///
+public interface IGamePaths
+{
+ /// Installation root: the folder holding eu07.exe.
+ string Root { get; }
+
+ /// Scenery definitions (*.scn).
+ string Scenery { get; }
+
+ /// Rolling stock: dynamic/<maker>/<vehicle>/textures.txt.
+ string Dynamic { get; }
+
+ /// Vehicle thumbnails used by the depot and the consist strip.
+ string MiniTextures { get; }
+
+ /// Simulator data files (load_weights.txt and friends).
+ string Data { get; }
+
+ /// Starter-owned configuration: presets, profiles, key bindings.
+ string StarterConfig { get; }
+
+ /// The starter's own error log (starter/bledy.txt).
+ string DiagnosticsLog { get; }
+
+ /// Resolves a path relative to .
+ 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..f72ce5b
--- /dev/null
+++ b/StarterNG/Application/Abstractions/ILocalizedStrings.cs
@@ -0,0 +1,22 @@
+namespace StarterNG.Application.Abstractions;
+
+///
+/// Port over the translation table. Anything that needs a user-facing string
+/// takes this, rather than reaching for the concrete localization service or a
+/// static on App.
+///
+public interface ILocalizedStrings
+{
+ ///
+ /// Translation for a key. Missing keys return the key itself (and are logged
+ /// once), so an untranslated screen stays usable.
+ ///
+ string this[string key] { get; }
+
+ /// Lookup for a key that may legitimately be absent; "" when missing.
+ string Opt(string key);
+
+ string CurrentLanguage { get; }
+
+ string CurrentLangCode { get; }
+}
diff --git a/StarterNG/Application/Abstractions/IProcessLauncher.cs b/StarterNG/Application/Abstractions/IProcessLauncher.cs
new file mode 100644
index 0000000..7029f36
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IProcessLauncher.cs
@@ -0,0 +1,42 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace StarterNG.Application.Abstractions;
+
+///
+/// A process the starter has launched or adopted. Exposes only what the starter
+/// needs — whether the simulator is still up, and a way to await its exit — so
+/// the presentation layer never handles
+/// directly.
+///
+public interface IProcessHandle
+{
+ bool HasExited { get; }
+
+ Task WaitForExitAsync(CancellationToken cancellationToken = default);
+}
+
+///
+/// Port over process creation: starting the simulator, opening a file, folder or
+/// URL in the desktop shell, and adopting an already running simulator.
+///
+public interface IProcessLauncher
+{
+ ///
+ /// Starts an executable with the given arguments. Returns null when the
+ /// process could not be created; then carries the
+ /// reason for display.
+ ///
+ IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory,
+ out string? error);
+
+ ///
+ /// Hands a path or URL to the desktop shell (explorer, xdg-open, ...).
+ /// Returns false and logs when the shell refused it.
+ ///
+ bool OpenInShell(string pathOrUrl);
+
+ /// The first running process with that name, or null.
+ IProcessHandle? FindRunning(string processName);
+}
diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs
new file mode 100644
index 0000000..5540e6c
--- /dev/null
+++ b/StarterNG/Application/AppServices.cs
@@ -0,0 +1,75 @@
+using System;
+using StarterNG.Application.Abstractions;
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Services;
+
+namespace StarterNG.Application;
+
+///
+/// The composition root: the single place where concrete adapters are chosen and
+/// wired to the ports the rest of the application depends on.
+///
+///
+/// Hand-written on purpose. The starter publishes with PublishAot and
+/// PublishTrimmed, which rules out reflection-based containers; the object
+/// graph is small enough that explicit construction is also the clearest
+/// documentation of what depends on what.
+///
+/// is a service locator and is used in exactly one kind of
+/// place — the entry point and the view constructors Avalonia creates for us,
+/// which cannot take arguments. Everything below that boundary receives its
+/// dependencies through its constructor; new code must not reach for
+/// .
+///
+public sealed class AppServices
+{
+ private static AppServices? _current;
+
+ private AppServices(IGamePaths paths, IFileSystem files, IClock clock)
+ {
+ Paths = paths;
+ Files = files;
+ Clock = clock;
+
+ var log = new FileDiagnosticsLog(files, clock, paths);
+ Log = log;
+ Processes = new SystemProcessLauncher(log);
+ Localization = new LocalizationService(log);
+ }
+
+ ///
+ /// The graph built by the entry point. Throws when read before
+ /// , which would mean a static initializer somewhere
+ /// ran ahead of the composition root.
+ ///
+ public static AppServices Current =>
+ _current ?? throw new InvalidOperationException(
+ "AppServices.Current read before Initialize(); the composition root must be built first.");
+
+ /// Builds the production graph. Called once, from the entry point.
+ public static AppServices Initialize(IGamePaths? paths = null, IFileSystem? files = null, IClock? clock = null)
+ {
+ _current = new AppServices(
+ paths ?? GamePaths.ForCurrentDirectory(),
+ files ?? new PhysicalFileSystem(),
+ clock ?? new SystemClock());
+ return _current;
+ }
+
+ public IGamePaths Paths { get; }
+
+ public IFileSystem Files { get; }
+
+ public IClock Clock { get; }
+
+ public IDiagnosticsLog Log { get; }
+
+ public IProcessLauncher Processes { get; }
+
+ ///
+ /// Exposed as the concrete service rather than
+ /// because the XAML binds to it as a change-notifying source; code should take
+ /// the port.
+ ///
+ public LocalizationService Localization { get; }
+}
diff --git a/StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs b/StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs
new file mode 100644
index 0000000..f8b1c18
--- /dev/null
+++ b/StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Infrastructure.Adapters;
+
+///
+/// that mirrors every line into starter/bledy.txt.
+/// Writing is best-effort: a read-only or full installation directory degrades to
+/// an in-memory log rather than taking the starter down, and a single retry is
+/// made at time.
+///
+///
+/// Instance state replaces the previous static Diagnostics accumulator, so
+/// tests can assert on what a use case logged without touching disk (pair it with
+/// an in-memory ).
+///
+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
+ {
+ // A log we cannot delete is not worth failing a user action over.
+ }
+ }
+ }
+
+ 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;
+
+ // The streaming writer never came up (or died mid-session): fall back
+ // to one bulk write so the session is not lost entirely.
+ try
+ {
+ _files.CreateDirectory(Path.GetDirectoryName(_logPath)!);
+ _files.WriteAllLines(_logPath, _lines);
+ }
+ catch
+ {
+ // Nothing left to try; the in-memory log still backs the UI.
+ }
+ }
+ }
+
+ 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
+ {
+ // Disposing a broken writer is allowed to fail.
+ }
+ _writer = null;
+ }
+}
diff --git a/StarterNG/Infrastructure/Adapters/GamePaths.cs b/StarterNG/Infrastructure/Adapters/GamePaths.cs
new file mode 100644
index 0000000..fe5d38e
--- /dev/null
+++ b/StarterNG/Infrastructure/Adapters/GamePaths.cs
@@ -0,0 +1,41 @@
+using System.IO;
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Infrastructure.Adapters;
+
+///
+/// The stock MaSzyna layout, rooted at the process working directory (the
+/// starter ships inside the installation folder, next to eu07.exe).
+///
+public sealed class GamePaths : IGamePaths
+{
+ public GamePaths(string root)
+ {
+ Root = root;
+ }
+
+ /// The installation the starter was launched from.
+ 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/PhysicalFileSystem.cs b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
new file mode 100644
index 0000000..124475a
--- /dev/null
+++ b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Infrastructure.Adapters;
+
+///
+/// backed by the real disk. The only place in the
+/// application allowed to touch and .
+///
+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 GetDirectories(string path) =>
+ Directory.Exists(path) ? Directory.GetDirectories(path) : Array.Empty();
+
+ public DateTime GetLastWriteTimeUtc(string path) => File.GetLastWriteTimeUtc(path);
+}
diff --git a/StarterNG/Infrastructure/Adapters/SystemClock.cs b/StarterNG/Infrastructure/Adapters/SystemClock.cs
new file mode 100644
index 0000000..6f3e514
--- /dev/null
+++ b/StarterNG/Infrastructure/Adapters/SystemClock.cs
@@ -0,0 +1,12 @@
+using System;
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Infrastructure.Adapters;
+
+/// backed by the machine clock.
+public sealed class SystemClock : IClock
+{
+ public DateTime Now => DateTime.Now;
+
+ public DateTime UtcNow => DateTime.UtcNow;
+}
diff --git a/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs
new file mode 100644
index 0000000..ae99789
--- /dev/null
+++ b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs
@@ -0,0 +1,106 @@
+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;
+
+///
+/// backed by . The only place
+/// in the application that starts an operating-system process.
+///
+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/Diagnostics.cs b/StarterNG/Infrastructure/Diagnostics.cs
index 2a70e20..05c1ee0 100644
--- a/StarterNG/Infrastructure/Diagnostics.cs
+++ b/StarterNG/Infrastructure/Diagnostics.cs
@@ -1,130 +1,41 @@
using System;
using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
+using StarterNG.Application;
using StarterNG.Views;
namespace StarterNG.Infrastructure;
+///
+/// Compatibility facade over
+/// plus the fault dialog.
+///
+///
+/// The log itself now lives in Infrastructure.Adapters.FileDiagnosticsLog
+/// and is reached through the composition root. This static shim keeps the
+/// existing call sites compiling while they are migrated to constructor-injected
+/// logging; new code should take IDiagnosticsLog instead.
+///
public static class Diagnostics
{
- private static readonly object Gate = new();
- private static readonly List Lines = new();
+ private static Application.Abstractions.IDiagnosticsLog Log_ => AppServices.Current.Log;
- private static StreamWriter? _writer;
- private static bool _writerFailed;
+ public static string LogPath => AppServices.Current.Paths.DiagnosticsLog;
- public static string LogPath =>
- Path.Combine(Directory.GetCurrentDirectory(), "starter", "bledy.txt");
+ public static string Text => Log_.Text;
- public static string Text
- {
- get { lock (Gate) return string.Join(Environment.NewLine, Lines); }
- }
+ public static void Clear() => Log_.Clear();
- public static void Clear()
- {
- lock (Gate)
- {
- Lines.Clear();
- CloseWriter();
- try
- {
- if (File.Exists(LogPath))
- File.Delete(LogPath);
- }
- catch
- {
- }
- }
- }
+ public static void Log(string message) => Log_.Log(message);
- public static void Log(string message)
- {
- string line = $"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)} {message}";
- lock (Gate)
- {
- Lines.Add(line);
- Append(line);
- }
- }
+ public static void Log(IEnumerable messages) => Log_.Log(messages);
- public static void Log(IEnumerable messages)
- {
- foreach (string message in messages)
- Log(message);
- }
+ public static void Log(string context, Exception ex) => Log_.Log(context, ex);
- public static void Log(string context, Exception ex) =>
- Log($"{context}: {ex.GetType().Name}: {ex.Message}");
-
- public static 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
- {
- Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
- File.WriteAllLines(LogPath, Lines);
- }
- catch
- {
- }
- }
- }
-
- private static void Append(string line)
- {
- if (_writerFailed)
- return;
-
- try
- {
- if (_writer is null)
- {
- Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
- _writer = new StreamWriter(LogPath, append: false) { AutoFlush = true };
- }
- _writer.WriteLine(line);
- }
- catch
- {
- _writerFailed = true;
- CloseWriter();
- }
- }
-
- private static void CloseWriter()
- {
- try
- {
- _writer?.Dispose();
- }
- catch
- {
- }
- _writer = null;
- }
+ public static void Flush() => Log_.Flush();
private static Window? Owner =>
(Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)
@@ -132,7 +43,7 @@ public static class Diagnostics
public static Task ReportAsync(string message, string? title = null)
{
- Log(message.Replace(Environment.NewLine, " | "));
+ Log_.Log(message.Replace(Environment.NewLine, " | "));
var owner = Owner;
if (owner is null)
@@ -152,31 +63,13 @@ public static class Diagnostics
Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title));
}
- public static List CheckInstallation()
- {
- var faults = new List();
- string root = Directory.GetCurrentDirectory();
-
- foreach (string folder in new[] { "dynamic", "sounds", "models", "scenery", "textures" })
- if (!Directory.Exists(Path.Combine(root, folder)))
- faults.Add(string.Format(App.Loc["FaultNoDir"], "/" + folder));
-
- if (!File.Exists(Path.Combine(root, "data", "load_weights.txt")))
- faults.Add(App.Loc["FaultNoWeights"]);
-
- if (Classes.GameData.Instance.Sceneries.Count == 0)
- faults.Add(App.Loc["FaultNoScenery"]);
-
- if (Classes.GameData.Instance.Vehicles.Textures.Count == 0)
- faults.Add(App.Loc["FaultNoVehicles"]);
-
- if (Classes.Physics.IndexedCount == 0)
- faults.Add(App.Loc["FaultNoPhysics"]);
-
- if (Classes.Settings.Instance.ResolveExecutable(out var problem) is var _ &&
- problem == Classes.ExeProblem.NotFound)
- faults.Add(App.Loc["FaultNoExe"]);
-
- return faults;
- }
+ ///
+ /// Sanity check of the installation the starter was launched from.
+ ///
+ public static List CheckInstallation() =>
+ new InstallationCheck(
+ AppServices.Current.Paths,
+ AppServices.Current.Files,
+ AppServices.Current.Localization)
+ .Run();
}
diff --git a/StarterNG/Infrastructure/InstallationCheck.cs b/StarterNG/Infrastructure/InstallationCheck.cs
new file mode 100644
index 0000000..61ed9c1
--- /dev/null
+++ b/StarterNG/Infrastructure/InstallationCheck.cs
@@ -0,0 +1,56 @@
+using System.Collections.Generic;
+using System.IO;
+using StarterNG.Application.Abstractions;
+using StarterNG.Classes;
+
+namespace StarterNG.Infrastructure;
+
+///
+/// Verifies that the folder the starter runs from actually looks like a MaSzyna
+/// installation, and reports what is missing in the user's language.
+///
+///
+/// Depends only on ports, so a broken installation can be described in a test by
+/// handing it an empty in-memory file system.
+///
+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;
+
+ public InstallationCheck(IGamePaths paths, IFileSystem files, ILocalizedStrings strings)
+ {
+ _paths = paths;
+ _files = files;
+ _strings = strings;
+ }
+
+ 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 (GameData.Instance.Sceneries.Count == 0)
+ faults.Add(_strings["FaultNoScenery"]);
+
+ if (GameData.Instance.Vehicles.Textures.Count == 0)
+ faults.Add(_strings["FaultNoVehicles"]);
+
+ if (Physics.IndexedCount == 0)
+ faults.Add(_strings["FaultNoPhysics"]);
+
+ if (Settings.Instance.ResolveExecutable(out var problem) is var _ && problem == ExeProblem.NotFound)
+ faults.Add(_strings["FaultNoExe"]);
+
+ return faults;
+ }
+}
diff --git a/StarterNG/Program.cs b/StarterNG/Program.cs
index f7d5eb7..635bec6 100644
--- a/StarterNG/Program.cs
+++ b/StarterNG/Program.cs
@@ -1,6 +1,7 @@
using Avalonia;
using System;
using System.Threading.Tasks;
+using StarterNG.Application;
using StarterNG.Infrastructure;
namespace StarterNG;
@@ -11,6 +12,10 @@ static class Program
[STAThread]
public static void Main(string[] args)
{
+ // Composition root first: everything below, including the crash handlers,
+ // resolves its collaborators from this graph.
+ AppServices.Initialize();
+
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
if (e.ExceptionObject is Exception ex)
diff --git a/StarterNG/Services/LocalizationService.cs b/StarterNG/Services/LocalizationService.cs
index 5f43819..8357d95 100644
--- a/StarterNG/Services/LocalizationService.cs
+++ b/StarterNG/Services/LocalizationService.cs
@@ -3,16 +3,29 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Xml;
+using StarterNG.Application.Abstractions;
namespace StarterNG.Services;
-public class LocalizationService : INotifyPropertyChanged
+///
+/// Loads the translation table from startercfg/lang/*.xml and exposes it as
+/// . Stays a change-notifying class because the
+/// XAML binds to it directly as a static source.
+///
+public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings
{
public static string LangDirectory =>
Path.Combine(AppContext.BaseDirectory, "startercfg", "lang");
+ private readonly IDiagnosticsLog _log;
+
private Dictionary _strings = new();
+ public LocalizationService(IDiagnosticsLog log)
+ {
+ _log = log;
+ }
+
public event PropertyChangedEventHandler? PropertyChanged;
public event Action? LanguageChanged;
@@ -43,7 +56,7 @@ public class LocalizationService : INotifyPropertyChanged
lock (_reportedMisses)
if (_reportedMisses.Add(key))
- Infrastructure.Diagnostics.Log($"lang/{CurrentLangCode}: missing key \"{key}\"");
+ _log.Log($"lang/{CurrentLangCode}: missing key \"{key}\"");
return key;
}
@@ -58,7 +71,7 @@ public class LocalizationService : INotifyPropertyChanged
public readonly record struct LanguageInfo(string Code, string Name, string Path);
- public static IReadOnlyList AvailableLanguages()
+ public static IReadOnlyList AvailableLanguages(IDiagnosticsLog? log = null)
{
var list = new List();
if (!Directory.Exists(LangDirectory))
@@ -74,7 +87,7 @@ public class LocalizationService : INotifyPropertyChanged
}
catch (Exception ex)
{
- StarterNG.Infrastructure.Diagnostics.Log($"lang/{Path.GetFileName(path)}", ex);
+ log?.Log($"lang/{Path.GetFileName(path)}", ex);
}
}
return list;
@@ -82,7 +95,7 @@ public class LocalizationService : INotifyPropertyChanged
public string Load(string codeOrName)
{
- var languages = AvailableLanguages();
+ var languages = AvailableLanguages(_log);
LanguageInfo? match = null;
foreach (var lang in languages)