mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-10-16 23:49:19 +02:00
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
14
.vscode/launch.json
vendored
Normal 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
48
README.md
Normal 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
|
||||
```
|
||||
82
StarterNG.Tests/ExecutableLocatorTests.cs
Normal file
82
StarterNG.Tests/ExecutableLocatorTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
34
StarterNG.Tests/Fakes/FakeEnvironment.cs
Normal file
34
StarterNG.Tests/Fakes/FakeEnvironment.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
26
StarterNG.Tests/Fakes/FakeStrings.cs
Normal file
26
StarterNG.Tests/Fakes/FakeStrings.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
16
StarterNG.Tests/Fakes/FixedClock.cs
Normal file
16
StarterNG.Tests/Fakes/FixedClock.cs
Normal 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();
|
||||
}
|
||||
168
StarterNG.Tests/Fakes/InMemoryFileSystem.cs
Normal file
168
StarterNG.Tests/Fakes/InMemoryFileSystem.cs
Normal 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);
|
||||
}
|
||||
22
StarterNG.Tests/Fakes/StubRandom.cs
Normal file
22
StarterNG.Tests/Fakes/StubRandom.cs
Normal 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;
|
||||
}
|
||||
58
StarterNG.Tests/Fakes/TestInstallation.cs
Normal file
58
StarterNG.Tests/Fakes/TestInstallation.cs
Normal 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());
|
||||
}
|
||||
45
StarterNG.Tests/FileDiagnosticsLogTests.cs
Normal file
45
StarterNG.Tests/FileDiagnosticsLogTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
75
StarterNG.Tests/GameLibraryTests.cs
Normal file
75
StarterNG.Tests/GameLibraryTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
26
StarterNG.Tests/GamePathsTests.cs
Normal file
26
StarterNG.Tests/GamePathsTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
43
StarterNG.Tests/InstallationCheckTests.cs
Normal file
43
StarterNG.Tests/InstallationCheckTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
38
StarterNG.Tests/LegacyTextTests.cs
Normal file
38
StarterNG.Tests/LegacyTextTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
41
StarterNG.Tests/LoadWeightsTests.cs
Normal file
41
StarterNG.Tests/LoadWeightsTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
55
StarterNG.Tests/MiniTextureIndexTests.cs
Normal file
55
StarterNG.Tests/MiniTextureIndexTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
128
StarterNG.Tests/PhysicsAndAssetsTests.cs
Normal file
128
StarterNG.Tests/PhysicsAndAssetsTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
167
StarterNG.Tests/ScenarioPresentationTests.cs
Normal file
167
StarterNG.Tests/ScenarioPresentationTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
146
StarterNG.Tests/SceneryExportTests.cs
Normal file
146
StarterNG.Tests/SceneryExportTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
154
StarterNG.Tests/SceneryParserTests.cs
Normal file
154
StarterNG.Tests/SceneryParserTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
101
StarterNG.Tests/SceneryRepositoryTests.cs
Normal file
101
StarterNG.Tests/SceneryRepositoryTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
52
StarterNG.Tests/SceneryTranslationsTests.cs
Normal file
52
StarterNG.Tests/SceneryTranslationsTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
87
StarterNG.Tests/SceneryWeatherTests.cs
Normal file
87
StarterNG.Tests/SceneryWeatherTests.cs
Normal 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()));
|
||||
}
|
||||
}
|
||||
208
StarterNG.Tests/SettingsStoreTests.cs
Normal file
208
StarterNG.Tests/SettingsStoreTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
25
StarterNG.Tests/StarterNG.Tests.csproj
Normal file
25
StarterNG.Tests/StarterNG.Tests.csproj
Normal 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>
|
||||
103
StarterNG.Tests/TexturesTxtParserTests.cs
Normal file
103
StarterNG.Tests/TexturesTxtParserTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
56
StarterNG.Tests/TrainsetReadyTests.cs
Normal file
56
StarterNG.Tests/TrainsetReadyTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
120
StarterNG.Tests/VehicleCatalogTests.cs
Normal file
120
StarterNG.Tests/VehicleCatalogTests.cs
Normal 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)!;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
10
StarterNG/Application/Abstractions/IClock.cs
Normal file
10
StarterNG/Application/Abstractions/IClock.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace StarterNG.Application.Abstractions;
|
||||
|
||||
using System;
|
||||
|
||||
public interface IClock
|
||||
{
|
||||
DateTime Now { get; }
|
||||
|
||||
DateTime UtcNow { get; }
|
||||
}
|
||||
19
StarterNG/Application/Abstractions/IDiagnosticsLog.cs
Normal file
19
StarterNG/Application/Abstractions/IDiagnosticsLog.cs
Normal 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();
|
||||
}
|
||||
14
StarterNG/Application/Abstractions/IEnvironment.cs
Normal file
14
StarterNG/Application/Abstractions/IEnvironment.cs
Normal 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; }
|
||||
}
|
||||
45
StarterNG/Application/Abstractions/IFileSystem.cs
Normal file
45
StarterNG/Application/Abstractions/IFileSystem.cs
Normal 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);
|
||||
}
|
||||
20
StarterNG/Application/Abstractions/IGamePaths.cs
Normal file
20
StarterNG/Application/Abstractions/IGamePaths.cs
Normal 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);
|
||||
}
|
||||
12
StarterNG/Application/Abstractions/ILocalizedStrings.cs
Normal file
12
StarterNG/Application/Abstractions/ILocalizedStrings.cs
Normal 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; }
|
||||
}
|
||||
12
StarterNG/Application/Abstractions/IMiniTextureIndex.cs
Normal file
12
StarterNG/Application/Abstractions/IMiniTextureIndex.cs
Normal 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; }
|
||||
}
|
||||
12
StarterNG/Application/Abstractions/IPhysicsRepository.cs
Normal file
12
StarterNG/Application/Abstractions/IPhysicsRepository.cs
Normal 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);
|
||||
}
|
||||
22
StarterNG/Application/Abstractions/IProcessLauncher.cs
Normal file
22
StarterNG/Application/Abstractions/IProcessLauncher.cs
Normal 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);
|
||||
}
|
||||
8
StarterNG/Application/Abstractions/IRandomSource.cs
Normal file
8
StarterNG/Application/Abstractions/IRandomSource.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace StarterNG.Application.Abstractions;
|
||||
|
||||
public interface IRandomSource
|
||||
{
|
||||
int Next(int minInclusive, int maxExclusive);
|
||||
|
||||
double NextDouble();
|
||||
}
|
||||
14
StarterNG/Application/Abstractions/ISceneryRepository.cs
Normal file
14
StarterNG/Application/Abstractions/ISceneryRepository.cs
Normal 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);
|
||||
}
|
||||
10
StarterNG/Application/Abstractions/ISceneryTranslations.cs
Normal file
10
StarterNG/Application/Abstractions/ISceneryTranslations.cs
Normal 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);
|
||||
}
|
||||
8
StarterNG/Application/Abstractions/IVehicleRepository.cs
Normal file
8
StarterNG/Application/Abstractions/IVehicleRepository.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Application.Abstractions;
|
||||
|
||||
public interface IVehicleRepository
|
||||
{
|
||||
int Load(VehicleCatalog catalog);
|
||||
}
|
||||
115
StarterNG/Application/AppServices.cs
Normal file
115
StarterNG/Application/AppServices.cs
Normal 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; }
|
||||
}
|
||||
50
StarterNG/Application/AppState.cs
Normal file
50
StarterNG/Application/AppState.cs
Normal 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();
|
||||
}
|
||||
70
StarterNG/Application/GameLibrary.cs
Normal file
70
StarterNG/Application/GameLibrary.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
40
StarterNG/Application/MissingVehicleLog.cs
Normal file
40
StarterNG/Application/MissingVehicleLog.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
143
StarterNG/Application/SettingsStore.cs
Normal file
143
StarterNG/Application/SettingsStore.cs
Normal 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);
|
||||
}
|
||||
135
StarterNG/Application/StartSimulation.cs
Normal file
135
StarterNG/Application/StartSimulation.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
BIN
StarterNG/Assets/starter.ico
Normal file
BIN
StarterNG/Assets/starter.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<dir>/<model>.fiz), mirroring
|
||||
/// the original Starter's TLexParser.ParsePhysics. Provides mass, top speed,
|
||||
/// length, accepted loads and the coupler AllowedFlag / ControlType used for the
|
||||
/// automatic coupling calculation.
|
||||
/// </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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 //$<letter> 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 //$<letter>).
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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"/> > 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<n>" - that is where the simulator reads
|
||||
/// it from (DynObj.cpp, the MoreParams loop). The older space-separated "L<n>"
|
||||
/// token in front of the load count is still read for legacy files, but never
|
||||
/// written back: the current scenario parser reads that slot as the load count.
|
||||
/// </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<n>" 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 "<flags>[.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<mode>[<load>][<switch>]</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<sway%>][F<flat mm>][R<random flat mm>][P<flat prob%>]</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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
33
StarterNG/Controls/DetailRows.cs
Normal file
33
StarterNG/Controls/DetailRows.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
69
StarterNG/Controls/SettingRow.cs
Normal file
69
StarterNG/Controls/SettingRow.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
16
StarterNG/Controls/SettingsSection.cs
Normal file
16
StarterNG/Controls/SettingsSection.cs
Normal 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
141
StarterNG/Domain/Cargo.cs
Normal 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
585
StarterNG/Domain/Consist.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
139
StarterNG/Domain/Sceneries/Scenery.cs
Normal file
139
StarterNG/Domain/Sceneries/Scenery.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
81
StarterNG/Domain/Sceneries/SceneryWeather.cs
Normal file
81
StarterNG/Domain/Sceneries/SceneryWeather.cs
Normal 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;
|
||||
}
|
||||
76
StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs
Normal file
76
StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
130
StarterNG/Domain/Settings/SimulatorSettings.cs
Normal file
130
StarterNG/Domain/Settings/SimulatorSettings.cs
Normal 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" };
|
||||
}
|
||||
228
StarterNG/Domain/TexRandomizer.cs
Normal file
228
StarterNG/Domain/TexRandomizer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
304
StarterNG/Domain/TrainsetDisplay.cs
Normal file
304
StarterNG/Domain/TrainsetDisplay.cs
Normal 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
199
StarterNG/Domain/UData.cs
Normal 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
154
StarterNG/Domain/UDepot.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
StarterNG/Domain/VehicleInfo.cs
Normal file
36
StarterNG/Domain/VehicleInfo.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
29
StarterNG/Domain/Vehicles/LoadWeightsTable.cs
Normal file
29
StarterNG/Domain/Vehicles/LoadWeightsTable.cs
Normal 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;
|
||||
}
|
||||
168
StarterNG/Domain/Vehicles/VehicleCatalog.cs
Normal file
168
StarterNG/Domain/Vehicles/VehicleCatalog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
StarterNG/Domain/Vehicles/VehiclePhysics.cs
Normal file
40
StarterNG/Domain/Vehicles/VehiclePhysics.cs
Normal 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);
|
||||
}
|
||||
74
StarterNG/Domain/Vehicles/VehicleTexture.cs
Normal file
74
StarterNG/Domain/Vehicles/VehicleTexture.cs
Normal 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();
|
||||
}
|
||||
134
StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs
Normal file
134
StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
36
StarterNG/Infrastructure/Adapters/GamePaths.cs
Normal file
36
StarterNG/Infrastructure/Adapters/GamePaths.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
37
StarterNG/Infrastructure/Adapters/LegacyText.cs
Normal file
37
StarterNG/Infrastructure/Adapters/LegacyText.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
Normal file
61
StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
11
StarterNG/Infrastructure/Adapters/SystemClock.cs
Normal file
11
StarterNG/Infrastructure/Adapters/SystemClock.cs
Normal 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;
|
||||
}
|
||||
17
StarterNG/Infrastructure/Adapters/SystemEnvironment.cs
Normal file
17
StarterNG/Infrastructure/Adapters/SystemEnvironment.cs
Normal 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;
|
||||
}
|
||||
102
StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs
Normal file
102
StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
StarterNG/Infrastructure/Adapters/SystemRandomSource.cs
Normal file
11
StarterNG/Infrastructure/Adapters/SystemRandomSource.cs
Normal 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();
|
||||
}
|
||||
65
StarterNG/Infrastructure/Diagnostics.cs
Normal file
65
StarterNG/Infrastructure/Diagnostics.cs
Normal 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();
|
||||
}
|
||||
85
StarterNG/Infrastructure/DiagnosticsLogSink.cs
Normal file
85
StarterNG/Infrastructure/DiagnosticsLogSink.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
67
StarterNG/Infrastructure/DropGap.cs
Normal file
67
StarterNG/Infrastructure/DropGap.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
59
StarterNG/Infrastructure/DropIndexTracker.cs
Normal file
59
StarterNG/Infrastructure/DropIndexTracker.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
63
StarterNG/Infrastructure/EdgeScroller.cs
Normal file
63
StarterNG/Infrastructure/EdgeScroller.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
58
StarterNG/Infrastructure/InstallationCheck.cs
Normal file
58
StarterNG/Infrastructure/InstallationCheck.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
123
StarterNG/Infrastructure/LoadingScreen.cs
Normal file
123
StarterNG/Infrastructure/LoadingScreen.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
47
StarterNG/Infrastructure/MonoFont.cs
Normal file
47
StarterNG/Infrastructure/MonoFont.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
55
StarterNG/Infrastructure/Sceneries/SceneryImageLocator.cs
Normal file
55
StarterNG/Infrastructure/Sceneries/SceneryImageLocator.cs
Normal 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
Reference in New Issue
Block a user