Merge pull request #21 from maj00r/wip/pre-reinstall

whole these
This commit is contained in:
2026-09-10 12:30:08 +02:00
committed by GitHub
157 changed files with 19329 additions and 4843 deletions

5
.gitignore vendored
View File

@@ -2,3 +2,8 @@
/StarterNG/obj /StarterNG/obj
/.idea /.idea
/.vs /.vs
starter/
# Build output of any project in the solution
[Bb]in/
[Oo]bj/

14
.vscode/launch.json vendored Normal file
View File

@@ -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",
}
]
}

48
README.md Normal file
View File

@@ -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
```

View File

@@ -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());
}
}

View File

@@ -0,0 +1,34 @@
using StarterNG.Application.Abstractions;
namespace StarterNG.Tests.Fakes;
/// <summary>A host machine described inline: platform, home folder, env vars.</summary>
public sealed class FakeEnvironment : IEnvironment
{
private readonly Dictionary<string, string> _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;
}
/// <summary>Switches the fake to Windows, with an APPDATA folder.</summary>
public FakeEnvironment AsWindows(string appData = "C:/Users/kolejarz/AppData/Roaming")
{
IsWindows = true;
IsLinux = false;
IsMacOS = false;
return With("APPDATA", appData);
}
}

View File

@@ -0,0 +1,26 @@
using StarterNG.Application.Abstractions;
namespace StarterNG.Tests.Fakes;
/// <summary>
/// Translation table that echoes keys back, so assertions can name the key that
/// a screen or check would have shown.
/// </summary>
public sealed class FakeStrings : ILocalizedStrings
{
private readonly Dictionary<string, string> _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;
}
}

View File

@@ -0,0 +1,16 @@
using StarterNG.Application.Abstractions;
namespace StarterNG.Tests.Fakes;
/// <summary>A clock frozen at a chosen instant.</summary>
public sealed class FixedClock : IClock
{
public FixedClock(DateTime now)
{
Now = now;
}
public DateTime Now { get; set; }
public DateTime UtcNow => Now.ToUniversalTime();
}

View File

@@ -0,0 +1,168 @@
using System.Text;
using StarterNG.Application.Abstractions;
namespace StarterNG.Tests.Fakes;
/// <summary>
/// An <see cref="IFileSystem"/> over a dictionary, so a whole MaSzyna
/// installation can be described inline in a test.
/// </summary>
/// <remarks>
/// Paths are normalised to forward slashes and compared case-insensitively,
/// matching how the parsers treat installation paths on both platforms.
/// </remarks>
public sealed class InMemoryFileSystem : IFileSystem
{
private readonly Dictionary<string, byte[]> _files = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _directories = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _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);
}
/// <summary>Adds a UTF-8 text file, creating its parent directories.</summary>
public InMemoryFileSystem WithFile(string path, string contents) =>
WithFile(path, contents, Encoding.UTF8);
/// <summary>Adds a text file in a given encoding, as the game's files are stored.</summary>
public InMemoryFileSystem WithFile(string path, string contents, Encoding encoding)
{
string key = Normalize(path);
_files[key] = encoding.GetBytes(contents);
AddParents(key);
return this;
}
/// <summary>Adds a file in the code page the .scn and .fiz formats use.</summary>
public InMemoryFileSystem WithLegacyFile(string path, string contents) =>
WithFile(path, contents, Encoding.GetEncoding(1250));
/// <summary>Adds an empty directory.</summary>
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<string> 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<string> 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<string> 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<string> 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;
/// <summary>Files added through <see cref="WithExecutable"/> carry the execute bit.</summary>
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);
}

View File

@@ -0,0 +1,22 @@
using StarterNG.Application.Abstractions;
namespace StarterNG.Tests.Fakes;
/// <summary>
/// Randomness pinned to the low end of every range, so weather that varies in
/// production is deterministic in tests.
/// </summary>
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;
}

View File

@@ -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;
/// <summary>
/// The production object graph wired over an in-memory installation: the same
/// composition as <c>AppServices</c>, with disk, clock and randomness replaced.
/// </summary>
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; }
/// <summary>Path of a file inside the fake installation.</summary>
public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray());
}

View File

@@ -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);
}
}

View File

@@ -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<LoadPhase>();
var progress = new Progress<LoadStatus>(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);
}
}

View File

@@ -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"));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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"));
}
}

View File

@@ -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));
}
}

View File

@@ -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<Scenery> 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));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<int>();
Build(files, out _).LoadAll(new Progress<StarterNG.Application.Abstractions.SceneryLoadProgress>(
step => { lock (steps) steps.Add(step.Loaded); }));
// Progress<T> 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));
}
}

View File

@@ -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));
}
}

View File

@@ -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()));
}
}

View File

@@ -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());
}
/// <summary>Stands in for the settings screen holding an unsaved edit.</summary>
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;
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StarterNG\StarterNG.csproj" />
</ItemGroup>
</Project>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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)));
}
/// <summary>Thumbnail index that knows about exactly one name.</summary>
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;
}
/// <summary>Builds an entry through the real parser, for catalogue-level tests.</summary>
private static VehicleEntry ParsedEntry(string directory, params string[] lines) =>
new StarterNG.Infrastructure.Vehicles.TexturesTxtParser().Parse(directory, lines)!;
}

View File

@@ -5,6 +5,8 @@ VisualStudioVersion = 18.1.11312.151 d18.0
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG", "StarterNG\StarterNG.csproj", "{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG", "StarterNG\StarterNG.csproj", "{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG.Tests", "StarterNG.Tests\StarterNG.Tests.csproj", "{00B068D7-2F49-4CAA-BC21-506503EBDFC8}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|Any CPU.Build.0 = Release|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.ActiveCfg = Release|x64 {D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.ActiveCfg = Release|x64
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@@ -1,22 +1,24 @@
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:chrome="clr-namespace:Avalonia.Controls.Chrome;assembly=Avalonia.Controls"
x:Class="StarterNG.App" x:Class="StarterNG.App"
x:CompileBindings="True" x:CompileBindings="True"
RequestedThemeVariant="Dark"> RequestedThemeVariant="Dark">
<!-- Dark variant matches the original Starter's "Carbon" VCL style. -->
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<materialIcons:MaterialIconStyles /> <materialIcons:MaterialIconStyles />
<StyleInclude Source="avares://Starter/Styles/Carbon.axaml" /> <StyleInclude Source="avares://Starter/Styles/Carbon.axaml" />
<StyleInclude Source="avares://Starter/Styles/Settings.axaml" />
<Style Selector="Button[(chrome|WindowDecorationProperties.ElementRole)=FullScreenButton]">
<Setter Property="IsVisible" Value="False" />
</Style>
</Application.Styles> </Application.Styles>
<Application.Resources> <Application.Resources>
<PathGeometry x:Key="DiscordLogo" Figures="M 81.15 0 c -1.2376 2.1973 -2.3489 4.4704 -3.3591 6.794 c -9.5975 -1.4396 -19.3718 -1.4396 -28.9945 0 c -0.985 -2.3236 -2.1216 -4.5967 -3.3591 -6.794 c -9.0166 1.5407 -17.8059 4.2431 -26.1405 8.0568 C 2.779 32.5304 -1.6914 56.3725 0.5312 79.8863 c 9.6732 7.1476 20.5083 12.603 32.0505 16.0884 c 2.6014 -3.4854 4.8998 -7.1981 6.8698 -11.0623 c -3.738 -1.3891 -7.3497 -3.1318 -10.8098 -5.1523 c 0.9092 -0.6567 1.7932 -1.3386 2.6519 -1.9953 c 20.281 9.547 43.7696 9.547 64.0758 0 c 0.8587 0.7072 1.7427 1.3891 2.6519 1.9953 c -3.4601 2.0457 -7.0718 3.7632 -10.835 5.1776 c 1.97 3.8642 4.2683 7.5769 6.8698 11.0623 c 11.5419 -3.4854 22.3769 -8.9156 32.0509 -16.0631 c 2.626 -27.2771 -4.496 -50.9172 -18.817 -71.8548 C 98.9811 4.2684 90.1918 1.5659 81.1752 0.0505 l -0.0252 -0.0505 Z M 42.2802 65.4144 c -6.2383 0 -11.4159 -5.6575 -11.4159 -12.6535 s 4.9755 -12.6788 11.3907 -12.6788 s 11.5169 5.708 11.4159 12.6788 c -0.101 6.9708 -5.026 12.6535 -11.3907 12.6535 Z M 84.3576 65.4144 c -6.2637 0 -11.3907 -5.6575 -11.3907 -12.6535 s 4.9755 -12.6788 11.3907 -12.6788 s 11.4917 5.708 11.3906 12.6788 c -0.101 6.9708 -5.026 12.6535 -11.3906 12.6535 Z" FillRule="NonZero"/> <PathGeometry x:Key="DiscordLogo" Figures="M 81.15 0 c -1.2376 2.1973 -2.3489 4.4704 -3.3591 6.794 c -9.5975 -1.4396 -19.3718 -1.4396 -28.9945 0 c -0.985 -2.3236 -2.1216 -4.5967 -3.3591 -6.794 c -9.0166 1.5407 -17.8059 4.2431 -26.1405 8.0568 C 2.779 32.5304 -1.6914 56.3725 0.5312 79.8863 c 9.6732 7.1476 20.5083 12.603 32.0505 16.0884 c 2.6014 -3.4854 4.8998 -7.1981 6.8698 -11.0623 c -3.738 -1.3891 -7.3497 -3.1318 -10.8098 -5.1523 c 0.9092 -0.6567 1.7932 -1.3386 2.6519 -1.9953 c 20.281 9.547 43.7696 9.547 64.0758 0 c 0.8587 0.7072 1.7427 1.3891 2.6519 1.9953 c -3.4601 2.0457 -7.0718 3.7632 -10.835 5.1776 c 1.97 3.8642 4.2683 7.5769 6.8698 11.0623 c 11.5419 -3.4854 22.3769 -8.9156 32.0509 -16.0631 c 2.626 -27.2771 -4.496 -50.9172 -18.817 -71.8548 C 98.9811 4.2684 90.1918 1.5659 81.1752 0.0505 l -0.0252 -0.0505 Z M 42.2802 65.4144 c -6.2383 0 -11.4159 -5.6575 -11.4159 -12.6535 s 4.9755 -12.6788 11.3907 -12.6788 s 11.5169 5.708 11.4159 12.6788 c -0.101 6.9708 -5.026 12.6535 -11.3907 12.6535 Z M 84.3576 65.4144 c -6.2637 0 -11.3907 -5.6575 -11.3907 -12.6535 s 4.9755 -12.6788 11.3907 -12.6788 s 11.4917 5.708 11.3906 12.6788 c -0.101 6.9708 -5.026 12.6535 -11.3906 12.6535 Z" FillRule="NonZero"/>
<!-- Override the system accent (the OS accent can be red) so every
accent-driven element - list/tree selection, checkboxes,
sliders - uses the MaSzyna green instead. -->
<Color x:Key="SystemAccentColor">#177f00</Color> <Color x:Key="SystemAccentColor">#177f00</Color>
<Color x:Key="SystemAccentColorDark1">#136B00</Color> <Color x:Key="SystemAccentColorDark1">#136B00</Color>
<Color x:Key="SystemAccentColorDark2">#0F5600</Color> <Color x:Key="SystemAccentColorDark2">#0F5600</Color>
@@ -29,5 +31,13 @@
<SolidColorBrush x:Key="AccentFillColorTertiaryBrush" Color="#136B00" /> <SolidColorBrush x:Key="AccentFillColorTertiaryBrush" Color="#136B00" />
<SolidColorBrush x:Key="SystemControlHighlightListAccentLowBrush" Color="#177f00" /> <SolidColorBrush x:Key="SystemControlHighlightListAccentLowBrush" Color="#177f00" />
<SolidColorBrush x:Key="SystemControlHighlightListAccentMediumBrush" Color="#1E9000" /> <SolidColorBrush x:Key="SystemControlHighlightListAccentMediumBrush" Color="#1E9000" />
<GridLength x:Key="SliderPreContentMargin">6</GridLength>
<GridLength x:Key="SliderPostContentMargin">6</GridLength>
<x:Double x:Key="SliderHorizontalHeight">24</x:Double>
<x:Double x:Key="SliderHorizontalThumbWidth">12</x:Double>
<x:Double x:Key="SliderHorizontalThumbHeight">12</x:Double>
<CornerRadius x:Key="SliderThumbCornerRadius">2</CornerRadius>
<x:Double x:Key="SliderTrackThemeHeight">4</x:Double>
</Application.Resources> </Application.Resources>
</Application> </Application>

View File

@@ -8,91 +8,181 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Threading; using Avalonia.Threading;
using StarterNG.Application;
using StarterNG.Classes; using StarterNG.Classes;
using StarterNG.Infrastructure;
using StarterNG.Services; using StarterNG.Services;
namespace StarterNG; namespace StarterNG;
public partial class App : Application public partial class App : Avalonia.Application
{ {
public static LocalizationService Loc { get; } = new(); /// <summary>
/// The translation table, as a static source for the XAML bindings. Code
/// should take <see cref="Application.Abstractions.ILocalizedStrings"/>
/// through its constructor rather than reading this.
/// </summary>
public static LocalizationService Loc => AppServices.Current.Localization;
public override void Initialize() public override void Initialize()
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
// Load the persisted configuration up front so the saved language wins; AppServices.Current.SettingsStore.Load();
// fall back to the system culture only when the file has no lang entry.
Settings.Instance.Load();
// Key bindings live in eu07_input-keyboard.ini next to eu07.ini.
KeyboardConfig.Instance.Load(); KeyboardConfig.Instance.Load();
CultureInfo ci = CultureInfo.InstalledUICulture ; CultureInfo ci = CultureInfo.InstalledUICulture ;
var langName = Settings.Instance.LanguageWasSet var langName = AppServices.Current.Settings.LanguageWasSet
? Settings.Instance.Language ? AppServices.Current.Settings.Language
: ci.Name switch : ci.Name switch
{ {
"pl-PL" => "Polski", "pl-PL" => "Polski",
_ => "English" _ => "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); 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;
} }
/// <summary>
/// Swaps the active language by loading the matching XML file from the
/// <c>lang/</c> folder next to the executable (see <see cref="LocalizationService"/>).
/// 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").
/// </summary>
public static void ApplyLanguage(string langNameOrCode) 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); 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() public override void OnFrameworkInitializationCompleted()
{ {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
// Code page 1250 is needed to parse scenery files (done during load).
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); 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 += (_, _) => desktop.ShutdownRequested += (_, _) =>
{ {
Settings.Instance.CaptureAndSave(); try { AppServices.Current.SettingsStore.CaptureAndSave(); }
if (KeyboardConfig.Instance.Dirty) catch (Exception ex) { Diagnostics.Log("Settings save", ex); }
KeyboardConfig.Instance.Save();
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(); SplashWindow? splash = null;
desktop.MainWindow = splash; bool mainReady = false;
splash.Show(); var sw = System.Diagnostics.Stopwatch.StartNew();
// Progress<T> marshals callbacks back to this (UI) thread.
var progress = new Progress<LoadStatus>(splash.Report);
Task.Run(async () => Task.Run(async () =>
{ {
GameData.Instance.Load(progress); var progress = new Progress<LoadStatus>(status =>
await Task.Delay(400); // let the completed bar be visible briefly
}).ContinueWith(_ =>
{
Dispatcher.UIThread.Post(() =>
{ {
var main = new MainWindow(); Dispatcher.UIThread.Post(() =>
desktop.MainWindow = main; // keep a main window before closing the splash {
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(); 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"]);
}
}); });
}); });
} }

View File

@@ -2,14 +2,9 @@ using System.Reflection;
namespace StarterNG; namespace StarterNG;
/// <summary>Static, display-ready information about the running launcher build.</summary>
public static class AppInfo public static class AppInfo
{ {
/// <summary>
/// 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}.
/// </summary>
public static string Version { get; } = BuildVersion(); public static string Version { get; } = BuildVersion();
private static string BuildVersion() private static string BuildVersion()

View File

@@ -0,0 +1,10 @@
namespace StarterNG.Application.Abstractions;
using System;
public interface IClock
{
DateTime Now { get; }
DateTime UtcNow { get; }
}

View File

@@ -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<string> messages);
string Text { get; }
void Clear();
void Flush();
}

View File

@@ -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; }
}

View File

@@ -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<string> lines);
void WriteAllBytes(string path, byte[] bytes);
void CreateDirectory(string path);
void DeleteFile(string path);
void CopyFile(string sourcePath, string destinationPath, bool overwrite);
IReadOnlyList<string> GetFiles(string path, string searchPattern = "*");
IReadOnlyList<string> GetDirectories(string path);
IReadOnlyList<string> GetFilesRecursive(string path, string searchPattern);
DateTime GetLastWriteTimeUtc(string path);
bool IsExecutable(string path);
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,12 @@
namespace StarterNG.Application.Abstractions;
public interface IMiniTextureIndex
{
void Preload();
bool Has(string? miniName);
string? PathFor(string? miniName);
string? FallbackPath { get; }
}

View File

@@ -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);
}

View File

@@ -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<string> arguments, string? workingDirectory,
out string? error);
bool OpenInShell(string pathOrUrl);
IProcessHandle? FindRunning(string processName);
}

View File

@@ -0,0 +1,8 @@
namespace StarterNG.Application.Abstractions;
public interface IRandomSource
{
int Next(int minInclusive, int maxExclusive);
double NextDouble();
}

View File

@@ -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<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null);
Scenery? Load(string path);
}

View File

@@ -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);
}

View File

@@ -0,0 +1,8 @@
using StarterNG.Domain.Vehicles;
namespace StarterNG.Application.Abstractions;
public interface IVehicleRepository
{
int Load(VehicleCatalog catalog);
}

View File

@@ -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; }
}

View File

@@ -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();
}

View File

@@ -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<Scenery> Sceneries { get; } = new();
public bool Loaded { get; private set; }
public void Load(IProgress<LoadStatus>? 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<SceneryLoadProgress>(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<SceneryLoadProgress>? progress = null)
{
var reloaded = _sceneries.LoadAll(progress);
Sceneries.Clear();
Sceneries.AddRange(reloaded);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -1,15 +0,0 @@
namespace StarterNG.Classes;
/// <summary>
/// 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 <see cref="GameData"/>).
/// </summary>
public sealed class AppState
{
public static AppState Instance { get; } = new();
public Scenery? CurrentScenery { get; set; }
public Trainset? CurrentTrainset { get; set; }
}

View File

@@ -5,35 +5,17 @@ using System.Text;
namespace StarterNG.Classes; namespace StarterNG.Classes;
/// <summary>
/// A line-oriented reader/writer for the EU07 <c>eu07.ini</c> configuration
/// file.
///
/// The file is not a classic INI: it is a flat list of <c>key value [value…]</c>
/// entries, one per line, with <c>//</c> 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 <see cref="Set"/> 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.
/// </summary>
public sealed class ConfigFile public sealed class ConfigFile
{ {
private readonly List<string> _lines = new(); private readonly List<string> _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<string, int> _index = private readonly Dictionary<string, int> _index =
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
/// <summary>Parses raw file text, preserving line breaks and content.</summary>
public static ConfigFile Parse(string text) public static ConfigFile Parse(string text)
{ {
var cfg = new ConfigFile(); var cfg = new ConfigFile();
// Normalise CRLF/CR to LF for splitting; output uses Environment.NewLine.
text = text.Replace("\r\n", "\n").Replace('\r', '\n'); text = text.Replace("\r\n", "\n").Replace('\r', '\n');
foreach (var line in text.Split('\n')) foreach (var line in text.Split('\n'))
cfg.AddLine(line); cfg.AddLine(line);
@@ -45,13 +27,11 @@ public sealed class ConfigFile
int idx = _lines.Count; int idx = _lines.Count;
_lines.Add(raw); _lines.Add(raw);
if (TryParseKey(raw, out string key)) if (TryParseKey(raw, out string key))
_index[key] = idx; // later duplicates override earlier ones _index[key] = idx;
} }
/// <summary>True if the key is present as an active (uncommented) entry.</summary>
public bool Has(string key) => _index.ContainsKey(key); public bool Has(string key) => _index.ContainsKey(key);
/// <summary>Raw value (all tokens after the key, comment stripped), or null.</summary>
public string? GetRaw(string key) public string? GetRaw(string key)
{ {
if (!_index.TryGetValue(key, out int i)) if (!_index.TryGetValue(key, out int i))
@@ -89,7 +69,6 @@ public sealed class ConfigFile
CultureInfo.InvariantCulture, out double d) ? d : fallback; CultureInfo.InvariantCulture, out double d) ? d : fallback;
} }
/// <summary>All whitespace-separated value tokens for a key (never null).</summary>
public string[] GetTokens(string key) public string[] GetTokens(string key)
{ {
var v = GetRaw(key); var v = GetRaw(key);
@@ -98,11 +77,6 @@ public sealed class ConfigFile
: v.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); : v.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
} }
/// <summary>
/// Sets the value of <paramref name="key"/>. If the key already exists its
/// line is rewritten in place, preserving original indentation and any
/// trailing comment. Otherwise a new line is appended.
/// </summary>
public void Set(string key, string value) public void Set(string key, string value)
{ {
value ??= string.Empty; value ??= string.Empty;
@@ -125,11 +99,6 @@ public sealed class ConfigFile
} }
} }
/// <summary>
/// Deactivates <paramref name="key"/> 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.
/// </summary>
public void Remove(string key) public void Remove(string key)
{ {
if (!_index.TryGetValue(key, out int i)) if (!_index.TryGetValue(key, out int i))
@@ -153,28 +122,19 @@ public sealed class ConfigFile
public void SetDouble(string key, double value) => public void SetDouble(string key, double value) =>
Set(key, value.ToString("0.###", CultureInfo.InvariantCulture)); Set(key, value.ToString("0.###", CultureInfo.InvariantCulture));
/// <summary>Serialises back to text using the platform newline.</summary>
public string ToText() => string.Join(Environment.NewLine, _lines); public string ToText() => string.Join(Environment.NewLine, _lines);
// --- line parsing helpers -------------------------------------------------
private static bool TryParseKey(string raw, out string key) private static bool TryParseKey(string raw, out string key)
{ {
SplitLine(raw, out _, out key, out _, out _); SplitLine(raw, out _, out key, out _, out _);
return key.Length > 0; return key.Length > 0;
} }
/// <summary>
/// Decomposes a raw line into leading whitespace, key, value (tokens after
/// the key) and trailing comment (including the <c>//</c>). For blank or
/// fully commented-out lines, key is empty.
/// </summary>
private static void SplitLine(string raw, out string indent, out string key, private static void SplitLine(string raw, out string indent, out string key,
out string value, out string comment) out string value, out string comment)
{ {
indent = key = value = comment = string.Empty; indent = key = value = comment = string.Empty;
// leading whitespace
int start = 0; int start = 0;
while (start < raw.Length && char.IsWhiteSpace(raw[start])) while (start < raw.Length && char.IsWhiteSpace(raw[start]))
start++; start++;
@@ -182,7 +142,6 @@ public sealed class ConfigFile
string body = raw.Substring(start); string body = raw.Substring(start);
// A line whose first non-space content is "//" is a pure comment.
if (body.StartsWith("//")) if (body.StartsWith("//"))
{ {
comment = body; comment = body;
@@ -191,8 +150,6 @@ public sealed class ConfigFile
if (body.Length == 0) if (body.Length == 0)
return; 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); int commentAt = FindCommentStart(body);
string content = commentAt >= 0 ? body.Substring(0, commentAt) : body; string content = commentAt >= 0 ? body.Substring(0, commentAt) : body;
if (commentAt >= 0) if (commentAt >= 0)

View File

@@ -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
}
/// <summary>A single progress update emitted while the game data loads.</summary>
public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail);
/// <summary>
/// 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.
/// </summary>
public sealed class GameData
{
public static GameData Instance { get; } = new();
public VehicleDatabase Vehicles { get; } = new();
public List<Scenery> Sceneries { get; } = new();
public bool Loaded { get; private set; }
/// <summary>
/// Parses the vehicle database and all sceneries, reporting progress.
/// Runs synchronously; call it from a background thread. Never throws.
/// </summary>
public void Load(IProgress<LoadStatus>? 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<string> EnumerateScenery(string sceneryDir)
{
if (!Directory.Exists(sceneryDir))
return new List<string>();
return Directory.GetFiles(sceneryDir, "*.scn")
.Where(p => !Path.GetFileName(p).StartsWith("$"))
.ToList();
}
}

View File

@@ -4,16 +4,11 @@ using Avalonia.Input;
namespace StarterNG.Classes; namespace StarterNG.Classes;
/// <summary>
/// One cap on the on-screen keyboard. <see cref="Token"/> 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.
/// </summary>
public sealed class KeyCap public sealed class KeyCap
{ {
public string Label = string.Empty; public string Label = string.Empty;
public string? Token; // null => filler / not bindable public string? Token;
public double Width = 1.0; // relative width (1 == a standard key) public double Width = 1.0;
public KeyCap(string label, string? token = null, double width = 1.0) public KeyCap(string label, string? token = null, double width = 1.0)
{ {
@@ -23,20 +18,12 @@ public sealed class KeyCap
} }
} }
/// <summary>
/// Translates between physical keyboard input, simulator key tokens and the
/// on-screen keyboard layout used by the controls visualisation.
/// </summary>
public static class KeyMap public static class KeyMap
{ {
/// <summary>
/// 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).
/// </summary>
public static string? FromInput(Key key, PhysicalKey physical) public static string? FromInput(Key key, PhysicalKey physical)
{ {
// Numeric keypad first - distinguished only by the physical key.
switch (physical) switch (physical)
{ {
case PhysicalKey.NumPad0: return "num_0"; case PhysicalKey.NumPad0: return "num_0";
@@ -57,15 +44,12 @@ public static class KeyMap
case PhysicalKey.NumPadEnter: return "num_enter"; case PhysicalKey.NumPadEnter: return "num_enter";
} }
// Numeric keypad via the virtual key (fallback when PhysicalKey is Unknown).
if (key >= Key.NumPad0 && key <= Key.NumPad9) if (key >= Key.NumPad0 && key <= Key.NumPad9)
return "num_" + (char)('0' + (key - Key.NumPad0)); return "num_" + (char)('0' + (key - Key.NumPad0));
// Letters.
if (key >= Key.A && key <= Key.Z) if (key >= Key.A && key <= Key.Z)
return ((char)('a' + (key - Key.A))).ToString(); return ((char)('a' + (key - Key.A))).ToString();
// Top-row digits.
if (key >= Key.D0 && key <= Key.D9) if (key >= Key.D0 && key <= Key.D9)
return ((char)('0' + (key - Key.D0))).ToString(); return ((char)('0' + (key - Key.D0))).ToString();
@@ -98,14 +82,12 @@ public static class KeyMap
}; };
} }
/// <summary>True for keys that only act as modifiers and never form a binding alone.</summary>
public static bool IsModifierKey(Key key) => key is public static bool IsModifierKey(Key key) => key is
Key.LeftShift or Key.RightShift or Key.LeftShift or Key.RightShift or
Key.LeftCtrl or Key.RightCtrl or Key.LeftCtrl or Key.RightCtrl or
Key.LeftAlt or Key.RightAlt or Key.LeftAlt or Key.RightAlt or
Key.LWin or Key.RWin; Key.LWin or Key.RWin;
/// <summary>Friendly, explicit name for a token, used in the binding list text.</summary>
public static string DisplayName(string? token) public static string DisplayName(string? token)
{ {
if (string.IsNullOrEmpty(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.
/// <summary>Rows of the main (alphanumeric) keyboard block.</summary>
public static readonly KeyCap[][] MainBlock = public static readonly KeyCap[][] MainBlock =
{ {
// F-row keys are reserved by the simulator, so they carry no token.
new[] new[]
{ {
new KeyCap("Esc", null, 1.4), new KeyCap("Esc", null, 1.4),
@@ -205,7 +182,6 @@ public static class KeyMap
}, },
}; };
/// <summary>Rows of the numeric-keypad block, drawn to the right of the main block.</summary>
public static readonly KeyCap[][] NumpadBlock = public static readonly KeyCap[][] NumpadBlock =
{ {
new[] new[]
@@ -234,10 +210,9 @@ public static class KeyMap
}, },
}; };
/// <summary>Navigation cluster (Insert/Home/PageUp · Delete/End/PageDown), drawn between the blocks.</summary>
public static readonly KeyCap[][] NavBlock = public static readonly KeyCap[][] NavBlock =
{ {
// PgUp / PgDn are reserved by the simulator (no token), like [ ] and Enter.
new[] new[]
{ {
new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp"), new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp"),

View File

@@ -2,63 +2,37 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text; using System.Text;
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Classes; namespace StarterNG.Classes;
/// <summary>
/// A single keyboard command binding parsed from <c>eu07_input-keyboard.ini</c>.
/// A binding is a simulator command name, an optional pair of modifier flags
/// (<c>shift</c> / <c>ctrl</c>, either or both) and the key it is mapped to.
/// <c>none</c> (or an empty key) means the command is intentionally unbound.
/// </summary>
public sealed class KeyBinding public sealed class KeyBinding
{ {
/// <summary>Simulator command token, e.g. <c>mastercontrollerincrease</c>.</summary>
public string Command = string.Empty; public string Command = string.Empty;
/// <summary>True when the binding requires the Shift modifier.</summary>
public bool Shift; public bool Shift;
/// <summary>True when the binding requires the Ctrl modifier.</summary>
public bool Ctrl; public bool Ctrl;
/// <summary>The key token (e.g. <c>q</c>, <c>num_+</c>) or <c>none</c> when unbound.</summary>
public string Key = "none"; public string Key = "none";
/// <summary>Human description taken from the line's trailing comment (no <c>//</c>).</summary>
public string Description = string.Empty; public string Description = string.Empty;
/// <summary>Index into the backing line list, so the original line can be rewritten in place.</summary>
internal int LineIndex = -1; internal int LineIndex = -1;
/// <summary>True when the command is mapped to an actual key.</summary>
public bool IsAssigned => public bool IsAssigned =>
!string.IsNullOrEmpty(Key) && !string.IsNullOrEmpty(Key) &&
!Key.Equals("none", StringComparison.OrdinalIgnoreCase); !Key.Equals("none", StringComparison.OrdinalIgnoreCase);
} }
/// <summary>
/// Reader/writer for the simulator's keyboard layout file,
/// <c>eu07_input-keyboard.ini</c>, which lives next to <c>eu07.ini</c>
/// (<c>%APPDATA%\MaSzyna\</c> on Windows, <c>~/.config/MaSzyna/</c> elsewhere).
///
/// <para>The file is a flat list of <c>command [shift] [ctrl] key // comment</c>
/// lines. Modifiers are the literal tokens <c>shift</c> and <c>ctrl</c> 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.</para>
/// </summary>
public sealed class KeyboardConfig public sealed class KeyboardConfig
{ {
public static KeyboardConfig Instance { get; } = new(); public static KeyboardConfig Instance { get; } = new();
private readonly List<string> _lines = new(); private readonly List<string> _lines = new();
/// <summary>All recognised bindings, in file order.</summary>
public List<KeyBinding> Bindings { get; private set; } = new(); public List<KeyBinding> Bindings { get; private set; } = new();
/// <summary>True when a binding has been changed since the last load/save.</summary>
public bool Dirty { get; set; } public bool Dirty { get; set; }
private string _savePath = string.Empty; private string _savePath = string.Empty;
@@ -67,11 +41,10 @@ public sealed class KeyboardConfig
private static Encoding ResolveEncoding() private static Encoding ResolveEncoding()
{ {
// Comments use Polish diacritics in Windows-1250, like eu07.ini; preserve them.
try try
{ {
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); return LegacyText.CodePage1250;
return Encoding.GetEncoding(1250);
} }
catch catch
{ {
@@ -79,7 +52,6 @@ public sealed class KeyboardConfig
} }
} }
/// <summary>Per-user keyboard config path for the current OS (next to eu07.ini).</summary>
public static string UserConfigPath() public static string UserConfigPath()
{ {
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
@@ -103,15 +75,12 @@ public sealed class KeyboardConfig
return Path.Combine(AppContext.BaseDirectory, "eu07_input-keyboard.ini"); return Path.Combine(AppContext.BaseDirectory, "eu07_input-keyboard.ini");
} }
/// <summary>Default file shipped in the simulator's working directory.</summary>
private static string WorkingDirDefaultPath() => private static string WorkingDirDefaultPath() =>
Path.Combine(Directory.GetCurrentDirectory(), "eu07_input-keyboard.ini"); Path.Combine(Directory.GetCurrentDirectory(), "eu07_input-keyboard.ini");
/// <summary>Fallback template bundled with the launcher (startercfg/).</summary>
private static string BundledDefaultPath() => private static string BundledDefaultPath() =>
Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini"); Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini");
/// <summary>Loads the per-user file, falling back to the working-dir or bundled default.</summary>
public void Load() public void Load()
{ {
_savePath = UserConfigPath(); _savePath = UserConfigPath();
@@ -120,7 +89,6 @@ public sealed class KeyboardConfig
Dirty = false; Dirty = false;
} }
/// <summary>Reloads the shipped defaults, discarding the user's current edits.</summary>
public void LoadDefaults() public void LoadDefaults()
{ {
if (string.IsNullOrEmpty(_savePath)) if (string.IsNullOrEmpty(_savePath))
@@ -151,7 +119,6 @@ public sealed class KeyboardConfig
return paths.Length > 0 ? paths[^1] : string.Empty; return paths.Length > 0 ? paths[^1] : string.Empty;
} }
/// <summary>Parses raw file text into <see cref="Bindings"/>, keeping every line verbatim.</summary>
public void Parse(string text) public void Parse(string text)
{ {
_lines.Clear(); _lines.Clear();
@@ -170,10 +137,6 @@ public sealed class KeyboardConfig
Bindings = bindings; Bindings = bindings;
} }
/// <summary>
/// Parses a single line into a binding. Returns false for blank lines, pure
/// comments and anything without a command token.
/// </summary>
private static bool TryParseBinding(string raw, int lineIndex, out KeyBinding binding) private static bool TryParseBinding(string raw, int lineIndex, out KeyBinding binding)
{ {
binding = new KeyBinding { LineIndex = lineIndex }; binding = new KeyBinding { LineIndex = lineIndex };
@@ -181,7 +144,6 @@ public sealed class KeyboardConfig
string body = raw; string body = raw;
string description = string.Empty; string description = string.Empty;
// Split off a trailing "// comment".
int commentAt = body.IndexOf("//", StringComparison.Ordinal); int commentAt = body.IndexOf("//", StringComparison.Ordinal);
if (commentAt >= 0) if (commentAt >= 0)
{ {
@@ -191,7 +153,7 @@ public sealed class KeyboardConfig
body = body.Trim(); body = body.Trim();
if (body.Length == 0) if (body.Length == 0)
return false; // blank or pure-comment line return false;
var tokens = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); var tokens = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length == 0) if (tokens.Length == 0)
@@ -200,7 +162,6 @@ public sealed class KeyboardConfig
binding.Command = tokens[0]; binding.Command = tokens[0];
binding.Description = description; binding.Description = description;
// Everything after the command is modifiers + (at most) one key.
bool shift = false, ctrl = false; bool shift = false, ctrl = false;
string key = "none"; string key = "none";
for (int i = 1; i < tokens.Length; i++) for (int i = 1; i < tokens.Length; i++)
@@ -208,7 +169,7 @@ public sealed class KeyboardConfig
string t = tokens[i]; string t = tokens[i];
if (t.Equals("shift", StringComparison.OrdinalIgnoreCase)) shift = true; if (t.Equals("shift", StringComparison.OrdinalIgnoreCase)) shift = true;
else if (t.Equals("ctrl", StringComparison.OrdinalIgnoreCase)) ctrl = 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; binding.Shift = shift;
@@ -217,7 +178,6 @@ public sealed class KeyboardConfig
return true; return true;
} }
/// <summary>Rewrites each binding's line, then serialises the file to text.</summary>
public string ToText() public string ToText()
{ {
foreach (var b in Bindings) foreach (var b in Bindings)
@@ -229,7 +189,6 @@ public sealed class KeyboardConfig
return string.Join(Environment.NewLine, _lines); return string.Join(Environment.NewLine, _lines);
} }
/// <summary>Formats one binding as <c>command [shift] [ctrl] key // comment</c>.</summary>
private static string FormatLine(KeyBinding b) private static string FormatLine(KeyBinding b)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -243,7 +202,6 @@ public sealed class KeyboardConfig
return sb.ToString(); return sb.ToString();
} }
/// <summary>Writes the bindings back to the per-user keyboard file.</summary>
public void Save() public void Save()
{ {
if (string.IsNullOrEmpty(_savePath)) if (string.IsNullOrEmpty(_savePath))
@@ -257,9 +215,9 @@ public sealed class KeyboardConfig
File.WriteAllText(_savePath, ToText(), FileEncoding); File.WriteAllText(_savePath, ToText(), FileEncoding);
Dirty = false; Dirty = false;
} }
catch catch (Exception ex)
{ {
// Could not write (permissions / path). Leave values in memory. StarterNG.Infrastructure.Diagnostics.Log($"saving {_savePath}", ex);
} }
} }
} }

View File

@@ -1,230 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace StarterNG.Classes;
/// <summary>
/// Vehicle physics read from a .fiz file (dynamic/&lt;dir&gt;/&lt;model&gt;.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.
/// </summary>
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<string, Physics?> 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<string, string>? _fizIndex;
/// <summary>Builds the .fiz index up front (call from the startup load).</summary>
public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot);
private static Dictionary<string, string> FizIndex(string dynamicRoot)
{
if (_fizIndex != null)
return _fizIndex;
var index = new Dictionary<string, string>(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;
}
/// <summary>
/// Loads (and caches) the physics for a model name (the .fiz is located by name
/// anywhere under dynamic/). The <paramref name="dataFolder"/> is unused now but
/// kept for call-site clarity. Returns null when nothing matches. Never throws.
/// </summary>
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<string> 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 <file> <params...> 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<string>();
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<string> 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();
}
}

View File

@@ -1,37 +1,35 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata; using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions;
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Classes; namespace StarterNG.Classes;
/// <summary>One saved consist in the user's vehicle warehouse.</summary> public enum PresetSort
{
Name,
Track
}
public sealed class TrainsetPreset public sealed class TrainsetPreset
{ {
/// <summary>Display name shown in the "load from warehouse" menu.</summary>
public string Name { get; set; } = ""; public string Name { get; set; } = "";
/// <summary>Complete "trainset … endtrainset" scenery entry (self-contained).</summary>
public string Entry { get; set; } = ""; public string Entry { get; set; } = "";
} }
/// <summary>Root object persisted to userpresets.json.</summary>
public sealed class PresetCollection public sealed class PresetCollection
{ {
public List<TrainsetPreset> Presets { get; set; } = new(); public List<TrainsetPreset> Presets { get; set; } = new();
} }
/// <summary>
/// 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.
/// </summary>
public static class PresetStore public static class PresetStore
{ {
private static string ResolvePath() private static string ResolvePath()
@@ -54,8 +52,8 @@ public static class PresetStore
public static string FilePath { get; } = ResolvePath(); public static string FilePath { get; } = ResolvePath();
// Source-generated metadata keeps (de)serialisation AOT/trim-safe, matching the public static PresetSort SortMode { get; set; } = PresetSort.Name;
// approach used for the vehicle database.
private static readonly JsonSerializerOptions Options = new() private static readonly JsonSerializerOptions Options = new()
{ {
WriteIndented = true, WriteIndented = true,
@@ -65,13 +63,16 @@ public static class PresetStore
private static JsonTypeInfo<PresetCollection> TypeInfo => private static JsonTypeInfo<PresetCollection> TypeInfo =>
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection)); (JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
/// <summary>All saved presets, sorted by name. Empty when none / unreadable.</summary> public static IReadOnlyList<TrainsetPreset> All()
public static IReadOnlyList<TrainsetPreset> All() => Load().Presets; {
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;
}
/// <summary>
/// Saves (or overwrites, by name) a consist preset. No-op on a blank name or
/// empty entry.
/// </summary>
public static void Save(string? name, string? entry) public static void Save(string? name, string? entry)
{ {
name = name?.Trim() ?? ""; name = name?.Trim() ?? "";
@@ -85,7 +86,6 @@ public static class PresetStore
Persist(col); Persist(col);
} }
/// <summary>Removes a preset by name (case-insensitive).</summary>
public static void Delete(string name) public static void Delete(string name)
{ {
var col = Load(); var col = Load();
@@ -93,6 +93,76 @@ public static class PresetStore
Persist(col); 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<string>();
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() private static PresetCollection Load()
{ {
try try
@@ -104,9 +174,9 @@ public static class PresetStore
return col; return col;
} }
} }
catch catch (Exception ex)
{ {
// unreadable / malformed - start from an empty warehouse StarterNG.Infrastructure.Diagnostics.Log($"reading {FilePath}", ex);
} }
return new PresetCollection(); return new PresetCollection();
} }
@@ -118,26 +188,17 @@ public static class PresetStore
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, JsonSerializer.Serialize(col, TypeInfo)); 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);
} }
} }
} }
/// <summary>
/// Helpers to convert a consist to/from its canonical scenery text - the full
/// "trainset … endtrainset" block, exactly what is placed on the clipboard.
/// </summary>
public static class ConsistText public static class ConsistText
{ {
/// <summary>The complete scenery entry for a trainset (incl. endtrainset).</summary>
public static string Serialize(Trainset trainset) => trainset.ToSceneryEntry(); public static string Serialize(Trainset trainset) => trainset.ToSceneryEntry();
/// <summary>
/// Parses the vehicles out of a complete trainset entry. Returns null when the
/// text holds no usable node::dynamic vehicles.
/// </summary>
public static List<Dynamic>? VehiclesFrom(string? text) public static List<Dynamic>? VehiclesFrom(string? text)
{ {
if (string.IsNullOrWhiteSpace(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(PresetCollection))]
[JsonSerializable(typeof(TrainsetPreset))] [JsonSerializable(typeof(TrainsetPreset))]
internal partial class PresetJsonContext : JsonSerializerContext internal partial class PresetJsonContext : JsonSerializerContext

View File

@@ -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<string> Lines;
public List<Trainset> 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)
/// <summary>True when the scenery declares the //$a "archival" flag.</summary>
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>" (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)
/// <summary>True when the scenery actually declared any weather command.</summary>
public bool HasWeather;
/// <summary>Set once the user edits the weather, so export rewrites the config.</summary>
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<Trainset>();
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<string> trainsetEntries = new List<string>();
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));
}
/// <summary>
/// Rebuilds the full .scn content with the (possibly modified) trainsets
/// substituted back into their original positions.
/// </summary>
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;
}
/// <summary>
/// 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).
/// </summary>
private void ParseWeather(string content)
{
// start time: "scenario.time.override h:mm" wins, else top-level "time h:mm"
var ovr = Regex.Match(content, @"(?i)scenario\.time\.override\s+(\d{1,2})[:.](\d{2})");
var time = ovr.Success
? ovr
: Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b");
if (time.Success)
{
WeatherTime = $"{time.Groups[1].Value.PadLeft(2, '0')}:{time.Groups[2].Value}";
HasWeather = true;
}
// "movelight <day>" - 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;
}
}
/// <summary>
/// Replaces the scenery's environment commands with a fresh config block built
/// from the current weather fields (the C# equivalent of the original Starter's
/// TLexParser.ChangeConfig). On any failure the original text is returned
/// unchanged so a launch is never blocked by a bad rewrite.
/// </summary>
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;
}
}
/// <summary>
/// Collects every //$&lt;letter&gt; line (e.g. all //$d lines) and joins them
/// into one multi-line string. Returns null when none are present.
/// </summary>
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()));
}
/// <summary>
/// Reads a single starter directive value (the text after //$&lt;letter&gt;).
/// Returns null when the directive is absent. The trailing whitespace
/// requirement separates e.g. //$d from //$decor and //$i from //$it.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<string>
{
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;
}
}

View File

@@ -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 }
/// <summary>
/// Application settings backing the Settings view, persisted to the simulator's
/// <c>eu07.ini</c>.
///
/// <para><b>Where it loads/saves.</b> The active file lives next to the
/// simulator's own config: <c>%APPDATA%\MaSzyna\eu07.ini</c> on Windows or
/// <c>~/.config/MaSzyna/eu07.ini</c> elsewhere. If that file does not exist yet,
/// defaults are read from an <c>eu07.ini</c> in the launcher's working directory
/// (the same fallback the simulator uses). Saving always writes to the per-user
/// path.</para>
///
/// <para><b>Unknown keys are kept.</b> The whole file is round-tripped through
/// <see cref="ConfigFile"/>; only the keys the UI actually edits are rewritten,
/// so any extra settings the launcher does not model survive a save.</para>
///
/// <para>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.</para>
/// </summary>
public sealed class Settings
{
public static Settings Instance { get; } = new();
/// <summary>
/// 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).
/// </summary>
public Action? CaptureFromUi { get; set; }
/// <summary>True if the loaded config explicitly contained a <c>lang</c> entry.</summary>
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
/// <summary>gfxrenderer tokens in the order shown by the render-engine combo.</summary>
public static readonly string[] RenderEngines =
{ "full", "legacy", "simpleshader", "simple", "off", "experimental" };
/// <summary>Anisotropic-filtering steps for the texture-quality slider (1-5).</summary>
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;
/// <summary>Resolves the per-user eu07.ini path for the current OS.</summary>
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");
}
/// <summary>Default config shipped in the launcher's working directory.</summary>
private static string DefaultConfigPath() =>
Path.Combine(Directory.GetCurrentDirectory(), "eu07.ini");
/// <summary>Directory holding the per-user eu07.ini, for locating launcher-only data alongside it.</summary>
public static string UserConfigDirectory() =>
Path.GetDirectoryName(UserConfigPath()) ?? AppContext.BaseDirectory;
/// <summary>
/// The simulator executable to launch. With auto-selection on, the working
/// directory is scanned for an <c>eu07*</c> binary - <c>eu07</c> on Linux/macOS,
/// <c>eu07.exe</c> on Windows - so the same procedure works on every OS. With it
/// off, the explicitly chosen path is used.
/// </summary>
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;
}
/// <summary>
/// Scans <paramref name="directory"/> (defaults to the working directory) for
/// plausible <c>eu07*</c> launcher binaries, newest first, for populating the
/// manual-selection dropdown. Does not affect <see cref="ResolveExecutable"/>.
/// </summary>
public static List<string> 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<string>(); }
}
// 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;
}
}
/// <summary>Loads settings from the per-user file, or the working-dir default.</summary>
public void Load()
{
_savePath = UserConfigPath();
string source = File.Exists(_savePath) ? _savePath : DefaultConfigPath();
LoadFrom(source);
}
/// <summary>Captures the latest UI values (if the view is open) and saves.</summary>
public void CaptureAndSave()
{
CaptureFromUi?.Invoke();
Save();
}
/// <summary>Loads settings from an arbitrary path without changing where <see cref="Save"/> writes.</summary>
public void LoadFrom(string path)
{
try
{
_config = File.Exists(path)
? ConfigFile.Parse(File.ReadAllText(path, FileEncoding))
: new ConfigFile();
}
catch
{
_config = new ConfigFile();
}
ReadFromConfig();
}
/// <summary>Persists settings to the per-user eu07.ini, keeping unknown keys.</summary>
public void Save()
{
if (string.IsNullOrEmpty(_savePath))
_savePath = UserConfigPath();
SaveTo(_savePath);
}
/// <summary>Writes settings to an arbitrary path without changing where <see cref="Save"/> writes.</summary>
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<string>(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;
}
}

View File

@@ -2,13 +2,14 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using StarterNG.Application;
namespace StarterNG.Classes; namespace StarterNG.Classes;
public static class SettingsProfileStore public static class SettingsProfileStore
{ {
public static string DirectoryPath { get; } = public static string DirectoryPath { get; } =
Path.Combine(Settings.UserConfigDirectory(), "starter", "profiles"); Path.Combine(AppServices.Current.SettingsPaths.UserConfigDirectory(), "starter", "profiles");
public static IReadOnlyList<string> ListProfiles() public static IReadOnlyList<string> ListProfiles()
{ {

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
@@ -22,18 +22,42 @@ public class Trainset
public float Offset; public float Offset;
public float Velocity; public float Velocity;
public float OriginalVelocity; 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 string Description;
public List<Dynamic> Vehicles; public List<Dynamic> Vehicles;
/// <summary>Original .scn text of this trainset block (incl. endtrainset).</summary>
public string RawEntry; public string RawEntry;
/// <summary>True if the block parsed cleanly; false blocks are exported verbatim.</summary>
public bool Parsed; public bool Parsed;
/// <summary>True when the block carries the //$decor flag (decoration, not drivable).</summary>
public bool Decor; public bool Decor;
public string? Logo;
public string? Mini;
public Trainset(string trainsetEntry) public Trainset(string trainsetEntry)
{ {
RawEntry = trainsetEntry; RawEntry = trainsetEntry;
@@ -59,8 +83,6 @@ public class Trainset
.Where(t => !t.StartsWith("//") && !t.StartsWith("/*")) .Where(t => !t.StartsWith("//") && !t.StartsWith("/*"))
.ToList(); .ToList();
// handle specific descriptions
// //$o
var match = Regex.Match( var match = Regex.Match(
trainsetEntry, trainsetEntry,
@"^\s*//\$o\s*(.*)$", @"^\s*//\$o\s*(.*)$",
@@ -69,14 +91,19 @@ public class Trainset
if (match.Success) if (match.Success)
this.Description = match.Groups[1].Value.Trim(); 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++) for (int i = 0; i < tokens.Count; i++)
{ {
if (tokens[i] == "endtrainset") if (tokens[i] == "endtrainset")
break; break;
// trainset properties
if (tokens[i] == "trainset") if (tokens[i] == "trainset")
{ {
this.Name = tokens[++i]; this.Name = tokens[++i];
@@ -87,25 +114,23 @@ public class Trainset
continue; continue;
} }
// skip entire assignments block
if (tokens[i] == "assignments") if (tokens[i] == "assignments")
{ {
while (i < tokens.Count && tokens[i] != "endassignment") while (i < tokens.Count && tokens[i] != "endassignment")
i++; i++;
i++; // jump over endassignment i++;
continue; continue;
} }
// load vehicles
if (tokens[i] == "node") if (tokens[i] == "node")
{ {
Dynamic nodeDynamic = new Dynamic(); Dynamic nodeDynamic = new Dynamic();
nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture); nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture); nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.Name = tokens[++i]; nodeDynamic.Name = tokens[++i];
i++; // dynamic keyword i++;
nodeDynamic.DataFolder = tokens[++i]; nodeDynamic.DataFolder = tokens[++i];
nodeDynamic.SkinFile = tokens[++i]; nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i]);
nodeDynamic.MmdFile = tokens[++i]; nodeDynamic.MmdFile = tokens[++i];
nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture); nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
@@ -117,68 +142,46 @@ public class Trainset
_ => eDriverType.Nobody _ => 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]); 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<string>(); var trailing = new List<string>();
while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic") while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic")
trailing.Add(tokens[++i]); trailing.Add(tokens[++i]);
nodeDynamic.ReadTrailing(trailing); nodeDynamic.ReadTrailing(trailing);
i++; // jump over enddynamic i++;
Vehicles.Add(nodeDynamic); Vehicles.Add(nodeDynamic);
} }
} }
} }
/// <summary>
/// The .scn text for this trainset for export. Unparsed blocks are written
/// back verbatim; parsed blocks are regenerated from the current vehicles.
/// </summary>
public string ToSceneryEntry() => public string ToSceneryEntry() =>
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry; Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
public string GetTrainsetEntry() public string GetTrainsetEntry()
{ {
string entry = "";
entry += "trainset "; var sb = new StringBuilder();
entry += this.Name + " "; if (Decor)
entry += this.Track + " "; sb.Append("//$decor\n");
entry += this.Offset.ToString(CultureInfo.InvariantCulture) + " "; if (!string.IsNullOrEmpty(Description))
entry += this.Velocity.ToString(CultureInfo.InvariantCulture) + " "; sb.Append("//$o ").Append(Description).Append('\n');
entry += "\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) foreach (Dynamic vehicle in Vehicles)
{ entry += vehicle.ToTrainsetNode();
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 +=
$"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; return entry;
} }
@@ -196,112 +199,92 @@ public class Dynamic
public float Offset; public float Offset;
public eDriverType DriverType; public eDriverType DriverType;
/// <summary>
/// Coupling with the next vehicle (the <c>couplingdata</c> field): the bit-mask
/// plus any ".B/.W/.T" parameter codes (brake / wheel / thermo settings).
/// </summary>
public Coupling Coupling = new(); public Coupling Coupling = new();
/// <summary>
/// Low byte of the coupling mask, exposed for the depot's per-bit editor.
/// Backed by <see cref="Coupling"/> so the two never diverge.
/// </summary>
public byte couplingData public byte couplingData
{ {
get => (byte)(Coupling.AbsFlags & 0xFF); get => (byte)(Coupling.AbsFlags & 0xFF);
set => Coupling.Flags = value; set => Coupling.Flags = value;
} }
// --- optional node::dynamic trailing parameters (all positional, all optional) --- public const string PantState = "pantstate";
/// <summary>"Lx" MaxLoad override token (e.g. "L0"), or null when absent.</summary> public const int PantStateMax = 3;
public string? MaxLoad;
/// <summary>Whether a per-vehicle velocity token is present / should be written.</summary>
public bool HasVelocity;
/// <summary>Per-vehicle starting velocity.</summary>
public float Velocity;
/// <summary>Cargo amount (loadcount).</summary>
public int LoadCount;
/// <summary>Cargo type (loadtype) — only meaningful when <see cref="LoadCount"/> &gt; 0.</summary>
public string? LoadType;
/// <summary>Optional cargo destination tokens (rarely used; preserved verbatim).</summary>
public List<string> Destinations = new();
// --- depot / consist-builder extras (not part of the .scn syntax) ---
/// <summary>Explicit miniature name (from the vehicle database). Falls back to SkinFile.</summary>
public string? MiniName;
/// <summary>Whether the vehicle is visually reversed in the consist.</summary>
public bool Flipped;
/// <summary> /// <summary>
/// Parses the optional positional parameters that follow couplingdata: /// Lives in the coupling token as ".L&lt;n&gt;" - that is where the simulator reads
/// <c>[Lx] velocity [loadcount [loadtype]] [destination [destination]]</c>. /// it from (DynObj.cpp, the MoreParams loop). The older space-separated "L&lt;n&gt;"
/// See https://wiki.eu07.pl/index.php?title=Obiekt_node::dynamic /// 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.
/// </summary> /// </summary>
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<string> t) internal void ReadTrailing(List<string> t)
{ {
int p = 0; int p = 0;
// Lx — MaxLoad override (e.g. "L0"); only when it genuinely looks like one. // Legacy form: max load as its own token in front of the load count. The
if (p < t.Count && (t[p][0] == 'L' || t[p][0] == 'l') // current scenario parser reads that slot as an int, so this is read only -
&& t[p].Length > 1 && char.IsDigit(t[p][1])) // the setter moves the value into the coupling token, where it is written.
MaxLoad = t[p++]; if (p < t.Count && t[p].Length > 0 && t[p][0] == 'L')
// velocity — the first plain number after the coupling
if (p < t.Count && float.TryParse(t[p], NumberStyles.Float,
CultureInfo.InvariantCulture, out float v))
{ {
Velocity = v; if (int.TryParse(t[p].AsSpan(1), NumberStyles.Integer,
HasVelocity = true; CultureInfo.InvariantCulture, out int ml))
MaxLoad = ml;
p++; p++;
} }
// loadcount [loadtype] — loadtype is present only when loadcount > 0
if (p < t.Count && int.TryParse(t[p], NumberStyles.Integer, if (p < t.Count && int.TryParse(t[p], NumberStyles.Integer,
CultureInfo.InvariantCulture, out int lc)) CultureInfo.InvariantCulture, out int lc))
{ {
LoadCount = lc; LoadCount = lc;
p++; p++;
if (lc > 0 && p < t.Count) if (p < t.Count && (lc > 0 || IsPantStateType(t[p])))
LoadType = t[p++]; LoadType = t[p++];
} }
// remaining tokens: optional destinations, kept verbatim
while (p < t.Count)
Destinations.Add(t[p++]);
} }
/// <summary> internal static string StripSkinExtension(string token)
/// Regenerates the trailing parameter string (each present token prefixed with {
/// a space), the inverse of <see cref="ReadTrailing"/>. A loadcount of 0 is int dot = token.LastIndexOf('.');
/// omitted, matching the canonical trainset entries the simulator expects. return dot > 0 ? token[..dot] : token;
/// </summary> }
internal string WriteTrailing() internal string WriteTrailing()
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
if (MaxLoad != null)
sb.Append(' ').Append(MaxLoad); bool loaded = !string.IsNullOrEmpty(LoadType);
if (HasVelocity) sb.Append(' ').Append((loaded ? LoadCount : 0).ToString(CultureInfo.InvariantCulture));
sb.Append(' ').Append(Velocity.ToString(CultureInfo.InvariantCulture));
if (LoadCount > 0) if (loaded && (LoadCount > 0 || IsPantState))
{ sb.Append(' ').Append(LoadType);
sb.Append(' ').Append(LoadCount.ToString(CultureInfo.InvariantCulture));
if (!string.IsNullOrEmpty(LoadType))
sb.Append(' ').Append(LoadType);
}
foreach (string d in Destinations)
sb.Append(' ').Append(d);
return sb.ToString(); return sb.ToString();
} }
/// <summary>Deep copy, used when importing a trainset into the editable consist.</summary>
public Dynamic Clone() => new Dynamic public Dynamic Clone() => new Dynamic
{ {
RangeMax = RangeMax, RangeMax = RangeMax,
@@ -314,50 +297,77 @@ public class Dynamic
Offset = Offset, Offset = Offset,
DriverType = DriverType, DriverType = DriverType,
Coupling = Coupling.Clone(), Coupling = Coupling.Clone(),
MaxLoad = MaxLoad,
HasVelocity = HasVelocity, HasVelocity = HasVelocity,
Velocity = Velocity, Velocity = Velocity,
LoadCount = LoadCount, LoadCount = LoadCount,
LoadType = LoadType, LoadType = LoadType,
Destinations = new List<string>(Destinations),
MiniName = MiniName, MiniName = MiniName,
Flipped = Flipped 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<Dynamic> Cars { get; set; } = new();
public bool Grouped { get; set; }
public bool Flipped { get; set; }
public eDriverType Driver { get; set; } = eDriverType.Nobody;
} }
/// <summary>
/// The <c>couplingdata</c> 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
/// </summary>
public sealed class Coupling 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)
/// <summary>Signed mask exactly as written; negative = locked.</summary> 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; public int Flags;
/// <summary>
/// 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.
/// </summary>
public List<string> Parameters = new(); public List<string> Parameters = new();
/// <summary>Mask with the lock sign removed (what the simulator tests bits on).</summary>
public int AbsFlags => Flags < 0 ? -Flags : Flags; public int AbsFlags => Flags < 0 ? -Flags : Flags;
/// <summary>True when the coupling is marked permanent (negative mask).</summary>
public bool Locked public bool Locked
{ {
get => Flags < 0; get => Flags < 0;
@@ -366,7 +376,6 @@ public sealed class Coupling
public bool Has(int bit) => (AbsFlags & bit) != 0; public bool Has(int bit) => (AbsFlags & bit) != 0;
/// <summary>Sets or clears a mask bit, preserving the lock sign.</summary>
public void Set(int bit, bool on) public void Set(int bit, bool on)
{ {
int abs = on ? (AbsFlags | bit) : (AbsFlags & ~bit); int abs = on ? (AbsFlags | bit) : (AbsFlags & ~bit);
@@ -388,25 +397,21 @@ public sealed class Coupling
return c; return c;
} }
/// <summary>The brake-rack setting carried in the "B" parameter, or null.</summary>
public BrakeSetting? GetBrake() => public BrakeSetting? GetBrake() =>
BrakeSetting.FromParameter( BrakeSetting.FromParameter(
Parameters.FirstOrDefault(p => p.StartsWith("B", StringComparison.Ordinal))); Parameters.FirstOrDefault(p => p.StartsWith("B", StringComparison.Ordinal)));
/// <summary>Replaces (or clears, when null) the "B" brake parameter.</summary>
public void SetBrake(BrakeSetting? brake) public void SetBrake(BrakeSetting? brake)
{ {
Parameters.RemoveAll(p => p.StartsWith("B", StringComparison.Ordinal)); Parameters.RemoveAll(p => p.StartsWith("B", StringComparison.Ordinal));
if (brake != null) if (brake is { IsEmpty: false })
Parameters.Insert(0, brake.ToParameter()); Parameters.Insert(0, brake.ToParameter());
} }
/// <summary>The wheel/damage setting carried in the "W" parameter, or null.</summary>
public WheelSettings? GetWheels() => public WheelSettings? GetWheels() =>
WheelSettings.FromParameter( WheelSettings.FromParameter(
Parameters.FirstOrDefault(p => p.StartsWith("W", StringComparison.Ordinal))); Parameters.FirstOrDefault(p => p.StartsWith("W", StringComparison.Ordinal)));
/// <summary>Replaces (or clears, when empty/null) the "W" wheel parameter.</summary>
public void SetWheels(WheelSettings? wheels) public void SetWheels(WheelSettings? wheels)
{ {
Parameters.RemoveAll(p => p.StartsWith("W", StringComparison.Ordinal)); Parameters.RemoveAll(p => p.StartsWith("W", StringComparison.Ordinal));
@@ -414,13 +419,44 @@ public sealed class Coupling
Parameters.Add(wheels.ToParameter()); Parameters.Add(wheels.ToParameter());
} }
/// <summary>
/// Max load override, the ".L&lt;n&gt;" sub-parameter. -1 means "not set", so the
/// vehicle keeps the MaxLoad from its .fiz.
/// </summary>
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() public Coupling Clone() => new()
{ {
Flags = Flags, Flags = Flags,
Parameters = new List<string>(Parameters) Parameters = new List<string>(Parameters)
}; };
/// <summary>Serialises back to "&lt;flags&gt;[.param[.param…]]".</summary>
public override string ToString() public override string ToString()
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -431,67 +467,72 @@ public sealed class Coupling
} }
} }
/// <summary>
/// A vehicle's brake-rack setting, carried in the "B" parameter of its coupling
/// field: <c>B&lt;mode&gt;[&lt;load&gt;][&lt;switch&gt;]</c> (e.g. "BP", "BRA", "BG1").
/// See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
/// </summary>
public sealed class BrakeSetting public sealed class BrakeSetting
{ {
/// <summary>Brake modes, longest first so "R+Mg" wins over "R" while parsing.</summary> public static readonly string[] Modes = { "G", "P", "R", "M" };
public static readonly string[] Modes = { "R+Mg", "G", "P", "R", "Q", "O", "A" };
/// <summary>Brake mode: G (freight), P (passenger), R (express), R+Mg, Q, O (off), A (auto).</summary> public static readonly string[] Loads = { "T", "H", "F" };
public string Mode = "P";
// 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;
/// <summary>Load adaptation: T (empty), H (medium), F (loaded), A (auto), or null.</summary>
public string? Load; public string? Load;
/// <summary>Brake on/off switch: 0 (off), 1 (off ~10%), A (on), or null.</summary>
public string? Switch; 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) 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; return null;
string body = param.Substring(1); string body = param.Substring(1).ToUpperInvariant();
string? mode = Modes.FirstOrDefault(m => body.StartsWith(m, StringComparison.Ordinal)); var b = new BrakeSetting
if (mode is null) {
return null; // "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 }; static string? First(string body, params string[] codes) =>
int p = mode.Length; Array.Find(codes, c => body.Contains(c, StringComparison.Ordinal));
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;
} }
public string ToParameter() => "B" + Mode + (Load ?? "") + (Switch ?? ""); public string ToParameter() => "B" + Mode + Switch + Load;
} }
/// <summary>
/// Wheel-damage settings carried in the "W" parameter of a vehicle's coupling
/// field: <c>W[H&lt;sway%&gt;][F&lt;flat mm&gt;][R&lt;random flat mm&gt;][P&lt;flat prob%&gt;]</c>
/// (e.g. "WH25F5R10P8"). See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
/// </summary>
public sealed class WheelSettings public sealed class WheelSettings
{ {
/// <summary>H — sway ("wężykowanie") probability, %.</summary>
public int Sway; public int Sway;
/// <summary>F — flat spot ("podkucie") of this size, mm.</summary>
public int Flatness; public int Flatness;
/// <summary>R — additional random flat spot, 0..x mm.</summary>
public int FlatnessRand; public int FlatnessRand;
/// <summary>P — flat-spot probability, % (guaranteed when omitted).</summary> /// <summary>
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.
/// </summary>
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) public static WheelSettings? FromParameter(string? param)
{ {
@@ -524,7 +565,7 @@ public sealed class WheelSettings
if (Sway > 0) sb.Append('H').Append(Sway); if (Sway > 0) sb.Append('H').Append(Sway);
if (Flatness > 0) sb.Append('F').Append(Flatness); if (Flatness > 0) sb.Append('F').Append(Flatness);
if (FlatnessRand > 0) sb.Append('R').Append(FlatnessRand); 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(); return sb.ToString();
} }
} }

View File

@@ -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).
// =====================================================================
/// <summary>Root object of a single vehicle JSON file.</summary>
public class VehicleEntry
{
[JsonPropertyName("uuid")] public string? Uuid { get; set; }
[JsonPropertyName("schema_version")] public int SchemaVersion { get; set; }
[JsonPropertyName("groups")] public List<VehicleGroup> Groups { get; set; } = new();
[JsonPropertyName("textures")] public List<VehicleTexture> Textures { get; set; } = new();
[JsonPropertyName("sets")] public List<VehicleSet> Sets { get; set; } = new();
[JsonPropertyName("unknown")] public List<string> Unknown { get; set; } = new();
}
/// <summary>Merged database root (databases/vehicles/vehicles.json).</summary>
public class VehicleEntryCollection
{
[JsonPropertyName("vehicles")] public List<VehicleEntry> Vehicles { get; set; } = new();
}
/// <summary>Legacy header / vehicle category group.</summary>
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; }
}
/// <summary>A single .mat skin entry.</summary>
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<VehicleAlias> Aliases { get; set; } = new();
[JsonPropertyName("meta")] public VehicleMeta? Meta { get; set; }
/// <summary>Full skin path = directory + skinfile.</summary>
[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).
/// <summary>Group's "mini" (or this texture's mini_ref) — the vehicle class. Never null.</summary>
[JsonIgnore] public string ResolvedClass { get; internal set; } = "";
/// <summary>Group's "category" letter, or null when the group is unknown.</summary>
[JsonIgnore] public string? ResolvedCategory { get; internal set; }
}
/// <summary>Alternate mapping from a malformed multi-= legacy line.</summary>
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; }
}
/// <summary>Parsed metadata from the legacy // comment section.</summary>
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<string> Extra { get; set; } = new();
}
/// <summary>
/// Aggregated, in-memory view over every vehicle JSON file found in
/// databases/vehicles/. Provides lookup helpers for the depot UI.
/// </summary>
public class VehicleDatabase
{
public List<VehicleTexture> Textures { get; } = new();
public Dictionary<string, VehicleGroup> GroupsById { get; } = new();
public List<VehicleSet> Sets { get; } = new();
/// <summary>Texture-uuid -> automatic-consist set that contains it.</summary>
public Dictionary<string, VehicleSet> SetByTextureUuid { get; } = new();
/// <summary>Texture-uuid -> texture (includes wrecks, so set refs resolve).</summary>
public Dictionary<string, VehicleTexture> TextureByUuid { get; } = new();
/// <summary>skinfile (without extension, lower-case) -> texture.</summary>
public Dictionary<string, VehicleTexture> 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,
};
/// <summary>
/// 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.
/// </summary>
public void Load(string directory = "databases/vehicles/")
{
BeginLoad();
foreach (string file in EnumerateFiles(directory))
LoadFile(file);
EndLoad();
}
/// <summary>Resets the aggregate before an incremental load.</summary>
public void BeginLoad()
{
Textures.Clear();
GroupsById.Clear();
Sets.Clear();
SetByTextureUuid.Clear();
TextureByUuid.Clear();
TextureBySkin.Clear();
}
/// <summary>Finalises an incremental load (builds lookup indexes).</summary>
public void EndLoad()
{
BuildSetIndex();
BuildTextureIndex();
}
/// <summary>
/// Files that make up the database: the merged vehicles.json if present,
/// otherwise every per-vehicle *.json. Used to drive load progress.
/// </summary>
public static List<string> EnumerateFiles(string directory = "databases/vehicles/")
{
if (!Directory.Exists(directory))
return new List<string>();
string merged = Path.Combine(directory, "vehicles.json");
if (File.Exists(merged))
return new List<string> { merged };
return Directory.GetFiles(directory, "*.json").ToList();
}
/// <summary>Ingests a single database file (merged or per-vehicle).</summary>
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<VehicleEntryCollection>)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<VehicleEntry>)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);
}
/// <summary>
/// If the texture belongs to an automatic-consist set, returns all of that
/// set's textures in their defined order; otherwise null.
/// </summary>
public List<VehicleTexture>? 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<VehicleTexture>();
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;
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Resolved mini for a skin file (matched case-insensitively), or null.</summary>
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;
}
/// <summary>The texture for a skin file (matched case-insensitively), or null.</summary>
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;
}
/// <summary>Header label for the group a texture belongs to.</summary>
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<string, string>? _miniIndex;
/// <summary>
/// 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.
/// </summary>
public static void PreloadMiniIndex(string miniDir = "textures/mini/") =>
_miniIndex ??= BuildMiniIndex(miniDir);
/// <summary>
/// Resolves a miniature .bmp path under textures/mini/ case-insensitively,
/// or null if missing. The directory is indexed once and reused.
/// </summary>
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<string, string> BuildMiniIndex(string miniDir)
{
var index = new Dictionary<string, string>();
if (!Directory.Exists(miniDir))
return index;
foreach (string file in Directory.GetFiles(miniDir, "*.bmp"))
index[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = file;
return index;
}
}
/// <summary>Automatic consist definition (legacy ^x grouping).</summary>
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<string> 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
{
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,69 @@
using Avalonia;
using Avalonia.Controls;
namespace StarterNG.Controls;
public class SettingRow : ContentControl
{
public static readonly StyledProperty<string?> LabelProperty =
AvaloniaProperty.Register<SettingRow, string?>(nameof(Label));
public static readonly StyledProperty<string?> DescriptionProperty =
AvaloniaProperty.Register<SettingRow, string?>(nameof(Description));
public static readonly StyledProperty<string?> ConfigKeyProperty =
AvaloniaProperty.Register<SettingRow, string?>(nameof(ConfigKey));
public static readonly StyledProperty<string?> ValueTextProperty =
AvaloniaProperty.Register<SettingRow, string?>(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);
}
}

View File

@@ -0,0 +1,16 @@
using Avalonia;
using Avalonia.Controls.Primitives;
namespace StarterNG.Controls;
public class SettingsSection : HeaderedContentControl
{
public static readonly StyledProperty<object?> ToggleProperty =
AvaloniaProperty.Register<SettingsSection, object?>(nameof(Toggle));
public object? Toggle
{
get => GetValue(ToggleProperty);
set => SetValue(ToggleProperty, value);
}
}

141
StarterNG/Domain/Cargo.cs Normal file
View File

@@ -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<string> AcceptedTypes(Dynamic car)
{
var p = _info.PhysicsFor(car);
if (!IsLoadable(car) || p == null)
return new List<string>();
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<ConsistItem> 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<ConsistItem> 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<ConsistItem> 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<ConsistItem> 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<ConsistItem> consist, ConsistItem item)
{
for (int i = 0; i < consist.Count; i++)
if (ReferenceEquals(consist[i], item))
return i;
return -1;
}
}

585
StarterNG/Domain/Consist.cs Normal file
View File

@@ -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<ConsistItem>
{
private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" };
private readonly VehicleCatalog _db;
private readonly VehicleInfo _info;
private readonly List<ConsistItem> _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<ConsistItem> 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<Dynamic> { 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<string>(set.TextureRefs.Where(r => !string.IsNullOrEmpty(r)));
var group = new List<Dynamic>();
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<Dynamic> { 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<Dynamic> { cars[i] },
Grouped = false,
Flipped = cars[i].Offset < 0,
Driver = cars[i].DriverType
});
i++;
}
Selected = _items.FirstOrDefault();
SyncStartingVehicle();
Raise();
}
public List<Dynamic> Flatten()
{
var flat = new List<Dynamic>();
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<string>(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<Dynamic> { 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<Dynamic>();
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<int>();
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<Dynamic> UnitTargets(ConsistItem item) => item.Cars;
public IEnumerable<Dynamic> 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<Dynamic> 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();
}
}

View File

@@ -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<Trainset> Trainsets { get; } = new();
public List<SceneryAttachment> Attachments { get; } = new();
public List<SceneryInclude> Includes { get; } = new();
public List<Dynamic> LooseVehicles { get; } = new();
public List<string> LooseVehicleNames { get; } = new();
public List<string> 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;
}
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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" };
}

View File

@@ -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<string[]> _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<VehicleTexture, Dynamic> 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<VehicleTexture> { 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<string> ExpandMini(string mini)
{
var list = new List<string> { 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<VehicleTexture> FindCandidates(
IReadOnlyList<string> minis,
string? owner,
int? revYear,
VehicleTexture? current)
{
var result = new List<VehicleTexture>();
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;
}
}

View File

@@ -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<Dynamic, VehiclePhysics?> physicsFor,
Func<Dynamic, string?>? categoryOf = null,
Func<string?, int>? 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<Dynamic, VehiclePhysics?> physicsFor,
string lengthLabel, string massLabel, string vehiclesLabel, string trackLabel,
Func<Dynamic, string?>? categoryOf = null,
Func<string?, int>? 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<Dynamic, VehiclePhysics?> physicsFor,
Func<Dynamic, string?>? categoryOf,
Func<string?, int>? 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<string> 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<string> CollectNames(Scenery scenery, Trainset? except = null)
{
var used = new HashSet<string>(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<string> used)
{
var (_, number) = SplitWagonNumber(v.Name);
if (!string.IsNullOrEmpty(v.Name))
used.Remove(v.Name!);
v.Name = EnsureUnique(PreferredBaseName(v), used) + number;
}
}

199
StarterNG/Domain/UData.cs Normal file
View File

@@ -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<Dynamic, VehiclePhysics?> 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<Dynamic, VehiclePhysics?> 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<int> CheckFlag(int flag)
{
var result = new List<int>();
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<Dynamic> vehicles, int leftVehicle,
Func<Dynamic, VehiclePhysics?> 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<int> GetMultiple(IList<Dynamic> vehicles, int index,
Func<Dynamic, string?> setIdOf)
{
var result = new List<int> { 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<Dynamic, VehiclePhysics?> physicsFor, Func<Dynamic, string?> 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<Dynamic, VehiclePhysics?> physicsFor, Func<Dynamic, string?> categoryOf,
Func<string?, int> 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<Dynamic> vehicles,
Func<Dynamic, VehiclePhysics?> 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<Dynamic> vehicles, int position,
Func<Dynamic, string?> setIdOf, Func<Dynamic, VehiclePhysics?> 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<Dynamic> vehicles, int fromPos, int toPos,
Func<Dynamic, string?> 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;
}
}

154
StarterNG/Domain/UDepot.cs Normal file
View File

@@ -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<Dynamic> 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<DepotTrain> ReadDepot(string? path = null)
{
var result = new List<DepotTrain>();
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<string>();
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<DepotTrain> 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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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<string, int> _weights;
public LoadWeightsTable(IReadOnlyDictionary<string, int>? weights = null)
{
_weights = new Dictionary<string, int>(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;
}

View File

@@ -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<VehicleTexture> Textures { get; } = new();
public Dictionary<string, VehicleGroup> GroupsById { get; } = new();
public List<VehicleSet> Sets { get; } = new();
public Dictionary<string, VehicleSet> SetByTextureUuid { get; } = new();
public Dictionary<string, VehicleTexture> TextureByUuid { get; } = new();
public Dictionary<string, VehicleTexture> 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<VehicleTexture>? 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<VehicleTexture>();
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;
}
}
}

View File

@@ -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; } = "";
/// <summary>
/// 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.
/// </summary>
public bool IsDieselEngine =>
EngineType.Equals("DieselEngine", StringComparison.OrdinalIgnoreCase)
|| EngineType.Equals("DieselElectric", StringComparison.OrdinalIgnoreCase)
|| EngineType.Equals("DumbDE", StringComparison.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,74 @@
using System.Collections.Generic;
namespace StarterNG.Domain.Vehicles;
public sealed class VehicleEntry
{
public string? Uuid { get; set; }
public List<VehicleGroup> Groups { get; } = new();
public List<VehicleTexture> Textures { get; } = new();
public List<VehicleSet> 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<VehicleAlias> 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<string> Extra { get; } = new();
}
public sealed class VehicleSet
{
public string? Uuid { get; set; }
public string? Mode { get; set; }
public int Count { get; set; }
public List<string> TextureRefs { get; set; } = new();
}

View File

@@ -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<string> _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<string> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Text;
namespace StarterNG.Infrastructure.Adapters;
/// <summary>
/// The code page every file the simulator owns is written in: sceneries,
/// textures.txt, .fiz, timetables, eu07.ini.
/// </summary>
/// <remarks>
/// Code page 1250 is not built into .NET; it arrives with
/// <see cref="CodePagesEncodingProvider"/>, which has to be registered before the
/// first <c>GetEncoding</c> 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.
/// </remarks>
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;
}
}
}

View File

@@ -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<string> 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<string> GetFiles(string path, string searchPattern = "*") =>
Directory.Exists(path) ? Directory.GetFiles(path, searchPattern) : Array.Empty<string>();
public IReadOnlyList<string> GetFilesRecursive(string path, string searchPattern) =>
Directory.Exists(path)
? Directory.GetFiles(path, searchPattern, SearchOption.AllDirectories)
: Array.Empty<string>();
public IReadOnlyList<string> GetDirectories(string path) =>
Directory.Exists(path) ? Directory.GetDirectories(path) : Array.Empty<string>();
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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<string> 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);
}
}

View File

@@ -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();
}

View File

@@ -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<string> 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<string> CheckInstallation() =>
new InstallationCheck(
AppServices.Current.Paths,
AppServices.Current.Files,
AppServices.Current.Localization,
AppServices.Current.Physics,
AppServices.Current.Library,
AppServices.Current.SettingsStore)
.Run();
}

View File

@@ -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<string> _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();
}
}

View File

@@ -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;
}
}

View File

@@ -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<double> _midpoints = new();
private DateTime _changedAt = DateTime.MinValue;
public int Index { get; private set; } = -1;
public void Capture(IEnumerable<double> 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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<string> Run()
{
var faults = new List<string>();
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;
}
}

View File

@@ -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);
}
}

View File

@@ -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<string>(
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;
}
}

View File

@@ -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<string, string?> _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;
}
}

Some files were not shown because too many files have changed in this diff Show More