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}}" /> + - - - + + + + + + - + SelectionChanged="CategoryCombo_OnSelectionChanged" /> + - - - + -