diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..64ca4fc
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,14 @@
+# Pull requests for new versions of the pinned actions and of the NuGet packages,
+# so a fix for a vulnerability reaches the build without anyone watching for it.
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+
+ - package-ecosystem: nuget
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 9ad94e7..fb83e2f 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -13,6 +13,9 @@ on:
workflow_dispatch:
+# Read-only unless a job says otherwise; only the release writes. Actions are
+# pinned to full commits - a tag can be moved under us, a commit cannot - and
+# checkout leaves no token behind for the pull request code that runs next.
permissions:
contents: read
@@ -20,11 +23,98 @@ env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
DOTNET_NOLOGO: true
PROJECT_PATH: StarterNG/StarterNG.csproj
+ TEST_PROJECT_PATH: StarterNG.Tests/StarterNG.Tests.csproj
jobs:
+ # On both systems the packages are built for: paths, encodings and the file
+ # system differ, and that is where these tests earn their keep.
+ test:
+ name: Tests (${{ matrix.name }})
+
+ strategy:
+ fail-fast: false
+
+ matrix:
+ include:
+ - name: Windows x64
+ os: windows-2022
+
+ - name: Linux x64
+ os: ubuntu-22.04
+
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Setup .NET 9
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ dotnet-version: 9.0.x
+
+ - name: Test
+ run: >
+ dotnet test
+ "${{ env.TEST_PROJECT_PATH }}"
+ -c Release
+
+ # A known vulnerability in a NuGet package, direct or pulled in, fails the run.
+ - name: Audit packages
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ dotnet list StarterNG.sln package --vulnerable --include-transitive 2>&1 | tee audit.txt
+ if grep -q "has the following vulnerable packages" audit.txt; then
+ echo "::error::Vulnerable NuGet packages, listed above"
+ exit 1
+ fi
+
+ # Major and minor are set by hand in the project file; the patch is the number
+ # of commits, so every build of main is higher than the one before and no bot
+ # has to commit a bump back to the branch.
+ version:
+ name: Version
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ outputs:
+ number: ${{ steps.number.outputs.number }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Number this build
+ id: number
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ prefix=$(sed -n 's:.*\([0-9][0-9]*\.[0-9][0-9]*\).*:\1:p' "$PROJECT_PATH")
+ if [ -z "$prefix" ]; then
+ echo "::error file=$PROJECT_PATH::No major.minor in the project file"
+ exit 1
+ fi
+
+ number="$prefix.$(git rev-list --count HEAD)"
+ echo "number=$number" >> "$GITHUB_OUTPUT"
+ echo "Version $number"
+
publish:
name: ${{ matrix.name }}
+ # A failing test stops the packages, and with them the release.
+ needs: [test, version]
+
strategy:
fail-fast: false
@@ -43,13 +133,16 @@ jobs:
package: artifacts/StarterNG-linux-x64.tar.gz
runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
steps:
- name: Checkout repository
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- name: Setup .NET 9
- uses: actions/setup-dotnet@v6
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 9.0.x
@@ -68,6 +161,9 @@ jobs:
-r "${{ matrix.rid }}"
- name: Publish NativeAOT
+ shell: bash
+ env:
+ VERSION: ${{ needs.version.outputs.number }}
run: >
dotnet publish
"${{ env.PROJECT_PATH }}"
@@ -77,6 +173,7 @@ jobs:
--no-restore
-p:PublishAot=true
-p:PublishTrimmed=true
+ -p:Version="$VERSION"
-o "publish/${{ matrix.rid }}"
- name: Package Windows build
@@ -100,8 +197,11 @@ jobs:
-czf "${{ matrix.package }}" \
.
+ # A fork's pull request is built, to prove it builds, but its binaries are not
+ # handed out under the repository's name.
- name: Upload artifact
- uses: actions/upload-artifact@v7
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.package }}
@@ -109,51 +209,96 @@ jobs:
archive: false
# Workflow artifacts are only reachable for signed-in users with repository
- # access, so every build of a release branch also lands in a rolling
- # pre-release, whose assets anyone can download:
- # https://github.com/${{ github.repository }}/releases/download//
- # main -> "latest", staging -> "staging"; the two never overwrite each other.
+ # access, so every build of a release branch also lands in a release, whose
+ # assets anyone can download. main gets a release of its own per build, tagged
+ # with its version - the newest is what the starter checks itself against:
+ # https://github.com/${{ github.repository }}/releases/latest/download/
+ # staging keeps one rolling pre-release, offered only to starters set to the
+ # testing channel, which tell its builds apart by commit.
release:
- name: Rolling pre-release
- needs: publish
+ name: Release
+ needs: [publish, version]
if: github.event_name == 'push'
runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ # Two pushes in a row release one after the other, not over each other.
+ concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
permissions:
contents: write
- env:
- RELEASE_TAG: ${{ github.ref_name == 'main' && 'latest' || 'staging' }}
- RELEASE_NAME: ${{ github.ref_name == 'main' && 'Latest build' || 'Staging build' }}
-
steps:
# v8, not v7: the packages are uploaded with archive: false, and only v8
# can pull those non-zipped artifacts back down. skip-decompress keeps the
# Windows .zip a file - unpacked, its contents would end up in the release
# instead of the package.
- name: Collect the packages
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
merge-multiple: true
skip-decompress: true
- # Recreated rather than edited, so the tag follows its branch and no asset
- # from an older build is left behind.
- - name: Replace the rolling pre-release
+ - name: Checksum the packages
+ working-directory: dist
+ run: sha256sum StarterNG-windows-x64.zip StarterNG-linux-x64.tar.gz | tee SHA256SUMS.txt
+
+ - name: Publish the release
+ if: github.ref_name == 'main'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: v${{ needs.version.outputs.number }}
+ VERSION: ${{ needs.version.outputs.number }}
run: |
set -euo pipefail
ls -l dist
- gh release delete "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag || true
- gh release create "$RELEASE_TAG" \
+ # A re-run of the same commit finds its release already there and refreshes
+ # it; a release of the same number for another commit - main rewritten - is
+ # left alone rather than given packages that are not its own.
+ if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then
+ tagged=$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)
+ if [ "$tagged" != "$GITHUB_SHA" ]; then
+ echo "::error::$TAG already belongs to $tagged, not to $GITHUB_SHA"
+ exit 1
+ fi
+ gh release upload "$TAG" dist/* --repo "$GITHUB_REPOSITORY" --clobber
+ exit 0
+ fi
+
+ gh release create "$TAG" \
dist/StarterNG-windows-x64.zip \
dist/StarterNG-linux-x64.tar.gz \
+ dist/SHA256SUMS.txt \
+ --repo "$GITHUB_REPOSITORY" \
+ --target "$GITHUB_SHA" \
+ --title "StarterNG $VERSION" \
+ --generate-notes \
+ --latest
+
+ # Recreated rather than edited, so the tag follows its branch and no asset
+ # from an older build is left behind.
+ - name: Replace the staging pre-release
+ if: github.ref_name == 'staging'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ needs.version.outputs.number }}
+ run: |
+ set -euo pipefail
+
+ ls -l dist
+
+ gh release delete staging --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag || true
+ gh release create staging \
+ dist/StarterNG-windows-x64.zip \
+ dist/StarterNG-linux-x64.tar.gz \
+ dist/SHA256SUMS.txt \
--repo "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" \
--prerelease \
- --title "$RELEASE_NAME" \
+ --title "Staging build $VERSION" \
--notes "Automatic build of $GITHUB_REF_NAME (${GITHUB_SHA::7}), $(date -u '+%Y-%m-%d %H:%M UTC')."
diff --git a/StarterNG.Tests/ConsistCrewTests.cs b/StarterNG.Tests/ConsistCrewTests.cs
new file mode 100644
index 0000000..5e6dcd5
--- /dev/null
+++ b/StarterNG.Tests/ConsistCrewTests.cs
@@ -0,0 +1,176 @@
+using StarterNG.Classes;
+using StarterNG.Domain;
+using StarterNG.Domain.Vehicles;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class ConsistCrewTests
+{
+ private readonly Consist _consist;
+ private readonly VehicleCatalog _catalog;
+
+ public ConsistCrewTests()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"),
+ "!=e\nep07-001.mat=ep07,EP07\nep07-002.mat=ep07,EP07")
+ .WithFile(TestInstallation.At("dynamic", "pkp", "b111", "textures.txt"),
+ "!=b\nb111-01.mat=b111,B111"));
+ installation.Vehicles.Load(installation.Library.Vehicles);
+
+ _catalog = installation.Library.Vehicles;
+ _consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
+ }
+
+ [Fact]
+ public void A_locomotive_added_at_the_head_gets_a_driver_in_cab_a()
+ {
+ var loco = Add("ep07-001", 0);
+
+ Assert.Equal(eDriverType.Headdriver, loco.Cars[0].DriverType);
+ }
+
+ [Fact]
+ public void Replacing_the_locomotive_keeps_the_driver_on_the_new_one()
+ {
+ Add("ep07-001", 0);
+ Add("b111-01", 1);
+
+ var replacement = Replace(0, "ep07-002");
+
+ Assert.Equal(eDriverType.Headdriver, replacement.Cars[0].DriverType);
+ Assert.Equal(eDriverType.Headdriver, replacement.Driver);
+ }
+
+ [Fact]
+ public void An_unstaffed_locomotive_replaced_at_the_head_is_staffed_in_cab_a()
+ {
+ var loco = Add("ep07-001", 0);
+ loco.Cars[0].DriverType = eDriverType.Nobody;
+ loco.Driver = eDriverType.Nobody;
+
+ var replacement = Replace(0, "ep07-002");
+
+ Assert.Equal(eDriverType.Headdriver, replacement.Cars[0].DriverType);
+ }
+
+ [Fact]
+ public void A_driver_in_cab_b_stays_in_cab_b()
+ {
+ var loco = Add("ep07-001", 0);
+ loco.Cars[0].DriverType = eDriverType.Reardriver;
+ loco.Driver = eDriverType.Reardriver;
+
+ var replacement = Replace(0, "ep07-002");
+
+ Assert.Equal(eDriverType.Reardriver, replacement.Cars[0].DriverType);
+ }
+
+ [Fact]
+ public void A_wagon_put_in_place_of_the_locomotive_gets_no_driver()
+ {
+ Add("ep07-001", 0);
+
+ var wagon = Replace(0, "b111-01");
+
+ Assert.Equal(eDriverType.Nobody, wagon.Cars[0].DriverType);
+ }
+
+ [Fact]
+ public void A_second_locomotive_behind_a_staffed_one_stays_unstaffed()
+ {
+ Add("ep07-001", 0);
+
+ var second = Add("ep07-002", 1);
+
+ Assert.Equal(eDriverType.Nobody, second.Cars[0].DriverType);
+ }
+
+ [Fact]
+ public void A_locomotive_moved_to_the_head_takes_the_driver_from_the_one_it_put_behind()
+ {
+ var first = Add("ep07-001", 0);
+ var second = Add("ep07-002", 1);
+
+ _consist.Move(1, 0);
+
+ Assert.Equal(eDriverType.Headdriver, second.Cars[0].DriverType);
+ Assert.Equal(eDriverType.Nobody, first.Cars[0].DriverType);
+ Assert.Equal(eDriverType.Nobody, first.Driver);
+ }
+
+ [Fact]
+ public void Stepping_a_locomotive_left_onto_the_head_hands_over_the_same_way()
+ {
+ var first = Add("ep07-001", 0);
+ var second = Add("ep07-002", 1);
+
+ _consist.MoveLeft(second);
+
+ Assert.Equal(eDriverType.Headdriver, second.Cars[0].DriverType);
+ Assert.Equal(eDriverType.Nobody, first.Cars[0].DriverType);
+ }
+
+ [Fact]
+ public void A_wagon_moved_to_the_head_leaves_the_driver_where_he_is()
+ {
+ var loco = Add("ep07-001", 0);
+ Add("b111-01", 1);
+
+ _consist.Move(1, 0);
+
+ Assert.Equal(eDriverType.Headdriver, loco.Cars[0].DriverType);
+ }
+
+ [Fact]
+ public void A_unit_staffed_at_the_head_has_its_driver_in_cab_1_of_the_first_car()
+ {
+ var unit = Unit("ep07-001", "ep07-002");
+
+ _consist.Staff(unit, 0);
+
+ Assert.Equal(eDriverType.Headdriver, unit.Cars[0].DriverType);
+ Assert.Equal(eDriverType.Nobody, unit.Cars[1].DriverType);
+ }
+
+ [Fact]
+ public void A_driver_in_cab_2_moves_into_the_last_car_of_a_unit()
+ {
+ var loco = Add("ep07-001", 0);
+ loco.Cars[0].DriverType = eDriverType.Reardriver;
+ loco.Driver = eDriverType.Reardriver;
+ var unit = Unit("ep07-001", "ep07-002");
+
+ _consist.Replace(0, unit);
+
+ Assert.Equal(eDriverType.Nobody, unit.Cars[0].DriverType);
+ Assert.Equal(eDriverType.Reardriver, unit.Cars[1].DriverType);
+ Assert.Equal(eDriverType.Reardriver, unit.Driver);
+ }
+
+ private ConsistItem Unit(params string[] skins) =>
+ new()
+ {
+ Cars = skins.Select(skin => _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null)).ToList(),
+ Grouped = true
+ };
+
+ private ConsistItem Add(string skin, int at)
+ {
+ var item = Item(skin);
+ _consist.Staff(item, at);
+ _consist.Insert(at, item);
+ return item;
+ }
+
+ private ConsistItem Replace(int at, string skin)
+ {
+ var item = Item(skin);
+ _consist.Replace(at, item);
+ return item;
+ }
+
+ private ConsistItem Item(string skin) =>
+ new() { Cars = new List { _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null) } };
+}
diff --git a/StarterNG.Tests/ConsistOrientationTests.cs b/StarterNG.Tests/ConsistOrientationTests.cs
new file mode 100644
index 0000000..42abd82
--- /dev/null
+++ b/StarterNG.Tests/ConsistOrientationTests.cs
@@ -0,0 +1,114 @@
+using StarterNG.Classes;
+using StarterNG.Domain;
+using StarterNG.Domain.Vehicles;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class ConsistOrientationTests
+{
+ private readonly VehicleCatalog _catalog;
+ private readonly TestInstallation _installation;
+
+ public ConsistOrientationTests()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("dynamic", "pkp", "et40", "textures.txt"),
+ "!=e\n^2\net40-10-a.mat=et40a,ET40-A\net40-10-b.mat=et40b,ET40-B\n" +
+ "et40-16-a.mat=et40a,ET40-A\net40-16-b.mat=et40b,ET40-B"));
+ installation.Vehicles.Load(installation.Library.Vehicles);
+ _catalog = installation.Library.Vehicles;
+ _installation = installation;
+ }
+
+ [Fact]
+ public void Randomly_turned_unit_comes_out_like_a_flipped_one()
+ {
+ var flipped = NewConsist(out var unit);
+ flipped.Flip(unit);
+
+ var randomized = NewConsist(out _);
+ randomized.RandomizeOrientation(new AlwaysTurn());
+
+ Assert.Equal(Describe(flipped), Describe(randomized));
+ }
+
+ [Fact]
+ public void A_turned_unit_has_its_cars_reversed_and_each_one_turned()
+ {
+ var consist = NewConsist(out _);
+
+ consist.RandomizeOrientation(new AlwaysTurn());
+
+ // B leads, A trails, and both face backwards - not A-B reordered but facing forward.
+ Assert.Equal(new[] { "et40-10-b -1", "et40-10-a -1" }, Describe(consist));
+ }
+
+ [Fact]
+ public void A_unit_put_in_place_of_a_turned_one_faces_the_same_way()
+ {
+ var consist = NewConsist(out var unit);
+ consist.Flip(unit);
+
+ consist.Replace(0, Unit(consist, "et40-16-a"));
+
+ Assert.Equal(new[] { "et40-16-b -1", "et40-16-a -1" }, Describe(consist));
+ }
+
+ [Fact]
+ public void Joining_units_that_face_different_ways_leaves_every_car_as_it_was()
+ {
+ var consist = NewConsist(out var first);
+ var second = Unit(consist, "et40-16-a");
+ consist.Insert(1, second);
+ consist.Flip(second);
+ var before = Describe(consist);
+
+ consist.Join(first);
+
+ Assert.Single(consist);
+ Assert.Equal(before, Describe(consist));
+ Assert.Equal(new[] { "et40-10-a 1", "et40-10-b 1", "et40-16-b -1", "et40-16-a -1" }, before);
+ }
+
+ [Fact]
+ public void Splitting_such_a_unit_gives_each_car_its_own_way_back()
+ {
+ var consist = NewConsist(out var first);
+ var second = Unit(consist, "et40-16-a");
+ consist.Insert(1, second);
+ consist.Flip(second);
+ var before = Describe(consist);
+ consist.Join(first);
+
+ consist.Split(consist[0]);
+
+ Assert.Equal(before, Describe(consist));
+ Assert.All(consist, card => Assert.Equal(Consist.IsTurned(card.Cars[0]), card.Flipped));
+ }
+
+ private Consist NewConsist(out ConsistItem unit)
+ {
+ var consist = new Consist(_catalog, new VehicleInfo(_catalog, _installation.Physics));
+ unit = Unit(consist, "et40-10-a");
+ consist.Insert(0, unit);
+ return consist;
+ }
+
+ private ConsistItem Unit(Consist consist, string lead) => new()
+ {
+ Cars = _catalog.ResolveSet(_catalog.TextureForSkin(lead)!)!
+ .Select(texture => consist.MakeDynamic(texture, null))
+ .ToList(),
+ Grouped = true
+ };
+
+ // The export order, each car with the way it faces.
+ private static string[] Describe(Consist consist) =>
+ consist.Flatten().Select(car => $"{car.SkinFile} {(car.Offset < 0 ? -1 : 1)}").ToArray();
+
+ private sealed class AlwaysTurn : Random
+ {
+ public override int Next(int maxValue) => 0;
+ }
+}
diff --git a/StarterNG.Tests/ConsistTailTests.cs b/StarterNG.Tests/ConsistTailTests.cs
new file mode 100644
index 0000000..1da3fe2
--- /dev/null
+++ b/StarterNG.Tests/ConsistTailTests.cs
@@ -0,0 +1,119 @@
+using StarterNG.Classes;
+using StarterNG.Domain;
+using StarterNG.Domain.Vehicles;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+// The simulator puts end signals only on a last vehicle whose coupler is 0.
+public class ConsistTailTests
+{
+ private readonly Consist _consist;
+ private readonly VehicleCatalog _catalog;
+
+ public ConsistTailTests()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("dynamic", "pkp", "b111", "textures.txt"),
+ "!=b\nb111-01.mat=b111,B111\nb111-02.mat=b111,B111"));
+ installation.Vehicles.Load(installation.Library.Vehicles);
+ _catalog = installation.Library.Vehicles;
+ _consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
+ }
+
+ [Fact]
+ public void The_vehicle_count_counts_cars_not_units()
+ {
+ Append("b111-01");
+ var pair = Append("b111-02");
+ pair.Cars.Add(pair.Cars[0].Clone());
+
+ Assert.Equal(2, _consist.Count);
+ Assert.Equal(3, _consist.VehicleCount);
+ }
+
+ [Fact]
+ public void A_new_last_car_is_left_uncoupled_at_the_back()
+ {
+ var first = Append("b111-01");
+ var last = Append("b111-02");
+
+ _consist.AutoConnectAll();
+
+ Assert.NotEqual(0, first.Cars[0].Coupling.Flags);
+ Assert.Equal(0, last.Cars[0].Coupling.Flags);
+ }
+
+ [Fact]
+ public void Taking_off_the_last_car_frees_the_one_now_at_the_back()
+ {
+ var first = Append("b111-01");
+ var last = Append("b111-02");
+ _consist.AutoConnectAll();
+
+ _consist.Remove(last);
+
+ Assert.Equal(0, first.Cars[0].Coupling.Flags);
+ }
+
+ [Fact]
+ public void Freeing_the_back_keeps_the_brake_setting()
+ {
+ var car = Append("b111-01");
+ car.Cars[0].Coupling = Coupling.Parse("3.BP");
+
+ _consist.AutoConnectAll();
+
+ Assert.Equal("0.BP", car.Cars[0].Coupling.ToString());
+ }
+
+ [Fact]
+ public void A_coupler_set_by_hand_on_the_last_car_stays()
+ {
+ Append("b111-01");
+ var last = Append("b111-02");
+ last.Cars[0].Coupling.Flags = 3;
+ last.Cars[0].CouplerSetByHand = true;
+
+ _consist.AutoConnectAll();
+
+ Assert.Equal(3, last.Cars[0].Coupling.Flags);
+ }
+
+ [Fact]
+ public void A_car_set_by_hand_that_gets_a_neighbour_is_freed_again_once_it_is_last_again()
+ {
+ var first = Append("b111-01");
+ first.Cars[0].CouplerSetByHand = true;
+ var second = Append("b111-02");
+ _consist.AutoConnectAll();
+
+ _consist.Remove(second);
+
+ Assert.Equal(0, first.Cars[0].Coupling.Flags);
+ }
+
+ [Fact]
+ public void The_auto_button_frees_the_back_even_when_it_was_set_by_hand()
+ {
+ Append("b111-01");
+ var last = Append("b111-02");
+ last.Cars[0].Coupling.Flags = 3;
+ last.Cars[0].CouplerSetByHand = true;
+
+ _consist.AutoCouple();
+
+ Assert.Equal(0, last.Cars[0].Coupling.Flags);
+ Assert.False(last.Cars[0].CouplerSetByHand);
+ }
+
+ private ConsistItem Append(string skin)
+ {
+ var item = new ConsistItem
+ {
+ Cars = new List { _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null) }
+ };
+ _consist.Insert(_consist.Count, item);
+ return item;
+ }
+}
diff --git a/StarterNG.Tests/CouplerLookTests.cs b/StarterNG.Tests/CouplerLookTests.cs
new file mode 100644
index 0000000..5fd1c83
--- /dev/null
+++ b/StarterNG.Tests/CouplerLookTests.cs
@@ -0,0 +1,25 @@
+using StarterNG.Classes;
+using StarterNG.Views;
+
+namespace StarterNG.Tests;
+
+public class CouplerLookTests
+{
+ [Theory]
+ [InlineData("0", CouplerState.Free)]
+ [InlineData("3", CouplerState.Coupled)]
+ [InlineData("7", CouplerState.MultipleUnit)]
+ [InlineData("131", CouplerState.Permanent)]
+ [InlineData("-3", CouplerState.Permanent)]
+ [InlineData("0.BP", CouplerState.Free)]
+ public void A_coupler_between_vehicles_reads_by_what_it_connects(string code, CouplerState expected) =>
+ Assert.Equal(expected, CouplerLook.StateOf(Coupling.Parse(code), trailing: false));
+
+ [Theory]
+ [InlineData("0", CouplerState.EndSignals)]
+ [InlineData("0.BP", CouplerState.EndSignals)]
+ [InlineData("3", CouplerState.NoEndSignals)]
+ [InlineData("-7", CouplerState.NoEndSignals)]
+ public void The_back_of_the_train_gets_end_signals_only_at_0(string code, CouplerState expected) =>
+ Assert.Equal(expected, CouplerLook.StateOf(Coupling.Parse(code), trailing: true));
+}
diff --git a/StarterNG.Tests/ExecutableLocatorTests.cs b/StarterNG.Tests/ExecutableLocatorTests.cs
index bcaed3f..01f45a1 100644
--- a/StarterNG.Tests/ExecutableLocatorTests.cs
+++ b/StarterNG.Tests/ExecutableLocatorTests.cs
@@ -68,6 +68,31 @@ public class ExecutableLocatorTests
Assert.Equal(ExeProblem.None, problem);
}
+ [Fact]
+ public void A_pick_from_another_installation_gives_way_to_the_search()
+ {
+ var files = new InMemoryFileSystem().WithExecutable(TestInstallation.At("eu07"), Elf);
+ var installation = new TestInstallation(files);
+ var settings = new SimulatorSettings { SelectExeAutomatically = false, ExecutablePath = "eu07-elsewhere" };
+
+ string resolved = installation.Executables.Resolve(settings, out var problem);
+
+ Assert.EndsWith("eu07", resolved);
+ Assert.Equal(ExeProblem.None, problem);
+ }
+
+ [Fact]
+ public void A_missing_pick_is_reported_when_the_installation_has_none_either()
+ {
+ var installation = new TestInstallation();
+ var settings = new SimulatorSettings { SelectExeAutomatically = false, ExecutablePath = "eu07-elsewhere" };
+
+ string resolved = installation.Executables.Resolve(settings, out var problem);
+
+ Assert.Equal("eu07-elsewhere", resolved);
+ Assert.Equal(ExeProblem.NotFound, problem);
+ }
+
[Fact]
public void Data_files_beside_the_binary_are_never_offered_as_candidates()
{
@@ -79,4 +104,79 @@ public class ExecutableLocatorTests
Assert.Equal(new[] { "eu07" }, installation.Executables.ListCandidates());
}
+
+ private static readonly string Wine = Path.Combine("/usr/bin", "wine");
+
+ private static TestInstallation WithWine(InMemoryFileSystem files)
+ {
+ var installation = new TestInstallation(files.WithExecutable(Wine));
+ installation.Environment.With("PATH", "/usr/bin");
+ return installation;
+ }
+
+ [Fact]
+ public void Without_wine_a_windows_build_on_linux_is_not_a_candidate()
+ {
+ var installation = new TestInstallation(
+ new InMemoryFileSystem().WithFile(TestInstallation.At("eu07.exe"), PortableExecutable));
+
+ installation.Executables.Resolve(new SimulatorSettings(), out var problem);
+
+ Assert.Equal(ExeProblem.NotFound, problem);
+ Assert.Null(installation.Executables.Wine());
+ }
+
+ [Fact]
+ public void With_wine_a_windows_build_on_linux_is_found_even_without_the_execute_bit()
+ {
+ var installation = WithWine(
+ new InMemoryFileSystem().WithFile(TestInstallation.At("eu07.exe"), PortableExecutable));
+
+ string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem);
+
+ Assert.EndsWith("eu07.exe", resolved);
+ Assert.Equal(ExeProblem.WrongPlatform, problem);
+ Assert.Equal(new[] { "eu07.exe" }, installation.Executables.ListCandidates());
+ }
+
+ [Fact]
+ public void A_native_build_wins_over_the_windows_one()
+ {
+ var installation = WithWine(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("eu07.exe"), PortableExecutable)
+ .WithExecutable(TestInstallation.At("eu07-dev"), Elf));
+
+ string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem);
+
+ Assert.EndsWith("eu07-dev", resolved);
+ Assert.Equal(ExeProblem.None, problem);
+ }
+
+ [Fact]
+ public void Wine_starts_the_windows_build_and_a_native_one_starts_itself()
+ {
+ string exe = TestInstallation.At("eu07.exe");
+ string native = TestInstallation.At("eu07");
+ var installation = WithWine(new InMemoryFileSystem()
+ .WithFile(exe, PortableExecutable)
+ .WithExecutable(native, Elf));
+ string[] arguments = { "-s", "$td.scn" };
+
+ var (wine, wineArguments) = installation.Executables.LaunchCommand(exe, arguments);
+ var (program, programArguments) = installation.Executables.LaunchCommand(native, arguments);
+
+ Assert.Equal(Wine, wine);
+ Assert.Equal(new[] { exe, "-s", "$td.scn" }, wineArguments);
+ Assert.Equal(native, program);
+ Assert.Equal(arguments, programArguments);
+ }
+
+ [Fact]
+ public void Wine_is_never_looked_for_on_windows()
+ {
+ var installation = WithWine(new InMemoryFileSystem());
+ installation.Environment.AsWindows();
+
+ Assert.Null(installation.Executables.Wine());
+ }
}
diff --git a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs
index 2dddd26..fca2cbd 100644
--- a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs
+++ b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs
@@ -63,6 +63,8 @@ public sealed class InMemoryFileSystem : IFileSystem
public string[] ReadAllLines(string path) =>
ReadAllText(path).Split('\n').Select(line => line.TrimEnd('\r')).ToArray();
+ public byte[] ReadAllBytes(string path) => Bytes(path);
+
public Stream OpenRead(string path) => new MemoryStream(Bytes(path), writable: false);
public void WriteAllText(string path, string contents) => WithFile(path, contents);
@@ -132,6 +134,8 @@ public sealed class InMemoryFileSystem : IFileSystem
public DateTime GetLastWriteTimeUtc(string path) => LastWriteTimeUtc;
/// Files added through carry the execute bit.
+ public long GetFileSize(string path) => Bytes(path).LongLength;
+
public bool IsExecutable(string path) => _executable.Contains(Normalize(path));
public InMemoryFileSystem WithExecutable(string path, string contents = "\u007fELF")
diff --git a/StarterNG.Tests/Fakes/TestInstallation.cs b/StarterNG.Tests/Fakes/TestInstallation.cs
index 724a8d7..fbb39a3 100644
--- a/StarterNG.Tests/Fakes/TestInstallation.cs
+++ b/StarterNG.Tests/Fakes/TestInstallation.cs
@@ -26,12 +26,15 @@ public sealed class TestInstallation
MiniTextures = new MiniTextureIndex(Files, Paths);
Physics = new FizPhysicsRepository(Files, Paths, Log);
Vehicles = new TexturesTxtVehicleRepository(Files, Paths, Log, new TexturesTxtParser());
+ VehicleJson = new VehicleJsonSerializer();
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);
+ VehicleCache = new VehicleCacheRepository(Files, Paths, Log, Vehicles, VehicleJson,
+ SettingsPaths.VehicleCacheDirectory());
+ Library = new GameLibrary(VehicleCache, Sceneries, MiniTextures, Physics, Log);
+
Executables = new ExecutableLocator(Files, Paths, Environment, Log);
SettingsStore = new SettingsStore(Files, Clock, Log, SettingsPaths, new SettingsSerializer(), Executables);
}
@@ -45,6 +48,8 @@ public sealed class TestInstallation
public MiniTextureIndex MiniTextures { get; }
public FizPhysicsRepository Physics { get; }
public TexturesTxtVehicleRepository Vehicles { get; }
+ public VehicleJsonSerializer VehicleJson { get; }
+ public VehicleCacheRepository VehicleCache { get; }
public SceneryRepository Sceneries { get; }
public MissingAssetScanner MissingAssets { get; }
public GameLibrary Library { get; }
diff --git a/StarterNG.Tests/FavoriteSceneriesTests.cs b/StarterNG.Tests/FavoriteSceneriesTests.cs
new file mode 100644
index 0000000..098fb8c
--- /dev/null
+++ b/StarterNG.Tests/FavoriteSceneriesTests.cs
@@ -0,0 +1,38 @@
+using StarterNG.Application;
+using StarterNG.Domain.Sceneries;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class FavoriteSceneriesTests
+{
+ private const string FavoritesPath = "/home/user/.config/MaSzyna/favorites.txt";
+
+ [Fact]
+ public void A_starred_scenery_stays_starred_after_a_restart()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa"));
+ var scenery = Assert.Single(installation.Sceneries.LoadAll());
+
+ new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath).Toggle(scenery);
+ var restarted = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath);
+
+ Assert.True(restarted.Contains(scenery));
+ Assert.Equal("td.scn", installation.Files.ReadAllText(FavoritesPath));
+ }
+
+ [Fact]
+ public void Starring_again_unstars()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa"));
+ var scenery = Assert.Single(installation.Sceneries.LoadAll());
+ var favorites = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath);
+
+ favorites.Toggle(scenery);
+ favorites.Toggle(scenery);
+
+ Assert.False(favorites.Contains(scenery));
+ }
+}
diff --git a/StarterNG.Tests/InstallationCheckTests.cs b/StarterNG.Tests/InstallationCheckTests.cs
index ffe1215..dc95d57 100644
--- a/StarterNG.Tests/InstallationCheckTests.cs
+++ b/StarterNG.Tests/InstallationCheckTests.cs
@@ -40,4 +40,38 @@ public class InstallationCheckTests
Assert.DoesNotContain(faults, fault => fault.StartsWith("missing "));
Assert.DoesNotContain("FaultNoWeights", faults);
}
+
+ [Fact]
+ public void A_missing_simulator_is_asked_for_rather_than_called_a_broken_installation()
+ {
+ // The simulator may be a build kept elsewhere, or a Windows build for Wine.
+ var installation = new TestInstallation();
+ var check = Check(installation);
+
+ Assert.DoesNotContain("FaultNoExe", check.Run());
+ Assert.True(check.ExecutableMissing());
+ }
+
+ [Fact]
+ public void A_simulator_in_the_installation_is_not_missing()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem().WithExecutable(TestInstallation.At("eu07")));
+
+ Assert.False(Check(installation).ExecutableMissing());
+ }
+
+ [Fact]
+ public void A_simulator_picked_from_elsewhere_is_not_missing()
+ {
+ string build = Path.Combine(Path.GetTempPath(), "maszyna", "bin", "eu07_2026-10-04_fc195880");
+ var installation = new TestInstallation(new InMemoryFileSystem().WithExecutable(build));
+ installation.SettingsStore.Settings.SelectExeAutomatically = false;
+ installation.SettingsStore.Settings.ExecutablePath = build;
+
+ Assert.False(Check(installation).ExecutableMissing());
+ }
+
+ private static InstallationCheck Check(TestInstallation installation) =>
+ new(installation.Paths, installation.Files, installation.Strings, installation.Physics, installation.Library,
+ installation.SettingsStore);
}
diff --git a/StarterNG.Tests/KeyboardConfigTests.cs b/StarterNG.Tests/KeyboardConfigTests.cs
new file mode 100644
index 0000000..7f20bac
--- /dev/null
+++ b/StarterNG.Tests/KeyboardConfigTests.cs
@@ -0,0 +1,10 @@
+using StarterNG.Classes;
+
+namespace StarterNG.Tests;
+
+public class KeyboardConfigTests
+{
+ [Fact]
+ public void A_description_without_polish_letters_matches_the_bundled_one() =>
+ Assert.Equal("zwiekszenie nastawnika glownego", KeyboardConfig.StripPolish("zwiększenie nastawnika głównego"));
+}
diff --git a/StarterNG.Tests/LegacyDecodingTests.cs b/StarterNG.Tests/LegacyDecodingTests.cs
new file mode 100644
index 0000000..c37e438
--- /dev/null
+++ b/StarterNG.Tests/LegacyDecodingTests.cs
@@ -0,0 +1,90 @@
+using System.Runtime.ExceptionServices;
+using System.Text;
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class LegacyDecodingTests
+{
+ private const string Polish = "Zakład_Świętokrzyski_w_Kielcach";
+
+ [Fact]
+ public void A_code_page_1250_file_decodes_to_its_letters()
+ {
+ Assert.Equal(Polish, LegacyText.Decode(LegacyText.CodePage1250.GetBytes(Polish)));
+ }
+
+ [Fact]
+ public void A_file_re_saved_as_utf8_decodes_too()
+ {
+ // A handful of textures.txt in the wild are UTF-8; read as 1250 they turn
+ // "Zakład" into "ZakĹ‚ad".
+ Assert.Equal(Polish, LegacyText.Decode(Encoding.UTF8.GetBytes(Polish)));
+ }
+
+ [Fact]
+ public void A_byte_order_mark_does_not_stick_to_the_text()
+ {
+ byte[] bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes("$a\n" + Polish)).ToArray();
+
+ Assert.Equal("$a\n" + Polish, LegacyText.Decode(bytes));
+ }
+
+ [Fact]
+ public void Telling_the_two_apart_does_not_throw()
+ {
+ // Decoding used to try strict UTF-8 and catch the failure: a first-chance
+ // exception for every 1250 file, hundreds of them on a load.
+ int thread = Environment.CurrentManagedThreadId;
+ int thrown = 0;
+ EventHandler count = (_, e) =>
+ {
+ if (e.Exception is DecoderFallbackException && Environment.CurrentManagedThreadId == thread)
+ thrown++;
+ };
+
+ AppDomain.CurrentDomain.FirstChanceException += count;
+ try
+ {
+ LegacyText.Decode(LegacyText.CodePage1250.GetBytes(Polish));
+ }
+ finally
+ {
+ AppDomain.CurrentDomain.FirstChanceException -= count;
+ }
+
+ Assert.Equal(0, thrown);
+ }
+
+ [Fact]
+ public void Plain_ascii_reads_the_same_either_way()
+ {
+ Assert.Equal("PESA_Bydgoszcz", LegacyText.Decode(Encoding.ASCII.GetBytes("PESA_Bydgoszcz")));
+ }
+
+ [Fact]
+ public void A_utf8_textures_file_reaches_the_catalogue_intact()
+ {
+ var files = new InMemoryFileSystem().WithFile(
+ TestInstallation.At("dynamic", "pkp", "elf", "textures.txt"),
+ "en96-001.mat=34we,EN96,EN96-001 // v2,EN96-001,PREG,Zakład_Świętokrzyski,29.09.2011");
+ var installation = new TestInstallation(files);
+
+ installation.Vehicles.Load(installation.Library.Vehicles);
+
+ var meta = Assert.Single(installation.Library.Vehicles.Textures).Meta;
+ Assert.Equal("Zakład_Świętokrzyski".Replace('_', ' '), meta!.Depot);
+ }
+
+ [Fact]
+ public void A_code_page_1250_scenery_reaches_the_list_intact()
+ {
+ var files = new InMemoryFileSystem().WithLegacyFile(
+ TestInstallation.At("scenery", "td.scn"), "//$n Strzebiń - Woźniki Śl.");
+
+ var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll());
+
+ Assert.Equal("Strzebiń - Woźniki Śl.", scenery.Name);
+ }
+}
diff --git a/StarterNG.Tests/ScenarioPresentationTests.cs b/StarterNG.Tests/ScenarioPresentationTests.cs
index b3f9ce5..70b3c2f 100644
--- a/StarterNG.Tests/ScenarioPresentationTests.cs
+++ b/StarterNG.Tests/ScenarioPresentationTests.cs
@@ -80,6 +80,36 @@ public class SceneryTreeBuilderTests
return (new SceneryTreeBuilder(new SceneryTranslations(files, installation.Log)), sceneries);
}
+ [Fact]
+ public void Starred_sceneries_are_gathered_on_top_and_starred_where_they_belong()
+ {
+ var (builder, sceneries) = Build("//$l Śląsk", "//$l Śląsk", "//$n Aaa luzem");
+ string starred = SceneryTreeBuilder.Star + sceneries.Single(scenery => scenery.Path.EndsWith("s1.scn")).DisplayName;
+ string plain = sceneries.Single(scenery => scenery.Path.EndsWith("s0.scn")).DisplayName;
+
+ var nodes = builder.Build(sceneries, includeArchival: true, "en",
+ scenery => scenery.Path.EndsWith("s1.scn"), "Ulubione");
+
+ var top = nodes[0];
+ Assert.True(top.IsGroup);
+ Assert.Equal(SceneryTreeBuilder.Star + "Ulubione", top.Label);
+ Assert.Equal(starred, Assert.Single(top.Children).Label);
+
+ var group = Assert.Single(nodes, node => node.Label == "Śląsk");
+ Assert.Contains(group.Children, node => node.Label == starred);
+ Assert.Contains(group.Children, node => node.Label == plain);
+ }
+
+ [Fact]
+ public void Without_favourites_there_is_no_favourites_group()
+ {
+ var (builder, sceneries) = Build("//$n Bytom");
+
+ var nodes = builder.Build(sceneries, includeArchival: true, "en", _ => false, "Ulubione");
+
+ Assert.DoesNotContain(nodes, node => node.Label.StartsWith(SceneryTreeBuilder.Star));
+ }
+
[Fact]
public void Scenarios_declaring_a_group_are_gathered_under_it()
{
diff --git a/StarterNG.Tests/SettingsStoreTests.cs b/StarterNG.Tests/SettingsStoreTests.cs
index ba870a9..f5501e5 100644
--- a/StarterNG.Tests/SettingsStoreTests.cs
+++ b/StarterNG.Tests/SettingsStoreTests.cs
@@ -5,7 +5,8 @@ namespace StarterNG.Tests;
public class SettingsStoreTests
{
- private const string UserIni = "/home/kolejarz/.config/MaSzyna/eu07.ini";
+ // Joined the way SettingsPaths joins it, so it matches on a Windows host too.
+ private static readonly string UserIni = Path.Combine("/home/kolejarz", ".config", "MaSzyna", "eu07.ini");
private static TestInstallation WithHome(InMemoryFileSystem? files = null)
{
diff --git a/StarterNG.Tests/SimulatorAftermathTests.cs b/StarterNG.Tests/SimulatorAftermathTests.cs
new file mode 100644
index 0000000..ecbdf93
--- /dev/null
+++ b/StarterNG.Tests/SimulatorAftermathTests.cs
@@ -0,0 +1,39 @@
+using StarterNG.Application;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class SimulatorAftermathTests
+{
+ private static readonly string Build =
+ Path.Combine(Path.GetTempPath(), "maszyna", "cmake-build-diag", "bin", "eu07_2026-10-04_fc195880");
+
+ [Fact]
+ public void Minidumps_of_this_run_are_found_in_the_installation_and_under_crashpad()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithExecutable(Build)
+ .WithFile(TestInstallation.At("crash_2026-10-04_13-03-12.dmp"), "MDMP")
+ .WithFile(TestInstallation.At("crashdumps", "reports", "1a2b.dmp"), "MDMP"));
+ installation.Files.LastWriteTimeUtc = new DateTime(2026, 10, 4, 11, 3, 12, DateTimeKind.Utc);
+ var aftermath = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log);
+
+ var thisRun = aftermath.CrashDumpsSince(new DateTime(2026, 10, 4, 11, 3, 0, DateTimeKind.Utc), Build);
+ var later = aftermath.CrashDumpsSince(new DateTime(2026, 10, 4, 12, 0, 0, DateTimeKind.Utc), Build);
+
+ Assert.Equal(2, thisRun.Count);
+ Assert.Contains(thisRun, dump => dump.EndsWith("crash_2026-10-04_13-03-12.dmp"));
+ Assert.Empty(later);
+ }
+
+ [Fact]
+ public void The_end_of_the_simulator_log_is_read_for_a_silent_crash()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("log.txt"), "first\r\nsecond\r\n\r\nthird\r\n"));
+
+ var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log).SimulatorLogTail(2);
+
+ Assert.Equal(new[] { "second", "third" }, tail);
+ }
+}
diff --git a/StarterNG.Tests/SimulatorExitTests.cs b/StarterNG.Tests/SimulatorExitTests.cs
new file mode 100644
index 0000000..ab905aa
--- /dev/null
+++ b/StarterNG.Tests/SimulatorExitTests.cs
@@ -0,0 +1,65 @@
+using StarterNG.Application;
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class SimulatorExitTests
+{
+ private static readonly TimeSpan Session = TimeSpan.FromMinutes(20);
+
+ [Fact]
+ public void A_session_ended_cleanly_is_no_failure()
+ {
+ Assert.False(new SimulatorExit(0, Session, []).Failed);
+ }
+
+ [Fact]
+ public void An_error_code_is_a_failure()
+ {
+ Assert.True(new SimulatorExit(1, Session, []).Failed);
+ }
+
+ [Fact]
+ public void A_window_that_only_flashed_is_a_failure_even_with_a_clean_code()
+ {
+ var exit = new SimulatorExit(0, TimeSpan.FromSeconds(0.2), []);
+
+ Assert.True(exit.Failed);
+ Assert.True(exit.Quick);
+ }
+
+ [Theory]
+ [InlineData(139, "139 (SIGSEGV)")]
+ [InlineData(134, "134 (SIGABRT)")]
+ [InlineData(-1073741819, "0xC0000005 (access violation)")]
+ [InlineData(3, "3")]
+ public void The_code_says_what_killed_it(int code, string described)
+ {
+ Assert.Equal(described, new SimulatorExit(code, Session, []).DescribeCode());
+ }
+
+ [Fact]
+ public void Colour_codes_and_blank_lines_are_dropped_from_the_errors()
+ {
+ var exit = new SimulatorExit(1, Session, ["\u001b[31mBad file: texture\u001b[0m", "", " "]);
+
+ Assert.Equal(["Bad file: texture"], exit.CleanErrors);
+ }
+
+ [Fact]
+ public async Task A_real_process_leaves_its_exit_code_and_error_stream()
+ {
+ var launcher = new SystemProcessLauncher(new TestInstallation().Log);
+ var (shell, script) = OperatingSystem.IsWindows()
+ ? ("cmd", new[] { "/c", "echo boom 1>&2 & exit 3" })
+ : ("/bin/sh", new[] { "-c", "echo boom >&2; exit 3" });
+
+ var process = launcher.Start(shell, script, null, captureErrors: true, out string? error);
+ Assert.Null(error);
+ await process!.WaitForExitAsync();
+
+ Assert.Equal(3, process.ExitCode);
+ Assert.Equal("boom", Assert.Single(process.ErrorTail).Trim());
+ }
+}
diff --git a/StarterNG.Tests/StartSimulationTests.cs b/StarterNG.Tests/StartSimulationTests.cs
new file mode 100644
index 0000000..2940fa6
--- /dev/null
+++ b/StarterNG.Tests/StartSimulationTests.cs
@@ -0,0 +1,159 @@
+using StarterNG.Application;
+using StarterNG.Application.Abstractions;
+using StarterNG.Infrastructure.Sceneries;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class StartSimulationTests
+{
+ // A simulator built from source: its own tree, away from the game data.
+ private static readonly string Build =
+ Path.Combine(Path.GetTempPath(), "maszyna", "cmake-build-diag", "bin", "eu07_2026-10-04_fc195880");
+
+ [Fact]
+ public void A_simulator_from_a_build_tree_runs_in_the_installation()
+ {
+ var (start, launcher, installation) = Rig(files => files.WithExecutable(Build));
+
+ var result = start.Execute(freeFly: true, saveSettings: false);
+
+ Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
+ Assert.Equal(Path.GetFullPath(Build), launcher.Executable);
+ Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory);
+ }
+
+ [Fact]
+ public void A_build_for_the_other_system_is_still_launched()
+ {
+ // A Windows build on Linux: Wine or Proton may well run it.
+ var (start, launcher, _) = Rig(files => files.WithExecutable(Build, "MZ\u0090\u0000"));
+
+ Assert.Equal(SimulationStartOutcome.Started, start.Execute(freeFly: true, saveSettings: false).Outcome);
+ Assert.NotNull(launcher.Executable);
+ }
+
+ [Fact]
+ public void A_windows_build_on_linux_is_started_through_wine()
+ {
+ string wine = Path.Combine("/usr/bin", "wine");
+ string exe = Path.Combine(Path.GetTempPath(), "maszyna", "eu07.exe");
+ var (start, launcher, installation) = Rig(files => files
+ .WithExecutable(wine)
+ .WithFile(exe, "MZ\u0090\u0000"));
+ installation.Environment.With("PATH", "/usr/bin");
+ installation.SettingsStore.Settings.ExecutablePath = exe;
+
+ var result = start.Execute(freeFly: true, saveSettings: false);
+
+ // Unpacked from the Windows zip, it has no execute bit - Wine does not need one.
+ Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
+ Assert.Equal(wine, launcher.Executable);
+ Assert.Equal(exe, launcher.Arguments![0]);
+ Assert.Equal("-s", launcher.Arguments[1]);
+ Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory);
+ }
+
+ [Fact]
+ public void The_error_stream_is_kept_only_while_the_starter_stays_to_read_it()
+ {
+ var (start, launcher, installation) = Rig(files => files.WithExecutable(Build));
+
+ start.Execute(freeFly: true, saveSettings: false);
+ Assert.True(launcher.CapturedErrors);
+
+ // Closing right after the launch would leave the pipe with no reader.
+ installation.SettingsStore.Settings.AutoCloseStarter = true;
+ start.Execute(freeFly: true, saveSettings: false);
+ Assert.False(launcher.CapturedErrors);
+ }
+
+ [Fact]
+ public void The_editor_opens_the_scenery_itself_not_an_export()
+ {
+ var (start, launcher, installation) = Rig(files => files.WithExecutable(Build));
+
+ var result = start.ExecuteEditor();
+
+ Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
+ Assert.Equal(new[] { "-edit", "td.scn" }, launcher.Arguments);
+ Assert.False(installation.Files.FileExists(TestInstallation.At("scenery", "$td.scn")));
+ }
+
+ [Fact]
+ public void A_file_that_cannot_be_run_is_not()
+ {
+ var (start, launcher, _) = Rig(files => files.WithFile(Build, "\u007fELF"));
+
+ var result = start.Execute(freeFly: true, saveSettings: false);
+
+ Assert.Equal(SimulationStartOutcome.ExecutableProblem, result.Outcome);
+ Assert.Equal(Domain.Settings.ExeProblem.NotExecutable, result.Problem);
+ Assert.Null(launcher.Executable);
+ }
+
+ private static (StartSimulation Start, RecordingLauncher Launcher, TestInstallation Installation) Rig(
+ Action addSimulator)
+ {
+ var installation = new TestInstallation();
+ addSimulator(installation.Files);
+
+ var settings = installation.SettingsStore.Settings;
+ settings.SelectExeAutomatically = false;
+ settings.ExecutablePath = Build;
+
+ var scenery = new SceneryParser(installation.Clock, installation.Random).Parse(
+ TestInstallation.At("scenery", "td.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 state = new AppState { CurrentScenery = scenery, CurrentTrainset = scenery.Trainsets[0] };
+
+ var launcher = new RecordingLauncher();
+ var start = new StartSimulation(state, installation.SettingsStore, installation.Library.Vehicles,
+ installation.Executables, installation.Files, installation.Paths, launcher,
+ installation.Random, installation.Log);
+ return (start, launcher, installation);
+ }
+
+ private sealed class RecordingLauncher : IProcessLauncher
+ {
+ public string? Executable { get; private set; }
+
+ public string? WorkingDirectory { get; private set; }
+
+ public IReadOnlyList? Arguments { get; private set; }
+
+ public bool? CapturedErrors { get; private set; }
+
+ public IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory,
+ bool captureErrors, out string? error)
+ {
+ Executable = executablePath;
+ CapturedErrors = captureErrors;
+ Arguments = arguments;
+ WorkingDirectory = workingDirectory;
+ error = null;
+ return new RunningProcess();
+ }
+
+ public bool OpenInShell(string pathOrUrl) => false;
+
+ public IProcessHandle? FindRunning(string processName) => null;
+ }
+
+ private sealed class RunningProcess : IProcessHandle
+ {
+ public bool HasExited => false;
+
+ public int? ExitCode => null;
+
+ public IReadOnlyList ErrorTail => Array.Empty();
+
+ public Task WaitForExitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+ }
+}
diff --git a/StarterNG.Tests/VehicleCacheTests.cs b/StarterNG.Tests/VehicleCacheTests.cs
new file mode 100644
index 0000000..5931ca8
--- /dev/null
+++ b/StarterNG.Tests/VehicleCacheTests.cs
@@ -0,0 +1,111 @@
+using System.Text;
+using StarterNG.Domain.Vehicles;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class VehicleCacheTests
+{
+ private const string Ep07 = "ep07-135.mat=ep07.e3d,ep07";
+
+ private const string En57 = "en57-1000ra.mat=en57.e3d,en57";
+
+ private static string Dynamic(string maker, string vehicle) =>
+ TestInstallation.At("dynamic", maker, vehicle, "textures.txt");
+
+ private static string Cached(TestInstallation installation, string file) =>
+ Path.Combine(installation.SettingsPaths.VehicleCacheDirectory(), file);
+
+ [Fact]
+ public void The_first_load_migrates_textures_txt_into_the_starters_own_copy()
+ {
+ var files = new InMemoryFileSystem().WithLegacyFile(Dynamic("pkp", "ep07"), Ep07);
+ var installation = new TestInstallation(files);
+
+ int liveries = installation.VehicleCache.Load(installation.Library.Vehicles);
+
+ Assert.Equal(1, liveries);
+ Assert.True(files.FileExists(Cached(installation, "vehicles.json")));
+ Assert.Contains("ep07-135", files.ReadAllText(Cached(installation, "vehicles.json")));
+ }
+
+ [Fact]
+ public void An_untouched_installation_is_served_from_the_copy_without_reading_textures_txt()
+ {
+ var files = new InMemoryFileSystem().WithLegacyFile(Dynamic("pkp", "ep07"), Ep07);
+ var installation = new TestInstallation(files);
+ installation.VehicleCache.Load(installation.Library.Vehicles);
+
+ // Different liveries, same length and same timestamp: nothing the stamp
+ // can see has changed, so an answer of ep07-135 can only come from the copy.
+ files.WithLegacyFile(Dynamic("pkp", "ep07"), "ep07-999.mat=ep07.e3d,ep07");
+ var catalog = new VehicleCatalog(installation.MiniTextures);
+ installation.VehicleCache.Load(catalog);
+
+ Assert.Equal("legacy:pkp/ep07/ep07-135", Assert.Single(catalog.Textures).Uuid);
+ }
+
+ [Fact]
+ public void An_edited_textures_txt_makes_the_copy_stale_and_it_is_rebuilt()
+ {
+ var files = new InMemoryFileSystem().WithLegacyFile(Dynamic("pkp", "ep07"), Ep07);
+ var installation = new TestInstallation(files);
+ installation.VehicleCache.Load(installation.Library.Vehicles);
+
+ files.WithLegacyFile(Dynamic("pkp", "ep07"), Ep07 + "\nep07-174.mat=ep07.e3d,ep07");
+ var catalog = new VehicleCatalog(installation.MiniTextures);
+ int liveries = installation.VehicleCache.Load(catalog);
+
+ Assert.Equal(2, liveries);
+ Assert.Contains("ep07-174", files.ReadAllText(Cached(installation, "vehicles.json")));
+ }
+
+ [Fact]
+ public void A_vehicle_folder_that_disappeared_upstream_leaves_the_copy_too()
+ {
+ var files = new InMemoryFileSystem()
+ .WithLegacyFile(Dynamic("pkp", "ep07"), Ep07)
+ .WithLegacyFile(Dynamic("pkp", "en57"), En57);
+ var installation = new TestInstallation(files);
+ installation.VehicleCache.Load(installation.Library.Vehicles);
+
+ files.DeleteFile(Dynamic("pkp", "en57"));
+ var catalog = new VehicleCatalog(installation.MiniTextures);
+ installation.VehicleCache.Load(catalog);
+
+ Assert.Equal("legacy:pkp/ep07/ep07-135", Assert.Single(catalog.Textures).Uuid);
+ Assert.DoesNotContain("en57", files.ReadAllText(Cached(installation, "vehicles.json")));
+ }
+
+ [Fact]
+ public void A_damaged_copy_costs_the_parse_it_saved_and_nothing_else()
+ {
+ var files = new InMemoryFileSystem().WithLegacyFile(Dynamic("pkp", "ep07"), Ep07);
+ var installation = new TestInstallation(files);
+ installation.VehicleCache.Load(installation.Library.Vehicles);
+ files.WithFile(Cached(installation, "vehicles.json"), "{ obcieta kop");
+
+ var catalog = new VehicleCatalog(installation.MiniTextures);
+ int liveries = installation.VehicleCache.Load(catalog);
+
+ Assert.Equal(1, liveries);
+ Assert.Contains("kopia nie do odczytu", installation.Log.Text);
+ }
+
+ [Fact]
+ public void The_copy_reads_the_same_catalogue_the_legacy_reader_does()
+ {
+ var files = new InMemoryFileSystem()
+ .WithLegacyFile(Dynamic("pkp", "ep07"), Ep07)
+ .WithLegacyFile(Dynamic("pkp", "en57"), En57);
+ var installation = new TestInstallation(files);
+
+ var direct = new VehicleCatalog(installation.MiniTextures);
+ installation.Vehicles.Load(direct);
+ var cached = new VehicleCatalog(installation.MiniTextures);
+ installation.VehicleCache.Load(cached);
+
+ Assert.Equal(direct.Textures.Select(t => t.Uuid), cached.Textures.Select(t => t.Uuid));
+ Assert.Equal(direct.GroupsById.Keys.Order(), cached.GroupsById.Keys.Order());
+ }
+}
diff --git a/StarterNG.Tests/VehicleCatalogTests.cs b/StarterNG.Tests/VehicleCatalogTests.cs
index 63901e2..19b9201 100644
--- a/StarterNG.Tests/VehicleCatalogTests.cs
+++ b/StarterNG.Tests/VehicleCatalogTests.cs
@@ -117,4 +117,48 @@ public class VehicleCatalogTests
/// Builds an entry through the real parser, for catalogue-level tests.
private static VehicleEntry ParsedEntry(string directory, params string[] lines) =>
new StarterNG.Infrastructure.Vehicles.TexturesTxtParser().Parse(directory, lines)!;
+
+ // The ET42-026: one skin for both halves, told apart by the model.
+ private static VehicleCatalog SharedSkinPair()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TexturesFile("pkp", "et42_v2"),
+ "!=e,ET42-A,ET42-B\n^2\n112E-026.mat=112E-A,ET42-A,ET42-026-A\n112E-026.mat=112E-B,ET42-B,ET42-026-B");
+ var installation = new TestInstallation(files);
+ installation.Vehicles.Load(installation.Library.Vehicles);
+ return installation.Library.Vehicles;
+ }
+
+ [Fact]
+ public void A_skin_shared_by_two_models_is_found_by_the_model()
+ {
+ var catalog = SharedSkinPair();
+
+ Assert.Equal("ET42-026-A", catalog.TextureFor(@"PKP\ET42_V2", "112E-026", "112E-A")!.TextureMini);
+ Assert.Equal("ET42-026-B", catalog.TextureFor(@"PKP\ET42_V2", "112E-026", "112E-B")!.TextureMini);
+ }
+
+ [Fact]
+ public void The_second_half_on_a_shared_skin_follows_the_first_in_its_set()
+ {
+ var catalog = SharedSkinPair();
+ var b = catalog.TextureFor(@"PKP\ET42_V2", "112E-026", "112E-B")!;
+
+ Assert.True(catalog.IsSetFollower(b));
+ Assert.Equal(new[] { "112E-A", "112E-B" }, catalog.ResolveSet(b)!.Select(t => t.Model));
+ }
+
+ [Fact]
+ public void A_skin_name_used_in_two_folders_is_found_by_the_folder()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TexturesFile("road", "clio"), "red.mat=clio,CLIO")
+ .WithFile(TexturesFile("industrial", "wls50_v1"), "red.mat=wls50,WLS50");
+ var installation = new TestInstallation(files);
+ installation.Vehicles.Load(installation.Library.Vehicles);
+ var catalog = installation.Library.Vehicles;
+
+ Assert.Equal("clio", catalog.TextureFor(@"road\clio", "red", "clio")!.Model);
+ Assert.Equal("wls50", catalog.TextureFor("industrial/wls50_v1", "red", "wls50.mmd")!.Model);
+ }
}
diff --git a/StarterNG.Tests/VehicleJsonTests.cs b/StarterNG.Tests/VehicleJsonTests.cs
new file mode 100644
index 0000000..2e48eed
--- /dev/null
+++ b/StarterNG.Tests/VehicleJsonTests.cs
@@ -0,0 +1,199 @@
+using System.Text;
+using StarterNG.Domain.Vehicles;
+using StarterNG.Infrastructure.Vehicles;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class VehicleJsonTests
+{
+ private static readonly TexturesTxtParser Parser = new();
+
+ private static readonly VehicleJsonSerializer Serializer = new();
+
+ ///
+ /// A vehicle folder exercising every piece of the legacy syntax at once: the
+ /// headers and comments that carry no data, a category marker, a credit line
+ /// with Polish text, an alternative model, a wreck and an archival section.
+ ///
+ private const string Ep07 = """
+ # EP07 - lokomotywy elektryczne
+ @ep07
+ !=e
+ ep07-135.mat=ep07.e3d,ep07 // 1.0,EP07-135,PKP Intercity,Warszawa Grochów,2019-04-01,Wrocław,Zażółć,Gęślą Jaźń
+ ep07-174.mat=ep07.e3d,ep07=ep07p.e3d,ep07p
+ ep07wrak.mat=ep07.e3d,ep07
+ linia bez znaku rownosci
+ $a
+ ep07-stary.mat=ep07.e3d,ep07
+ """;
+
+ /// A unit whose cars are coupled into a fixed set.
+ private const string En57 = """
+ ^2
+ en57-1000ra.mat=en57.e3d,en57
+ en57-1000rb.mat=en57.e3d,en57
+ """;
+
+ [Fact]
+ public void An_entry_written_out_and_read_back_is_the_entry_it_started_as()
+ {
+ var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
+
+ var restored = Serializer.Read(Serializer.Write(entry));
+
+ Assert.Equal(Describe(entry), Describe(restored!));
+ }
+
+ [Fact]
+ public void The_catalogue_built_from_json_matches_the_one_built_from_textures_txt()
+ {
+ var files = new InMemoryFileSystem()
+ .WithLegacyFile(Dynamic("pkp", "ep07"), Ep07)
+ .WithLegacyFile(Dynamic("pkp", "en57"), En57);
+ var installation = new TestInstallation(files);
+
+ installation.Vehicles.Load(installation.Library.Vehicles);
+ var fromJson = ReloadThroughJson(installation);
+
+ Assert.Equal(5, installation.Library.Vehicles.Textures.Count);
+ Assert.Equal(Describe(installation.Library.Vehicles), Describe(fromJson));
+ }
+
+ [Fact]
+ public void Headers_and_unparsable_lines_survive_the_trip_so_nothing_is_dropped()
+ {
+ var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
+
+ var restored = Serializer.Read(Serializer.Write(entry))!;
+
+ Assert.Equal(new[] { "# EP07 - lokomotywy elektryczne", "@ep07", "linia bez znaku rownosci" },
+ restored.Unknown);
+ }
+
+ [Fact]
+ public void Polish_text_is_written_as_itself_rather_than_as_escapes()
+ {
+ var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
+
+ string json = Serializer.Write(entry);
+
+ Assert.Contains("Warszawa Grochów", json);
+ Assert.DoesNotContain("\\u", json);
+ }
+
+ [Fact]
+ public void The_merged_database_is_read_before_the_packages_layered_on_it()
+ {
+ var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
+ var package = Parser.Parse("pkp/en57/", Lines(En57))!;
+ var files = new InMemoryFileSystem()
+ .WithFile(Database("vehicles.json"), Serializer.WriteCollection(new[] { entry }))
+ .WithFile(Database("aaa-pakiet.json"), Serializer.Write(package));
+ var installation = new TestInstallation(files);
+ var catalog = new VehicleCatalog(installation.MiniTextures);
+
+ int liveries = Repository(installation).Load(catalog);
+
+ Assert.Equal(6, liveries);
+ Assert.Equal("legacy:pkp/ep07/ep07-135", catalog.Textures[0].Uuid);
+ }
+
+ [Fact]
+ public void An_entry_from_a_newer_format_is_refused_instead_of_half_read()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(Database("z-przyszlosci.json"),
+ """{"uuid":"x","schema_version":99,"textures":[{"skinfile":"ep07-135"}]}""");
+ var installation = new TestInstallation(files);
+ var catalog = new VehicleCatalog(installation.MiniTextures);
+
+ int liveries = Repository(installation).Load(catalog);
+
+ Assert.Equal(0, liveries);
+ Assert.Empty(catalog.Textures);
+ Assert.Contains("z-przyszlosci.json", installation.Log.Text);
+ }
+
+ [Fact]
+ public void An_unreadable_package_is_logged_and_leaves_the_rest_of_the_database_alone()
+ {
+ var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
+ var files = new InMemoryFileSystem()
+ .WithFile(Database("aaa-polamany.json"), "{ to nie jest json")
+ .WithFile(Database("bbb-dobry.json"), Serializer.Write(entry));
+ var installation = new TestInstallation(files);
+ var catalog = new VehicleCatalog(installation.MiniTextures);
+
+ int liveries = Repository(installation).Load(catalog);
+
+ Assert.Equal(4, liveries);
+ Assert.Contains("aaa-polamany.json", installation.Log.Text);
+ }
+
+ private static VehicleJsonRepository Repository(TestInstallation installation) =>
+ new(installation.Files, installation.Paths, installation.Log, Serializer);
+
+ ///
+ /// Writes every vehicle folder of an installation out as its own package file
+ /// and loads the catalogue back from those, which is the migration the starter
+ /// will do silently once this format is the one it reads.
+ ///
+ private static VehicleCatalog ReloadThroughJson(TestInstallation installation)
+ {
+ foreach (string maker in installation.Files.GetDirectories(installation.Paths.Dynamic))
+ foreach (string vehicle in installation.Files.GetDirectories(maker))
+ {
+ string path = Path.Combine(vehicle, "textures.txt");
+ if (!installation.Files.FileExists(path))
+ continue;
+
+ string directory = $"{Path.GetFileName(maker)}/{Path.GetFileName(vehicle)}/";
+ var entry = Parser.Parse(directory,
+ installation.Files.ReadAllText(path, Encoding.GetEncoding(1250)).Split('\n'));
+ if (entry is not null)
+ installation.Files.WithFile(Database($"{directory.Replace('/', '.')}json"),
+ Serializer.Write(entry));
+ }
+
+ var catalog = new VehicleCatalog(installation.MiniTextures);
+ Repository(installation).Load(catalog);
+ return catalog;
+ }
+
+ private static string[] Lines(string file) => file.Split('\n');
+
+ private static string Dynamic(string maker, string vehicle) =>
+ TestInstallation.At("dynamic", maker, vehicle, "textures.txt");
+
+ private static string Database(string file) => TestInstallation.At("databases", "vehicles", file);
+
+ /// Everything an entry carries, flattened so a difference reads as one.
+ private static string Describe(VehicleEntry entry) =>
+ string.Join('\n',
+ new[] { $"entry {entry.Uuid}" }
+ .Concat(entry.Groups.Select(Describe))
+ .Concat(entry.Textures.Select(Describe))
+ .Concat(entry.Sets.Select(Describe))
+ .Concat(entry.Unknown.Select(line => $"unknown {line}")));
+
+ /// The same for a whole catalogue, wrecks and resolved fields included.
+ private static string Describe(VehicleCatalog catalog) =>
+ string.Join('\n',
+ catalog.GroupsById.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => Describe(pair.Value))
+ .Concat(catalog.TextureByUuid.OrderBy(pair => pair.Key, StringComparer.Ordinal)
+ .Select(pair => Describe(pair.Value)))
+ .Concat(catalog.Sets.Select(Describe)));
+
+ private static string Describe(VehicleGroup group) =>
+ $"group {group.Id}|{group.Category}|{group.Mini}|{group.Archived}|{group.Implicit}";
+
+ private static string Describe(VehicleSet set) =>
+ $"set {set.Uuid}|{set.Mode}|{set.Count}|{string.Join(',', set.TextureRefs)}";
+
+ private static string Describe(VehicleTexture texture) =>
+ $"texture {texture.Uuid}|{texture.Directory}|{texture.Skinfile}|{texture.Model}|{texture.Group}|" +
+ $"{texture.MiniRef}|{texture.TextureMini}|{texture.Wreck}|{texture.ResolvedClass}|" +
+ $"{texture.ResolvedCategory}|{texture.ResolvedArchived}|{texture.Meta?.Raw}|" +
+ string.Join(';', texture.Aliases.Select(alias => $"{alias.Model},{alias.MiniRef},{alias.TextureMini}"));
+}
diff --git a/StarterNG/App.axaml.cs b/StarterNG/App.axaml.cs
index 52a2cad..283d5bf 100644
--- a/StarterNG/App.axaml.cs
+++ b/StarterNG/App.axaml.cs
@@ -176,13 +176,17 @@ public partial class App : Avalonia.Application
$"{Loc["FaultLoadData"]}{Environment.NewLine}{Environment.NewLine}{Loc["FaultDetail"]} {inner.Message}");
}
- var faults = Diagnostics.CheckInstallation();
+ var check = Diagnostics.InstallationCheck();
+ var faults = check.Run();
if (faults.Count > 0)
{
await Diagnostics.ReportAsync(
string.Join(Environment.NewLine, faults) + Environment.NewLine + Environment.NewLine +
Loc["FaultBadInstall"]);
}
+
+ if (check.ExecutableMissing())
+ await main.OfferExecutablePickAsync();
});
});
}
diff --git a/StarterNG/Application/Abstractions/IFileSystem.cs b/StarterNG/Application/Abstractions/IFileSystem.cs
index 93c1083..b30f657 100644
--- a/StarterNG/Application/Abstractions/IFileSystem.cs
+++ b/StarterNG/Application/Abstractions/IFileSystem.cs
@@ -17,6 +17,8 @@ public interface IFileSystem
string[] ReadAllLines(string path);
+ byte[] ReadAllBytes(string path);
+
Stream OpenRead(string path);
void WriteAllText(string path, string contents);
@@ -41,5 +43,7 @@ public interface IFileSystem
DateTime GetLastWriteTimeUtc(string path);
+ long GetFileSize(string path);
+
bool IsExecutable(string path);
}
diff --git a/StarterNG/Application/Abstractions/IGamePaths.cs b/StarterNG/Application/Abstractions/IGamePaths.cs
index 314647c..5adcf5d 100644
--- a/StarterNG/Application/Abstractions/IGamePaths.cs
+++ b/StarterNG/Application/Abstractions/IGamePaths.cs
@@ -12,6 +12,9 @@ public interface IGamePaths
string Data { get; }
+ /// Where the JSON vehicle database and the packages layered on it live.
+ string VehicleDatabase { get; }
+
string StarterConfig { get; }
string DiagnosticsLog { get; }
diff --git a/StarterNG/Application/Abstractions/IProcessLauncher.cs b/StarterNG/Application/Abstractions/IProcessLauncher.cs
index a8ecab1..a62eacf 100644
--- a/StarterNG/Application/Abstractions/IProcessLauncher.cs
+++ b/StarterNG/Application/Abstractions/IProcessLauncher.cs
@@ -8,13 +8,24 @@ public interface IProcessHandle
{
bool HasExited { get; }
+ /// Once it has ended; null while running, or for a process the starter did not start.
+ int? ExitCode { get; }
+
+ /// The last lines of the error stream, when it was captured.
+ IReadOnlyList ErrorTail { get; }
+
Task WaitForExitAsync(CancellationToken cancellationToken = default);
}
public interface IProcessLauncher
{
+ ///
+ /// Starts a process. With captureErrors its error stream is kept for the
+ /// handle - only for a process the starter outlives: a write to a pipe nobody
+ /// reads any more can kill the writer.
+ ///
IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory,
- out string? error);
+ bool captureErrors, out string? error);
bool OpenInShell(string pathOrUrl);
diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs
index e223746..744912b 100644
--- a/StarterNG/Application/AppServices.cs
+++ b/StarterNG/Application/AppServices.cs
@@ -31,7 +31,16 @@ public sealed class AppServices
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());
+ var settingsPaths = new SettingsPaths(environment, paths);
+ SettingsPaths = settingsPaths;
+
+ // textures.txt stays the truth; the starter reads its own JSON copy of it
+ // and rebuilds that copy whenever the installation has moved on.
+ TexturesTxt = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
+ VehicleJson = new VehicleJsonSerializer();
+ VehicleCache = new VehicleCacheRepository(files, paths, log, TexturesTxt, VehicleJson,
+ settingsPaths.VehicleCacheDirectory());
+ Vehicles = VehicleCache;
Physics = new FizPhysicsRepository(files, paths, log);
MissingAssets = new MissingAssetScanner(files, paths);
SceneryTexts = new SceneryTranslations(files, log);
@@ -40,12 +49,13 @@ public sealed class AppServices
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);
+ Favorites = new FavoriteSceneries(files, log, settingsPaths.FavoritesPath());
+ StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, Executables, files, paths,
+ Processes, Random, log);
+ Aftermath = new SimulatorAftermath(files, paths, log);
}
public static AppServices Current =>
@@ -85,6 +95,14 @@ public sealed class AppServices
public IVehicleRepository Vehicles { get; }
+ /// The same object as , typed for the developer tab.
+ public VehicleCacheRepository VehicleCache { get; }
+
+ /// The legacy reader behind the cache, for tools that go to the source.
+ public TexturesTxtVehicleRepository TexturesTxt { get; }
+
+ public VehicleJsonSerializer VehicleJson { get; }
+
public IPhysicsRepository Physics { get; }
public MissingAssetScanner MissingAssets { get; }
@@ -109,7 +127,11 @@ public sealed class AppServices
public MissingVehicleLog MissingVehicleLog { get; }
+ public FavoriteSceneries Favorites { get; }
+
public StartSimulation StartSimulation { get; }
+ public SimulatorAftermath Aftermath { get; }
+
public LocalizationService Localization { get; }
}
diff --git a/StarterNG/Application/FavoriteSceneries.cs b/StarterNG/Application/FavoriteSceneries.cs
new file mode 100644
index 0000000..4a00831
--- /dev/null
+++ b/StarterNG/Application/FavoriteSceneries.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using StarterNG.Application.Abstractions;
+using StarterNG.Domain.Sceneries;
+
+namespace StarterNG.Application;
+
+///
+/// The sceneries starred in the list, by file name, one per line in a file of the
+/// starter's own - eu07.ini is the simulator's as much as ours.
+///
+public sealed class FavoriteSceneries
+{
+ private readonly IFileSystem _files;
+ private readonly IDiagnosticsLog _log;
+ private readonly string _path;
+ private readonly HashSet _names = new(StringComparer.OrdinalIgnoreCase);
+
+ public FavoriteSceneries(IFileSystem files, IDiagnosticsLog log, string path)
+ {
+ _files = files;
+ _log = log;
+ _path = path;
+ Load();
+ }
+
+ public bool Contains(Scenery scenery) => _names.Contains(Key(scenery));
+
+ public void Toggle(Scenery scenery)
+ {
+ string key = Key(scenery);
+ if (!_names.Remove(key))
+ _names.Add(key);
+ Save();
+ }
+
+ private static string Key(Scenery scenery) => Path.GetFileName(scenery.Path);
+
+ private void Load()
+ {
+ try
+ {
+ if (!_files.FileExists(_path))
+ return;
+
+ foreach (string line in _files.ReadAllText(_path).Split('\n'))
+ if (line.Trim() is { Length: > 0 } name)
+ _names.Add(name);
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"reading {_path}", ex);
+ }
+ }
+
+ private void Save()
+ {
+ try
+ {
+ if (Path.GetDirectoryName(_path) is { Length: > 0 } directory)
+ _files.CreateDirectory(directory);
+
+ _files.WriteAllText(_path, string.Join('\n', _names.Order(StringComparer.OrdinalIgnoreCase)));
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"writing {_path}", ex);
+ }
+ }
+}
diff --git a/StarterNG/Application/SimulatorAftermath.cs b/StarterNG/Application/SimulatorAftermath.cs
new file mode 100644
index 0000000..90f63c7
--- /dev/null
+++ b/StarterNG/Application/SimulatorAftermath.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using StarterNG.Application.Abstractions;
+using StarterNG.Infrastructure.Adapters;
+
+namespace StarterNG.Application;
+
+/// What a simulator that went down left behind: its log and its crash dumps.
+public sealed class SimulatorAftermath
+{
+ private readonly IFileSystem _files;
+ private readonly IGamePaths _paths;
+ private readonly IDiagnosticsLog _log;
+
+ public SimulatorAftermath(IFileSystem files, IGamePaths paths, IDiagnosticsLog log)
+ {
+ _files = files;
+ _paths = paths;
+ _log = log;
+ }
+
+ ///
+ /// Minidumps written since the simulator was started, newest first. The
+ /// simulator names them crash_*.dmp relative to its working directory - the
+ /// installation - and crashpad, where it is set up, keeps its own under
+ /// crashdumps; the folder of the binary is looked at too, for a simulator that
+ /// was once run from there.
+ ///
+ public IReadOnlyList CrashDumpsSince(DateTime startedUtc, string executable)
+ {
+ var dumps = new List();
+ try
+ {
+ foreach (string directory in new[] { _paths.Root, Path.GetDirectoryName(executable) ?? _paths.Root }
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Where(_files.DirectoryExists))
+ dumps.AddRange(_files.GetFiles(directory, "*.dmp"));
+
+ string crashpad = _paths.FromRoot("crashdumps");
+ if (_files.DirectoryExists(crashpad))
+ dumps.AddRange(_files.GetFilesRecursive(crashpad, "*.dmp"));
+
+ // A little slack: the start is taken just after the launch, not before it.
+ var since = startedUtc - TimeSpan.FromSeconds(5);
+ return dumps.Distinct(StringComparer.OrdinalIgnoreCase)
+ .Where(dump => _files.GetLastWriteTimeUtc(dump) >= since)
+ .OrderByDescending(_files.GetLastWriteTimeUtc)
+ .ToList();
+ }
+ catch (Exception ex)
+ {
+ _log.Log("looking for crash dumps", ex);
+ return Array.Empty();
+ }
+ }
+
+ /// The end of the simulator's own log, for when it died without a word on its error stream.
+ public IReadOnlyList SimulatorLogTail(int count)
+ {
+ string path = _paths.FromRoot("log.txt");
+ try
+ {
+ if (!_files.FileExists(path))
+ return Array.Empty();
+
+ var lines = LegacyText.Decode(_files.ReadAllBytes(path))
+ .Split('\n')
+ .Select(line => line.TrimEnd('\r'))
+ .Where(line => line.Trim().Length > 0)
+ .ToList();
+ return lines.Skip(Math.Max(0, lines.Count - count)).ToList();
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"reading {path}", ex);
+ return Array.Empty();
+ }
+ }
+}
diff --git a/StarterNG/Application/SimulatorExit.cs b/StarterNG/Application/SimulatorExit.cs
new file mode 100644
index 0000000..1ac75a5
--- /dev/null
+++ b/StarterNG/Application/SimulatorExit.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace StarterNG.Application;
+
+/// How a simulator the starter launched has ended.
+public sealed record SimulatorExit(int ExitCode, TimeSpan RunTime, IReadOnlyList Errors)
+{
+ /// Gone before anyone could have played - however clean the exit code.
+ public static readonly TimeSpan QuickExit = TimeSpan.FromSeconds(10);
+
+ public bool Quick => RunTime < QuickExit;
+
+ public bool Failed => ExitCode != 0 || Quick;
+
+ /// The error stream without its colour codes and blank lines.
+ public IReadOnlyList CleanErrors =>
+ Errors.Select(line => AnsiCode.Replace(line, "").TrimEnd())
+ .Where(line => line.Length > 0)
+ .ToList();
+
+ /// The exit code, with what it means where it is a known way to die.
+ public string DescribeCode()
+ {
+ // Linux reports a death by signal as 128 plus the signal.
+ string? meaning = ExitCode switch
+ {
+ 132 => "SIGILL",
+ 134 => "SIGABRT",
+ 135 => "SIGBUS",
+ 136 => "SIGFPE",
+ 137 => "SIGKILL",
+ 139 => "SIGSEGV",
+ 143 => "SIGTERM",
+ _ => null
+ };
+
+ // Windows reports an unhandled exception as its NTSTATUS.
+ meaning ??= unchecked((uint)ExitCode) switch
+ {
+ 0xC0000005 => "access violation",
+ 0xC00000FD => "stack overflow",
+ 0xC0000374 => "heap corruption",
+ 0xC0000409 => "stack buffer overrun",
+ _ => null
+ };
+
+ string code = ExitCode < 0
+ ? "0x" + unchecked((uint)ExitCode).ToString("X8", CultureInfo.InvariantCulture)
+ : ExitCode.ToString(CultureInfo.InvariantCulture);
+ return meaning is null ? code : $"{code} ({meaning})";
+ }
+
+ private static readonly Regex AnsiCode = new(@"\x1B\[[0-9;]*m");
+}
diff --git a/StarterNG/Application/StartSimulation.cs b/StarterNG/Application/StartSimulation.cs
index 8e05d29..d8da998 100644
--- a/StarterNG/Application/StartSimulation.cs
+++ b/StarterNG/Application/StartSimulation.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
@@ -6,7 +7,9 @@ using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Domain.Settings;
+using StarterNG.Domain.Vehicles;
using StarterNG.Infrastructure.Adapters;
+using StarterNG.Infrastructure.Settings;
namespace StarterNG.Application;
@@ -37,17 +40,24 @@ public sealed class StartSimulation
private readonly AppState _state;
private readonly SettingsStore _settings;
+ private readonly VehicleCatalog _vehicles;
+ private readonly ExecutableLocator _executables;
private readonly IFileSystem _files;
+ private readonly IGamePaths _paths;
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)
+ public StartSimulation(AppState state, SettingsStore settings, VehicleCatalog vehicles,
+ ExecutableLocator executables, IFileSystem files, IGamePaths paths,
+ IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log)
{
_state = state;
_settings = settings;
+ _vehicles = vehicles;
+ _executables = executables;
_files = files;
+ _paths = paths;
_processes = processes;
_random = random;
_log = log;
@@ -91,7 +101,7 @@ public sealed class StartSimulation
break;
}
- string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName)
+ string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles)
?? StartableVehicle(trainset, _state.StartingVehicleName);
_state.StartingVehicleName = vehicle;
@@ -115,16 +125,42 @@ public sealed class StartSimulation
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);
+ return Launch(arguments);
+ }
+
+ ///
+ /// Opens the selected scenery in the simulator's editor. The scenery file itself,
+ /// not an export: what the editor saves has to land in the scenery.
+ ///
+ public SimulationStartResult ExecuteEditor()
+ {
+ if (_state.CurrentScenery is not { } scenery)
+ return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
+
+ return Launch(new[] { "-edit", Path.GetFileName(scenery.Path) });
+ }
+
+ private SimulationStartResult Launch(IReadOnlyList arguments)
+ {
+ string executable = Path.GetFullPath(_settings.ResolveExecutable(out var problem));
+
+ // A binary for the other system is let through: on Linux a Windows build
+ // runs under Wine, started below, or Proton, and only the launch can tell.
+ if (problem is not (ExeProblem.None or ExeProblem.WrongPlatform))
+ return new SimulationStartResult(SimulationStartOutcome.ExecutableProblem, ExecutablePath: executable,
+ Problem: problem);
+
+ // The installation, not the folder of the binary: a simulator picked from a
+ // build tree lives beside the data, and the data is what it opens.
+ var (program, programArguments) = _executables.LaunchCommand(executable, arguments);
+ // Its error stream is kept for a report on a crash - unless the starter closes
+ // now, leaving nobody to read it or to report.
+ bool watched = !_settings.Settings.AutoCloseStarter;
+ var process = _processes.Start(program, programArguments, _paths.Root, watched, out string? error);
if (process is null)
return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable,
Detail: error);
diff --git a/StarterNG/Classes/KeyboardConfig.cs b/StarterNG/Classes/KeyboardConfig.cs
index b717853..99acdaa 100644
--- a/StarterNG/Classes/KeyboardConfig.cs
+++ b/StarterNG/Classes/KeyboardConfig.cs
@@ -86,9 +86,65 @@ public sealed class KeyboardConfig
_savePath = UserConfigPath();
string source = FirstExisting(_savePath, WorkingDirDefaultPath(), BundledDefaultPath());
ParseSource(source);
+ LoadPolishDescriptions();
Dirty = false;
}
+ ///
+ /// The game ships its key file with the descriptions stripped of Polish letters
+ /// ("zwiekszenie nastawnika glownego"); the copy bundled with the starter has
+ /// them. Read once, shown in place of the bare ones, never written back.
+ ///
+ private readonly Dictionary _polishDescriptions = new(StringComparer.OrdinalIgnoreCase);
+
+ private void LoadPolishDescriptions()
+ {
+ _polishDescriptions.Clear();
+ try
+ {
+ string path = BundledDefaultPath();
+ if (!File.Exists(path))
+ return;
+
+ var bundled = new KeyboardConfig();
+ bundled.Parse(File.ReadAllText(path, FileEncoding));
+ foreach (var b in bundled.Bindings)
+ if (!string.IsNullOrEmpty(b.Description))
+ _polishDescriptions.TryAdd(b.Command, b.Description);
+ }
+ catch (Exception ex)
+ {
+ StarterNG.Infrastructure.Diagnostics.Log("reading the bundled key descriptions", ex);
+ }
+ }
+
+ ///
+ /// The description to show: the bundled one with Polish letters when the file's
+ /// is the same text without them, the file's own when someone wrote their own.
+ ///
+ public string DescriptionOf(KeyBinding binding)
+ {
+ if (_polishDescriptions.TryGetValue(binding.Command, out string? polish) &&
+ (string.IsNullOrEmpty(binding.Description) ||
+ string.Equals(StripPolish(polish), binding.Description.Trim(), StringComparison.OrdinalIgnoreCase)))
+ return polish;
+
+ return binding.Description;
+ }
+
+ public static string StripPolish(string text)
+ {
+ var sb = new StringBuilder(text.Length);
+ foreach (char c in text.Trim())
+ sb.Append(c switch
+ {
+ 'ą' => 'a', 'ć' => 'c', 'ę' => 'e', 'ł' => 'l', 'ń' => 'n', 'ó' => 'o', 'ś' => 's', 'ź' => 'z', 'ż' => 'z',
+ 'Ą' => 'A', 'Ć' => 'C', 'Ę' => 'E', 'Ł' => 'L', 'Ń' => 'N', 'Ó' => 'O', 'Ś' => 'S', 'Ź' => 'Z', 'Ż' => 'Z',
+ _ => c
+ });
+ return sb.ToString();
+ }
+
public void LoadDefaults()
{
if (string.IsNullOrEmpty(_savePath))
diff --git a/StarterNG/Classes/Trainset.cs b/StarterNG/Classes/Trainset.cs
index 47ed387..e17c464 100644
--- a/StarterNG/Classes/Trainset.cs
+++ b/StarterNG/Classes/Trainset.cs
@@ -235,6 +235,12 @@ public class Dynamic
public bool Flipped;
+ ///
+ /// The coupler was set in the editor, so the consist leaves it be at the back of
+ /// the train. Not written out: in the scenery a coupler is just a coupler.
+ ///
+ public bool CouplerSetByHand;
+
public static bool IsPantStateType(string? type) =>
string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase);
@@ -302,7 +308,8 @@ public class Dynamic
LoadCount = LoadCount,
LoadType = LoadType,
MiniName = MiniName,
- Flipped = Flipped
+ Flipped = Flipped,
+ CouplerSetByHand = CouplerSetByHand
};
public string ToTrainsetNode()
diff --git a/StarterNG/Domain/Cargo.cs b/StarterNG/Domain/Cargo.cs
index 4584bc5..0b2c76a 100644
--- a/StarterNG/Domain/Cargo.cs
+++ b/StarterNG/Domain/Cargo.cs
@@ -117,6 +117,12 @@ public sealed class Cargo
}
}
+ public void MaxAmounts(IReadOnlyList consist)
+ {
+ foreach (var c in consist.SelectMany(u => u.Cars))
+ FillToMax(c);
+ }
+
public void RandomAmounts(IReadOnlyList consist)
{
foreach (var c in consist.SelectMany(u => u.Cars))
diff --git a/StarterNG/Domain/Consist.cs b/StarterNG/Domain/Consist.cs
index a41d0d0..7112afd 100644
--- a/StarterNG/Domain/Consist.cs
+++ b/StarterNG/Domain/Consist.cs
@@ -35,6 +35,9 @@ public sealed class Consist : IReadOnlyList
public int Count => _items.Count;
+ /// Every vehicle, a multi-unit counted car by car - counts units.
+ public int VehicleCount => _items.Sum(i => i.Cars.Count);
+
public ConsistItem this[int index]
{
get => _items[index];
@@ -57,7 +60,7 @@ public sealed class Consist : IReadOnlyList
var cars = trainset.Vehicles.Select(v =>
{
var c = v.Clone();
- c.MiniName = _db.MiniForSkin(c.SkinFile) ?? c.MiniName;
+ c.MiniName = _db.MiniFor(c.DataFolder, c.SkinFile, c.MmdFile) ?? c.MiniName;
return c;
}).ToList();
@@ -83,7 +86,7 @@ public sealed class Consist : IReadOnlyList
}
VehicleSet? set = null;
- if (_db.TextureForSkin(cars[i].SkinFile)?.Uuid is { } uuid)
+ if (_db.TextureFor(cars[i].DataFolder, cars[i].SkinFile, cars[i].MmdFile)?.Uuid is { } uuid)
_db.SetByTextureUuid.TryGetValue(uuid, out set);
if (set?.TextureRefs is { Count: > 1 })
@@ -94,7 +97,7 @@ public sealed class Consist : IReadOnlyList
int j = i;
while (j < cars.Count && group.Count < set.TextureRefs.Count)
{
- if (_db.TextureForSkin(cars[j].SkinFile)?.Uuid is { } u
+ if (_db.TextureFor(cars[j].DataFolder, cars[j].SkinFile, cars[j].MmdFile)?.Uuid is { } u
&& members.Contains(u))
group.Add(cars[j++]);
else
@@ -152,17 +155,11 @@ public sealed class Consist : IReadOnlyList
Raise();
}
- public List Flatten()
- {
- var flat = new List();
- 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 List Flatten() => _items.SelectMany(RunningOrder).ToList();
+
+ /// A card's cars in the order they run, which is the order the export writes.
+ public static IEnumerable RunningOrder(ConsistItem item) =>
+ item.Flipped ? Enumerable.Reverse(item.Cars) : item.Cars;
public void SyncStartingVehicle()
{
@@ -236,45 +233,139 @@ public sealed class Consist : IReadOnlyList
public void Insert(int at, ConsistItem item) =>
_items.Insert(Math.Clamp(at, 0, _items.Count), item);
+ ///
+ /// Crew for a vehicle joining at position: a driver in cab A when it is powered
+ /// and either leads or the consist has no driver yet, nobody otherwise.
+ ///
+ public void Staff(ConsistItem item, int position)
+ {
+ item.Driver = eDriverType.Nobody;
+ foreach (var car in item.Cars)
+ car.DriverType = eDriverType.Nobody;
+
+ if (!IsPowered(item))
+ return;
+
+ bool staffed = _items.Any(other => !ReferenceEquals(other, item) &&
+ other.Driver is eDriverType.Headdriver or eDriverType.Reardriver);
+ if (position == 0 || !staffed)
+ Crew(item, eDriverType.Headdriver);
+ }
+
+ ///
+ /// Puts item in place of the vehicle at index, facing the same way. Its crew
+ /// moves over where the replacement can take it - a driver only into something
+ /// powered - and the rest is staffed as a newly added vehicle would be.
+ ///
+ public void Replace(int index, ConsistItem item)
+ {
+ var replaced = _items[index];
+ _items[index] = item;
+ Staff(item, index);
+
+ // Facing the way the replaced vehicle did - cars and all, as Flip would.
+ if (item.Flipped != replaced.Flipped)
+ Turn(index, index);
+
+ var crew = replaced.Driver;
+ if (crew == eDriverType.Passenger ||
+ (crew is eDriverType.Headdriver or eDriverType.Reardriver && IsPowered(item)))
+ Crew(item, crew);
+ }
+
+ // The crew lives on the cars - they are what is shown and exported - and the
+ // card only mirrors it. A unit has one crew, in the car that has the cab.
+ private static void Crew(ConsistItem item, eDriverType crew)
+ {
+ if (item.Cars.Count == 0)
+ return;
+
+ foreach (var car in item.Cars)
+ car.DriverType = eDriverType.Nobody;
+ CrewCar(item, crew).DriverType = crew;
+ item.Driver = crew;
+ }
+
+ ///
+ /// Where a unit's crew sits: cab 1 is at the front of its first car and cab 2
+ /// at the back of its last - the ET42-A has only cab 1, the ET42-B only cab 2.
+ /// The unit's own order, so turning it round takes the cabs along.
+ ///
+ public static Dynamic CrewCar(ConsistItem item, eDriverType crew) =>
+ crew == eDriverType.Reardriver ? item.Cars[^1] : item.Cars[0];
+
+ /// Puts the crew into the unit, in the car its cab is in.
+ public void SetCrew(ConsistItem item, eDriverType crew)
+ {
+ Crew(item, crew);
+ if (ReferenceEquals(Selected, item))
+ SyncStartingVehicle();
+ Raise();
+ AppServices.Current.State.NotifyChanged();
+ }
+
+ private bool IsPowered(ConsistItem item) =>
+ item.Cars.Count > 0 && _db.TextureFor(item.Cars[0].DataFolder, item.Cars[0].SkinFile, item.Cars[0].MmdFile) is { } texture &&
+ VehicleInfo.IsPoweredCategory(VehicleInfo.CategoryOf(texture));
+
public void MoveLeft(ConsistItem item)
{
int i = _items.IndexOf(item);
if (i > 0)
- {
- (_items[i - 1], _items[i]) = (_items[i], _items[i - 1]);
- Raise();
- }
+ Move(i, i - 1);
}
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();
- }
+ Move(i, i + 1);
}
public void Move(int from, int to)
{
if (from < 0 || from >= _items.Count) return;
+ var previousHead = _items[0];
var item = _items[from];
_items.RemoveAt(from);
to = Math.Clamp(to, 0, _items.Count);
_items.Insert(to, item);
+ HandOverLead(previousHead);
Selected = item;
AutoConnectAll();
Raise();
}
+ ///
+ /// A locomotive that comes to the head of the train takes over the driver of
+ /// the one it put behind, which is left unstaffed: the train is driven from its
+ /// front, from cab A.
+ ///
+ private void HandOverLead(ConsistItem previousHead)
+ {
+ var head = _items[0];
+ if (ReferenceEquals(head, previousHead) || !IsPowered(head) ||
+ previousHead.Driver is not (eDriverType.Headdriver or eDriverType.Reardriver))
+ return;
+
+ previousHead.Driver = eDriverType.Nobody;
+ foreach (var car in previousHead.Cars)
+ car.DriverType = eDriverType.Nobody;
+
+ Crew(head, eDriverType.Headdriver);
+ }
+
public void Remove(ConsistItem item)
{
int gap = _items.IndexOf(item);
_items.Remove(item);
+ // The car now at the end still holds the coupler of the one taken off it.
+ if (gap == _items.Count)
+ FreeTail();
+
if (ReferenceEquals(Selected, item))
Selected = _items.Count == 0
? null
@@ -288,6 +379,16 @@ public sealed class Consist : IReadOnlyList
int index = _items.IndexOf(item);
if (index < 0) return;
+ var (first, last) = UnitAround(index);
+ Turn(first, last);
+
+ AutoConnectAll();
+ Raise();
+ }
+
+ /// The cards locked together with the one at index, as the range they span.
+ private (int First, int Last) UnitAround(int index)
+ {
int first = index;
while (first > 0 && HoldsTail(_items[first - 1]))
first--;
@@ -296,6 +397,17 @@ public sealed class Consist : IReadOnlyList
while (last + 1 < _items.Count && HoldsTail(_items[last]))
last++;
+ return (first, last);
+ }
+
+ ///
+ /// Turns the cards first..last end for end - the flag on each card, the cars it
+ /// holds and the order of the cards. All three or nothing: the export writes the
+ /// cars in card order and each car with its own orientation, so a flag turned
+ /// alone couples a multi-car unit back to front.
+ ///
+ private void Turn(int first, int last)
+ {
for (int k = first; k <= last; k++)
{
var card = _items[k];
@@ -306,36 +418,23 @@ public sealed class Consist : IReadOnlyList
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
+ public void CycleDriver(ConsistItem item) =>
+ SetCrew(item, item.Driver 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;
+ var order = RunningOrder(item).ToList();
_items.RemoveAt(i);
for (int c = 0; c < order.Count; c++)
@@ -344,7 +443,8 @@ public sealed class Consist : IReadOnlyList
{
Cars = new List { order[c] },
Grouped = false,
- Flipped = item.Flipped,
+ // Each car's own way - a unit joined from both ways splits back into them.
+ Flipped = IsTurned(order[c]),
Driver = order[c].DriverType
});
}
@@ -377,13 +477,17 @@ public sealed class Consist : IReadOnlyList
if (last == first) return;
+ // The cars as they run, each card read the way it faces, so joining vehicles
+ // that face different ways leaves every car where and how it was. The unit
+ // keeps the first card's flag; its cars are stored against that flag.
var cars = new List();
var driver = eDriverType.Nobody;
for (int k = first; k <= last; k++)
{
- cars.AddRange(_items[k].Cars);
+ var card = _items[k];
+ cars.AddRange(RunningOrder(card));
if (driver == eDriverType.Nobody)
- driver = _items[k].Driver;
+ driver = card.Driver;
}
bool flipped = _items[first].Flipped;
@@ -441,28 +545,42 @@ public sealed class Consist : IReadOnlyList
if (start < 0) start = 0;
if (start >= _items.Count) return;
- for (int i = start; i < _items.Count; i++)
+ // Unit by unit, as Flip turns them: half a locked unit turned on its own
+ // would be coupled back to front.
+ for (int i = start; i < _items.Count;)
+ {
+ var (first, last) = UnitAround(i);
if (rng.Next(2) == 0)
- _items[i].Flipped = !_items[i].Flipped;
+ Turn(first, last);
+ i = last + 1;
+ }
AutoConnectAll();
Raise();
}
+ ///
+ /// The Auto button: couplers picked afresh for the whole consist, hand-set ones
+ /// included, so the back of the train is 0 again.
+ ///
+ public void AutoCouple()
+ {
+ foreach (var car in Flatten())
+ car.CouplerSetByHand = false;
+
+ AutoConnectAll();
+ }
+
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));
- }
+ var flat = Flatten();
for (int i = 0; i < flat.Count - 1; i++)
{
- var (left, lf) = flat[i];
- var (right, rf) = flat[i + 1];
+ var left = flat[i];
+ var right = flat[i + 1];
+ bool lf = IsTurned(left);
+ bool rf = IsTurned(right);
var lp = _info.PhysicsFor(left);
var rp = _info.PhysicsFor(right);
@@ -478,7 +596,10 @@ public sealed class Consist : IReadOnlyList
common &= ~Coupling.ControlMU;
left.Coupling.Flags = left.Coupling.Locked ? -common : common;
+ left.CouplerSetByHand = false;
}
+
+ FreeTail();
}
public bool CanFormUnit(ConsistItem left, ConsistItem right)
@@ -491,8 +612,8 @@ public sealed class Consist : IReadOnlyList
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);
+ int leftMax = lp == null ? 3 : (IsTurned(tail) ? lp.AllowedFlagA : lp.AllowedFlagB);
+ int rightMax = rp == null ? 3 : (IsTurned(head) ? rp.AllowedFlagB : rp.AllowedFlagA);
return (leftMax & rightMax & Coupling.WorkshopLock) != 0 || SameSet(tail, head);
}
@@ -502,8 +623,8 @@ public sealed class Consist : IReadOnlyList
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;
+ string? ua = _db.TextureFor(a.DataFolder, a.SkinFile, a.MmdFile)?.Uuid;
+ string? ub = _db.TextureFor(b.DataFolder, b.SkinFile, b.MmdFile)?.Uuid;
if (string.IsNullOrEmpty(ua) || string.IsNullOrEmpty(ub))
return false;
@@ -526,6 +647,29 @@ public sealed class Consist : IReadOnlyList
public static Dynamic HeadCar(ConsistItem item) =>
item.Flipped ? item.Cars[^1] : item.Cars[0];
+ ///
+ /// Clears the coupler at the back of the consist, unless it was set in the
+ /// editor. The simulator hangs the end signals only on a last vehicle whose
+ /// coupler is 0 - the code does nothing else at the back of a train - and every
+ /// new car comes with 3. Its parameters, the brake setting among them, stay.
+ ///
+ private void FreeTail()
+ {
+ if (_items.Count == 0 || _items[^1].Cars.Count == 0)
+ return;
+
+ var tail = TailCar(_items[^1]);
+ if (!tail.CouplerSetByHand)
+ tail.Coupling.Flags = 0;
+ }
+
+ ///
+ /// Whether a car runs turned. Its own sign is the truth - it is what the export
+ /// writes - and decides which coupler is at which end; a card's flag agrees
+ /// with it except in a unit joined from vehicles facing different ways.
+ ///
+ public static bool IsTurned(Dynamic car) => car.Offset < 0;
+
public static bool HoldsTail(ConsistItem item) =>
item.Cars.Count > 0 && HoldsNext(TailCar(item));
diff --git a/StarterNG/Domain/Settings/SimulatorSettings.cs b/StarterNG/Domain/Settings/SimulatorSettings.cs
index a93169e..e1dea3c 100644
--- a/StarterNG/Domain/Settings/SimulatorSettings.cs
+++ b/StarterNG/Domain/Settings/SimulatorSettings.cs
@@ -35,6 +35,7 @@ public sealed class SimulatorSettings
public bool DebugMode;
public bool VirtualShunting = true;
public bool LogMissingVehicleFiles;
+ public bool DeveloperTools;
public int RenderEngine;
public int Width = 1280;
diff --git a/StarterNG/Domain/TexRandomizer.cs b/StarterNG/Domain/TexRandomizer.cs
index 450afd9..23b4206 100644
--- a/StarterNG/Domain/TexRandomizer.cs
+++ b/StarterNG/Domain/TexRandomizer.cs
@@ -68,7 +68,7 @@ public sealed class TexRandomizer
var item = consist[i];
if (item.Cars.Count == 0) continue;
- var current = _db.TextureForSkin(item.Cars[0].SkinFile);
+ var current = _db.TextureFor(item.Cars[0].DataFolder, item.Cars[0].SkinFile, item.Cars[0].MmdFile);
if (current != null && _db.IsSetFollower(current))
continue;
@@ -128,13 +128,13 @@ public sealed class TexRandomizer
if (index > 0)
{
- var prev = _db.TextureForSkin(consist[index - 1].Cars[0].SkinFile);
+ var prev = _db.TextureFor(consist[index - 1].Cars[0].DataFolder, consist[index - 1].Cars[0].SkinFile, consist[index - 1].Cars[0].MmdFile);
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);
+ var next = _db.TextureFor(consist[index + 1].Cars[0].DataFolder, consist[index + 1].Cars[0].SkinFile, consist[index + 1].Cars[0].MmdFile);
if (next != null && !string.IsNullOrEmpty(next.ResolvedClass))
return next.ResolvedClass;
}
diff --git a/StarterNG/Domain/TrainsetDisplay.cs b/StarterNG/Domain/TrainsetDisplay.cs
index 10037af..1877da9 100644
--- a/StarterNG/Domain/TrainsetDisplay.cs
+++ b/StarterNG/Domain/TrainsetDisplay.cs
@@ -67,14 +67,14 @@ public static class TrainsetDisplay
public static string VehicleLabel(Dynamic v, bool head = false)
{
- var tex = AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile);
+ var tex = AppServices.Current.Library.Vehicles.TextureFor(v.DataFolder, v.SkinFile, v.MmdFile);
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
+ AppServices.Current.Library.Vehicles.TextureFor(v.DataFolder, v.SkinFile, v.MmdFile)?.ResolvedCategory
is "e" or "s" or "p" or "z" or "a";
private static string BaseSkin(Dynamic v) =>
@@ -199,14 +199,14 @@ public static class TrainsetDisplay
return p.LoadAccepted.Contains(loadType, StringComparison.OrdinalIgnoreCase);
}
- public static string? UniquifyForLaunch(Trainset trainset, Scenery scenery, string? startName)
+ public static string? UniquifyForLaunch(Trainset trainset, Scenery scenery, string? startName,
+ VehicleCatalog db)
{
var used = CollectNames(scenery);
- var db = AppServices.Current.Library.Vehicles;
bool mu = trainset.Vehicles.Any(v =>
{
- string? cat = db.TextureForSkin(v.SkinFile)?.ResolvedCategory;
+ string? cat = db.TextureFor(v.DataFolder, v.SkinFile, v.MmdFile)?.ResolvedCategory;
return cat is "z" or "a";
});
@@ -221,18 +221,18 @@ public static class TrainsetDisplay
if (mu)
{
foreach (var v in trainset.Vehicles)
- Rename(v, used);
+ Rename(v, used, db);
return target?.Name;
}
if (target != null)
- Rename(target, used);
+ Rename(target, used, db);
return target?.Name;
}
- public static string PreferredBaseName(Dynamic v)
+ public static string PreferredBaseName(Dynamic v, VehicleCatalog db)
{
- var tex = AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile);
+ var tex = db.TextureFor(v.DataFolder, v.SkinFile, v.MmdFile);
if (tex != null)
{
@@ -294,11 +294,11 @@ public static class TrainsetDisplay
return (n, "");
}
- private static void Rename(Dynamic v, HashSet used)
+ private static void Rename(Dynamic v, HashSet used, VehicleCatalog db)
{
var (_, number) = SplitWagonNumber(v.Name);
if (!string.IsNullOrEmpty(v.Name))
used.Remove(v.Name!);
- v.Name = EnsureUnique(PreferredBaseName(v), used) + number;
+ v.Name = EnsureUnique(PreferredBaseName(v, db), used) + number;
}
}
diff --git a/StarterNG/Domain/VehicleInfo.cs b/StarterNG/Domain/VehicleInfo.cs
index 1193e58..a88019d 100644
--- a/StarterNG/Domain/VehicleInfo.cs
+++ b/StarterNG/Domain/VehicleInfo.cs
@@ -1,14 +1,19 @@
using StarterNG.Classes;
using StarterNG.Domain.Vehicles;
-using StarterNG.Application;
+using StarterNG.Application.Abstractions;
namespace StarterNG.Domain;
public sealed class VehicleInfo
{
private readonly VehicleCatalog _db;
+ private readonly IPhysicsRepository _physics;
- public VehicleInfo(VehicleCatalog db) => _db = db;
+ public VehicleInfo(VehicleCatalog db, IPhysicsRepository physics)
+ {
+ _db = db;
+ _physics = physics;
+ }
public static string ClassOf(VehicleTexture t) => t.ResolvedClass;
@@ -17,20 +22,20 @@ public sealed class VehicleInfo
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 VehicleTexture? TextureFor(Dynamic car) => _db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile);
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);
+ _physics.For(texture.Directory, texture.Model)
+ ?? _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);
+ return _physics.For(car.DataFolder, dbModel)
+ ?? _physics.For(car.DataFolder, car.MmdFile)
+ ?? _physics.For(car.DataFolder, car.SkinFile);
}
}
diff --git a/StarterNG/Domain/Vehicles/VehicleCatalog.cs b/StarterNG/Domain/Vehicles/VehicleCatalog.cs
index 095837e..8c8098b 100644
--- a/StarterNG/Domain/Vehicles/VehicleCatalog.cs
+++ b/StarterNG/Domain/Vehicles/VehicleCatalog.cs
@@ -27,6 +27,17 @@ public sealed class VehicleCatalog
public Dictionary TextureBySkin { get; } = new();
+ ///
+ /// The way the old starter matched a scenery vehicle: folder, skin and model.
+ /// A skin name recurs across folders ("red" for a Clio and a crane), and one
+ /// skin may dress several models (both halves of the ET42-026).
+ ///
+ private readonly Dictionary _textureByFolderSkinModel = new();
+
+ private readonly Dictionary _textureByFolderSkin = new();
+
+ private readonly Dictionary _textureBySkinModel = new();
+
public void BeginLoad()
{
Textures.Clear();
@@ -35,6 +46,9 @@ public sealed class VehicleCatalog
SetByTextureUuid.Clear();
TextureByUuid.Clear();
TextureBySkin.Clear();
+ _textureByFolderSkinModel.Clear();
+ _textureByFolderSkin.Clear();
+ _textureBySkinModel.Clear();
}
public void Ingest(VehicleEntry entry)
@@ -49,7 +63,12 @@ public sealed class VehicleCatalog
TextureByUuid[texture.Uuid] = texture;
if (!string.IsNullOrEmpty(texture.Skinfile))
- TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture);
+ {
+ TextureBySkin.TryAdd(Key(texture.Skinfile), texture);
+ _textureByFolderSkinModel.TryAdd(FolderSkinModelKey(texture.Directory, texture.Skinfile, texture.Model), texture);
+ _textureByFolderSkin.TryAdd(FolderSkinKey(texture.Directory, texture.Skinfile), texture);
+ _textureBySkinModel.TryAdd(SkinModelKey(texture.Skinfile, texture.Model), texture);
+ }
if (texture.Wreck)
continue;
@@ -105,18 +124,48 @@ public sealed class VehicleCatalog
return texture.TextureMini;
}
- public string? MiniForSkin(string? skinFile) =>
- TextureForSkin(skinFile) is { } texture ? ResolveMiniName(texture) : null;
+ public string? MiniFor(string? dataFolder, string? skinFile, string? model) =>
+ TextureFor(dataFolder, skinFile, model) is { } texture ? ResolveMiniName(texture) : null;
+
+ ///
+ /// The texture a scenery vehicle wears, matched like the old starter did: by
+ /// folder, skin and model, then by less of it for a vehicle that moved.
+ ///
+ public VehicleTexture? TextureFor(string? dataFolder, string? skinFile, string? model)
+ {
+ if (string.IsNullOrEmpty(skinFile))
+ return null;
+
+ if (_textureByFolderSkinModel.TryGetValue(FolderSkinModelKey(dataFolder, skinFile, model), out var texture) ||
+ _textureByFolderSkin.TryGetValue(FolderSkinKey(dataFolder, skinFile), out texture) ||
+ _textureBySkinModel.TryGetValue(SkinModelKey(skinFile, model), out texture))
+ return texture;
+
+ return TextureForSkin(skinFile);
+ }
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;
+ return TextureBySkin.TryGetValue(Key(skinFile), out var texture) ? texture : null;
}
+ private static string Key(string skinFile) => Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant();
+
+ private static string SkinModelKey(string skinFile, string? model) =>
+ Key(skinFile) + "|" + (string.IsNullOrEmpty(model) ? "" : Path.GetFileNameWithoutExtension(model).ToLowerInvariant());
+
+ private static string FolderSkinKey(string? folder, string skinFile) => Folder(folder) + "|" + Key(skinFile);
+
+ private static string FolderSkinModelKey(string? folder, string skinFile, string? model) =>
+ Folder(folder) + "|" + SkinModelKey(skinFile, model);
+
+ /// "PKP\ET42_V2" in a scenery, "pkp/et42_v2/" in the catalogue.
+ private static string Folder(string? folder) =>
+ (folder ?? "").Replace('\\', '/').Trim('/').ToLowerInvariant();
+
public string GroupHeader(string? groupId)
{
if (string.IsNullOrEmpty(groupId) || !GroupsById.TryGetValue(groupId, out var group))
diff --git a/StarterNG/Domain/Vehicles/VehicleTexture.cs b/StarterNG/Domain/Vehicles/VehicleTexture.cs
index 820b08a..e6469b2 100644
--- a/StarterNG/Domain/Vehicles/VehicleTexture.cs
+++ b/StarterNG/Domain/Vehicles/VehicleTexture.cs
@@ -5,9 +5,25 @@ namespace StarterNG.Domain.Vehicles;
public sealed class VehicleEntry
{
public string? Uuid { get; set; }
+
+ ///
+ /// Version of the file format the entry was read from, zero when it came from
+ /// a legacy textures.txt. Kept so a file written by a newer starter is refused
+ /// outright instead of being read with half its meaning missing.
+ ///
+ public int SchemaVersion { get; set; }
+
public List Groups { get; } = new();
public List Textures { get; } = new();
public List Sets { get; } = new();
+
+ ///
+ /// Source lines the reader did not turn into anything else - legacy headers,
+ /// comments and malformed liveries - kept verbatim so an entry written back
+ /// out does not quietly drop what its author put in the file. Content only:
+ /// blank lines and the original spacing are not preserved.
+ ///
+ public List Unknown { get; } = new();
}
public sealed class VehicleGroup
@@ -17,6 +33,13 @@ public sealed class VehicleGroup
public string? Mini { get; set; }
public bool Archived { get; set; }
+
+ ///
+ /// True when nothing declared the group and it was inferred from the liveries
+ /// that share a thumbnail - which is every group textures.txt produces, as the
+ /// legacy format has no syntax for declaring one.
+ ///
+ public bool Implicit { get; set; }
}
public sealed class VehicleTexture
diff --git a/StarterNG/Infrastructure/Adapters/GamePaths.cs b/StarterNG/Infrastructure/Adapters/GamePaths.cs
index 3dd5c67..bbf86db 100644
--- a/StarterNG/Infrastructure/Adapters/GamePaths.cs
+++ b/StarterNG/Infrastructure/Adapters/GamePaths.cs
@@ -22,6 +22,8 @@ public sealed class GamePaths : IGamePaths
public string Data => Path.Combine(Root, "data");
+ public string VehicleDatabase => Path.Combine(Root, "databases", "vehicles");
+
public string StarterConfig => Path.Combine(Root, "starter");
public string DiagnosticsLog => Path.Combine(StarterConfig, "bledy.txt");
diff --git a/StarterNG/Infrastructure/Adapters/LegacyText.cs b/StarterNG/Infrastructure/Adapters/LegacyText.cs
index 991b023..5eebd01 100644
--- a/StarterNG/Infrastructure/Adapters/LegacyText.cs
+++ b/StarterNG/Infrastructure/Adapters/LegacyText.cs
@@ -1,5 +1,6 @@
using System;
using System.Text;
+using System.Text.Unicode;
namespace StarterNG.Infrastructure.Adapters;
@@ -21,6 +22,25 @@ public static class LegacyText
public static bool IsFallback { get; private set; }
+ ///
+ /// Decodes a game file. Most are code page 1250, but a few have been re-saved
+ /// as UTF-8 over the years, and reading those as 1250 turns "Zakład" into
+ /// "Zakład". Valid UTF-8 is taken at its word; anything else is 1250, which
+ /// cannot fail. Checked up front rather than by catching a failed strict decode:
+ /// most files are 1250, and that was an exception per file on every load.
+ /// A byte order mark is dropped, or it would stick to the first token.
+ ///
+ public static string Decode(byte[] bytes)
+ {
+ ReadOnlySpan text = bytes;
+ if (text.StartsWith(Utf8Bom))
+ text = text[Utf8Bom.Length..];
+
+ return Utf8.IsValid(text) ? Encoding.UTF8.GetString(text) : CodePage1250.GetString(text);
+ }
+
+ private static ReadOnlySpan Utf8Bom => [0xEF, 0xBB, 0xBF];
+
private static Encoding Resolve()
{
try
diff --git a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
index 3c3effe..69e0c0f 100644
--- a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
+++ b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
@@ -18,6 +18,8 @@ public sealed class PhysicalFileSystem : IFileSystem
public string[] ReadAllLines(string path) => File.ReadAllLines(path);
+ public byte[] ReadAllBytes(string path) => File.ReadAllBytes(path);
+
public Stream OpenRead(string path) => File.OpenRead(path);
public void WriteAllText(string path, string contents) => File.WriteAllText(path, contents);
@@ -49,6 +51,8 @@ public sealed class PhysicalFileSystem : IFileSystem
public DateTime GetLastWriteTimeUtc(string path) => File.GetLastWriteTimeUtc(path);
+ public long GetFileSize(string path) => new FileInfo(path).Length;
+
public bool IsExecutable(string path)
{
if (OperatingSystem.IsWindows())
diff --git a/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs
index c72eae0..59ceacd 100644
--- a/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs
+++ b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs
@@ -18,29 +18,38 @@ public sealed class SystemProcessLauncher : IProcessLauncher
}
public IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory,
- out string? error)
+ bool captureErrors, out string? error)
{
var info = new ProcessStartInfo
{
FileName = executablePath,
WorkingDirectory = workingDirectory ?? string.Empty,
- UseShellExecute = false
+ UseShellExecute = false,
+ RedirectStandardError = captureErrors
};
foreach (string argument in arguments)
info.ArgumentList.Add(argument);
try
{
- var process = Process.Start(info);
- if (process is null)
+ var process = new Process { StartInfo = info };
+ var handle = new ProcessHandle(process, started: true);
+ if (captureErrors)
+ process.ErrorDataReceived += (_, e) => handle.AddError(e.Data);
+
+ if (!process.Start())
{
error = executablePath;
_log.Log($"start {executablePath}: process not created");
return null;
}
+ // Drained as it comes: a full pipe would stall the simulator.
+ if (captureErrors)
+ process.BeginErrorReadLine();
+
error = null;
- return new ProcessHandle(process);
+ return handle;
}
catch (Exception ex)
{
@@ -69,7 +78,7 @@ public sealed class SystemProcessLauncher : IProcessLauncher
try
{
var process = Process.GetProcessesByName(processName).FirstOrDefault();
- return process is null ? null : new ProcessHandle(process);
+ return process is null ? null : new ProcessHandle(process, started: false);
}
catch (Exception ex)
{
@@ -80,11 +89,16 @@ public sealed class SystemProcessLauncher : IProcessLauncher
private sealed class ProcessHandle : IProcessHandle
{
- private readonly Process _process;
+ private const int KeptErrorLines = 20;
- public ProcessHandle(Process process)
+ private readonly Process _process;
+ private readonly bool _started;
+ private readonly Queue _errors = new();
+
+ public ProcessHandle(Process process, bool started)
{
_process = process;
+ _started = started;
}
public bool HasExited
@@ -96,6 +110,39 @@ public sealed class SystemProcessLauncher : IProcessLauncher
}
}
+ // Only a process started here has an exit code to read; one merely found
+ // running throws on it.
+ public int? ExitCode
+ {
+ get
+ {
+ try { return _started && _process.HasExited ? _process.ExitCode : null; }
+ catch { return null; }
+ }
+ }
+
+ public IReadOnlyList ErrorTail
+ {
+ get
+ {
+ lock (_errors)
+ return _errors.ToList();
+ }
+ }
+
+ public void AddError(string? line)
+ {
+ if (line is null)
+ return;
+
+ lock (_errors)
+ {
+ _errors.Enqueue(line);
+ if (_errors.Count > KeptErrorLines)
+ _errors.Dequeue();
+ }
+ }
+
public Task WaitForExitAsync(CancellationToken cancellationToken = default) =>
_process.WaitForExitAsync(cancellationToken);
}
diff --git a/StarterNG/Infrastructure/Diagnostics.cs b/StarterNG/Infrastructure/Diagnostics.cs
index 2696797..4430690 100644
--- a/StarterNG/Infrastructure/Diagnostics.cs
+++ b/StarterNG/Infrastructure/Diagnostics.cs
@@ -53,13 +53,11 @@ public static class Diagnostics
Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title));
}
- public static List CheckInstallation() =>
- new InstallationCheck(
- AppServices.Current.Paths,
- AppServices.Current.Files,
- AppServices.Current.Localization,
- AppServices.Current.Physics,
- AppServices.Current.Library,
- AppServices.Current.SettingsStore)
- .Run();
+ public static InstallationCheck InstallationCheck() =>
+ new(AppServices.Current.Paths,
+ AppServices.Current.Files,
+ AppServices.Current.Localization,
+ AppServices.Current.Physics,
+ AppServices.Current.Library,
+ AppServices.Current.SettingsStore);
}
diff --git a/StarterNG/Infrastructure/InstallationCheck.cs b/StarterNG/Infrastructure/InstallationCheck.cs
index 0c0bb0c..42ce84f 100644
--- a/StarterNG/Infrastructure/InstallationCheck.cs
+++ b/StarterNG/Infrastructure/InstallationCheck.cs
@@ -49,10 +49,17 @@ public sealed class InstallationCheck
if (_physics.IndexedCount == 0)
faults.Add(_strings["FaultNoPhysics"]);
- _settings.ResolveExecutable(out var problem);
- if (problem == ExeProblem.NotFound)
- faults.Add(_strings["FaultNoExe"]);
-
return faults;
}
+
+ ///
+ /// Kept out of : a simulator missing from the installation does
+ /// not make it a broken one - it may be a build kept elsewhere, or a Windows
+ /// build for Wine, waiting to be picked.
+ ///
+ public bool ExecutableMissing()
+ {
+ _settings.ResolveExecutable(out var problem);
+ return problem == ExeProblem.NotFound;
+ }
}
diff --git a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs
index 5da5dee..cbbde38 100644
--- a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs
+++ b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs
@@ -20,7 +20,6 @@ public sealed class SceneryRepository : ISceneryRepository
private readonly IGamePaths _paths;
private readonly IDiagnosticsLog _log;
private readonly SceneryParser _parser;
- private readonly Encoding _encoding;
public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser)
{
@@ -28,7 +27,6 @@ public sealed class SceneryRepository : ISceneryRepository
_paths = paths;
_log = log;
_parser = parser;
- _encoding = LegacyText.CodePage1250;
}
public IReadOnlyList LoadAll(IProgress? progress = null)
@@ -60,7 +58,7 @@ public sealed class SceneryRepository : ISceneryRepository
if (!_files.FileExists(path))
return null;
- var scenery = _parser.Parse(path, _files.ReadAllText(path, _encoding));
+ var scenery = _parser.Parse(path, LegacyText.Decode(_files.ReadAllBytes(path)));
scenery.HasCompanionTimetable = _files.FileExists(Path.ChangeExtension(path, ".sbt"));
return scenery;
}
diff --git a/StarterNG/Infrastructure/Settings/ExecutableLocator.cs b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs
index 2283eee..cf580d2 100644
--- a/StarterNG/Infrastructure/Settings/ExecutableLocator.cs
+++ b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs
@@ -19,6 +19,10 @@ public sealed class ExecutableLocator
private readonly IEnvironment _environment;
private readonly IDiagnosticsLog _log;
+ private string? _wine;
+ private bool _wineLookedUp;
+ private string? _missingPickLogged;
+
public ExecutableLocator(IFileSystem files, IGamePaths paths, IEnvironment environment, IDiagnosticsLog log)
{
_files = files;
@@ -29,21 +33,47 @@ public sealed class ExecutableLocator
public string CanonicalName => _environment.IsWindows ? "eu07.exe" : "eu07";
+ ///
+ /// The simulator to start: the one picked in the settings, or the best one in
+ /// the installation. A pick that is not there - the settings live per user and
+ /// are shared by every installation, so it may come from another one - gives
+ /// way to the search rather than stopping the start.
+ ///
public string Resolve(SimulatorSettings settings, out ExeProblem problem)
{
if (!settings.SelectExeAutomatically && !string.IsNullOrWhiteSpace(settings.ExecutablePath))
{
problem = Validate(FullPath(settings.ExecutablePath));
- return settings.ExecutablePath;
+ if (problem != ExeProblem.NotFound)
+ return settings.ExecutablePath;
+
+ string found = Search(out var searched);
+ if (searched == ExeProblem.NotFound)
+ return settings.ExecutablePath;
+
+ // Resolved on every start and for the title bar, so said once.
+ if (_missingPickLogged != settings.ExecutablePath)
+ {
+ _log.Log($"The chosen simulator {settings.ExecutablePath} is not in {_paths.Root}, using {found}");
+ _missingPickLogged = settings.ExecutablePath;
+ }
+ problem = searched;
+ return found;
}
+ return Search(out problem);
+ }
+
+ private string Search(out ExeProblem problem)
+ {
string? fallback = null;
try
{
var candidates = _files.GetFiles(_paths.Root, "eu07*")
.Where(IsCandidate)
- .OrderBy(path => string.Equals(Path.GetFileName(path), CanonicalName,
- StringComparison.OrdinalIgnoreCase) ? 0 : 1)
+ .OrderBy(path => IsExeOnLinux(path) ? 1 : 0)
+ .ThenBy(path => string.Equals(Path.GetFileName(path), CanonicalName,
+ StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(path => Path.GetFileName(path).Length);
foreach (string path in candidates)
@@ -94,10 +124,16 @@ public sealed class ExecutableLocator
if (!_files.FileExists(path))
return ExeProblem.NotFound;
+ bool foreign = !MatchesPlatform(path);
+
+ // Started through Wine, a Windows build needs no execute bit of its own.
+ if (foreign && RunsThroughWine(path))
+ return ExeProblem.WrongPlatform;
+
if (!_environment.IsWindows && !_files.IsExecutable(path))
return ExeProblem.NotExecutable;
- return MatchesPlatform(path) ? ExeProblem.None : ExeProblem.WrongPlatform;
+ return foreign ? ExeProblem.WrongPlatform : ExeProblem.None;
}
catch (Exception)
{
@@ -105,24 +141,92 @@ public sealed class ExecutableLocator
}
}
- private bool MatchesPlatform(string path)
+ ///
+ /// What to start for a simulator binary: the binary itself, or Wine with the
+ /// binary as its first argument when it is a Windows build on Linux.
+ ///
+ public (string FileName, IReadOnlyList Arguments) LaunchCommand(string executable,
+ IReadOnlyList arguments)
+ {
+ if (!RunsThroughWine(executable))
+ return (executable, arguments);
+
+ return (Wine()!, new[] { executable }.Concat(arguments).ToList());
+ }
+
+ /// Wine from the PATH, or null where there is none - and always on Windows.
+ public string? Wine()
+ {
+ if (_environment.IsWindows)
+ return null;
+
+ if (!_wineLookedUp)
+ {
+ _wine = FindOnPath("wine") ?? FindOnPath("wine64");
+ _wineLookedUp = true;
+ }
+
+ return _wine;
+ }
+
+ private string? FindOnPath(string name)
+ {
+ // The PATH of Linux and macOS, whatever the host the tests run on.
+ string[] directories = (_environment.GetVariable("PATH") ?? "").Split(':', StringSplitOptions.RemoveEmptyEntries);
+ foreach (string directory in directories)
+ {
+ string candidate = Path.Combine(directory, name);
+ if (_files.FileExists(candidate) && _files.IsExecutable(candidate))
+ return candidate;
+ }
+
+ return null;
+ }
+
+ private bool RunsThroughWine(string path)
+ {
+ try
+ {
+ return !_environment.IsWindows && Wine() is not null && Format(path) == BinaryFormat.PortableExecutable;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ private bool MatchesPlatform(string path) => Format(path) switch
+ {
+ BinaryFormat.Elf => !_environment.IsWindows,
+ BinaryFormat.PortableExecutable => !_environment.IsLinux,
+ _ => true
+ };
+
+ private enum BinaryFormat
+ {
+ Unknown,
+ Elf,
+ PortableExecutable
+ }
+
+ private BinaryFormat Format(string path)
{
byte[] head = new byte[4];
using var stream = _files.OpenRead(path);
if (stream.Read(head, 0, 4) != 4)
- return true;
+ return BinaryFormat.Unknown;
- bool portableExecutable = head[0] == 0x4D && head[1] == 0x5A;
- bool elf = head[0] == 0x7F && head[1] == 0x45 && head[2] == 0x4C && head[3] == 0x46;
+ if (head[0] == 0x4D && head[1] == 0x5A)
+ return BinaryFormat.PortableExecutable;
+ if (head[0] == 0x7F && head[1] == 0x45 && head[2] == 0x4C && head[3] == 0x46)
+ return BinaryFormat.Elf;
- if (_environment.IsWindows && elf)
- return false;
- if (_environment.IsLinux && portableExecutable)
- return false;
-
- return true;
+ return BinaryFormat.Unknown;
}
+ private bool IsExeOnLinux(string path) =>
+ _environment.IsLinux && string.Equals(Path.GetExtension(path), ".exe", StringComparison.OrdinalIgnoreCase);
+
private bool IsCandidate(string path)
{
string extension = Path.GetExtension(path).ToLowerInvariant();
@@ -133,8 +237,11 @@ public sealed class ExecutableLocator
if (_environment.IsWindows)
return extension == ".exe";
+ // A Windows build is offered only where Wine can start it, and after any
+ // native one - see the ordering in Resolve.
if (_environment.IsLinux)
- return extension.Length == 0 || extension is ".x86_64" or ".run" or ".appimage";
+ return extension.Length == 0 || extension is ".x86_64" or ".run" or ".appimage" ||
+ (IsExeOnLinux(path) && Wine() is not null);
return extension.Length == 0 || extension is ".exe" or ".x86_64" or ".run" or ".appimage";
}
diff --git a/StarterNG/Infrastructure/Settings/SettingsPaths.cs b/StarterNG/Infrastructure/Settings/SettingsPaths.cs
index d9eba18..a80a297 100644
--- a/StarterNG/Infrastructure/Settings/SettingsPaths.cs
+++ b/StarterNG/Infrastructure/Settings/SettingsPaths.cs
@@ -43,6 +43,16 @@ public sealed class SettingsPaths
public string InstallationConfigPath() => _paths.FromRoot(ConfigFileName);
+ ///
+ /// Where the starter keeps its own copy of the vehicle database. Per user
+ /// rather than under the installation: that one can be read-only, is a working
+ /// copy someone updates from under us, and the copy is ours, not the game's.
+ ///
+ public string VehicleCacheDirectory() => Path.Combine(UserConfigDirectory(), "vehicles");
+
+ /// The sceneries starred in the list.
+ public string FavoritesPath() => Path.Combine(UserConfigDirectory(), "favorites.txt");
+
public string UserConfigDirectory() =>
Path.GetDirectoryName(UserConfigPath()) ?? _environment.BaseDirectory;
}
diff --git a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs
index 498a50f..79260d8 100644
--- a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs
+++ b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs
@@ -56,6 +56,7 @@ public sealed class SettingsSerializer
s.DebugMode = c.GetBool("debugmode", false);
s.VirtualShunting = c.GetBool("ai.trainman", true);
s.LogMissingVehicleFiles = c.GetBool("starter.logmissingvehicles", false);
+ s.DeveloperTools = c.GetBool("starter.developertools", false);
s.RenderEngine = IndexOf(SimulatorSettings.RenderEngines, c.GetString("gfxrenderer", "full"), 0);
s.Width = c.GetInt("width", 1280);
@@ -195,6 +196,7 @@ public sealed class SettingsSerializer
c.SetBool("debugmode", s.DebugMode);
c.SetBool("ai.trainman", s.VirtualShunting);
c.SetBool("starter.logmissingvehicles", s.LogMissingVehicleFiles);
+ c.SetBool("starter.developertools", s.DeveloperTools);
c.Set("gfxrenderer", SimulatorSettings.RenderEngines[Clamp(s.RenderEngine, 0, SimulatorSettings.RenderEngines.Length - 1)]);
c.SetInt("width", s.Width);
diff --git a/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs b/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs
index 52f52eb..36a5a2c 100644
--- a/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs
+++ b/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs
@@ -25,7 +25,6 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
private readonly IFileSystem _files;
private readonly IGamePaths _paths;
private readonly IDiagnosticsLog _log;
- private readonly Encoding _encoding;
private readonly ConcurrentDictionary _cache =
new(StringComparer.OrdinalIgnoreCase);
@@ -38,7 +37,6 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
_files = files;
_paths = paths;
_log = log;
- _encoding = LegacyText.CodePage1250;
}
public void Preload() => Index();
@@ -118,7 +116,7 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
if (depth > MaxIncludeDepth || !_files.FileExists(path))
return;
- var tokens = Tokenize(_files.ReadAllText(path, _encoding));
+ var tokens = Tokenize(LegacyText.Decode(_files.ReadAllBytes(path)));
string section = "";
string? pendingKey = null;
diff --git a/StarterNG/Infrastructure/Vehicles/LoadWeightsRepository.cs b/StarterNG/Infrastructure/Vehicles/LoadWeightsRepository.cs
index 53a4f1f..dea430e 100644
--- a/StarterNG/Infrastructure/Vehicles/LoadWeightsRepository.cs
+++ b/StarterNG/Infrastructure/Vehicles/LoadWeightsRepository.cs
@@ -44,7 +44,7 @@ public sealed class LoadWeightsRepository
try
{
- return new LoadWeightsTable(ParsePairs(_files.ReadAllText(path, LegacyText.CodePage1250)));
+ return new LoadWeightsTable(ParsePairs(LegacyText.Decode(_files.ReadAllBytes(path))));
}
catch (Exception ex)
{
diff --git a/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs b/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs
index c99a88e..3eebfe8 100644
--- a/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs
+++ b/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs
@@ -8,6 +8,9 @@ public sealed class TexturesTxtParser
{
private const string DeriveCategory = "*";
+ /// Goes into the vehicle cache stamp, so a change to what is parsed rebuilds the cache.
+ public const int Version = 2;
+
public VehicleEntry? Parse(string directory, IReadOnlyList lines)
{
var entry = new VehicleEntry { Uuid = "legacy:" + directory };
@@ -25,7 +28,10 @@ public sealed class TexturesTxtParser
char first = line[0];
if (first is '#' or '@' or '*')
+ {
+ entry.Unknown.Add(line);
continue;
+ }
if (line.StartsWith("$a", StringComparison.OrdinalIgnoreCase))
{
@@ -47,11 +53,17 @@ public sealed class TexturesTxtParser
}
if (line.IndexOf('=') < 0 || line.StartsWith("//", StringComparison.Ordinal))
+ {
+ entry.Unknown.Add(line);
continue;
+ }
var texture = ParseLivery(line, directory, categorySign, archived, entry);
if (texture is null)
+ {
+ entry.Unknown.Add(line);
continue;
+ }
entry.Textures.Add(texture);
@@ -120,12 +132,20 @@ public sealed class TexturesTxtParser
Id = groupId,
Category = categorySign,
Mini = lead.Mini,
- Archived = archived
+ Archived = archived,
+ Implicit = true
});
+ // One skin may dress several models - the ET42-026 has a single 112E-026 for
+ // both halves - and the scenery tells them apart by the model, so the later
+ // ones are named by it as well, or they would all be the first.
+ string uuid = $"legacy:{directory}{skin}";
+ if (entry.Textures.Exists(t => string.Equals(t.Skinfile, skin, StringComparison.OrdinalIgnoreCase)))
+ uuid += "#" + lead.Model;
+
var texture = new VehicleTexture
{
- Uuid = $"legacy:{directory}{skin}",
+ Uuid = uuid,
Directory = directory,
Skinfile = skin,
Wreck = skin.Contains("wreck", StringComparison.OrdinalIgnoreCase) ||
diff --git a/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs b/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs
index 4a9a30d..986a55c 100644
--- a/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs
+++ b/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Linq;
using System.Text;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Vehicles;
@@ -16,7 +17,6 @@ public sealed class TexturesTxtVehicleRepository : IVehicleRepository
private readonly IGamePaths _paths;
private readonly IDiagnosticsLog _log;
private readonly TexturesTxtParser _parser;
- private readonly Encoding _encoding;
public TexturesTxtVehicleRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log,
TexturesTxtParser parser)
@@ -25,7 +25,6 @@ public sealed class TexturesTxtVehicleRepository : IVehicleRepository
_paths = paths;
_log = log;
_parser = parser;
- _encoding = LegacyText.CodePage1250;
}
public int Load(VehicleCatalog catalog)
@@ -33,9 +32,9 @@ public sealed class TexturesTxtVehicleRepository : IVehicleRepository
catalog.BeginLoad();
int liveries = 0;
- foreach (string file in TexturesFiles())
+ foreach (string file in Sources())
{
- var entry = ReadEntry(file);
+ var entry = Read(file);
if (entry is null)
continue;
@@ -47,11 +46,31 @@ public sealed class TexturesTxtVehicleRepository : IVehicleRepository
return liveries;
}
- private VehicleEntry? ReadEntry(string path)
+ ///
+ /// Every textures.txt of the installation, in name order. Sorted rather than
+ /// taken as the file system hands them over, so that which of two folders
+ /// claiming the same skin wins is the same on every machine.
+ ///
+ public IEnumerable Sources()
+ {
+ foreach (string maker in Sorted(_files.GetDirectories(_paths.Dynamic)))
+ foreach (string vehicle in Sorted(_files.GetDirectories(maker)))
+ {
+ string path = Path.Combine(vehicle, TexturesFileName);
+ if (_files.FileExists(path))
+ yield return path;
+ }
+ }
+
+ private static IEnumerable Sorted(IEnumerable directories) =>
+ directories.OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase);
+
+ /// Reads one textures.txt; a file that will not parse is logged and skipped.
+ public VehicleEntry? Read(string path)
{
try
{
- string[] lines = _files.ReadAllText(path, _encoding).Split('\n');
+ string[] lines = LegacyText.Decode(_files.ReadAllBytes(path)).Split('\n');
return _parser.Parse(RelativeDirectory(path), lines);
}
catch (Exception ex)
@@ -61,17 +80,6 @@ public sealed class TexturesTxtVehicleRepository : IVehicleRepository
}
}
- private IEnumerable TexturesFiles()
- {
- foreach (string maker in _files.GetDirectories(_paths.Dynamic))
- foreach (string vehicle in _files.GetDirectories(maker))
- {
- string path = Path.Combine(vehicle, TexturesFileName);
- if (_files.FileExists(path))
- yield return path;
- }
- }
-
private string RelativeDirectory(string texturesPath)
{
string directory = Path.GetDirectoryName(texturesPath) ?? "";
diff --git a/StarterNG/Infrastructure/Vehicles/VehicleCacheRepository.cs b/StarterNG/Infrastructure/Vehicles/VehicleCacheRepository.cs
new file mode 100644
index 0000000..3e83ece
--- /dev/null
+++ b/StarterNG/Infrastructure/Vehicles/VehicleCacheRepository.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using StarterNG.Application.Abstractions;
+using StarterNG.Domain.Vehicles;
+
+namespace StarterNG.Infrastructure.Vehicles;
+
+///
+/// Loads the catalogue from the starter's own JSON copy of the installation,
+/// rebuilding that copy from textures.txt whenever the installation has moved on.
+///
+///
+/// The copy is a plain merged database, the same format a package is written in,
+/// so it can be read by anything that reads the format - and it is a copy, never
+/// a dependency: a missing, stale, unreadable or unwritable cache only costs the
+/// parse it was meant to save. textures.txt stays the truth.
+///
+public sealed class VehicleCacheRepository : IVehicleRepository
+{
+ private const string DatabaseFileName = "vehicles.json";
+
+ /// What the copy was built from, and what makes it stale.
+ private const string SourcesFileName = "sources.txt";
+
+ private readonly IFileSystem _files;
+ private readonly IGamePaths _paths;
+ private readonly IDiagnosticsLog _log;
+ private readonly TexturesTxtVehicleRepository _legacy;
+ private readonly VehicleJsonSerializer _serializer;
+ private readonly string _directory;
+
+ public VehicleCacheRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log,
+ TexturesTxtVehicleRepository legacy, VehicleJsonSerializer serializer,
+ string directory)
+ {
+ _files = files;
+ _paths = paths;
+ _log = log;
+ _legacy = legacy;
+ _serializer = serializer;
+ _directory = directory;
+ }
+
+ /// What the last load did, for the developer tab to report.
+ public VehicleCacheStatus Status { get; private set; } = VehicleCacheStatus.None;
+
+ public int Load(VehicleCatalog catalog) => Load(catalog, useCopy: true);
+
+ /// Throws the copy away and reads the installation again.
+ public int Rebuild(VehicleCatalog catalog) => Load(catalog, useCopy: false);
+
+ private int Load(VehicleCatalog catalog, bool useCopy)
+ {
+ var (stamp, sources) = Stamp();
+ var cached = useCopy ? Cached(stamp) : null;
+ var entries = cached ?? Migrate(stamp);
+
+ Status = new VehicleCacheStatus(cached is not null, sources, WrittenAt(), _directory);
+
+ catalog.BeginLoad();
+
+ int liveries = 0;
+ foreach (var entry in entries)
+ {
+ liveries += entry.Textures.Count;
+ catalog.Ingest(entry);
+ }
+
+ catalog.EndLoad();
+ return liveries;
+ }
+
+ ///
+ /// The installation as one comparable line per textures.txt. Every way the
+ /// installation can move on - a file edited, added, removed, or the whole
+ /// installation swapped for another one - shows up as a different text.
+ ///
+ private (string Text, int Sources) Stamp()
+ {
+ var stamp = new StringBuilder().Append("root|").Append(Path.GetFullPath(_paths.Root)).Append('\n')
+ .Append("parser|").Append(TexturesTxtParser.Version).Append('\n');
+ int sources = 0;
+
+ foreach (string path in _legacy.Sources())
+ {
+ sources++;
+ long ticks = 0;
+ long size = 0;
+ try
+ {
+ ticks = _files.GetLastWriteTimeUtc(path).Ticks;
+ size = _files.GetFileSize(path);
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"textures.txt {path}", ex);
+ }
+
+ stamp.Append(ticks).Append('|').Append(size).Append('|')
+ .Append(path.Replace('\\', '/')).Append('\n');
+ }
+
+ return (stamp.ToString(), sources);
+ }
+
+ private DateTime? WrittenAt()
+ {
+ try
+ {
+ return _files.FileExists(DatabasePath) ? _files.GetLastWriteTimeUtc(DatabasePath) : null;
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"{DatabaseFileName}: nie znam daty kopii", ex);
+ return null;
+ }
+ }
+
+ private List? Cached(string stamp)
+ {
+ try
+ {
+ if (!_files.FileExists(SourcesPath) || !_files.FileExists(DatabasePath))
+ return null;
+
+ if (!string.Equals(_files.ReadAllText(SourcesPath), stamp, StringComparison.Ordinal))
+ return null;
+
+ var entries = _serializer.ReadCollection(_files.ReadAllText(DatabasePath));
+ foreach (var entry in entries)
+ if (!VehicleJsonSerializer.IsReadable(entry))
+ {
+ _log.Log($"{DatabaseFileName}: kopia w wersji {entry.SchemaVersion}, " +
+ $"ten starter czyta do {VehicleJsonSerializer.SchemaVersion} - odtwarzam");
+ return null;
+ }
+
+ return entries;
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"{DatabaseFileName}: kopia nie do odczytu, odtwarzam", ex);
+ return null;
+ }
+ }
+
+ private List Migrate(string stamp)
+ {
+ var entries = new List();
+ foreach (string path in _legacy.Sources())
+ if (_legacy.Read(path) is { } entry)
+ entries.Add(entry);
+
+ Store(entries, stamp);
+ return entries;
+ }
+
+ ///
+ /// Writes the copy out. Best effort by design - an installation on a read-only
+ /// disk, or a home folder we cannot write, still gets its vehicles.
+ ///
+ private void Store(List entries, string stamp)
+ {
+ try
+ {
+ _files.CreateDirectory(_directory);
+ _files.WriteAllText(DatabasePath, _serializer.WriteCollection(entries));
+ _files.WriteAllText(SourcesPath, stamp);
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"{DatabaseFileName}: kopia nie do zapisania", ex);
+ }
+ }
+
+ private string DatabasePath => Path.Combine(_directory, DatabaseFileName);
+
+ private string SourcesPath => Path.Combine(_directory, SourcesFileName);
+}
+
+///
+/// How the catalogue was last filled: from the starter's copy or by reading the
+/// installation, over how many textures.txt, and when the copy was last written.
+///
+public sealed record VehicleCacheStatus(bool ServedFromCopy, int Sources, DateTime? Written, string Directory)
+{
+ public static readonly VehicleCacheStatus None = new(false, 0, null, "");
+}
diff --git a/StarterNG/Infrastructure/Vehicles/VehicleJsonDocument.cs b/StarterNG/Infrastructure/Vehicles/VehicleJsonDocument.cs
new file mode 100644
index 0000000..b75962f
--- /dev/null
+++ b/StarterNG/Infrastructure/Vehicles/VehicleJsonDocument.cs
@@ -0,0 +1,124 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace StarterNG.Infrastructure.Vehicles;
+
+// The vehicle database as it is written to disk (schema_version 1). Deliberately
+// separate from the domain model: the model is free to change shape with the
+// starter, the file format is not - packages written by someone else have to keep
+// parsing. The property names are the ones the format shipped with, so a file
+// written for the older reader still loads.
+//
+// Collections are nullable and left out when empty, which keeps a generated file
+// close to what a person would have typed by hand.
+
+public sealed class VehicleEntryDocument
+{
+ [JsonPropertyName("uuid")] public string? Uuid { get; set; }
+
+ [JsonPropertyName("schema_version")] public int SchemaVersion { get; set; }
+
+ [JsonPropertyName("groups")] public List? Groups { get; set; }
+
+ [JsonPropertyName("textures")] public List? Textures { get; set; }
+
+ [JsonPropertyName("sets")] public List? Sets { get; set; }
+
+ [JsonPropertyName("unknown")] public List? Unknown { get; set; }
+}
+
+/// Root of a merged database holding many entries in one file.
+public sealed class VehicleCollectionDocument
+{
+ [JsonPropertyName("vehicles")] public List? Vehicles { get; set; }
+}
+
+public sealed class VehicleGroupDocument
+{
+ [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; }
+}
+
+public sealed class VehicleTextureDocument
+{
+ [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? Aliases { get; set; }
+
+ [JsonPropertyName("meta")] public VehicleMetaDocument? Meta { get; set; }
+}
+
+public sealed class VehicleAliasDocument
+{
+ [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; }
+}
+
+public sealed class VehicleMetaDocument
+{
+ [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? Extra { get; set; }
+}
+
+public sealed class VehicleSetDocument
+{
+ [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? TextureRefs { get; set; }
+}
+
+// Source-generated metadata: PublishAot and PublishTrimmed rule out the reflection
+// based serializer. The generator pulls in every nested document type on its own,
+// and the reader's leniency is set on the options in VehicleJsonSerializer.
+[JsonSerializable(typeof(VehicleEntryDocument))]
+[JsonSerializable(typeof(VehicleCollectionDocument))]
+internal partial class VehicleJsonContext : JsonSerializerContext
+{
+}
diff --git a/StarterNG/Infrastructure/Vehicles/VehicleJsonRepository.cs b/StarterNG/Infrastructure/Vehicles/VehicleJsonRepository.cs
new file mode 100644
index 0000000..a0e0d72
--- /dev/null
+++ b/StarterNG/Infrastructure/Vehicles/VehicleJsonRepository.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using StarterNG.Application.Abstractions;
+using StarterNG.Domain.Vehicles;
+
+namespace StarterNG.Infrastructure.Vehicles;
+
+///
+/// Reads the vehicle catalogue from databases/vehicles: a merged vehicles.json
+/// holding the whole database, and one file per package layered on top of it.
+///
+public sealed class VehicleJsonRepository : IVehicleRepository
+{
+ private const string MergedFileName = "vehicles.json";
+
+ private readonly IFileSystem _files;
+ private readonly IGamePaths _paths;
+ private readonly IDiagnosticsLog _log;
+ private readonly VehicleJsonSerializer _serializer;
+
+ public VehicleJsonRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log,
+ VehicleJsonSerializer serializer)
+ {
+ _files = files;
+ _paths = paths;
+ _log = log;
+ _serializer = serializer;
+ }
+
+ public int Load(VehicleCatalog catalog)
+ {
+ catalog.BeginLoad();
+
+ int liveries = 0;
+ foreach (string file in DatabaseFiles())
+ liveries += ReadInto(catalog, file);
+
+ catalog.EndLoad();
+ return liveries;
+ }
+
+ private int ReadInto(VehicleCatalog catalog, string path)
+ {
+ try
+ {
+ string json = _files.ReadAllText(path);
+ var entries = IsMerged(path)
+ ? _serializer.ReadCollection(json)
+ : Listed(_serializer.Read(json));
+
+ int liveries = 0;
+ foreach (var entry in entries)
+ {
+ if (!VehicleJsonSerializer.IsReadable(entry))
+ {
+ _log.Log($"{Name(path)}: format w wersji {entry.SchemaVersion}, " +
+ $"ten starter czyta do {VehicleJsonSerializer.SchemaVersion}");
+ continue;
+ }
+
+ liveries += entry.Textures.Count;
+ catalog.Ingest(entry);
+ }
+
+ return liveries;
+ }
+ catch (Exception ex)
+ {
+ _log.Log(Name(path), ex);
+ return 0;
+ }
+ }
+
+ ///
+ /// The merged database first and the packages after it, both in a fixed order,
+ /// so which entry wins a clash is the same on every machine - the file system
+ /// does not promise one.
+ ///
+ private IEnumerable DatabaseFiles()
+ {
+ var files = _files.GetFiles(_paths.VehicleDatabase, "*.json")
+ .OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ foreach (string file in files.Where(IsMerged))
+ yield return file;
+
+ foreach (string file in files.Where(file => !IsMerged(file)))
+ yield return file;
+ }
+
+ private static bool IsMerged(string path) =>
+ string.Equals(Path.GetFileName(path), MergedFileName, StringComparison.OrdinalIgnoreCase);
+
+ private static string Name(string path) => $"databases/vehicles/{Path.GetFileName(path)}";
+
+ private static List Listed(VehicleEntry? entry) =>
+ entry is null ? new List() : new List { entry };
+}
diff --git a/StarterNG/Infrastructure/Vehicles/VehicleJsonSerializer.cs b/StarterNG/Infrastructure/Vehicles/VehicleJsonSerializer.cs
new file mode 100644
index 0000000..d4fc885
--- /dev/null
+++ b/StarterNG/Infrastructure/Vehicles/VehicleJsonSerializer.cs
@@ -0,0 +1,215 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using StarterNG.Domain.Vehicles;
+
+namespace StarterNG.Infrastructure.Vehicles;
+
+///
+/// Reads and writes in the JSON database format,
+/// mapping it on and off the document types that define the format.
+///
+public sealed class VehicleJsonSerializer
+{
+ /// The format this build writes, and the newest one it can read.
+ public const int SchemaVersion = 1;
+
+ // Leniency a hand-written package file deserves: names matched case
+ // insensitively, // and /* */ comments skipped, trailing commas allowed. The
+ // relaxed encoder keeps Polish text in the credits and in the preserved legacy
+ // comments readable instead of turning it into \uXXXX escapes, and the
+ // source-generated resolver keeps all of it AOT-safe.
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ WriteIndented = true,
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ TypeInfoResolver = VehicleJsonContext.Default
+ };
+
+ private static readonly JsonTypeInfo EntryInfo =
+ (JsonTypeInfo)Options.GetTypeInfo(typeof(VehicleEntryDocument));
+
+ private static readonly JsonTypeInfo CollectionInfo =
+ (JsonTypeInfo)Options.GetTypeInfo(typeof(VehicleCollectionDocument));
+
+ public string Write(VehicleEntry entry) => JsonSerializer.Serialize(ToDocument(entry), EntryInfo);
+
+ public string WriteCollection(IEnumerable entries) =>
+ JsonSerializer.Serialize(
+ new VehicleCollectionDocument { Vehicles = entries.Select(ToDocument).ToList() },
+ CollectionInfo);
+
+ /// One entry per file, the form a package is authored in.
+ public VehicleEntry? Read(string json)
+ {
+ var document = JsonSerializer.Deserialize(json, EntryInfo);
+ return document is null ? null : FromDocument(document);
+ }
+
+ /// Many entries in one file, the form a whole database is shipped in.
+ public List ReadCollection(string json)
+ {
+ var document = JsonSerializer.Deserialize(json, CollectionInfo);
+ if (document?.Vehicles is null)
+ return new List();
+
+ return document.Vehicles.Select(FromDocument).ToList();
+ }
+
+ ///
+ /// Whether this build understands the file. An entry written by a newer
+ /// starter is refused rather than half-read, an entry with no version at all
+ /// is taken as the current one - the format carried no version when the first
+ /// files were written by hand.
+ ///
+ public static bool IsReadable(VehicleEntry entry) => entry.SchemaVersion <= SchemaVersion;
+
+ private static VehicleEntryDocument ToDocument(VehicleEntry entry) =>
+ new()
+ {
+ Uuid = entry.Uuid,
+ SchemaVersion = SchemaVersion,
+ Groups = OrNull(entry.Groups.Select(group => new VehicleGroupDocument
+ {
+ Id = group.Id,
+ Category = group.Category,
+ Mini = group.Mini,
+ Archived = group.Archived,
+ Implicit = group.Implicit
+ }).ToList()),
+ Textures = OrNull(entry.Textures.Select(ToDocument).ToList()),
+ Sets = OrNull(entry.Sets.Select(set => new VehicleSetDocument
+ {
+ Uuid = set.Uuid,
+ Mode = set.Mode,
+ Count = set.Count,
+ TextureRefs = OrNull(set.TextureRefs)
+ }).ToList()),
+ Unknown = OrNull(entry.Unknown)
+ };
+
+ private static VehicleTextureDocument ToDocument(VehicleTexture texture) =>
+ new()
+ {
+ Uuid = texture.Uuid,
+ Directory = texture.Directory,
+ Skinfile = texture.Skinfile,
+ Model = texture.Model,
+ Group = texture.Group,
+ MiniRef = texture.MiniRef,
+ TextureMini = texture.TextureMini,
+ Wreck = texture.Wreck,
+ Aliases = OrNull(texture.Aliases.Select(alias => new VehicleAliasDocument
+ {
+ Model = alias.Model,
+ Group = alias.Group,
+ MiniRef = alias.MiniRef,
+ TextureMini = alias.TextureMini
+ }).ToList()),
+ Meta = texture.Meta is null
+ ? null
+ : new VehicleMetaDocument
+ {
+ Raw = texture.Meta.Raw,
+ Version = texture.Meta.Version,
+ Vehicle = texture.Meta.Vehicle,
+ Operator = texture.Meta.Operator,
+ Depot = texture.Meta.Depot,
+ RevisionDate = texture.Meta.RevisionDate,
+ RevisionPlace = texture.Meta.RevisionPlace,
+ TextureAuthor = texture.Meta.TextureAuthor,
+ PhotoAuthor = texture.Meta.PhotoAuthor,
+ Extra = OrNull(texture.Meta.Extra)
+ }
+ };
+
+ private static VehicleEntry FromDocument(VehicleEntryDocument document)
+ {
+ var entry = new VehicleEntry
+ {
+ Uuid = document.Uuid,
+ SchemaVersion = document.SchemaVersion == 0 ? SchemaVersion : document.SchemaVersion
+ };
+
+ foreach (var group in document.Groups ?? Enumerable.Empty())
+ entry.Groups.Add(new VehicleGroup
+ {
+ Id = group.Id ?? "",
+ Category = group.Category,
+ Mini = group.Mini,
+ Archived = group.Archived,
+ Implicit = group.Implicit
+ });
+
+ foreach (var texture in document.Textures ?? Enumerable.Empty())
+ entry.Textures.Add(FromDocument(texture));
+
+ foreach (var set in document.Sets ?? Enumerable.Empty())
+ entry.Sets.Add(new VehicleSet
+ {
+ Uuid = set.Uuid,
+ Mode = set.Mode,
+ Count = set.Count,
+ TextureRefs = set.TextureRefs ?? new List()
+ });
+
+ if (document.Unknown is not null)
+ entry.Unknown.AddRange(document.Unknown);
+
+ return entry;
+ }
+
+ private static VehicleTexture FromDocument(VehicleTextureDocument document)
+ {
+ var texture = new VehicleTexture
+ {
+ Uuid = document.Uuid,
+ Directory = document.Directory ?? "",
+ Skinfile = document.Skinfile ?? "",
+ Model = document.Model,
+ Group = document.Group,
+ MiniRef = document.MiniRef,
+ TextureMini = document.TextureMini,
+ Wreck = document.Wreck
+ };
+
+ foreach (var alias in document.Aliases ?? Enumerable.Empty())
+ texture.Aliases.Add(new VehicleAlias
+ {
+ Model = alias.Model,
+ Group = alias.Group,
+ MiniRef = alias.MiniRef,
+ TextureMini = alias.TextureMini
+ });
+
+ if (document.Meta is not null)
+ {
+ texture.Meta = new VehicleMeta
+ {
+ Raw = document.Meta.Raw,
+ Version = document.Meta.Version,
+ Vehicle = document.Meta.Vehicle,
+ Operator = document.Meta.Operator,
+ Depot = document.Meta.Depot,
+ RevisionDate = document.Meta.RevisionDate,
+ RevisionPlace = document.Meta.RevisionPlace,
+ TextureAuthor = document.Meta.TextureAuthor,
+ PhotoAuthor = document.Meta.PhotoAuthor
+ };
+
+ if (document.Meta.Extra is not null)
+ texture.Meta.Extra.AddRange(document.Meta.Extra);
+ }
+
+ return texture;
+ }
+
+ private static List? OrNull(List items) => items.Count > 0 ? items : null;
+}
diff --git a/StarterNG/MainWindow.axaml b/StarterNG/MainWindow.axaml
index 9d2b8bc..c631bd1 100644
--- a/StarterNG/MainWindow.axaml
+++ b/StarterNG/MainWindow.axaml
@@ -37,6 +37,10 @@
Tag="settings"
IsCheckedChanged="Nav_OnCheckedChanged"
Content="{Binding [NavSettings], Source={x:Static starterNg:App.Loc}}" />
+