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

View File

@@ -1,22 +1,24 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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:CompileBindings="True"
RequestedThemeVariant="Dark">
<!-- Dark variant matches the original Starter's "Carbon" VCL style. -->
<Application.Styles>
<FluentTheme />
<materialIcons:MaterialIconStyles />
<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.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"/>
<!-- 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="SystemAccentColorDark1">#136B00</Color>
<Color x:Key="SystemAccentColorDark2">#0F5600</Color>
@@ -29,5 +31,13 @@
<SolidColorBrush x:Key="AccentFillColorTertiaryBrush" Color="#136B00" />
<SolidColorBrush x:Key="SystemControlHighlightListAccentLowBrush" Color="#177f00" />
<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>

View File

@@ -8,95 +8,185 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using StarterNG.Application;
using StarterNG.Classes;
using StarterNG.Infrastructure;
using StarterNG.Services;
namespace StarterNG;
public partial class App : Application
public partial class App : Avalonia.Application
{
public static LocalizationService Loc { get; } = new();
/// <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()
{
AvaloniaXamlLoader.Load(this);
// Load the persisted configuration up front so the saved language wins;
// fall back to the system culture only when the file has no lang entry.
Settings.Instance.Load();
AppServices.Current.SettingsStore.Load();
// Key bindings live in eu07_input-keyboard.ini next to eu07.ini.
KeyboardConfig.Instance.Load();
CultureInfo ci = CultureInfo.InstalledUICulture ;
var langName = Settings.Instance.LanguageWasSet
? Settings.Instance.Language
var langName = AppServices.Current.Settings.LanguageWasSet
? AppServices.Current.Settings.Language
: ci.Name switch
{
"pl-PL" => "Polski",
_ => "English"
};
Settings.Instance.Language = langName;
AppServices.Current.Settings.Language = langName;
// What eu07.ini asked for, before the starter picks a language it can
// actually display.
string requestedCode = AppServices.Current.Settings.LanguageCode;
ApplyLanguage(langName);
// The starter ships no translation for this code, so its own interface
// falls back - but the simulator has one and keeps it. Overwriting the key
// here would silently move eu07.exe to English on the next save.
if (!string.IsNullOrWhiteSpace(requestedCode) &&
!string.Equals(requestedCode, Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase))
AppServices.Current.Settings.LanguageCode = requestedCode;
}
/// <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)
{
// Loc.Load resolves the file and returns the canonical display name,
// which we mirror back into the settings so the stored value stays valid.
var resolvedName = Loc.Load(langNameOrCode);
Settings.Instance.Language = resolvedName;
// The service is the authority on which language actually loaded: the code
// goes to eu07.ini for the simulator, the name to the picker.
AppServices.Current.Settings.Language = resolvedName;
AppServices.Current.Settings.LanguageCode = Loc.CurrentLangCode;
ApplyCulture(Loc.CurrentLangCode);
}
private static void ApplyCulture(string langCode)
{
CultureInfo culture;
try
{
culture = langCode switch
{
"" or "en" => CultureInfo.InvariantCulture,
"cz" => CultureInfo.GetCultureInfo("cs"),
_ => CultureInfo.GetCultureInfo(langCode)
};
}
catch (CultureNotFoundException)
{
culture = CultureInfo.InvariantCulture;
}
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
CultureInfo.CurrentCulture = culture;
CultureInfo.CurrentUICulture = culture;
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Code page 1250 is needed to parse scenery files (done during load).
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
// Persist settings when the launcher is closing. Key bindings are only
// rewritten when actually edited, so an untouched file keeps its layout.
desktop.ShutdownRequested += (_, _) =>
{
Settings.Instance.CaptureAndSave();
if (KeyboardConfig.Instance.Dirty)
KeyboardConfig.Instance.Save();
try { AppServices.Current.SettingsStore.CaptureAndSave(); }
catch (Exception ex) { Diagnostics.Log("Settings save", ex); }
AppServices.Current.MissingVehicleLog.Dump();
try
{
if (KeyboardConfig.Instance.Dirty)
KeyboardConfig.Instance.Save();
}
catch (Exception ex) { Diagnostics.Log("Keyboard config save", ex); }
Diagnostics.Flush();
};
var splash = new SplashWindow();
desktop.MainWindow = splash;
splash.Show();
// Progress<T> marshals callbacks back to this (UI) thread.
var progress = new Progress<LoadStatus>(splash.Report);
SplashWindow? splash = null;
bool mainReady = false;
var sw = System.Diagnostics.Stopwatch.StartNew();
Task.Run(async () =>
{
GameData.Instance.Load(progress);
await Task.Delay(400); // let the completed bar be visible briefly
}).ContinueWith(_ =>
{
Dispatcher.UIThread.Post(() =>
var progress = new Progress<LoadStatus>(status =>
{
var main = new MainWindow();
desktop.MainWindow = main; // keep a main window before closing the splash
Dispatcher.UIThread.Post(() =>
{
if (mainReady)
return;
if (sw.ElapsedMilliseconds > 300 && splash == null)
{
splash = new SplashWindow();
desktop.MainWindow = splash;
splash.Show();
}
splash?.Report(status);
});
});
AppServices.Current.Library.Load(progress);
}).ContinueWith(load =>
{
Dispatcher.UIThread.Post(async () =>
{
MainWindow main;
try
{
main = new MainWindow();
}
catch (Exception ex)
{
Diagnostics.Log("Main window", ex);
Diagnostics.Flush();
Console.Error.WriteLine(ex);
mainReady = true;
splash?.Close();
desktop.Shutdown(1);
return;
}
mainReady = true;
desktop.MainWindow = main;
main.Show();
splash.Close();
splash?.Close();
splash = null;
if (load.IsFaulted && load.Exception is { } error)
{
var inner = error.GetBaseException();
Diagnostics.Log("Game data load", inner);
await Diagnostics.ReportAsync(
$"{Loc["FaultLoadData"]}{Environment.NewLine}{Environment.NewLine}{Loc["FaultDetail"]} {inner.Message}");
}
var faults = Diagnostics.CheckInstallation();
if (faults.Count > 0)
{
await Diagnostics.ReportAsync(
string.Join(Environment.NewLine, faults) + Environment.NewLine + Environment.NewLine +
Loc["FaultBadInstall"]);
}
});
});
}
base.OnFrameworkInitializationCompleted();
}
}
}

View File

@@ -2,14 +2,9 @@ using System.Reflection;
namespace StarterNG;
/// <summary>Static, display-ready information about the running launcher build.</summary>
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();
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;
/// <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
{
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 =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Parses raw file text, preserving line breaks and content.</summary>
public static ConfigFile Parse(string text)
{
var cfg = new ConfigFile();
// Normalise CRLF/CR to LF for splitting; output uses Environment.NewLine.
text = text.Replace("\r\n", "\n").Replace('\r', '\n');
foreach (var line in text.Split('\n'))
cfg.AddLine(line);
@@ -45,13 +27,11 @@ public sealed class ConfigFile
int idx = _lines.Count;
_lines.Add(raw);
if (TryParseKey(raw, out string key))
_index[key] = idx; // later duplicates override earlier ones
_index[key] = idx;
}
/// <summary>True if the key is present as an active (uncommented) entry.</summary>
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)
{
if (!_index.TryGetValue(key, out int i))
@@ -89,7 +69,6 @@ public sealed class ConfigFile
CultureInfo.InvariantCulture, out double d) ? d : fallback;
}
/// <summary>All whitespace-separated value tokens for a key (never null).</summary>
public string[] GetTokens(string key)
{
var v = GetRaw(key);
@@ -98,11 +77,6 @@ public sealed class ConfigFile
: 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)
{
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)
{
if (!_index.TryGetValue(key, out int i))
@@ -153,28 +122,19 @@ public sealed class ConfigFile
public void SetDouble(string key, double value) =>
Set(key, value.ToString("0.###", CultureInfo.InvariantCulture));
/// <summary>Serialises back to text using the platform newline.</summary>
public string ToText() => string.Join(Environment.NewLine, _lines);
// --- line parsing helpers -------------------------------------------------
private static bool TryParseKey(string raw, out string key)
{
SplitLine(raw, out _, out key, out _, out _);
return key.Length > 0;
}
/// <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,
out string value, out string comment)
{
indent = key = value = comment = string.Empty;
// leading whitespace
int start = 0;
while (start < raw.Length && char.IsWhiteSpace(raw[start]))
start++;
@@ -182,7 +142,6 @@ public sealed class ConfigFile
string body = raw.Substring(start);
// A line whose first non-space content is "//" is a pure comment.
if (body.StartsWith("//"))
{
comment = body;
@@ -191,8 +150,6 @@ public sealed class ConfigFile
if (body.Length == 0)
return;
// Split off a trailing comment: the first "//" preceded by whitespace
// (so URLs such as tcp://… inside a value are not mistaken for one).
int commentAt = FindCommentStart(body);
string content = commentAt >= 0 ? body.Substring(0, commentAt) : body;
if (commentAt >= 0)

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;
/// <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 string Label = string.Empty;
public string? Token; // null => filler / not bindable
public double Width = 1.0; // relative width (1 == a standard key)
public string? Token;
public double Width = 1.0;
public KeyCap(string label, string? token = null, double width = 1.0)
{
@@ -23,20 +18,12 @@ public sealed class KeyCap
}
}
/// <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
{
/// <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)
{
// Numeric keypad first - distinguished only by the physical key.
switch (physical)
{
case PhysicalKey.NumPad0: return "num_0";
@@ -57,15 +44,12 @@ public static class KeyMap
case PhysicalKey.NumPadEnter: return "num_enter";
}
// Numeric keypad via the virtual key (fallback when PhysicalKey is Unknown).
if (key >= Key.NumPad0 && key <= Key.NumPad9)
return "num_" + (char)('0' + (key - Key.NumPad0));
// Letters.
if (key >= Key.A && key <= Key.Z)
return ((char)('a' + (key - Key.A))).ToString();
// Top-row digits.
if (key >= Key.D0 && key <= Key.D9)
return ((char)('0' + (key - Key.D0))).ToString();
@@ -98,14 +82,12 @@ public static class KeyMap
};
}
/// <summary>True for keys that only act as modifiers and never form a binding alone.</summary>
public static bool IsModifierKey(Key key) => key is
Key.LeftShift or Key.RightShift or
Key.LeftCtrl or Key.RightCtrl or
Key.LeftAlt or Key.RightAlt or
Key.LWin or Key.RWin;
/// <summary>Friendly, explicit name for a token, used in the binding list text.</summary>
public static string DisplayName(string? token)
{
if (string.IsNullOrEmpty(token) ||
@@ -144,14 +126,9 @@ public static class KeyMap
};
}
// ── On-screen keyboard layout ──────────────────────────────────────────
// Filler caps (Token == null) keep the shape familiar; only caps with a
// token are coloured by assignment state.
/// <summary>Rows of the main (alphanumeric) keyboard block.</summary>
public static readonly KeyCap[][] MainBlock =
{
// F-row keys are reserved by the simulator, so they carry no token.
new[]
{
new KeyCap("Esc", null, 1.4),
@@ -205,7 +182,6 @@ public static class KeyMap
},
};
/// <summary>Rows of the numeric-keypad block, drawn to the right of the main block.</summary>
public static readonly KeyCap[][] NumpadBlock =
{
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 =
{
// PgUp / PgDn are reserved by the simulator (no token), like [ ] and Enter.
new[]
{
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.IO;
using System.Text;
using StarterNG.Infrastructure.Adapters;
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
{
/// <summary>Simulator command token, e.g. <c>mastercontrollerincrease</c>.</summary>
public string Command = string.Empty;
/// <summary>True when the binding requires the Shift modifier.</summary>
public bool Shift;
/// <summary>True when the binding requires the Ctrl modifier.</summary>
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";
/// <summary>Human description taken from the line's trailing comment (no <c>//</c>).</summary>
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;
/// <summary>True when the command is mapped to an actual key.</summary>
public bool IsAssigned =>
!string.IsNullOrEmpty(Key) &&
!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 static KeyboardConfig Instance { get; } = new();
private readonly List<string> _lines = new();
/// <summary>All recognised bindings, in file order.</summary>
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; }
private string _savePath = string.Empty;
@@ -67,11 +41,10 @@ public sealed class KeyboardConfig
private static Encoding ResolveEncoding()
{
// Comments use Polish diacritics in Windows-1250, like eu07.ini; preserve them.
try
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(1250);
return LegacyText.CodePage1250;
}
catch
{
@@ -79,7 +52,6 @@ public sealed class KeyboardConfig
}
}
/// <summary>Per-user keyboard config path for the current OS (next to eu07.ini).</summary>
public static string UserConfigPath()
{
if (OperatingSystem.IsWindows())
@@ -103,15 +75,12 @@ public sealed class KeyboardConfig
return Path.Combine(AppContext.BaseDirectory, "eu07_input-keyboard.ini");
}
/// <summary>Default file shipped in the simulator's working directory.</summary>
private static string WorkingDirDefaultPath() =>
Path.Combine(Directory.GetCurrentDirectory(), "eu07_input-keyboard.ini");
/// <summary>Fallback template bundled with the launcher (startercfg/).</summary>
private static string BundledDefaultPath() =>
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()
{
_savePath = UserConfigPath();
@@ -120,7 +89,6 @@ public sealed class KeyboardConfig
Dirty = false;
}
/// <summary>Reloads the shipped defaults, discarding the user's current edits.</summary>
public void LoadDefaults()
{
if (string.IsNullOrEmpty(_savePath))
@@ -151,7 +119,6 @@ public sealed class KeyboardConfig
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)
{
_lines.Clear();
@@ -170,10 +137,6 @@ public sealed class KeyboardConfig
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)
{
binding = new KeyBinding { LineIndex = lineIndex };
@@ -181,7 +144,6 @@ public sealed class KeyboardConfig
string body = raw;
string description = string.Empty;
// Split off a trailing "// comment".
int commentAt = body.IndexOf("//", StringComparison.Ordinal);
if (commentAt >= 0)
{
@@ -191,7 +153,7 @@ public sealed class KeyboardConfig
body = body.Trim();
if (body.Length == 0)
return false; // blank or pure-comment line
return false;
var tokens = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length == 0)
@@ -200,7 +162,6 @@ public sealed class KeyboardConfig
binding.Command = tokens[0];
binding.Description = description;
// Everything after the command is modifiers + (at most) one key.
bool shift = false, ctrl = false;
string key = "none";
for (int i = 1; i < tokens.Length; i++)
@@ -208,7 +169,7 @@ public sealed class KeyboardConfig
string t = tokens[i];
if (t.Equals("shift", StringComparison.OrdinalIgnoreCase)) shift = true;
else if (t.Equals("ctrl", StringComparison.OrdinalIgnoreCase)) ctrl = true;
else key = t; // last non-modifier token wins as the key
else key = t;
}
binding.Shift = shift;
@@ -217,7 +178,6 @@ public sealed class KeyboardConfig
return true;
}
/// <summary>Rewrites each binding's line, then serialises the file to text.</summary>
public string ToText()
{
foreach (var b in Bindings)
@@ -229,7 +189,6 @@ public sealed class KeyboardConfig
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)
{
var sb = new StringBuilder();
@@ -243,7 +202,6 @@ public sealed class KeyboardConfig
return sb.ToString();
}
/// <summary>Writes the bindings back to the per-user keyboard file.</summary>
public void Save()
{
if (string.IsNullOrEmpty(_savePath))
@@ -257,9 +215,9 @@ public sealed class KeyboardConfig
File.WriteAllText(_savePath, ToText(), FileEncoding);
Dirty = false;
}
catch
catch (Exception ex)
{
// Could not write (permissions / path). Leave values in memory.
StarterNG.Infrastructure.Diagnostics.Log($"saving {_savePath}", ex);
}
}
}

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.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions;
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Classes;
/// <summary>One saved consist in the user's vehicle warehouse.</summary>
public enum PresetSort
{
Name,
Track
}
public sealed class TrainsetPreset
{
/// <summary>Display name shown in the "load from warehouse" menu.</summary>
public string Name { get; set; } = "";
/// <summary>Complete "trainset … endtrainset" scenery entry (self-contained).</summary>
public string Entry { get; set; } = "";
}
/// <summary>Root object persisted to userpresets.json.</summary>
public sealed class PresetCollection
{
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
{
private static string ResolvePath()
@@ -54,8 +52,8 @@ public static class PresetStore
public static string FilePath { get; } = ResolvePath();
// Source-generated metadata keeps (de)serialisation AOT/trim-safe, matching the
// approach used for the vehicle database.
public static PresetSort SortMode { get; set; } = PresetSort.Name;
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
@@ -65,13 +63,16 @@ public static class PresetStore
private static JsonTypeInfo<PresetCollection> TypeInfo =>
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
/// <summary>All saved presets, sorted by name. Empty when none / unreadable.</summary>
public static IReadOnlyList<TrainsetPreset> All() => Load().Presets;
public static IReadOnlyList<TrainsetPreset> All()
{
var list = Load().Presets.ToList();
if (SortMode == PresetSort.Track)
list.Sort((a, b) => string.Compare(TrackOf(a), TrackOf(b), StringComparison.OrdinalIgnoreCase));
else
list.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
return list;
}
/// <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)
{
name = name?.Trim() ?? "";
@@ -85,7 +86,6 @@ public static class PresetStore
Persist(col);
}
/// <summary>Removes a preset by name (case-insensitive).</summary>
public static void Delete(string name)
{
var col = Load();
@@ -93,6 +93,76 @@ public static class PresetStore
Persist(col);
}
public static int ImportMagazyn(string? path = null)
{
path ??= FindMagazynPath();
if (path is null || !File.Exists(path)) return 0;
int added = 0;
try
{
string text = File.ReadAllText(path, LegacyText.CodePage1250);
var section = Regex.Matches(text,
@"\[TRAINSET\d+\s*=\s*([^\]]*)\](.*?)(?=\[TRAINSET|\z)",
RegexOptions.IgnoreCase | RegexOptions.Singleline);
foreach (Match m in section)
{
string name = m.Groups[1].Value.Trim();
if (name.Length == 0) name = $"import_{added + 1}";
var nodes = new List<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()
{
try
@@ -104,9 +174,9 @@ public static class PresetStore
return col;
}
}
catch
catch (Exception ex)
{
// unreadable / malformed - start from an empty warehouse
StarterNG.Infrastructure.Diagnostics.Log($"reading {FilePath}", ex);
}
return new PresetCollection();
}
@@ -118,26 +188,17 @@ public static class PresetStore
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, JsonSerializer.Serialize(col, TypeInfo));
}
catch
catch (Exception ex)
{
// best-effort: a failed save just means the warehouse isn't updated
StarterNG.Infrastructure.Diagnostics.Log($"saving {FilePath}", ex);
}
}
}
/// <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
{
/// <summary>The complete scenery entry for a trainset (incl. endtrainset).</summary>
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)
{
if (string.IsNullOrWhiteSpace(text))
@@ -154,7 +215,6 @@ public static class ConsistText
}
}
// Source-generated JSON metadata (AOT/trim-safe; no runtime reflection).
[JsonSerializable(typeof(PresetCollection))]
[JsonSerializable(typeof(TrainsetPreset))]
internal partial class PresetJsonContext : JsonSerializerContext

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.IO;
using System.Linq;
using StarterNG.Application;
namespace StarterNG.Classes;
public static class SettingsProfileStore
{
public static string DirectoryPath { get; } =
Path.Combine(Settings.UserConfigDirectory(), "starter", "profiles");
Path.Combine(AppServices.Current.SettingsPaths.UserConfigDirectory(), "starter", "profiles");
public static IReadOnlyList<string> ListProfiles()
{

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -10,7 +10,7 @@ namespace StarterNG.Classes;
public enum eDriverType
{
Headdriver,
Reardriver,
Reardriver,
Passenger,
Nobody
}
@@ -22,18 +22,42 @@ public class Trainset
public float Offset;
public float Velocity;
public float OriginalVelocity;
// The scenery format has no battery field: a trainset with a non-zero speed is
// the prepared one. DynObj.cpp derives ReadyFlag from it, and that branch of
// CheckLocomotiveParameters is what charges the pipes, sets the brake handle
// and closes the battery contactor. The sign carries the direction, so a
// consist authored on the move keeps its own speed.
private const float StandingStill = 0.01f;
private const float PreparedCreep = 0.1f;
public bool ReadyToGo
{
get => MathF.Abs(Velocity) >= StandingStill;
set
{
if (!value)
{
Velocity = 0f;
return;
}
Velocity = MathF.Abs(OriginalVelocity) < StandingStill ? PreparedCreep : OriginalVelocity;
}
}
public string Description;
public List<Dynamic> Vehicles;
/// <summary>Original .scn text of this trainset block (incl. endtrainset).</summary>
public string RawEntry;
/// <summary>True if the block parsed cleanly; false blocks are exported verbatim.</summary>
public bool Parsed;
/// <summary>True when the block carries the //$decor flag (decoration, not drivable).</summary>
public bool Decor;
public string? Logo;
public string? Mini;
public Trainset(string trainsetEntry)
{
RawEntry = trainsetEntry;
@@ -59,8 +83,6 @@ public class Trainset
.Where(t => !t.StartsWith("//") && !t.StartsWith("/*"))
.ToList();
// handle specific descriptions
// //$o
var match = Regex.Match(
trainsetEntry,
@"^\s*//\$o\s*(.*)$",
@@ -68,15 +90,20 @@ public class Trainset
);
if (match.Success)
this.Description = match.Groups[1].Value.Trim();
// parse trainset
var logo = Regex.Match(trainsetEntry, @"^\s*//\$il\b[ \t]*([^\r\n]*)", RegexOptions.Multiline);
if (logo.Success)
Logo = logo.Groups[1].Value.Trim();
var mini = Regex.Match(trainsetEntry, @"^\s*//\$it\b[ \t]*([^\r\n]*)", RegexOptions.Multiline);
if (mini.Success)
Mini = mini.Groups[1].Value.Trim();
for (int i = 0; i < tokens.Count; i++)
{
if (tokens[i] == "endtrainset")
break;
// trainset properties
if (tokens[i] == "trainset")
{
this.Name = tokens[++i];
@@ -86,26 +113,24 @@ public class Trainset
this.OriginalVelocity = this.Velocity;
continue;
}
// skip entire assignments block
if (tokens[i] == "assignments")
{
while (i < tokens.Count && tokens[i] != "endassignment")
i++;
i++; // jump over endassignment
i++;
continue;
}
// load vehicles
if (tokens[i] == "node")
{
Dynamic nodeDynamic = new Dynamic();
nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.Name = tokens[++i];
i++; // dynamic keyword
i++;
nodeDynamic.DataFolder = tokens[++i];
nodeDynamic.SkinFile = tokens[++i];
nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i]);
nodeDynamic.MmdFile = tokens[++i];
nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
@@ -117,69 +142,47 @@ public class Trainset
_ => eDriverType.Nobody
};
// couplingdata: numeric bit-mask plus optional ".B/.W/.T" parameter
// codes (brake / wheel / thermo settings) kept verbatim.
nodeDynamic.Coupling = Coupling.Parse(tokens[++i]);
// Everything before enddynamic is the optional, positional tail
// documented for node::dynamic:
// [Lx] velocity [loadcount [loadtype]] [destination [destination]]
var trailing = new List<string>();
while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic")
trailing.Add(tokens[++i]);
nodeDynamic.ReadTrailing(trailing);
i++; // jump over enddynamic
i++;
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() =>
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
public string GetTrainsetEntry()
{
string entry = "";
entry += "trainset ";
entry += this.Name + " ";
entry += this.Track + " ";
entry += this.Offset.ToString(CultureInfo.InvariantCulture) + " ";
entry += this.Velocity.ToString(CultureInfo.InvariantCulture) + " ";
entry += "\n";
var sb = new StringBuilder();
if (Decor)
sb.Append("//$decor\n");
if (!string.IsNullOrEmpty(Description))
sb.Append("//$o ").Append(Description).Append('\n');
if (!string.IsNullOrEmpty(Logo))
sb.Append("//$il ").Append(Logo).Append('\n');
if (!string.IsNullOrEmpty(Mini))
sb.Append("//$it ").Append(Mini).Append('\n');
sb.Append("trainset ");
sb.Append(Name).Append(' ');
sb.Append(Track).Append(' ');
sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' ');
sb.Append(Velocity.ToString(CultureInfo.InvariantCulture));
sb.Append('\n');
string entry = sb.ToString();
foreach (Dynamic vehicle in Vehicles)
{
string driverType = "";
switch (vehicle.DriverType)
{
case eDriverType.Headdriver:
driverType = "headdriver";
break;
case eDriverType.Reardriver:
driverType = "reardriver";
break;
case eDriverType.Passenger:
driverType = "passenger";
break;
default:
driverType = "nobody";
break;
}
entry += vehicle.ToTrainsetNode();
entry +=
$"node {vehicle.RangeMax} {vehicle.RangeMin} {vehicle.Name} dynamic " +
$"{vehicle.DataFolder} {vehicle.SkinFile} {vehicle.MmdFile} " +
$"{vehicle.Offset.ToString(CultureInfo.InvariantCulture)} " +
$"{driverType} {vehicle.Coupling}{vehicle.WriteTrailing()} enddynamic\n";
}
return entry;
}
}
@@ -196,112 +199,92 @@ public class Dynamic
public float Offset;
public eDriverType DriverType;
/// <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();
/// <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
{
get => (byte)(Coupling.AbsFlags & 0xFF);
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 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;
public const int PantStateMax = 3;
/// <summary>
/// Parses the optional positional parameters that follow couplingdata:
/// <c>[Lx] velocity [loadcount [loadtype]] [destination [destination]]</c>.
/// See https://wiki.eu07.pl/index.php?title=Obiekt_node::dynamic
/// Lives in the coupling token as ".L&lt;n&gt;" - that is where the simulator reads
/// it from (DynObj.cpp, the MoreParams loop). The older space-separated "L&lt;n&gt;"
/// 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>
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)
{
int p = 0;
// Lx — MaxLoad override (e.g. "L0"); only when it genuinely looks like one.
if (p < t.Count && (t[p][0] == 'L' || t[p][0] == 'l')
&& t[p].Length > 1 && char.IsDigit(t[p][1]))
MaxLoad = t[p++];
// velocity — the first plain number after the coupling
if (p < t.Count && float.TryParse(t[p], NumberStyles.Float,
CultureInfo.InvariantCulture, out float v))
// Legacy form: max load as its own token in front of the load count. The
// current scenario parser reads that slot as an int, so this is read only -
// the setter moves the value into the coupling token, where it is written.
if (p < t.Count && t[p].Length > 0 && t[p][0] == 'L')
{
Velocity = v;
HasVelocity = true;
if (int.TryParse(t[p].AsSpan(1), NumberStyles.Integer,
CultureInfo.InvariantCulture, out int ml))
MaxLoad = ml;
p++;
}
// loadcount [loadtype] — loadtype is present only when loadcount > 0
if (p < t.Count && int.TryParse(t[p], NumberStyles.Integer,
CultureInfo.InvariantCulture, out int lc))
{
LoadCount = lc;
p++;
if (lc > 0 && p < t.Count)
if (p < t.Count && (lc > 0 || IsPantStateType(t[p])))
LoadType = t[p++];
}
// remaining tokens: optional destinations, kept verbatim
while (p < t.Count)
Destinations.Add(t[p++]);
}
/// <summary>
/// Regenerates the trailing parameter string (each present token prefixed with
/// a space), the inverse of <see cref="ReadTrailing"/>. A loadcount of 0 is
/// omitted, matching the canonical trainset entries the simulator expects.
/// </summary>
internal static string StripSkinExtension(string token)
{
int dot = token.LastIndexOf('.');
return dot > 0 ? token[..dot] : token;
}
internal string WriteTrailing()
{
var sb = new StringBuilder();
if (MaxLoad != null)
sb.Append(' ').Append(MaxLoad);
if (HasVelocity)
sb.Append(' ').Append(Velocity.ToString(CultureInfo.InvariantCulture));
if (LoadCount > 0)
{
sb.Append(' ').Append(LoadCount.ToString(CultureInfo.InvariantCulture));
if (!string.IsNullOrEmpty(LoadType))
sb.Append(' ').Append(LoadType);
}
foreach (string d in Destinations)
sb.Append(' ').Append(d);
bool loaded = !string.IsNullOrEmpty(LoadType);
sb.Append(' ').Append((loaded ? LoadCount : 0).ToString(CultureInfo.InvariantCulture));
if (loaded && (LoadCount > 0 || IsPantState))
sb.Append(' ').Append(LoadType);
return sb.ToString();
}
/// <summary>Deep copy, used when importing a trainset into the editable consist.</summary>
public Dynamic Clone() => new Dynamic
{
RangeMax = RangeMax,
@@ -314,50 +297,77 @@ public class Dynamic
Offset = Offset,
DriverType = DriverType,
Coupling = Coupling.Clone(),
MaxLoad = MaxLoad,
HasVelocity = HasVelocity,
Velocity = Velocity,
LoadCount = LoadCount,
LoadType = LoadType,
Destinations = new List<string>(Destinations),
MiniName = MiniName,
Flipped = Flipped
};
public string ToTrainsetNode()
{
string driver = DriverType switch
{
eDriverType.Headdriver => "headdriver",
eDriverType.Reardriver => "reardriver",
eDriverType.Passenger => "passenger",
_ => "nobody"
};
return
$"node {RangeMax} {RangeMin} {Name} dynamic " +
$"{DataFolder} {SkinFile} {MmdFile} " +
$"{Offset.ToString(CultureInfo.InvariantCulture)} " +
$"{driver} {Coupling}{WriteTrailing()} enddynamic\n";
}
public string ToLooseNode()
{
string driver = DriverType switch
{
eDriverType.Headdriver => "headdriver",
eDriverType.Reardriver => "reardriver",
eDriverType.Passenger => "passenger",
_ => "nobody"
};
string path = string.IsNullOrEmpty(PathName) ? "none" : PathName;
string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture);
return
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
$"{DataFolder} {SkinFile} {MmdFile} {path} " +
$"{Offset.ToString(CultureInfo.InvariantCulture)} {driver} {vel}" +
$"{WriteTrailing()} enddynamic\n";
}
}
public sealed class ConsistItem
{
public List<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
{
// 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;
/// <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();
/// <summary>Mask with the lock sign removed (what the simulator tests bits on).</summary>
public int AbsFlags => Flags < 0 ? -Flags : Flags;
/// <summary>True when the coupling is marked permanent (negative mask).</summary>
public bool Locked
{
get => Flags < 0;
@@ -366,7 +376,6 @@ public sealed class Coupling
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)
{
int abs = on ? (AbsFlags | bit) : (AbsFlags & ~bit);
@@ -388,25 +397,21 @@ public sealed class Coupling
return c;
}
/// <summary>The brake-rack setting carried in the "B" parameter, or null.</summary>
public BrakeSetting? GetBrake() =>
BrakeSetting.FromParameter(
Parameters.FirstOrDefault(p => p.StartsWith("B", StringComparison.Ordinal)));
/// <summary>Replaces (or clears, when null) the "B" brake parameter.</summary>
public void SetBrake(BrakeSetting? brake)
{
Parameters.RemoveAll(p => p.StartsWith("B", StringComparison.Ordinal));
if (brake != null)
if (brake is { IsEmpty: false })
Parameters.Insert(0, brake.ToParameter());
}
/// <summary>The wheel/damage setting carried in the "W" parameter, or null.</summary>
public WheelSettings? GetWheels() =>
WheelSettings.FromParameter(
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)
{
Parameters.RemoveAll(p => p.StartsWith("W", StringComparison.Ordinal));
@@ -414,13 +419,44 @@ public sealed class Coupling
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()
{
Flags = Flags,
Parameters = new List<string>(Parameters)
};
/// <summary>Serialises back to "&lt;flags&gt;[.param[.param…]]".</summary>
public override string ToString()
{
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
{
/// <summary>Brake modes, longest first so "R+Mg" wins over "R" while parsing.</summary>
public static readonly string[] Modes = { "R+Mg", "G", "P", "R", "Q", "O", "A" };
public static readonly string[] Modes = { "G", "P", "R", "M" };
/// <summary>Brake mode: G (freight), P (passenger), R (express), R+Mg, Q, O (off), A (auto).</summary>
public string Mode = "P";
public static readonly string[] Loads = { "T", "H", "F" };
// The simulator tests every letter of the B parameter independently, so the
// "off" states are a separate axis from the G/P/R/M delay position. They stay
// one combo because they contradict each other, but they no longer block a
// delay position from being picked alongside them (e.g. "BRQ").
public static readonly string[] Switches = { "<>", "0", "1", "X", "E", "Q" };
public string? Mode;
/// <summary>Load adaptation: T (empty), H (medium), F (loaded), A (auto), or null.</summary>
public string? Load;
/// <summary>Brake on/off switch: 0 (off), 1 (off ~10%), A (on), or null.</summary>
public string? Switch;
// A brake switch or a load adaptation with no delay position is still a
// parameter worth writing, so all three axes count towards emptiness.
public bool IsEmpty => Mode is null && Load is null && Switch is null;
public static BrakeSetting? FromParameter(string? param)
{
if (string.IsNullOrEmpty(param) || param![0] != 'B' || param.Length < 2)
if (string.IsNullOrEmpty(param) || char.ToUpperInvariant(param![0]) != 'B')
return null;
string body = param.Substring(1);
string? mode = Modes.FirstOrDefault(m => body.StartsWith(m, StringComparison.Ordinal));
if (mode is null)
return null;
string body = param.Substring(1).ToUpperInvariant();
var b = new BrakeSetting
{
// "M" first: the simulator reads it as R plus the magnetic rail brake,
// so a bare "R" must not swallow it. Switches are matched in the
// declared order too, so "<>" wins over the digits it cannot contain.
Mode = First(body, "M", "G", "P", "R"),
Load = First(body, Loads),
Switch = First(body, Switches)
};
return b.Mode is null && b.Load is null && b.Switch is null ? null : b;
var b = new BrakeSetting { Mode = mode };
int p = mode.Length;
if (p < body.Length && "THFA".IndexOf(body[p]) >= 0)
b.Load = body[p++].ToString();
if (p < body.Length && "01A".IndexOf(body[p]) >= 0)
b.Switch = body[p].ToString();
return b;
static string? First(string body, params string[] codes) =>
Array.Find(codes, c => body.Contains(c, StringComparison.Ordinal));
}
public string ToParameter() => "B" + Mode + (Load ?? "") + (Switch ?? "");
public string ToParameter() => "B" + Mode + Switch + Load;
}
/// <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
{
/// <summary>H — sway ("wężykowanie") probability, %.</summary>
public int Sway;
/// <summary>F — flat spot ("podkucie") of this size, mm.</summary>
public int Flatness;
/// <summary>R — additional random flat spot, 0..x mm.</summary>
public int FlatnessRand;
/// <summary>P — flat-spot probability, % (guaranteed when omitted).</summary>
public int FlatnessProb;
/// <summary>
/// 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)
{
@@ -524,7 +565,7 @@ public sealed class WheelSettings
if (Sway > 0) sb.Append('H').Append(Sway);
if (Flatness > 0) sb.Append('F').Append(Flatness);
if (FlatnessRand > 0) sb.Append('R').Append(FlatnessRand);
if (FlatnessProb > 0) sb.Append('P').Append(FlatnessProb);
if (FlatnessProb != DefaultFlatnessProb) sb.Append('P').Append(FlatnessProb);
return sb.ToString();
}
}
}

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