mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-13 20:03:19 +01:00
Compare commits
22 Commits
feature/bl
...
fix/linux-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cd3ef8f1a | ||
|
|
1b41ef752a | ||
|
|
42f702a096 | ||
|
|
7f57160aae | ||
|
|
2efae83b57 | ||
|
|
34f2ce99e8 | ||
|
|
52408a9931 | ||
|
|
290b753f40 | ||
|
|
6279228a7d | ||
|
|
d4975796a8 | ||
|
|
48af11b4b2 | ||
|
|
744720d922 | ||
|
|
b5ba67430f | ||
|
|
c261d4b0ab | ||
|
|
3aa6a256a5 | ||
|
|
95c510b3ac | ||
|
|
5af567be7f | ||
|
|
b201b6f7ab | ||
|
|
3a19fd5da2 | ||
|
|
36df14abee | ||
|
|
da5a1efea1 | ||
|
|
e5330d91a6 |
13
.config/dotnet-tools.json
Normal file
13
.config/dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"dotnet-sonarscanner": {
|
||||||
|
"version": "12.1.0",
|
||||||
|
"commands": [
|
||||||
|
"dotnet-sonarscanner"
|
||||||
|
],
|
||||||
|
"rollForward": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
.github/workflows/sonar.yml
vendored
Normal file
114
.github/workflows/sonar.yml
vendored
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# .github/workflows/sonar.yml
|
||||||
|
# Has SONAR_TOKEN, so it only ever builds code that is already trusted: pushes to
|
||||||
|
# main and pull requests opened from a branch of this repository.
|
||||||
|
#
|
||||||
|
# Unlike the C++ analysis of maszyna, this one cannot be split into an untrusted
|
||||||
|
# build and a trusted scan. The C# analyzers run inside the compiler, and the
|
||||||
|
# scanner's begin step, which needs the token, has to run before the build on the
|
||||||
|
# same machine. A fork's build could leave a process behind to read the token from
|
||||||
|
# the end step, so fork pull requests are not analysed here. GitHub withholds the
|
||||||
|
# secret from them (and from Dependabot) anyway.
|
||||||
|
#
|
||||||
|
# The scanner is pinned in .config/dotnet-tools.json, where Dependabot updates it.
|
||||||
|
# SonarScanner for .NET takes its settings on the command line only; it refuses
|
||||||
|
# to run next to a sonar-project.properties, so there is none.
|
||||||
|
|
||||||
|
name: SonarQube Cloud
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Main branch only: analysing other branches is a paid feature, pull requests
|
||||||
|
# are not.
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- staging
|
||||||
|
types: [ opened, reopened, synchronize ]
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: sonar-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: 1
|
||||||
|
DOTNET_NOLOGO: true
|
||||||
|
SOLUTION_PATH: StarterNG.sln
|
||||||
|
TEST_PROJECT_PATH: StarterNG.Tests/StarterNG.Tests.csproj
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sonar:
|
||||||
|
name: Analyze
|
||||||
|
if: >
|
||||||
|
github.event_name != 'pull_request' ||
|
||||||
|
(github.event.pull_request.head.repo.full_name == github.repository &&
|
||||||
|
github.actor != 'dependabot[bot]')
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Full history, so new code and blame are told apart correctly.
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup .NET 9
|
||||||
|
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
|
with:
|
||||||
|
dotnet-version: 9.0.x
|
||||||
|
|
||||||
|
- name: Restore tools
|
||||||
|
run: dotnet tool restore
|
||||||
|
|
||||||
|
# sonar.qualitygate.wait=true blocks the end step until the server has
|
||||||
|
# processed the report, so the job's result is the quality gate's.
|
||||||
|
- name: Begin analysis
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
run: >
|
||||||
|
dotnet tool run dotnet-sonarscanner begin
|
||||||
|
/k:"MaSzyna-EU07_StarterNG"
|
||||||
|
/o:"maszyna-eu07"
|
||||||
|
/n:"StarterNG"
|
||||||
|
/d:sonar.host.url="https://sonarcloud.io"
|
||||||
|
/d:sonar.token="$SONAR_TOKEN"
|
||||||
|
/d:sonar.scm.revision="${{ github.event.pull_request.head.sha || github.sha }}"
|
||||||
|
/d:sonar.cs.opencover.reportsPaths="TestResults/**/coverage.opencover.xml"
|
||||||
|
/d:sonar.cs.vstest.reportsPaths="TestResults/*.trx"
|
||||||
|
/d:sonar.qualitygate.wait=true
|
||||||
|
|
||||||
|
# A full, non-incremental build: the analyzers only see what is compiled.
|
||||||
|
- name: Build
|
||||||
|
run: >
|
||||||
|
dotnet build "${{ env.SOLUTION_PATH }}"
|
||||||
|
-c Release
|
||||||
|
--no-incremental
|
||||||
|
|
||||||
|
# Coverlet is already referenced by the test project; OpenCover is the
|
||||||
|
# format the C# analyzer reads.
|
||||||
|
- name: Test with coverage
|
||||||
|
run: >
|
||||||
|
dotnet test "${{ env.TEST_PROJECT_PATH }}"
|
||||||
|
-c Release
|
||||||
|
--no-build
|
||||||
|
--results-directory TestResults
|
||||||
|
--logger trx
|
||||||
|
--collect "XPlat Code Coverage"
|
||||||
|
--
|
||||||
|
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||||
|
|
||||||
|
- name: End analysis
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
run: >
|
||||||
|
dotnet tool run dotnet-sonarscanner end
|
||||||
|
/d:sonar.token="$SONAR_TOKEN"
|
||||||
119
StarterNG.Tests/CaseInsensitivePathsTests.cs
Normal file
119
StarterNG.Tests/CaseInsensitivePathsTests.cs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
using StarterNG.Infrastructure;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class CaseInsensitivePathsTests
|
||||||
|
{
|
||||||
|
private static InMemoryFileSystem Disk() => new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithFile("game/scenery/Braniewo/Base.ctr", "")
|
||||||
|
.WithFile("game/scenery/Braniewo/rozklady/TGS3657.txt", "")
|
||||||
|
.WithDirectory("game/textures/mini");
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("braniewo/base.ctr", "Braniewo/Base.ctr")]
|
||||||
|
[InlineData(@"BRANIEWO\Rozklady\tgs3657.TXT", "Braniewo/rozklady/TGS3657.txt")]
|
||||||
|
[InlineData("Braniewo/Base.ctr", "Braniewo/Base.ctr")]
|
||||||
|
[InlineData("braniewo//base.ctr", "Braniewo/Base.ctr")]
|
||||||
|
[InlineData("/braniewo/base.ctr", "Braniewo/Base.ctr")]
|
||||||
|
[InlineData(@"\Braniewo\Base.ctr", "Braniewo/Base.ctr")]
|
||||||
|
public void A_name_in_any_case_is_given_as_it_is_on_disk(string name, string onDisk) =>
|
||||||
|
Assert.Equal(onDisk, new CaseInsensitivePaths(Disk()).Resolve("game/scenery", name));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("braniewo/brak.ctr")]
|
||||||
|
[InlineData("brak/base.ctr")]
|
||||||
|
[InlineData("")]
|
||||||
|
public void A_name_nothing_answers_to_is_not_found(string name) =>
|
||||||
|
Assert.Null(new CaseInsensitivePaths(Disk()).Resolve("game/scenery", name));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_folder_is_found_only_when_one_is_asked_for()
|
||||||
|
{
|
||||||
|
var names = new CaseInsensitivePaths(Disk());
|
||||||
|
|
||||||
|
Assert.Equal("Braniewo/rozklady", names.Resolve("game/scenery", "braniewo/ROZKLADY", directory: true));
|
||||||
|
Assert.Null(names.Resolve("game/scenery", "braniewo/ROZKLADY"));
|
||||||
|
Assert.Null(names.Resolve("game/scenery", "braniewo/base.ctr", directory: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_name_written_as_on_disk_wins_over_one_in_other_case()
|
||||||
|
{
|
||||||
|
var files = Disk().WithFile("game/scenery/braniewo/base.ctr", "");
|
||||||
|
var names = new CaseInsensitivePaths(files);
|
||||||
|
|
||||||
|
Assert.Equal("braniewo/base.ctr", names.Resolve("game/scenery", "braniewo/base.ctr"));
|
||||||
|
Assert.Equal("Braniewo/Base.ctr", names.Resolve("game/scenery", "Braniewo/base.CTR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Of_folders_named_alike_the_one_holding_the_file_is_taken()
|
||||||
|
{
|
||||||
|
var files = Disk().WithFile("game/scenery/braniewo/tory.scm", "");
|
||||||
|
var names = new CaseInsensitivePaths(files);
|
||||||
|
|
||||||
|
Assert.Equal("Braniewo/Base.ctr", names.Resolve("game/scenery", "braniewo/base.ctr"));
|
||||||
|
Assert.Equal("braniewo/tory.scm", names.Resolve("game/scenery", "Braniewo/Tory.scm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_full_path_joins_the_folder_and_the_name_on_disk() =>
|
||||||
|
Assert.Equal(Path.Combine("game/scenery", "Braniewo", "Base.ctr"),
|
||||||
|
new CaseInsensitivePaths(Disk()).Find("game/scenery", "BRANIEWO/BASE.CTR"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_full_path_is_found_from_its_own_root()
|
||||||
|
{
|
||||||
|
string scenery = Path.GetFullPath(Path.Combine("game", "scenery"));
|
||||||
|
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithFile(Path.Combine(scenery, "Braniewo", "Base.ctr"), "");
|
||||||
|
|
||||||
|
Assert.Equal(Path.Combine(scenery, "Braniewo", "Base.ctr"),
|
||||||
|
new CaseInsensitivePaths(files).Find("elsewhere", Path.Combine(scenery, "BRANIEWO", "base.CTR")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_disk_that_ignores_case_gives_the_name_back_as_written()
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem().WithFile("game/scenery/Braniewo/Base.ctr", "");
|
||||||
|
var names = new CaseInsensitivePaths(files);
|
||||||
|
|
||||||
|
Assert.Equal("BRANIEWO/base.CTR", names.Resolve("game/scenery", "BRANIEWO/base.CTR"));
|
||||||
|
Assert.Null(names.Resolve("game/scenery", "braniewo/brak.ctr"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Kept_listings_hold_until_forgotten()
|
||||||
|
{
|
||||||
|
var files = Disk();
|
||||||
|
var names = new CaseInsensitivePaths(files, keepListings: true);
|
||||||
|
Assert.Null(names.Resolve("game/scenery", "braniewo/nowy.scm"));
|
||||||
|
|
||||||
|
files.WithFile("game/scenery/Braniewo/Nowy.scm", "");
|
||||||
|
Assert.Null(names.Resolve("game/scenery", "braniewo/nowy.scm"));
|
||||||
|
|
||||||
|
names.Forget();
|
||||||
|
Assert.Equal("Braniewo/Nowy.scm", names.Resolve("game/scenery", "braniewo/nowy.scm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Listings_not_kept_see_a_file_added_since()
|
||||||
|
{
|
||||||
|
var files = Disk();
|
||||||
|
var names = new CaseInsensitivePaths(files);
|
||||||
|
Assert.Null(names.Resolve("game/scenery", "braniewo/nowy.scm"));
|
||||||
|
|
||||||
|
files.WithFile("game/scenery/Braniewo/Nowy.scm", "");
|
||||||
|
|
||||||
|
Assert.Equal("Braniewo/Nowy.scm", names.Resolve("game/scenery", "braniewo/nowy.scm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_folder_that_cannot_be_listed_holds_nothing_to_find()
|
||||||
|
{
|
||||||
|
var files = Disk().DenyListing("game/scenery");
|
||||||
|
|
||||||
|
Assert.Null(new CaseInsensitivePaths(files).Resolve("game/scenery", "braniewo/base.ctr"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,8 @@ public sealed class InMemoryFileSystem : IFileSystem
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IgnoresCase => _comparison == StringComparison.OrdinalIgnoreCase;
|
||||||
|
|
||||||
public bool FileExists(string path) => _files.ContainsKey(Normalize(path));
|
public bool FileExists(string path) => _files.ContainsKey(Normalize(path));
|
||||||
|
|
||||||
/// <summary>A link counts as the directory it points at, as the disk has it.</summary>
|
/// <summary>A link counts as the directory it points at, as the disk has it.</summary>
|
||||||
@@ -273,6 +275,9 @@ public sealed class InMemoryFileSystem : IFileSystem
|
|||||||
return;
|
return;
|
||||||
slash = key.LastIndexOf('/');
|
slash = key.LastIndexOf('/');
|
||||||
}
|
}
|
||||||
|
// An absolute Unix path's parent is the root, "/", which normalises to "".
|
||||||
|
if (slash == 0)
|
||||||
|
_directories.Add("");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Normalize(string path) =>
|
private static string Normalize(string path) =>
|
||||||
|
|||||||
28
StarterNG.Tests/LoadingScreenLogoTests.cs
Normal file
28
StarterNG.Tests/LoadingScreenLogoTests.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using StarterNG.Infrastructure;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class LoadingScreenLogoTests
|
||||||
|
{
|
||||||
|
private static InMemoryFileSystem Logos() => new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithFile("game/Textures/Logo/EP09_Logo.PNG", "png")
|
||||||
|
.WithFile("game/Textures/Logo/Braniewo.JPG", "jpg")
|
||||||
|
.WithFile("game/Textures/Logo/Logo1.BMP", "bmp")
|
||||||
|
.WithFile("game/Textures/Logo/notes.txt", "");
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ep09_logo", null, "EP09_Logo.PNG")]
|
||||||
|
[InlineData("ep09_logo.bmp", "braniewo", "EP09_Logo.PNG")]
|
||||||
|
[InlineData(null, "braniewo", "Braniewo.JPG")]
|
||||||
|
[InlineData("brak", "brak", "Logo1.BMP")]
|
||||||
|
public void The_logo_is_found_whatever_the_case_of_its_name_and_extension(string? logo, string? scenery,
|
||||||
|
string onDisk) =>
|
||||||
|
Assert.Equal(Path.GetFullPath(Path.Combine("game", "Textures", "Logo", onDisk)),
|
||||||
|
Path.GetFullPath(LoadingScreen.FindSource(Logos(), "game", logo, scenery, new Random(1))!));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void With_no_logo_folder_there_is_no_logo() =>
|
||||||
|
Assert.Null(LoadingScreen.FindSource(new InMemoryFileSystem(caseSensitive: true), "game", "x", "y",
|
||||||
|
new Random(1)));
|
||||||
|
}
|
||||||
@@ -21,6 +21,20 @@ public sealed class PhysicalFileSystemTests : IDisposable
|
|||||||
Assert.Equal(new[] { path }, Directory.GetFiles(_root));
|
Assert.Equal(new[] { path }, Directory.GetFiles(_root));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_pattern_lists_names_in_any_letter_case()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.Combine(_root, "PKP", "EP09"));
|
||||||
|
foreach (string name in new[] { "Braniewo.SCN", "kato.scn", "notes.txt", Path.Combine("PKP", "EP09", "EP09.FIZ"),
|
||||||
|
Path.Combine("PKP", "EP09", "ep09dumb.fiz") })
|
||||||
|
File.WriteAllText(Path.Combine(_root, name), "");
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "Braniewo.SCN", "kato.scn" },
|
||||||
|
_files.GetFiles(_root, "*.scn").Select(Path.GetFileName).Order(StringComparer.Ordinal));
|
||||||
|
Assert.Equal(new[] { "EP09.FIZ", "ep09dumb.fiz" },
|
||||||
|
_files.GetFilesRecursive(_root, "*.fiz").Select(Path.GetFileName).Order(StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void An_atomic_write_that_cannot_finish_leaves_what_stood_there()
|
public void An_atomic_write_that_cannot_finish_leaves_what_stood_there()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -65,6 +65,32 @@ public class PhysicsAndAssetsTests
|
|||||||
Assert.Equal(125, physics.VMax);
|
Assert.Equal(125, physics.VMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_include_named_in_other_case_is_followed()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.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_dot_in_the_model_name_is_part_of_the_name()
|
||||||
|
{
|
||||||
|
// As in the game: cd/9-107_v1 holds 9-107.13.fiz, 9-107.6.fiz and more.
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(Fiz("cd", "9-107_v1", "9-107.13"), "Load: LoadAccepted=coal MaxLoad=58")
|
||||||
|
.WithFile(Fiz("cd", "9-107_v1", "9-107.6"), "Load: LoadAccepted=coal MaxLoad=55"));
|
||||||
|
|
||||||
|
Assert.Equal(58, installation.Physics.For("cd/9-107_v1/", "9-107.13")!.MaxLoad);
|
||||||
|
Assert.Equal(55, installation.Physics.For("cd/9-107_v1/", "9-107.6")!.MaxLoad);
|
||||||
|
Assert.Equal(58, installation.Physics.For("cd/9-107_v1/", "9-107.13.e3d")!.MaxLoad);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void A_dumb_variant_stands_in_when_the_model_has_no_fiz_of_its_own()
|
public void A_dumb_variant_stands_in_when_the_model_has_no_fiz_of_its_own()
|
||||||
{
|
{
|
||||||
@@ -189,4 +215,31 @@ public class PhysicsAndAssetsTests
|
|||||||
|
|
||||||
Assert.Empty(installation.MissingAssets.Scan(installation.Library.Vehicles));
|
Assert.Empty(installation.MissingAssets.Scan(installation.Library.Vehicles));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_livery_with_no_folder_is_looked_for_in_dynamic_itself()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "EP09.bmp"), "bitmap")
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "ep09.e3d"), "model"));
|
||||||
|
var catalog = new StarterNG.Domain.Vehicles.VehicleCatalog(installation.MiniTextures);
|
||||||
|
catalog.Textures.Add(new StarterNG.Domain.Vehicles.VehicleTexture { Directory = "", Skinfile = "ep09", Model = "EP09" });
|
||||||
|
|
||||||
|
Assert.Empty(installation.MissingAssets.Scan(catalog));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_skin_and_model_on_disk_in_other_case_are_not_reported()
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "PKP", "EP09", "textures.txt"),
|
||||||
|
"EP09-014.mat=ep09.e3d,ep09")
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "PKP", "EP09", "ep09-014.BMP"), "bitmap")
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "PKP", "EP09", "EP09.E3D"), "model");
|
||||||
|
var installation = new TestInstallation(files);
|
||||||
|
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||||
|
Assert.NotEmpty(installation.Library.Vehicles.Textures);
|
||||||
|
|
||||||
|
Assert.Empty(installation.MissingAssets.Scan(installation.Library.Vehicles));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,20 @@ public class TimetableLocatorTests
|
|||||||
Assert.NotNull(new TimetableLocator(files).Resolve(scenery, "r1.txt"));
|
Assert.NotNull(new TimetableLocator(files).Resolve(scenery, "r1.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(@"rozklady\tgs3657")]
|
||||||
|
[InlineData("ROZKLADY/TGS3657.TXT")]
|
||||||
|
public void A_timetable_named_in_other_case_or_with_backslashes_is_found_as_on_disk(string name)
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithFile(TestInstallation.At("scenery", "Braniewo.scn"), "//$n Braniewo")
|
||||||
|
.WithFile(TestInstallation.At("scenery", "Rozklady", "TGS3657.txt"), "rozkład");
|
||||||
|
var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll());
|
||||||
|
|
||||||
|
Assert.Equal(TestInstallation.At("scenery", "Rozklady", "TGS3657.txt"),
|
||||||
|
new TimetableLocator(files).Resolve(scenery, name));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void The_formats_none_and_a_missing_file_both_resolve_to_nothing()
|
public void The_formats_none_and_a_missing_file_both_resolve_to_nothing()
|
||||||
{
|
{
|
||||||
@@ -195,3 +209,26 @@ public class TimetableLocatorTests
|
|||||||
Assert.Null(locator.Resolve(scenery, null));
|
Assert.Null(locator.Resolve(scenery, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class AttachmentLocatorTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(@"scenery\Braniewo\opis.PDF")]
|
||||||
|
[InlineData("SCENERY/braniewo/Opis.pdf")]
|
||||||
|
public void An_attachment_named_in_other_case_is_found_as_on_disk(string named)
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithFile(TestInstallation.At("scenery", "Braniewo", "Opis.pdf"), "%PDF"));
|
||||||
|
|
||||||
|
Assert.Equal(TestInstallation.At("scenery", "Braniewo", "Opis.pdf"),
|
||||||
|
new AttachmentLocator(installation.Files, installation.Paths)
|
||||||
|
.Resolve(new SceneryAttachment { FilePath = named }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("scenery/brak.pdf")]
|
||||||
|
[InlineData("")]
|
||||||
|
public void An_attachment_that_is_not_there_is_not_found(string named) =>
|
||||||
|
Assert.Null(new AttachmentLocator(new InMemoryFileSystem(caseSensitive: true), new TestInstallation().Paths)
|
||||||
|
.Resolve(new SceneryAttachment { FilePath = named }));
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,6 +89,23 @@ public class SceneryRepositoryTests
|
|||||||
Assert.Equal(Path.Combine("game", "scenery", "images", "kato.jpg"), image);
|
Assert.Equal(Path.Combine("game", "scenery", "images", "kato.jpg"), image);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("calkowo_v2_kopalniaA.jpg", "game/scenery/images/calkowo_v2_kopalniaA.jpg")]
|
||||||
|
[InlineData(@"Images\Calkowo_V2_KopalniaA.JPG", "game/scenery/images/calkowo_v2_kopalniaA.jpg")]
|
||||||
|
[InlineData("CALKOWO_V2.JPG", "game/scenery/Calkowo_v2.jpg")]
|
||||||
|
public void The_preview_image_is_found_whatever_the_case_it_is_named_in(string named, string onDisk)
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithFile("game/scenery/calkowo.scn", "//$i " + named)
|
||||||
|
.WithFile("game/scenery/images/calkowo_v2_kopalniaA.jpg", "binary")
|
||||||
|
.WithFile("game/scenery/Calkowo_v2.jpg", "binary");
|
||||||
|
var scenery = Assert.Single(Build(files, out _).LoadAll());
|
||||||
|
|
||||||
|
string? image = new SceneryImageLocator(files).Resolve(scenery);
|
||||||
|
|
||||||
|
Assert.Equal(Path.GetFullPath(onDisk), Path.GetFullPath(image!));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void A_scenery_without_an_image_directive_resolves_to_nothing()
|
public void A_scenery_without_an_image_directive_resolves_to_nothing()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ public class ScenerySyntaxTests
|
|||||||
include koniec.scm
|
include koniec.scm
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn, _ => new IncludeLookup(true));
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn, _ => true);
|
||||||
|
|
||||||
Assert.Empty(scenery.Faults);
|
Assert.Empty(scenery.Faults);
|
||||||
Assert.Equal("td/rozklad", Assert.Single(scenery.Trainsets).Name);
|
Assert.Equal("td/rozklad", Assert.Single(scenery.Trainsets).Name);
|
||||||
@@ -326,7 +326,7 @@ public class ScenerySyntaxTests
|
|||||||
endoptional
|
endoptional
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn, name => new IncludeLookup(name is "tory.scm" or "budynek.inc")).Faults;
|
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn, name => name is "tory.scm" or "budynek.inc").Faults;
|
||||||
|
|
||||||
Assert.Equal((SceneryFaultKind.MissingInclude, "x.scn", 2, null, "brak.scm"), Seen(Assert.Single(faults)));
|
Assert.Equal((SceneryFaultKind.MissingInclude, "x.scn", 2, null, "brak.scm"), Seen(Assert.Single(faults)));
|
||||||
}
|
}
|
||||||
@@ -347,9 +347,9 @@ public class ScenerySyntaxTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void An_include_on_disk_only_in_other_letter_case_is_what_linux_misses()
|
public void An_include_on_disk_in_other_letter_case_is_no_fault()
|
||||||
{
|
{
|
||||||
// The simulator folds the name to lower case and opens it so.
|
// The simulator finds the file whatever the case of its letters, on Linux too.
|
||||||
var files = new InMemoryFileSystem(caseSensitive: true)
|
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||||
.WithLegacyFile("game/scenery/braniewo_szeroki.scn",
|
.WithLegacyFile("game/scenery/braniewo_szeroki.scn",
|
||||||
"include braniewo/base.ctr end\ninclude Braniewo/Tory.SCM end\n")
|
"include braniewo/base.ctr end\ninclude Braniewo/Tory.SCM end\n")
|
||||||
@@ -357,21 +357,20 @@ public class ScenerySyntaxTests
|
|||||||
.WithFile("game/scenery/braniewo/tory.scm", "");
|
.WithFile("game/scenery/braniewo/tory.scm", "");
|
||||||
var repository = TestScenery.Repository(files);
|
var repository = TestScenery.Repository(files);
|
||||||
|
|
||||||
var faults = repository.Load("game/scenery/braniewo_szeroki.scn")!.Faults;
|
Assert.Empty(repository.Load("game/scenery/braniewo_szeroki.scn")!.Faults);
|
||||||
|
|
||||||
Assert.Equal((SceneryFaultKind.IncludeLetterCase, "braniewo_szeroki.scn", 1, "Braniewo/Base.ctr", "braniewo/base.ctr"),
|
|
||||||
Seen(Assert.Single(faults)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(true)]
|
[InlineData(null)]
|
||||||
[InlineData(false)]
|
[InlineData("lk2_sandbox.sbt")]
|
||||||
public void A_missing_terrain_file_is_no_fault_while_the_scenery_has_its_own_sbt(bool sbt)
|
[InlineData("Lk2_Sandbox.SBT")]
|
||||||
|
public void A_missing_terrain_file_is_no_fault_while_the_scenery_has_its_own_sbt(string? sbtOnDisk)
|
||||||
{
|
{
|
||||||
|
bool sbt = sbtOnDisk is not null;
|
||||||
var files = new InMemoryFileSystem(caseSensitive: true)
|
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||||
.WithLegacyFile("game/scenery/Lk2_Sandbox.scn", "include nmt/nmt100_mazowieckie_ter.scm end\nFirstInit\n");
|
.WithLegacyFile("game/scenery/Lk2_Sandbox.scn", "include nmt/nmt100_mazowieckie_ter.scm end\nFirstInit\n");
|
||||||
if (sbt)
|
if (sbt)
|
||||||
files.WithFile("game/scenery/lk2_sandbox.sbt", "EU07");
|
files.WithFile("game/scenery/" + sbtOnDisk, "EU07");
|
||||||
var repository = TestScenery.Repository(files);
|
var repository = TestScenery.Repository(files);
|
||||||
|
|
||||||
var scenery = repository.Load("game/scenery/Lk2_Sandbox.scn")!;
|
var scenery = repository.Load("game/scenery/Lk2_Sandbox.scn")!;
|
||||||
|
|||||||
@@ -39,6 +39,24 @@ public class SceneryTranslationsTests
|
|||||||
Assert.Equal("Polski", scenery.LocalizedDescription(translations));
|
Assert.Equal("Polski", scenery.LocalizedDescription(translations));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_tables_are_found_whatever_the_case_of_their_names()
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithFile(TestInstallation.At("scenery", "Braniewo.scn"), "//$d @brief")
|
||||||
|
.WithFile(Path.Combine(Path.GetFullPath(TestInstallation.At("scenery")), "I18N", "braniewo_en.json"),
|
||||||
|
"""{ "brief": "English" }""")
|
||||||
|
.WithFile(Path.Combine(Path.GetFullPath(TestInstallation.At("scenery")), "I18N", "BRANIEWO_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]
|
[Fact]
|
||||||
public void Literal_text_and_unknown_keys_are_left_alone()
|
public void Literal_text_and_unknown_keys_are_left_alone()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ public class TapeLaunchTests
|
|||||||
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
|
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
|
||||||
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""), name));
|
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""), name));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_train_number_comes_from_a_timetable_named_in_other_case_with_backslashes()
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithFile(TestInstallation.At("scenery", "Baltyk", "EIE1531.txt"), "[ Rodzaj i numer pociagu | EIE1531 ]\r\n");
|
||||||
|
|
||||||
|
Assert.Equal("EIE1531", new StarterNG.Infrastructure.Sceneries.TimetableLocator(files)
|
||||||
|
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""),
|
||||||
|
@"baltyk\eie1531"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void A_timetable_that_is_not_there_gives_no_train_number() =>
|
public void A_timetable_that_is_not_there_gives_no_train_number() =>
|
||||||
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
|
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
|
||||||
|
|||||||
22
StarterNG.Tests/TextureFolderTests.cs
Normal file
22
StarterNG.Tests/TextureFolderTests.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using StarterNG.Domain.Vehicles;
|
||||||
|
using StarterNG.Infrastructure.Vehicles;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class TextureFolderTests
|
||||||
|
{
|
||||||
|
private static readonly TestInstallation Installation = new(new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithFile("game/dynamic/PKP/EP09_v1/textures.txt", "")
|
||||||
|
.WithDirectory("game/Other/Folder"));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("pkp/ep09_v1/", "dynamic/PKP/EP09_v1")]
|
||||||
|
[InlineData(@"PKP\EP09_V1", "dynamic/PKP/EP09_v1")]
|
||||||
|
[InlineData("other/folder/", "Other/Folder")]
|
||||||
|
[InlineData("pkp/brak/", "pkp/brak")]
|
||||||
|
public void A_liverys_folder_is_found_as_it_is_named_on_disk(string named, string onDisk) =>
|
||||||
|
Assert.Equal(Path.GetFullPath(Path.Combine("game", onDisk)),
|
||||||
|
Path.GetFullPath(TextureFolder.Of(Installation.Files, Installation.Paths,
|
||||||
|
new VehicleTexture { Directory = named })));
|
||||||
|
}
|
||||||
@@ -13,6 +13,6 @@ public static class AppInfo
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
text += " (DEBUG)";
|
text += " (DEBUG)";
|
||||||
#endif
|
#endif
|
||||||
return text + " public beta";
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ namespace StarterNG.Application.Abstractions;
|
|||||||
|
|
||||||
public interface IFileSystem
|
public interface IFileSystem
|
||||||
{
|
{
|
||||||
|
/// <summary>Whether a name finds its file whatever the case of its letters, as on Windows and macOS.</summary>
|
||||||
|
bool IgnoresCase { get; }
|
||||||
|
|
||||||
bool FileExists(string path);
|
bool FileExists(string path);
|
||||||
|
|
||||||
bool DirectoryExists(string path);
|
bool DirectoryExists(string path);
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ public sealed class AppServices
|
|||||||
var missingAssets = new MissingAssetScanner(files, paths);
|
var missingAssets = new MissingAssetScanner(files, paths);
|
||||||
SceneryTexts = new SceneryTranslations(files, log);
|
SceneryTexts = new SceneryTranslations(files, log);
|
||||||
Timetables = new TimetableLocator(files);
|
Timetables = new TimetableLocator(files);
|
||||||
|
Attachments = new AttachmentLocator(files, paths);
|
||||||
LoadWeights = new LoadWeightsRepository(files, paths, log);
|
LoadWeights = new LoadWeightsRepository(files, paths, log);
|
||||||
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
|
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
|
||||||
State = new AppState();
|
State = new AppState();
|
||||||
@@ -119,6 +120,8 @@ public sealed class AppServices
|
|||||||
|
|
||||||
public TimetableLocator Timetables { get; }
|
public TimetableLocator Timetables { get; }
|
||||||
|
|
||||||
|
public AttachmentLocator Attachments { get; }
|
||||||
|
|
||||||
public LoadWeightsRepository LoadWeights { get; }
|
public LoadWeightsRepository LoadWeights { get; }
|
||||||
|
|
||||||
public GameLibrary Library { get; }
|
public GameLibrary Library { get; }
|
||||||
|
|||||||
@@ -61,13 +61,6 @@ public enum SceneryFaultKind
|
|||||||
/// <summary>An include whose file is not there; <see cref="SceneryFault.Token"/> names it.</summary>
|
/// <summary>An include whose file is not there; <see cref="SceneryFault.Token"/> names it.</summary>
|
||||||
MissingInclude,
|
MissingInclude,
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// An include whose file is there only under a name in other letter case: the
|
|
||||||
/// simulator looks for it in lower case, which on Linux misses it. The
|
|
||||||
/// <see cref="SceneryFault.Subject"/> holds the name on disk.
|
|
||||||
/// </summary>
|
|
||||||
IncludeLetterCase,
|
|
||||||
|
|
||||||
/// <summary>An include with no <c>end</c>, which swallows what follows it.</summary>
|
/// <summary>An include with no <c>end</c>, which swallows what follows it.</summary>
|
||||||
IncludeNotClosed,
|
IncludeNotClosed,
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,6 @@ using System.Linq;
|
|||||||
|
|
||||||
namespace StarterNG.Domain.Sceneries;
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// What the simulator makes of an include: whether it opens the file - and when
|
|
||||||
/// it does not, the file that is there under a name differing only in letter case.
|
|
||||||
/// </summary>
|
|
||||||
public readonly record struct IncludeLookup(bool Opens, string? OnDisk = null);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Goes through a scenery's text for the parts the starter reads - trainsets,
|
/// Goes through a scenery's text for the parts the starter reads - trainsets,
|
||||||
/// vehicles, includes - finding where each trainset and loose vehicle is, and
|
/// vehicles, includes - finding where each trainset and loose vehicle is, and
|
||||||
@@ -35,17 +29,17 @@ public static class ScenerySyntax
|
|||||||
|
|
||||||
/// <param name="text">The scenery's text, its includes put in place.</param>
|
/// <param name="text">The scenery's text, its includes put in place.</param>
|
||||||
/// <param name="file">The scenery's file name, for faults in its own text.</param>
|
/// <param name="file">The scenery's file name, for faults in its own text.</param>
|
||||||
/// <param name="findInclude">
|
/// <param name="opensInclude">
|
||||||
/// What the simulator makes of an include, named as the include names it; null
|
/// Whether the simulator opens an include, named as the include names it; null
|
||||||
/// leaves includes unchecked.
|
/// leaves includes unchecked.
|
||||||
/// </param>
|
/// </param>
|
||||||
public static Result Scan(string text, string file, Func<string, IncludeLookup>? findInclude = null)
|
public static Result Scan(string text, string file, Func<string, bool>? opensInclude = null)
|
||||||
{
|
{
|
||||||
string code = SceneryCode.Mask(text, out int unclosed);
|
string code = SceneryCode.Mask(text, out int unclosed);
|
||||||
var tokens = SceneryCode.Tokens(code);
|
var tokens = SceneryCode.Tokens(code);
|
||||||
var lines = new SceneryLines(text, file);
|
var lines = new SceneryLines(text, file);
|
||||||
|
|
||||||
var simulator = new Reading(tokens, lines, findInclude, asSimulator: true);
|
var simulator = new Reading(tokens, lines, opensInclude, asSimulator: true);
|
||||||
simulator.Run();
|
simulator.Run();
|
||||||
var author = new Reading(tokens, lines, null, asSimulator: false);
|
var author = new Reading(tokens, lines, null, asSimulator: false);
|
||||||
author.Run();
|
author.Run();
|
||||||
@@ -116,7 +110,7 @@ public static class ScenerySyntax
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sealed class Reading(
|
private sealed class Reading(
|
||||||
List<SceneryCode.Token> tokens, SceneryLines lines, Func<string, IncludeLookup>? findInclude, bool asSimulator)
|
List<SceneryCode.Token> tokens, SceneryLines lines, Func<string, bool>? opensInclude, bool asSimulator)
|
||||||
{
|
{
|
||||||
public readonly List<(int Start, int End, string Name)> Trainsets = new();
|
public readonly List<(int Start, int End, string Name)> Trainsets = new();
|
||||||
public readonly List<(int, int)> Loose = new();
|
public readonly List<(int, int)> Loose = new();
|
||||||
@@ -375,13 +369,8 @@ public static class ScenerySyntax
|
|||||||
return stop < tokens.Count && tokens[stop].Is("end") ? stop : stop - 1;
|
return stop < tokens.Count && tokens[stop].Is("end") ? stop : stop - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (findInclude is not null && Plain(name) && findInclude(name) is { Opens: false } lookup)
|
if (opensInclude is not null && Plain(name) && !opensInclude(name))
|
||||||
{
|
Fault(SceneryFaultKind.MissingInclude, token.Index, token: name);
|
||||||
if (lookup.OnDisk is { } onDisk)
|
|
||||||
Fault(SceneryFaultKind.IncludeLetterCase, token.Index, onDisk, name);
|
|
||||||
else
|
|
||||||
Fault(SceneryFaultKind.MissingInclude, token.Index, token: name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The simulator takes its parameters up to the word "end", written just so,
|
// The simulator takes its parameters up to the word "end", written just so,
|
||||||
// whatever comes before it.
|
// whatever comes before it.
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ public sealed class PhysicalFileSystem : IFileSystem
|
|||||||
{
|
{
|
||||||
public const string TempSuffix = ".starter.tmp";
|
public const string TempSuffix = ".starter.tmp";
|
||||||
|
|
||||||
|
// Their disks as they come; a Linux one tells case apart.
|
||||||
|
public bool IgnoresCase { get; } = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS();
|
||||||
|
|
||||||
public bool FileExists(string path) => File.Exists(path);
|
public bool FileExists(string path) => File.Exists(path);
|
||||||
|
|
||||||
public bool DirectoryExists(string path) => Directory.Exists(path);
|
public bool DirectoryExists(string path) => Directory.Exists(path);
|
||||||
@@ -88,8 +91,18 @@ public sealed class PhysicalFileSystem : IFileSystem
|
|||||||
public void CopyFile(string sourcePath, string destinationPath, bool overwrite) =>
|
public void CopyFile(string sourcePath, string destinationPath, bool overwrite) =>
|
||||||
File.Copy(sourcePath, destinationPath, overwrite);
|
File.Copy(sourcePath, destinationPath, overwrite);
|
||||||
|
|
||||||
|
// The pattern matches names in any letter case, as on Windows: the game's data,
|
||||||
|
// made there, has X.SCN as well as x.scn.
|
||||||
public IReadOnlyList<string> GetFiles(string path, string searchPattern = "*") =>
|
public IReadOnlyList<string> GetFiles(string path, string searchPattern = "*") =>
|
||||||
Directory.Exists(path) ? Directory.GetFiles(path, searchPattern) : Array.Empty<string>();
|
Directory.Exists(path)
|
||||||
|
? Directory.GetFiles(path, searchPattern, new EnumerationOptions
|
||||||
|
{
|
||||||
|
IgnoreInaccessible = false,
|
||||||
|
AttributesToSkip = 0,
|
||||||
|
MatchType = MatchType.Win32,
|
||||||
|
MatchCasing = MatchCasing.CaseInsensitive
|
||||||
|
})
|
||||||
|
: Array.Empty<string>();
|
||||||
|
|
||||||
// A folder it may not enter costs what is in it, not the whole listing.
|
// A folder it may not enter costs what is in it, not the whole listing.
|
||||||
public IReadOnlyList<string> GetFilesRecursive(string path, string searchPattern) =>
|
public IReadOnlyList<string> GetFilesRecursive(string path, string searchPattern) =>
|
||||||
@@ -99,7 +112,8 @@ public sealed class PhysicalFileSystem : IFileSystem
|
|||||||
RecurseSubdirectories = true,
|
RecurseSubdirectories = true,
|
||||||
IgnoreInaccessible = true,
|
IgnoreInaccessible = true,
|
||||||
AttributesToSkip = 0,
|
AttributesToSkip = 0,
|
||||||
MatchType = MatchType.Win32
|
MatchType = MatchType.Win32,
|
||||||
|
MatchCasing = MatchCasing.CaseInsensitive
|
||||||
})
|
})
|
||||||
: Array.Empty<string>();
|
: Array.Empty<string>();
|
||||||
|
|
||||||
|
|||||||
132
StarterNG/Infrastructure/CaseInsensitivePaths.cs
Normal file
132
StarterNG/Infrastructure/CaseInsensitivePaths.cs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using StarterNG.Application.Abstractions;
|
||||||
|
|
||||||
|
namespace StarterNG.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds a file or folder of the game's data by a name whose letters may stand in
|
||||||
|
/// another case than on disk, as Windows, Wine and the simulator find it, and gives
|
||||||
|
/// the name as the disk has it. The data is made on Windows: its names and the
|
||||||
|
/// names written in it seldom agree letter for letter, which on Linux matters.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CaseInsensitivePaths
|
||||||
|
{
|
||||||
|
private readonly IFileSystem _files;
|
||||||
|
private readonly Func<(string Folder, bool Files), ILookup<string, string>> _list;
|
||||||
|
|
||||||
|
/// <summary>Each folder's files and folders by name, listed once until <see cref="Forget"/>; null lists every time.</summary>
|
||||||
|
private readonly ConcurrentDictionary<(string Folder, bool Files), ILookup<string, string>>? _listings;
|
||||||
|
|
||||||
|
/// <param name="keepListings">
|
||||||
|
/// Lists each folder once, for many names looked up in data that stays as it is
|
||||||
|
/// meanwhile, until <see cref="Forget"/>.
|
||||||
|
/// </param>
|
||||||
|
public CaseInsensitivePaths(IFileSystem files, bool keepListings = false)
|
||||||
|
{
|
||||||
|
_files = files;
|
||||||
|
_list = List;
|
||||||
|
if (keepListings)
|
||||||
|
_listings = new ConcurrentDictionary<(string, bool), ILookup<string, string>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lets the next lookups list the folders again.</summary>
|
||||||
|
public void Forget() => _listings?.Clear();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file named so under <paramref name="folder"/> as it is named on disk, relative
|
||||||
|
/// to the folder with forward slashes; null when nothing there answers to it. A name
|
||||||
|
/// written with its letters as on disk wins over one differing only in their case;
|
||||||
|
/// a disk that does not tell case apart gives it back as written.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">
|
||||||
|
/// A name under the folder, with either slash, joined to it as the simulator joins
|
||||||
|
/// it: a slash ahead of it counts for nothing.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="directory">Looks for a folder instead of a file.</param>
|
||||||
|
public string? Resolve(string folder, string name, bool directory = false)
|
||||||
|
{
|
||||||
|
string[] parts = name.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string exact = string.Join('/', parts);
|
||||||
|
string whole = Path.Combine(folder, exact);
|
||||||
|
if (directory ? _files.DirectoryExists(whole) : _files.FileExists(whole))
|
||||||
|
return exact;
|
||||||
|
// Where case does not matter, missed as written is missed in any case.
|
||||||
|
if (_files.IgnoresCase)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var found = new string[parts.Length];
|
||||||
|
return Walk(folder, parts, 0, directory, found) ? string.Join('/', found) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The full path of the file named so, its letters as on disk; null when there is
|
||||||
|
/// none. A name given in full is looked up from its own root, any other under
|
||||||
|
/// <paramref name="folder"/> as <see cref="Resolve"/> does.
|
||||||
|
/// </summary>
|
||||||
|
public string? Find(string folder, string name, bool directory = false)
|
||||||
|
{
|
||||||
|
if (Path.IsPathFullyQualified(name))
|
||||||
|
{
|
||||||
|
folder = Path.GetPathRoot(name)!;
|
||||||
|
name = name[folder.Length..];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Resolve(folder, name, directory) is { } found
|
||||||
|
? Path.Combine(folder, found.Replace('/', Path.DirectorySeparatorChar))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the parts from <paramref name="at"/> on under <paramref name="current"/>. Of
|
||||||
|
/// folders named alike in other case - two copies of the data on Linux - each is
|
||||||
|
/// tried, the one written as on disk first.
|
||||||
|
/// </summary>
|
||||||
|
private bool Walk(string current, string[] parts, int at, bool directory, string[] found)
|
||||||
|
{
|
||||||
|
if (at == parts.Length)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
string part = parts[at];
|
||||||
|
if (part is "." or "..")
|
||||||
|
{
|
||||||
|
found[at] = part;
|
||||||
|
return Walk(Path.Combine(current, part), parts, at + 1, directory, found);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool last = at == parts.Length - 1;
|
||||||
|
var matches = Listing(current, files: last && !directory)[part]
|
||||||
|
.OrderBy(entry => string.Equals(Path.GetFileName(entry), part, StringComparison.Ordinal) ? 0 : 1);
|
||||||
|
foreach (string match in matches)
|
||||||
|
{
|
||||||
|
found[at] = Path.GetFileName(match);
|
||||||
|
if (Walk(match, parts, at + 1, directory, found))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ILookup<string, string> Listing(string folder, bool files) =>
|
||||||
|
_listings is null ? List((folder, files)) : _listings.GetOrAdd((folder, files), _list);
|
||||||
|
|
||||||
|
// A folder that cannot be listed holds nothing to be found.
|
||||||
|
private ILookup<string, string> List((string Folder, bool Files) key)
|
||||||
|
{
|
||||||
|
var entries = Array.Empty<string>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_files.DirectoryExists(key.Folder))
|
||||||
|
entries = (key.Files ? _files.GetFiles(key.Folder) : _files.GetDirectories(key.Folder)).ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Nothing listed, nothing found.
|
||||||
|
}
|
||||||
|
return entries.ToLookup(entry => Path.GetFileName(entry), StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,16 +5,21 @@ using System.Runtime.InteropServices;
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
|
using StarterNG.Application.Abstractions;
|
||||||
|
using StarterNG.Infrastructure.Adapters;
|
||||||
|
|
||||||
namespace StarterNG.Infrastructure;
|
namespace StarterNG.Infrastructure;
|
||||||
|
|
||||||
public static class LoadingScreen
|
public static class LoadingScreen
|
||||||
{
|
{
|
||||||
|
private static readonly string[] Extensions = { ".bmp", ".jpg", ".jpeg", ".png" };
|
||||||
|
|
||||||
public static void Prepare(string? logoKey, string? sceneryName = null)
|
public static void Prepare(string? logoKey, string? sceneryName = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string? src = ResolveSource(logoKey) ?? ResolveSource(sceneryName) ?? PickRandom();
|
string? src = FindSource(new PhysicalFileSystem(), Directory.GetCurrentDirectory(), logoKey, sceneryName,
|
||||||
|
Random.Shared);
|
||||||
if (src is null) return;
|
if (src is null) return;
|
||||||
|
|
||||||
string destDir = Path.Combine(Directory.GetCurrentDirectory(), "textures");
|
string destDir = Path.Combine(Directory.GetCurrentDirectory(), "textures");
|
||||||
@@ -35,32 +40,36 @@ public static class LoadingScreen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ResolveSource(string? key)
|
/// <summary>
|
||||||
|
/// The picture of textures/logo under <paramref name="root"/> to show: the trainset's,
|
||||||
|
/// else the scenery's, else one of logo*.* at random; its name and extension in any
|
||||||
|
/// letter case. Null when there is none.
|
||||||
|
/// </summary>
|
||||||
|
public static string? FindSource(IFileSystem files, string root, string? logoKey, string? sceneryName,
|
||||||
|
Random random)
|
||||||
|
{
|
||||||
|
var names = new CaseInsensitivePaths(files);
|
||||||
|
if (names.Find(root, "textures/logo", directory: true) is not { } dir)
|
||||||
|
return null;
|
||||||
|
return Named(names, dir, logoKey) ?? Named(names, dir, sceneryName) ?? PickRandom(files, dir, random);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Named(CaseInsensitivePaths names, string dir, string? key)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(key)) return null;
|
if (string.IsNullOrWhiteSpace(key)) return null;
|
||||||
key = Path.GetFileNameWithoutExtension(key.Trim());
|
key = Path.GetFileNameWithoutExtension(key.Trim());
|
||||||
string dir = Path.Combine(Directory.GetCurrentDirectory(), "textures", "logo");
|
if (key.Length == 0) return null;
|
||||||
foreach (string ext in new[] { ".bmp", ".jpg", ".jpeg", ".png" })
|
return Extensions.Select(ext => names.Find(dir, key + ext)).FirstOrDefault(path => path is not null);
|
||||||
{
|
|
||||||
string path = Path.Combine(dir, key + ext);
|
|
||||||
if (File.Exists(path)) return path;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? PickRandom()
|
private static string? PickRandom(IFileSystem files, string dir, Random random)
|
||||||
{
|
{
|
||||||
string dir = Path.Combine(Directory.GetCurrentDirectory(), "textures", "logo");
|
var logos = files.GetFiles(dir, "logo*.*")
|
||||||
if (!Directory.Exists(dir)) return null;
|
.Where(f => Extensions.Contains(Path.GetExtension(f), StringComparer.OrdinalIgnoreCase))
|
||||||
var files = Directory.GetFiles(dir, "logo*.*")
|
.Order(StringComparer.Ordinal)
|
||||||
.Where(f =>
|
|
||||||
{
|
|
||||||
string e = Path.GetExtension(f).ToLowerInvariant();
|
|
||||||
return e is ".bmp" or ".jpg" or ".jpeg" or ".png";
|
|
||||||
})
|
|
||||||
.ToArray();
|
.ToArray();
|
||||||
if (files.Length == 0) return null;
|
if (logos.Length == 0) return null;
|
||||||
return files[Random.Shared.Next(files.Length)];
|
return logos[random.Next(logos.Length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void WriteBmp(string sourcePath, string destPath)
|
private static void WriteBmp(string sourcePath, string destPath)
|
||||||
|
|||||||
24
StarterNG/Infrastructure/Sceneries/AttachmentLocator.cs
Normal file
24
StarterNG/Infrastructure/Sceneries/AttachmentLocator.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using StarterNG.Application.Abstractions;
|
||||||
|
using StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
namespace StarterNG.Infrastructure.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>Where the file a scenery attaches (<c>//$f</c>) is on disk.</summary>
|
||||||
|
public sealed class AttachmentLocator
|
||||||
|
{
|
||||||
|
private readonly IGamePaths _paths;
|
||||||
|
private readonly CaseInsensitivePaths _names;
|
||||||
|
|
||||||
|
public AttachmentLocator(IFileSystem files, IGamePaths paths)
|
||||||
|
{
|
||||||
|
_paths = paths;
|
||||||
|
_names = new CaseInsensitivePaths(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The attachment's file, named from the game's folder or in full, with its letters
|
||||||
|
/// as on disk; null when it is not there.
|
||||||
|
/// </summary>
|
||||||
|
public string? Resolve(SceneryAttachment attachment) =>
|
||||||
|
string.IsNullOrWhiteSpace(attachment.FilePath) ? null : _names.Find(_paths.Root, attachment.FilePath.Trim());
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using StarterNG.Application.Abstractions;
|
using StarterNG.Application.Abstractions;
|
||||||
using StarterNG.Domain.Sceneries;
|
using StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
@@ -8,11 +9,13 @@ namespace StarterNG.Infrastructure.Sceneries;
|
|||||||
public sealed class SceneryImageLocator
|
public sealed class SceneryImageLocator
|
||||||
{
|
{
|
||||||
private readonly IFileSystem _files;
|
private readonly IFileSystem _files;
|
||||||
|
private readonly CaseInsensitivePaths _names;
|
||||||
private readonly Dictionary<string, string?> _cache = new();
|
private readonly Dictionary<string, string?> _cache = new();
|
||||||
|
|
||||||
public SceneryImageLocator(IFileSystem files)
|
public SceneryImageLocator(IFileSystem files)
|
||||||
{
|
{
|
||||||
_files = files;
|
_files = files;
|
||||||
|
_names = new CaseInsensitivePaths(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? Resolve(Scenery scenery)
|
public string? Resolve(Scenery scenery)
|
||||||
@@ -34,22 +37,23 @@ public sealed class SceneryImageLocator
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
string name = scenery.ImageName.Replace('\\', '/').Trim();
|
string name = scenery.ImageName.Replace('\\', '/').Trim();
|
||||||
|
if (Path.IsPathFullyQualified(name))
|
||||||
|
return _names.Find("", name);
|
||||||
|
if (_files.FileExists(name))
|
||||||
|
return name;
|
||||||
|
|
||||||
string sceneryDir = Path.GetDirectoryName(scenery.Path) ?? ".";
|
string sceneryDir = Path.GetDirectoryName(scenery.Path) ?? ".";
|
||||||
string root = Path.GetDirectoryName(sceneryDir) ?? ".";
|
string root = Path.GetDirectoryName(sceneryDir) ?? ".";
|
||||||
|
|
||||||
string[] candidates =
|
(string Folder, string Name)[] candidates =
|
||||||
{
|
{
|
||||||
name,
|
(root, name),
|
||||||
Path.Combine(root, name),
|
(sceneryDir, name),
|
||||||
Path.Combine(sceneryDir, name),
|
(sceneryDir, "images/" + name),
|
||||||
Path.Combine(sceneryDir, "images", name),
|
(root, "scenery/images/" + name)
|
||||||
Path.Combine(root, "scenery", "images", name)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (string candidate in candidates)
|
return candidates.Select(candidate => _names.Find(candidate.Folder, candidate.Name))
|
||||||
if (_files.FileExists(candidate))
|
.FirstOrDefault(found => found is not null);
|
||||||
return candidate;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ public sealed class SceneryParser
|
|||||||
_random = random;
|
_random = random;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <param name="findInclude">
|
/// <param name="opensInclude">
|
||||||
/// What the simulator makes of an include, as the include names it; null leaves
|
/// Whether the simulator opens an include, as the include names it; null leaves
|
||||||
/// includes unchecked.
|
/// includes unchecked.
|
||||||
/// </param>
|
/// </param>
|
||||||
public Scenery Parse(string path, string content, Func<string, IncludeLookup>? findInclude = null)
|
public Scenery Parse(string path, string content, Func<string, bool>? opensInclude = null)
|
||||||
{
|
{
|
||||||
string original = content;
|
string original = content;
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ public sealed class SceneryParser
|
|||||||
|
|
||||||
// Read from the text with its optional includes blanked, which keeps every
|
// Read from the text with its optional includes blanked, which keeps every
|
||||||
// place where it was, so a fault's line is the line in the file.
|
// place where it was, so a fault's line is the line in the file.
|
||||||
var syntax = ScenerySyntax.Scan(content, System.IO.Path.GetFileName(path), findInclude);
|
var syntax = ScenerySyntax.Scan(content, System.IO.Path.GetFileName(path), opensInclude);
|
||||||
var faults = syntax.Faults.ToList();
|
var faults = syntax.Faults.ToList();
|
||||||
var inlined = SceneryIncludes.Ranges(content);
|
var inlined = SceneryIncludes.Ranges(content);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
private readonly SceneryParser _parser;
|
private readonly SceneryParser _parser;
|
||||||
|
|
||||||
/// <summary>The scenery folders' files and folders, listed once per load of them all.</summary>
|
/// <summary>The scenery folders' files and folders, listed once per load of them all.</summary>
|
||||||
private readonly ConcurrentDictionary<(string, bool), IReadOnlyList<string>> _listings = new();
|
private readonly CaseInsensitivePaths _names;
|
||||||
|
|
||||||
public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser)
|
public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser)
|
||||||
{
|
{
|
||||||
@@ -31,11 +31,12 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
_paths = paths;
|
_paths = paths;
|
||||||
_log = log;
|
_log = log;
|
||||||
_parser = parser;
|
_parser = parser;
|
||||||
|
_names = new CaseInsensitivePaths(files, keepListings: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null)
|
public IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null)
|
||||||
{
|
{
|
||||||
_listings.Clear();
|
_names.Forget();
|
||||||
var files = SceneryFiles().ToList();
|
var files = SceneryFiles().ToList();
|
||||||
var loaded = new ConcurrentBag<Scenery>();
|
var loaded = new ConcurrentBag<Scenery>();
|
||||||
int total = Math.Max(1, files.Count);
|
int total = Math.Max(1, files.Count);
|
||||||
@@ -58,7 +59,7 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
|
|
||||||
public Scenery? Load(string path)
|
public Scenery? Load(string path)
|
||||||
{
|
{
|
||||||
_listings.Clear();
|
_names.Forget();
|
||||||
return LoadOne(path);
|
return LoadOne(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,10 +80,8 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
// With its own .sbt the scenery's terrain comes from it, and the simulator
|
// With its own .sbt the scenery's terrain comes from it, and the simulator
|
||||||
// passes its terrain includes over without opening them.
|
// passes its terrain includes over without opening them.
|
||||||
bool binaryTerrain = Opens(Path.GetFileNameWithoutExtension(path) + ".sbt");
|
bool binaryTerrain = Opens(Path.GetFileNameWithoutExtension(path) + ".sbt");
|
||||||
IncludeLookup FindInclude(string name) =>
|
bool OpensInclude(string name) =>
|
||||||
Opens(name) || (binaryTerrain && SimulatorNames.Lower(name).Contains("_ter.scm"))
|
Opens(name) || (binaryTerrain && SimulatorNames.Lower(name).Contains("_ter.scm"));
|
||||||
? new IncludeLookup(true)
|
|
||||||
: new IncludeLookup(false, ResolveInclude(name));
|
|
||||||
|
|
||||||
(string File, string Text)? ReadInclude(string name)
|
(string File, string Text)? ReadInclude(string name)
|
||||||
{
|
{
|
||||||
@@ -99,7 +98,7 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
|
|
||||||
string own = LegacyText.Decode(_files.ReadAllBytes(path), out var ownEncoding);
|
string own = LegacyText.Decode(_files.ReadAllBytes(path), out var ownEncoding);
|
||||||
string text = SceneryIncludes.Inline(own, ReadInclude);
|
string text = SceneryIncludes.Inline(own, ReadInclude);
|
||||||
var scenery = _parser.Parse(path, text, FindInclude);
|
var scenery = _parser.Parse(path, text, OpensInclude);
|
||||||
scenery.HasCompanionTimetable = binaryTerrain;
|
scenery.HasCompanionTimetable = binaryTerrain;
|
||||||
scenery.Sources = sources;
|
scenery.Sources = sources;
|
||||||
scenery.TextEncoding = ownEncoding;
|
scenery.TextEncoding = ownEncoding;
|
||||||
@@ -114,11 +113,11 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the simulator opens a file of the scenery folder named so: by the
|
/// Whether the simulator opens a file of the scenery folder named so: whatever the
|
||||||
/// name in lower case, which on Linux has to be how it is named on disk.
|
/// case of its letters, on Linux too. A shade more lenient than eu07 there, which
|
||||||
|
/// folds only the letters A to Z and of two folders named alike takes the first.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool Opens(string name) =>
|
private bool Opens(string name) => ResolveInclude(name) is not null;
|
||||||
_files.FileExists(Path.Combine(_paths.Scenery, SimulatorNames.Lower(name.Replace('\\', '/'))));
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An include's file as it is named on disk, from the scenery folder, whatever
|
/// An include's file as it is named on disk, from the scenery folder, whatever
|
||||||
@@ -126,44 +125,7 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
/// copy written for a start carries in place of the include, so they come along
|
/// copy written for a start carries in place of the include, so they come along
|
||||||
/// on Linux too.
|
/// on Linux too.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private string? ResolveInclude(string name)
|
private string? ResolveInclude(string name) => _names.Resolve(_paths.Scenery, name);
|
||||||
{
|
|
||||||
string relative = name.Replace('\\', '/');
|
|
||||||
if (_files.FileExists(Path.Combine(_paths.Scenery, relative)))
|
|
||||||
return relative;
|
|
||||||
|
|
||||||
string[] parts = relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
string directory = _paths.Scenery;
|
|
||||||
var found = new List<string>();
|
|
||||||
for (int i = 0; i < parts.Length; i++)
|
|
||||||
{
|
|
||||||
if (parts[i] is "." or "..")
|
|
||||||
{
|
|
||||||
directory = Path.Combine(directory, parts[i]);
|
|
||||||
found.Add(parts[i]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool last = i == parts.Length - 1;
|
|
||||||
string? match = Listing(directory, last)
|
|
||||||
.FirstOrDefault(entry => string.Equals(Path.GetFileName(entry), parts[i],
|
|
||||||
StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (match is null)
|
|
||||||
return null;
|
|
||||||
directory = match;
|
|
||||||
found.Add(Path.GetFileName(match));
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.Join('/', found);
|
|
||||||
}
|
|
||||||
|
|
||||||
private IReadOnlyList<string> Listing(string directory, bool files) =>
|
|
||||||
_listings.GetOrAdd((directory, files), key =>
|
|
||||||
{
|
|
||||||
if (!_files.DirectoryExists(key.Item1))
|
|
||||||
return Array.Empty<string>();
|
|
||||||
return key.Item2 ? _files.GetFiles(key.Item1) : _files.GetDirectories(key.Item1);
|
|
||||||
});
|
|
||||||
|
|
||||||
private IEnumerable<string> SceneryFiles() =>
|
private IEnumerable<string> SceneryFiles() =>
|
||||||
_files.GetFiles(_paths.Scenery, "*.scn")
|
_files.GetFiles(_paths.Scenery, "*.scn")
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ public sealed class SceneryTranslations : ISceneryTranslations
|
|||||||
private const string BaseLanguage = "en";
|
private const string BaseLanguage = "en";
|
||||||
|
|
||||||
private readonly IFileSystem _files;
|
private readonly IFileSystem _files;
|
||||||
|
private readonly CaseInsensitivePaths _names;
|
||||||
private readonly IDiagnosticsLog _log;
|
private readonly IDiagnosticsLog _log;
|
||||||
private readonly Dictionary<string, string> _map = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, string> _map = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public SceneryTranslations(IFileSystem files, IDiagnosticsLog log)
|
public SceneryTranslations(IFileSystem files, IDiagnosticsLog log)
|
||||||
{
|
{
|
||||||
_files = files;
|
_files = files;
|
||||||
|
_names = new CaseInsensitivePaths(files);
|
||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,12 +31,11 @@ public sealed class SceneryTranslations : ISceneryTranslations
|
|||||||
|
|
||||||
string sceneryDir = Path.GetDirectoryName(Path.GetFullPath(scenery.Path)) ?? "scenery";
|
string sceneryDir = Path.GetDirectoryName(Path.GetFullPath(scenery.Path)) ?? "scenery";
|
||||||
string id = Path.GetFileNameWithoutExtension(scenery.Path);
|
string id = Path.GetFileNameWithoutExtension(scenery.Path);
|
||||||
string i18nDir = Path.Combine(sceneryDir, "i18n");
|
|
||||||
string language = NormalizeLanguage(langCode);
|
string language = NormalizeLanguage(langCode);
|
||||||
|
|
||||||
TryLoad(Path.Combine(i18nDir, $"{id}_{BaseLanguage}.json"));
|
TryLoad(_names.Find(sceneryDir, $"i18n/{id}_{BaseLanguage}.json"));
|
||||||
if (!string.Equals(language, BaseLanguage, StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(language, BaseLanguage, StringComparison.OrdinalIgnoreCase))
|
||||||
TryLoad(Path.Combine(i18nDir, $"{id}_{language}.json"));
|
TryLoad(_names.Find(sceneryDir, $"i18n/{id}_{language}.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Translate(string? text)
|
public string Translate(string? text)
|
||||||
@@ -50,9 +51,9 @@ public sealed class SceneryTranslations : ISceneryTranslations
|
|||||||
return _map.TryGetValue(key, out string? translated) ? translated : text;
|
return _map.TryGetValue(key, out string? translated) ? translated : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TryLoad(string path)
|
private void TryLoad(string? path)
|
||||||
{
|
{
|
||||||
if (!_files.FileExists(path))
|
if (path is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ public sealed class TimetableLocator
|
|||||||
RegexOptions.Multiline | RegexOptions.IgnoreCase);
|
RegexOptions.Multiline | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
private readonly IFileSystem _files;
|
private readonly IFileSystem _files;
|
||||||
|
private readonly CaseInsensitivePaths _names;
|
||||||
|
|
||||||
public TimetableLocator(IFileSystem files)
|
public TimetableLocator(IFileSystem files)
|
||||||
{
|
{
|
||||||
_files = files;
|
_files = files;
|
||||||
|
_names = new CaseInsensitivePaths(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? Resolve(Scenery scenery, string? name)
|
public string? Resolve(Scenery scenery, string? name)
|
||||||
@@ -30,17 +32,19 @@ public sealed class TimetableLocator
|
|||||||
|
|
||||||
string sceneryDir = Path.GetDirectoryName(scenery.Path) ?? ".";
|
string sceneryDir = Path.GetDirectoryName(scenery.Path) ?? ".";
|
||||||
string root = Path.GetDirectoryName(sceneryDir) ?? ".";
|
string root = Path.GetDirectoryName(sceneryDir) ?? ".";
|
||||||
|
name = name.Replace('\\', '/');
|
||||||
|
|
||||||
string[] candidates =
|
(string Folder, string Name)[] candidates =
|
||||||
{
|
{
|
||||||
Path.Combine(root, "timetables", name + ".txt"),
|
(root, "timetables/" + name + ".txt"),
|
||||||
Path.Combine(sceneryDir, name + ".txt"),
|
(sceneryDir, name + ".txt"),
|
||||||
Path.Combine(root, "scenario", name + ".txt"),
|
(root, "scenario/" + name + ".txt"),
|
||||||
Path.Combine(root, "timetables", name),
|
(root, "timetables/" + name),
|
||||||
Path.Combine(sceneryDir, name)
|
(sceneryDir, name)
|
||||||
};
|
};
|
||||||
|
|
||||||
return candidates.FirstOrDefault(_files.FileExists);
|
return candidates.Select(candidate => _names.Find(candidate.Folder, candidate.Name))
|
||||||
|
.FirstOrDefault(found => found is not null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The number the timetable gives its train; null with no timetable or no such heading in it.</summary>
|
/// <summary>The number the timetable gives its train; null with no timetable or no such heading in it.</summary>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly IFileSystem _files;
|
private readonly IFileSystem _files;
|
||||||
|
private readonly CaseInsensitivePaths _names;
|
||||||
private readonly IGamePaths _paths;
|
private readonly IGamePaths _paths;
|
||||||
private readonly IDiagnosticsLog _log;
|
private readonly IDiagnosticsLog _log;
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
|
|||||||
public FizPhysicsRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log)
|
public FizPhysicsRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log)
|
||||||
{
|
{
|
||||||
_files = files;
|
_files = files;
|
||||||
|
_names = new CaseInsensitivePaths(files);
|
||||||
_paths = paths;
|
_paths = paths;
|
||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
@@ -51,7 +53,7 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
|
|||||||
if (string.IsNullOrEmpty(model))
|
if (string.IsNullOrEmpty(model))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
string name = Path.GetFileNameWithoutExtension(model);
|
string name = Path.GetFileName(model);
|
||||||
if (name.Length == 0)
|
if (name.Length == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@@ -60,7 +62,10 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
|
|||||||
if (_cache.TryGetValue(key, out var cached))
|
if (_cache.TryGetValue(key, out var cached))
|
||||||
return cached;
|
return cached;
|
||||||
|
|
||||||
var physics = Read(folder, name);
|
// The name as written first: in 9-107.13 the dot is part of the name, not an extension.
|
||||||
|
string bare = Path.GetFileNameWithoutExtension(name);
|
||||||
|
var physics = Read(folder, name)
|
||||||
|
?? (bare.Length > 0 && bare != name ? Read(folder, bare) : null);
|
||||||
_cache[key] = physics;
|
_cache[key] = physics;
|
||||||
return physics;
|
return physics;
|
||||||
}
|
}
|
||||||
@@ -162,8 +167,9 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
|
|||||||
while (i < tokens.Count && !tokens[i].Equals("end", StringComparison.OrdinalIgnoreCase))
|
while (i < tokens.Count && !tokens[i].Equals("end", StringComparison.OrdinalIgnoreCase))
|
||||||
includeParameters.Add(Resolve(tokens[i++], parameters));
|
includeParameters.Add(Resolve(tokens[i++], parameters));
|
||||||
|
|
||||||
Parse(physics, Path.Combine(directory, includeFile), directory, includeParameters.ToArray(),
|
// Named in the letter case of whoever wrote the include, not always that of the file.
|
||||||
depth + 1);
|
string included = _names.Find(directory, includeFile) ?? Path.Combine(directory, includeFile);
|
||||||
|
Parse(physics, included, directory, includeParameters.ToArray(), depth + 1);
|
||||||
pendingKey = null;
|
pendingKey = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,44 +10,53 @@ public sealed class MissingAssetScanner
|
|||||||
private static readonly string[] TextureExtensions = { ".mat", ".bmp" };
|
private static readonly string[] TextureExtensions = { ".mat", ".bmp" };
|
||||||
private static readonly string[] ModelExtensions = { ".t3d", ".e3d" };
|
private static readonly string[] ModelExtensions = { ".t3d", ".e3d" };
|
||||||
|
|
||||||
private readonly IFileSystem _files;
|
|
||||||
private readonly IGamePaths _paths;
|
private readonly IGamePaths _paths;
|
||||||
|
private readonly CaseInsensitivePaths _names;
|
||||||
|
|
||||||
public MissingAssetScanner(IFileSystem files, IGamePaths paths)
|
public MissingAssetScanner(IFileSystem files, IGamePaths paths)
|
||||||
{
|
{
|
||||||
_files = files;
|
|
||||||
_paths = paths;
|
_paths = paths;
|
||||||
|
_names = new CaseInsensitivePaths(files, keepListings: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<string> Scan(VehicleCatalog catalog)
|
public List<string> Scan(VehicleCatalog catalog)
|
||||||
{
|
{
|
||||||
var lines = new List<string>();
|
var lines = new List<string>();
|
||||||
|
try
|
||||||
foreach (var texture in catalog.Textures)
|
|
||||||
{
|
{
|
||||||
string directory = Path.Combine(
|
foreach (var texture in catalog.Textures)
|
||||||
_paths.Dynamic,
|
{
|
||||||
texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/'));
|
string directory = texture.Directory.Replace('\\', '/').TrimEnd('/');
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(texture.Skinfile) &&
|
if (!string.IsNullOrEmpty(texture.Skinfile) &&
|
||||||
!ExistsWithAnyExtension(Path.Combine(directory, texture.Skinfile), TextureExtensions))
|
!ExistsWithAnyExtension(Under(directory, texture.Skinfile), TextureExtensions))
|
||||||
lines.Add($"# no file: {texture.Directory}{texture.Skinfile}");
|
lines.Add($"# no file: {texture.Directory}{texture.Skinfile}");
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(texture.Model) &&
|
if (!string.IsNullOrEmpty(texture.Model) &&
|
||||||
!ExistsWithAnyExtension(Path.Combine(directory, texture.Model), ModelExtensions))
|
!ExistsWithAnyExtension(Under(directory, texture.Model), ModelExtensions))
|
||||||
lines.Add($"# no model: {texture.Directory}{texture.Model}");
|
lines.Add($"# no model: {texture.Directory}{texture.Model}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// The folders are listed once for the whole catalog, and not kept past it.
|
||||||
|
_names.Forget();
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ExistsWithAnyExtension(string path, string[] extensions)
|
private static string Under(string directory, string name) =>
|
||||||
|
directory.Length == 0 ? name : directory + "/" + name;
|
||||||
|
|
||||||
|
/// <summary>Whether the file is under dynamic as named, or with one of the extensions, in any letter case.</summary>
|
||||||
|
private bool ExistsWithAnyExtension(string name, string[] extensions)
|
||||||
{
|
{
|
||||||
if (_files.FileExists(path))
|
if (_names.Resolve(_paths.Dynamic, name) is not null)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
foreach (string extension in extensions)
|
foreach (string extension in extensions)
|
||||||
if (_files.FileExists(Path.ChangeExtension(path, extension)))
|
if (_names.Resolve(_paths.Dynamic, Path.ChangeExtension(name, extension)) is not null)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
24
StarterNG/Infrastructure/Vehicles/TextureFolder.cs
Normal file
24
StarterNG/Infrastructure/Vehicles/TextureFolder.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using System.IO;
|
||||||
|
using StarterNG.Application.Abstractions;
|
||||||
|
using StarterNG.Domain.Vehicles;
|
||||||
|
|
||||||
|
namespace StarterNG.Infrastructure.Vehicles;
|
||||||
|
|
||||||
|
public static class TextureFolder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A livery's folder as it is named on disk: under dynamic, or from the game's folder
|
||||||
|
/// when its name starts there, in any letter case. Where it would be when it is not there.
|
||||||
|
/// </summary>
|
||||||
|
public static string Of(IFileSystem files, IGamePaths paths, VehicleTexture texture)
|
||||||
|
{
|
||||||
|
string directory = texture.Directory.Replace('\\', '/').TrimEnd('/');
|
||||||
|
if (directory.Length == 0)
|
||||||
|
return paths.Dynamic;
|
||||||
|
|
||||||
|
var names = new CaseInsensitivePaths(files);
|
||||||
|
return names.Find(paths.Dynamic, directory, directory: true) ??
|
||||||
|
names.Find(paths.Root, directory, directory: true) ??
|
||||||
|
Path.Combine(paths.Root, directory.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,9 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
// The top bar lets the version go first when it runs short of room, which at the usual widths it does;
|
||||||
|
// the title has room for it at any width.
|
||||||
|
Title = $"{Title} {AppInfo.Version}";
|
||||||
|
|
||||||
ExtendClientAreaToDecorationsHint = true;
|
ExtendClientAreaToDecorationsHint = true;
|
||||||
ExtendClientAreaTitleBarHeightHint = TitleBarHeight;
|
ExtendClientAreaTitleBarHeightHint = TitleBarHeight;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
<!-- Major and minor only: CI adds the patch, the number of commits, so every
|
<!-- Major and minor only: CI adds the patch, the number of commits, so every
|
||||||
build of main is a higher version without anyone bumping it. -->
|
build of main is a higher version without anyone bumping it. -->
|
||||||
<VersionPrefix>1.5</VersionPrefix>
|
<VersionPrefix>1.6</VersionPrefix>
|
||||||
<AssemblyName>Starter</AssemblyName>
|
<AssemblyName>Starter</AssemblyName>
|
||||||
<Company>eu07.pl</Company>
|
<Company>eu07.pl</Company>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input.Platform;
|
using Avalonia.Input.Platform;
|
||||||
|
using StarterNG.Application;
|
||||||
using StarterNG.Domain;
|
using StarterNG.Domain;
|
||||||
using StarterNG.Domain.Vehicles;
|
using StarterNG.Domain.Vehicles;
|
||||||
|
using StarterNG.Infrastructure.Vehicles;
|
||||||
|
|
||||||
namespace StarterNG.Views;
|
namespace StarterNG.Views;
|
||||||
|
|
||||||
@@ -17,13 +19,7 @@ internal static class TextureMenuItems
|
|||||||
if (TopLevel.GetTopLevel(owner)?.Clipboard is { } clipboard)
|
if (TopLevel.GetTopLevel(owner)?.Clipboard is { } clipboard)
|
||||||
await clipboard.SetTextAsync(Consist.Base(texture.Skinfile));
|
await clipboard.SetTextAsync(Consist.Base(texture.Skinfile));
|
||||||
});
|
});
|
||||||
menu.AddItem(App.Loc["OpenTextureFolder"], () => Infrastructure.Diagnostics.OpenOrReport(FolderOf(texture)));
|
menu.AddItem(App.Loc["OpenTextureFolder"], () => Infrastructure.Diagnostics.OpenOrReport(
|
||||||
}
|
Path.GetFullPath(TextureFolder.Of(AppServices.Current.Files, AppServices.Current.Paths, texture))));
|
||||||
|
|
||||||
private static string FolderOf(VehicleTexture texture)
|
|
||||||
{
|
|
||||||
string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic",
|
|
||||||
texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/'));
|
|
||||||
return Directory.Exists(dir) ? dir : Path.Combine(Directory.GetCurrentDirectory(), texture.Directory);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -492,20 +492,18 @@ public partial class Scenarios : UserControl
|
|||||||
btn.Classes.Add("Flat");
|
btn.Classes.Add("Flat");
|
||||||
ToolTip.SetTip(btn, att.FilePath);
|
ToolTip.SetTip(btn, att.FilePath);
|
||||||
// A button that does nothing is no way to say the file is not there.
|
// A button that does nothing is no way to say the file is not there.
|
||||||
btn.IsEnabled = File.Exists(Path.GetFullPath(att.FilePath));
|
btn.IsEnabled = AppServices.Current.Attachments.Resolve(att) is not null;
|
||||||
ToolTip.SetShowOnDisabled(btn, true);
|
ToolTip.SetShowOnDisabled(btn, true);
|
||||||
btn.Click += (_, _) => OpenAttachment(att.FilePath);
|
btn.Click += (_, _) => OpenAttachment(att);
|
||||||
attachmentsPanel.Children.Add(btn);
|
attachmentsPanel.Children.Add(btn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void OpenAttachment(string relativePath)
|
private static void OpenAttachment(SceneryAttachment attachment)
|
||||||
{
|
{
|
||||||
string full = Path.GetFullPath(relativePath);
|
if (AppServices.Current.Attachments.Resolve(attachment) is not { } found)
|
||||||
if (!File.Exists(full))
|
|
||||||
full = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), relativePath));
|
|
||||||
if (!File.Exists(full))
|
|
||||||
return;
|
return;
|
||||||
|
string full = Path.GetFullPath(found);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Process.Start(new ProcessStartInfo(full) { UseShellExecute = true });
|
Process.Start(new ProcessStartInfo(full) { UseShellExecute = true });
|
||||||
@@ -1009,12 +1007,14 @@ public partial class Scenarios : UserControl
|
|||||||
int thumbH = AppServices.Current.Settings.LargeThumbnails ? 64 : 32;
|
int thumbH = AppServices.Current.Settings.LargeThumbnails ? 64 : 32;
|
||||||
|
|
||||||
var bmp = LoadMini(miniName, thumbH);
|
var bmp = LoadMini(miniName, thumbH);
|
||||||
|
// Fill, not Uniform: at a scale such as 125 % layout rounds the slot up to whole pixels,
|
||||||
|
// and a mini keeping its own width inside it left a line of the background between two vehicles.
|
||||||
Control visual = bmp is not null
|
Control visual = bmp is not null
|
||||||
? MiniTextures.Sharp(new Image
|
? MiniTextures.Sharp(new Image
|
||||||
{
|
{
|
||||||
Source = bmp,
|
Source = bmp,
|
||||||
Height = thumbH,
|
Height = thumbH,
|
||||||
Stretch = Avalonia.Media.Stretch.Uniform,
|
Stretch = Avalonia.Media.Stretch.Fill,
|
||||||
Margin = new Thickness(0)
|
Margin = new Thickness(0)
|
||||||
})
|
})
|
||||||
: MiniTextures.MissingVisual(train.SkinFile, thumbH);
|
: MiniTextures.MissingVisual(train.SkinFile, thumbH);
|
||||||
@@ -1022,6 +1022,22 @@ public partial class Scenarios : UserControl
|
|||||||
bool selected = !string.IsNullOrEmpty(train.Name) &&
|
bool selected = !string.IsNullOrEmpty(train.Name) &&
|
||||||
string.Equals(train.Name, AppServices.Current.State.StartingVehicleName,
|
string.Equals(train.Name, AppServices.Current.State.StartingVehicleName,
|
||||||
StringComparison.OrdinalIgnoreCase);
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
// The vehicle started in gets a frame over its picture as well as the lines above and below:
|
||||||
|
// two thin lines alone were hard to see. Drawn over the picture, it moves no vehicle aside.
|
||||||
|
if (selected)
|
||||||
|
visual = new Panel
|
||||||
|
{
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
visual,
|
||||||
|
new Border
|
||||||
|
{
|
||||||
|
BorderThickness = new Thickness(3),
|
||||||
|
BorderBrush = SelectedVehicleBrush,
|
||||||
|
IsHitTestVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
var slot = new Border
|
var slot = new Border
|
||||||
{
|
{
|
||||||
@@ -1210,7 +1226,9 @@ public partial class Scenarios : UserControl
|
|||||||
var source = consistStack.Children[i];
|
var source = consistStack.Children[i];
|
||||||
// The ghost mirrors what the slot shows, stand-in wagons included, so
|
// The ghost mirrors what the slot shows, stand-in wagons included, so
|
||||||
// dragging a vehicle with no thumbnail still carries something visible.
|
// dragging a vehicle with no thumbnail still carries something visible.
|
||||||
Control copy = source is Border { Child: Image image }
|
// The picked vehicle's picture sits under its frame.
|
||||||
|
var shown = source is Border { Child: Panel { Children: [Image framed, ..] } } ? framed : (source as Border)?.Child;
|
||||||
|
Control copy = shown is Image image
|
||||||
? new Image { Source = image.Source, Height = image.Height, Stretch = image.Stretch }
|
? new Image { Source = image.Source, Height = image.Height, Stretch = image.Stretch }
|
||||||
: MiniTextures.MissingVisual(null, (int)source.Bounds.Height);
|
: MiniTextures.MissingVisual(null, (int)source.Bounds.Height);
|
||||||
panel.Children.Add(copy);
|
panel.Children.Add(copy);
|
||||||
|
|||||||
@@ -742,7 +742,6 @@
|
|||||||
<String key="SyntaxVehicleOutsideTrainset">Vozidlo {0} stojí mimo soupravu: chybí záhlaví trainset?</String>
|
<String key="SyntaxVehicleOutsideTrainset">Vozidlo {0} stojí mimo soupravu: chybí záhlaví trainset?</String>
|
||||||
<String key="SyntaxNotANumber">{0}: „{1}“ místo čísla</String>
|
<String key="SyntaxNotANumber">{0}: „{1}“ místo čísla</String>
|
||||||
<String key="SyntaxMissingInclude">Soubor {1} nenalezen (include)</String>
|
<String key="SyntaxMissingInclude">Soubor {1} nenalezen (include)</String>
|
||||||
<String key="SyntaxIncludeLetterCase">Soubor {1} je na disku jako {0}: linuxový eu07 ho nenačte</String>
|
|
||||||
<String key="SyntaxIncludeNotClosed">include {1} bez end</String>
|
<String key="SyntaxIncludeNotClosed">include {1} bez end</String>
|
||||||
<String key="SyntaxTrainsetUnreadable">Soupravu {0} se nepodařilo načíst</String>
|
<String key="SyntaxTrainsetUnreadable">Soupravu {0} se nepodařilo načíst</String>
|
||||||
<String key="SyntaxVehicleUnreadable">Vozidlo {0} se nepodařilo načíst</String>
|
<String key="SyntaxVehicleUnreadable">Vozidlo {0} se nepodařilo načíst</String>
|
||||||
|
|||||||
@@ -772,7 +772,6 @@
|
|||||||
<String key="SyntaxVehicleOutsideTrainset">Vehicle {0} stands outside a trainset: header missing?</String>
|
<String key="SyntaxVehicleOutsideTrainset">Vehicle {0} stands outside a trainset: header missing?</String>
|
||||||
<String key="SyntaxNotANumber">{0}: “{1}” instead of a number</String>
|
<String key="SyntaxNotANumber">{0}: “{1}” instead of a number</String>
|
||||||
<String key="SyntaxMissingInclude">File {1} not found (include)</String>
|
<String key="SyntaxMissingInclude">File {1} not found (include)</String>
|
||||||
<String key="SyntaxIncludeLetterCase">File {1} is on disk as {0}: the Linux eu07 will not load it</String>
|
|
||||||
<String key="SyntaxIncludeNotClosed">include {1} without end</String>
|
<String key="SyntaxIncludeNotClosed">include {1} without end</String>
|
||||||
<String key="SyntaxTrainsetUnreadable">Trainset {0} could not be read</String>
|
<String key="SyntaxTrainsetUnreadable">Trainset {0} could not be read</String>
|
||||||
<String key="SyntaxVehicleUnreadable">Vehicle {0} could not be read</String>
|
<String key="SyntaxVehicleUnreadable">Vehicle {0} could not be read</String>
|
||||||
|
|||||||
@@ -742,7 +742,6 @@
|
|||||||
<String key="SyntaxVehicleOutsideTrainset">A(z) {0} jármű szerelvényen kívül áll: hiányzik a trainset fejléc?</String>
|
<String key="SyntaxVehicleOutsideTrainset">A(z) {0} jármű szerelvényen kívül áll: hiányzik a trainset fejléc?</String>
|
||||||
<String key="SyntaxNotANumber">{0}: „{1}” szám helyett</String>
|
<String key="SyntaxNotANumber">{0}: „{1}” szám helyett</String>
|
||||||
<String key="SyntaxMissingInclude">A(z) {1} fájl nem található (include)</String>
|
<String key="SyntaxMissingInclude">A(z) {1} fájl nem található (include)</String>
|
||||||
<String key="SyntaxIncludeLetterCase">A(z) {1} fájl a lemezen {0} néven van: a linuxos eu07 nem tölti be</String>
|
|
||||||
<String key="SyntaxIncludeNotClosed">include {1} end nélkül</String>
|
<String key="SyntaxIncludeNotClosed">include {1} end nélkül</String>
|
||||||
<String key="SyntaxTrainsetUnreadable">A(z) {0} szerelvényt nem sikerült beolvasni</String>
|
<String key="SyntaxTrainsetUnreadable">A(z) {0} szerelvényt nem sikerült beolvasni</String>
|
||||||
<String key="SyntaxVehicleUnreadable">A(z) {0} járművet nem sikerült beolvasni</String>
|
<String key="SyntaxVehicleUnreadable">A(z) {0} járművet nem sikerült beolvasni</String>
|
||||||
|
|||||||
@@ -769,7 +769,6 @@
|
|||||||
<String key="SyntaxVehicleOutsideTrainset">Pojazd {0} stoi poza składem: brak nagłówka trainset?</String>
|
<String key="SyntaxVehicleOutsideTrainset">Pojazd {0} stoi poza składem: brak nagłówka trainset?</String>
|
||||||
<String key="SyntaxNotANumber">{0}: „{1}” zamiast liczby</String>
|
<String key="SyntaxNotANumber">{0}: „{1}” zamiast liczby</String>
|
||||||
<String key="SyntaxMissingInclude">Brak pliku {1} (include)</String>
|
<String key="SyntaxMissingInclude">Brak pliku {1} (include)</String>
|
||||||
<String key="SyntaxIncludeLetterCase">Plik {1} jest na dysku jako {0}: linuksowy eu07 go nie wczyta</String>
|
|
||||||
<String key="SyntaxIncludeNotClosed">include {1} bez end</String>
|
<String key="SyntaxIncludeNotClosed">include {1} bez end</String>
|
||||||
<String key="SyntaxTrainsetUnreadable">Nie udało się odczytać składu {0}</String>
|
<String key="SyntaxTrainsetUnreadable">Nie udało się odczytać składu {0}</String>
|
||||||
<String key="SyntaxVehicleUnreadable">Nie udało się odczytać pojazdu {0}</String>
|
<String key="SyntaxVehicleUnreadable">Nie udało się odczytać pojazdu {0}</String>
|
||||||
|
|||||||
@@ -742,7 +742,6 @@
|
|||||||
<String key="SyntaxVehicleOutsideTrainset">Транспорт {0} стоит вне состава: нет заголовка trainset?</String>
|
<String key="SyntaxVehicleOutsideTrainset">Транспорт {0} стоит вне состава: нет заголовка trainset?</String>
|
||||||
<String key="SyntaxNotANumber">{0}: «{1}» вместо числа</String>
|
<String key="SyntaxNotANumber">{0}: «{1}» вместо числа</String>
|
||||||
<String key="SyntaxMissingInclude">Файл {1} не найден (include)</String>
|
<String key="SyntaxMissingInclude">Файл {1} не найден (include)</String>
|
||||||
<String key="SyntaxIncludeLetterCase">Файл {1} на диске назван {0}: eu07 на Linux его не загрузит</String>
|
|
||||||
<String key="SyntaxIncludeNotClosed">include {1} без end</String>
|
<String key="SyntaxIncludeNotClosed">include {1} без end</String>
|
||||||
<String key="SyntaxTrainsetUnreadable">Не удалось прочитать состав {0}</String>
|
<String key="SyntaxTrainsetUnreadable">Не удалось прочитать состав {0}</String>
|
||||||
<String key="SyntaxVehicleUnreadable">Не удалось прочитать единицу {0}</String>
|
<String key="SyntaxVehicleUnreadable">Не удалось прочитать единицу {0}</String>
|
||||||
|
|||||||
Reference in New Issue
Block a user