Merge pull request #26 from MaSzyna-EU07/staging

Staging
This commit is contained in:
2026-10-06 20:29:38 +02:00
committed by GitHub
84 changed files with 4613 additions and 591 deletions

14
.github/dependabot.yml vendored Normal file
View File

@@ -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

View File

@@ -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:.*<VersionPrefix>\([0-9][0-9]*\.[0-9][0-9]*\)</VersionPrefix>.*:\1:p' "$PROJECT_PATH")
if [ -z "$prefix" ]; then
echo "::error file=$PROJECT_PATH::No major.minor <VersionPrefix> 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/<tag>/<file>
# 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/<file>
# 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')."

View File

@@ -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<Dynamic> { _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null) } };
}

View File

@@ -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;
}
}

View File

@@ -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<Dynamic> { _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null) }
};
_consist.Insert(_consist.Count, item);
return item;
}
}

View File

@@ -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));
}

View File

@@ -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());
}
}

View File

@@ -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;
/// <summary>Files added through <see cref="WithExecutable"/> carry the execute bit.</summary>
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")

View File

@@ -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; }

View File

@@ -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));
}
}

View File

@@ -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);
}

View File

@@ -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"));
}

View File

@@ -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<FirstChanceExceptionEventArgs> 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);
}
}

View File

@@ -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()
{

View File

@@ -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)
{

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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<InMemoryFileSystem> 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<string>? Arguments { get; private set; }
public bool? CapturedErrors { get; private set; }
public IProcessHandle? Start(string executablePath, IReadOnlyList<string> 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<string> ErrorTail => Array.Empty<string>();
public Task WaitForExitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}

View File

@@ -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());
}
}

View File

@@ -117,4 +117,48 @@ public class VehicleCatalogTests
/// <summary>Builds an entry through the real parser, for catalogue-level tests.</summary>
private static VehicleEntry ParsedEntry(string directory, params string[] lines) =>
new StarterNG.Infrastructure.Vehicles.TexturesTxtParser().Parse(directory, lines)!;
// 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);
}
}

View File

@@ -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();
/// <summary>
/// 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.
/// </summary>
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
""";
/// <summary>A unit whose cars are coupled into a fixed set.</summary>
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);
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>Everything an entry carries, flattened so a difference reads as one.</summary>
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}")));
/// <summary>The same for a whole catalogue, wrecks and resolved fields included.</summary>
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}"));
}

View File

@@ -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();
});
});
}

View File

@@ -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);
}

View File

@@ -12,6 +12,9 @@ public interface IGamePaths
string Data { get; }
/// <summary>Where the JSON vehicle database and the packages layered on it live.</summary>
string VehicleDatabase { get; }
string StarterConfig { get; }
string DiagnosticsLog { get; }

View File

@@ -8,13 +8,24 @@ public interface IProcessHandle
{
bool HasExited { get; }
/// <summary>Once it has ended; null while running, or for a process the starter did not start.</summary>
int? ExitCode { get; }
/// <summary>The last lines of the error stream, when it was captured.</summary>
IReadOnlyList<string> ErrorTail { get; }
Task WaitForExitAsync(CancellationToken cancellationToken = default);
}
public interface IProcessLauncher
{
/// <summary>
/// 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.
/// </summary>
IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
out string? error);
bool captureErrors, out string? error);
bool OpenInShell(string pathOrUrl);

View File

@@ -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; }
/// <summary>The same object as <see cref="Vehicles"/>, typed for the developer tab.</summary>
public VehicleCacheRepository VehicleCache { get; }
/// <summary>The legacy reader behind the cache, for tools that go to the source.</summary>
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; }
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class FavoriteSceneries
{
private readonly IFileSystem _files;
private readonly IDiagnosticsLog _log;
private readonly string _path;
private readonly HashSet<string> _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);
}
}
}

View File

@@ -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;
/// <summary>What a simulator that went down left behind: its log and its crash dumps.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<string> CrashDumpsSince(DateTime startedUtc, string executable)
{
var dumps = new List<string>();
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<string>();
}
}
/// <summary>The end of the simulator's own log, for when it died without a word on its error stream.</summary>
public IReadOnlyList<string> SimulatorLogTail(int count)
{
string path = _paths.FromRoot("log.txt");
try
{
if (!_files.FileExists(path))
return Array.Empty<string>();
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<string>();
}
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
namespace StarterNG.Application;
/// <summary>How a simulator the starter launched has ended.</summary>
public sealed record SimulatorExit(int ExitCode, TimeSpan RunTime, IReadOnlyList<string> Errors)
{
/// <summary>Gone before anyone could have played - however clean the exit code.</summary>
public static readonly TimeSpan QuickExit = TimeSpan.FromSeconds(10);
public bool Quick => RunTime < QuickExit;
public bool Failed => ExitCode != 0 || Quick;
/// <summary>The error stream without its colour codes and blank lines.</summary>
public IReadOnlyList<string> CleanErrors =>
Errors.Select(line => AnsiCode.Replace(line, "").TrimEnd())
.Where(line => line.Length > 0)
.ToList();
/// <summary>The exit code, with what it means where it is a known way to die.</summary>
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");
}

View File

@@ -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);
}
/// <summary>
/// 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.
/// </summary>
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<string> 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);

View File

@@ -86,9 +86,65 @@ public sealed class KeyboardConfig
_savePath = UserConfigPath();
string source = FirstExisting(_savePath, WorkingDirDefaultPath(), BundledDefaultPath());
ParseSource(source);
LoadPolishDescriptions();
Dirty = false;
}
/// <summary>
/// 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.
/// </summary>
private readonly Dictionary<string, string> _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);
}
}
/// <summary>
/// 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.
/// </summary>
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))

View File

@@ -235,6 +235,12 @@ public class Dynamic
public bool Flipped;
/// <summary>
/// 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.
/// </summary>
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()

View File

@@ -117,6 +117,12 @@ public sealed class Cargo
}
}
public void MaxAmounts(IReadOnlyList<ConsistItem> consist)
{
foreach (var c in consist.SelectMany(u => u.Cars))
FillToMax(c);
}
public void RandomAmounts(IReadOnlyList<ConsistItem> consist)
{
foreach (var c in consist.SelectMany(u => u.Cars))

View File

@@ -35,6 +35,9 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
public int Count => _items.Count;
/// <summary>Every vehicle, a multi-unit counted car by car - <see cref="Count"/> counts units.</summary>
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<ConsistItem>
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<ConsistItem>
}
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<ConsistItem>
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<ConsistItem>
Raise();
}
public List<Dynamic> Flatten()
{
var flat = new List<Dynamic>();
foreach (var item in _items)
{
var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse().ToList() : item.Cars;
foreach (var car in cars)
flat.Add(car);
}
return flat;
}
public List<Dynamic> Flatten() => _items.SelectMany(RunningOrder).ToList();
/// <summary>A card's cars in the order they run, which is the order the export writes.</summary>
public static IEnumerable<Dynamic> RunningOrder(ConsistItem item) =>
item.Flipped ? Enumerable.Reverse(item.Cars) : item.Cars;
public void SyncStartingVehicle()
{
@@ -236,45 +233,139 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
public void Insert(int at, ConsistItem item) =>
_items.Insert(Math.Clamp(at, 0, _items.Count), item);
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public static Dynamic CrewCar(ConsistItem item, eDriverType crew) =>
crew == eDriverType.Reardriver ? item.Cars[^1] : item.Cars[0];
/// <summary>Puts the crew into the unit, in the car its cab is in.</summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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<ConsistItem>
int index = _items.IndexOf(item);
if (index < 0) return;
var (first, last) = UnitAround(index);
Turn(first, last);
AutoConnectAll();
Raise();
}
/// <summary>The cards locked together with the one at index, as the range they span.</summary>
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<ConsistItem>
while (last + 1 < _items.Count && HoldsTail(_items[last]))
last++;
return (first, last);
}
/// <summary>
/// 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.
/// </summary>
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<ConsistItem>
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<ConsistItem>
{
Cars = new List<Dynamic> { 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<ConsistItem>
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<Dynamic>();
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<ConsistItem>
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();
}
/// <summary>
/// The Auto button: couplers picked afresh for the whole consist, hand-set ones
/// included, so the back of the train is 0 again.
/// </summary>
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<ConsistItem>
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<ConsistItem>
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<ConsistItem>
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<ConsistItem>
public static Dynamic HeadCar(ConsistItem item) =>
item.Flipped ? item.Cars[^1] : item.Cars[0];
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public static bool IsTurned(Dynamic car) => car.Offset < 0;
public static bool HoldsTail(ConsistItem item) =>
item.Cars.Count > 0 && HoldsNext(TailCar(item));

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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<string> used)
private static void Rename(Dynamic v, HashSet<string> 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;
}
}

View File

@@ -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);
}
}

View File

@@ -27,6 +27,17 @@ public sealed class VehicleCatalog
public Dictionary<string, VehicleTexture> TextureBySkin { get; } = new();
/// <summary>
/// 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).
/// </summary>
private readonly Dictionary<string, VehicleTexture> _textureByFolderSkinModel = new();
private readonly Dictionary<string, VehicleTexture> _textureByFolderSkin = new();
private readonly Dictionary<string, VehicleTexture> _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;
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>"PKP\ET42_V2" in a scenery, "pkp/et42_v2/" in the catalogue.</summary>
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))

View File

@@ -5,9 +5,25 @@ namespace StarterNG.Domain.Vehicles;
public sealed class VehicleEntry
{
public string? Uuid { get; set; }
/// <summary>
/// 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.
/// </summary>
public int SchemaVersion { get; set; }
public List<VehicleGroup> Groups { get; } = new();
public List<VehicleTexture> Textures { get; } = new();
public List<VehicleSet> Sets { get; } = new();
/// <summary>
/// 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.
/// </summary>
public List<string> 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; }
/// <summary>
/// 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.
/// </summary>
public bool Implicit { get; set; }
}
public sealed class VehicleTexture

View File

@@ -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");

View File

@@ -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; }
/// <summary>
/// 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.
/// </summary>
public static string Decode(byte[] bytes)
{
ReadOnlySpan<byte> text = bytes;
if (text.StartsWith(Utf8Bom))
text = text[Utf8Bom.Length..];
return Utf8.IsValid(text) ? Encoding.UTF8.GetString(text) : CodePage1250.GetString(text);
}
private static ReadOnlySpan<byte> Utf8Bom => [0xEF, 0xBB, 0xBF];
private static Encoding Resolve()
{
try

View File

@@ -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())

View File

@@ -18,29 +18,38 @@ public sealed class SystemProcessLauncher : IProcessLauncher
}
public IProcessHandle? Start(string executablePath, IReadOnlyList<string> 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<string> _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<string> 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);
}

View File

@@ -53,13 +53,11 @@ public static class Diagnostics
Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title));
}
public static List<string> CheckInstallation() =>
new InstallationCheck(
AppServices.Current.Paths,
AppServices.Current.Files,
AppServices.Current.Localization,
AppServices.Current.Physics,
AppServices.Current.Library,
AppServices.Current.SettingsStore)
.Run();
public static InstallationCheck InstallationCheck() =>
new(AppServices.Current.Paths,
AppServices.Current.Files,
AppServices.Current.Localization,
AppServices.Current.Physics,
AppServices.Current.Library,
AppServices.Current.SettingsStore);
}

View File

@@ -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;
}
/// <summary>
/// Kept out of <see cref="Run"/>: 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.
/// </summary>
public bool ExecutableMissing()
{
_settings.ResolveExecutable(out var problem);
return problem == ExeProblem.NotFound;
}
}

View File

@@ -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<Scenery> LoadAll(IProgress<SceneryLoadProgress>? 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;
}

View File

@@ -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";
/// <summary>
/// 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.
/// </summary>
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)
/// <summary>
/// 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.
/// </summary>
public (string FileName, IReadOnlyList<string> Arguments) LaunchCommand(string executable,
IReadOnlyList<string> arguments)
{
if (!RunsThroughWine(executable))
return (executable, arguments);
return (Wine()!, new[] { executable }.Concat(arguments).ToList());
}
/// <summary>Wine from the PATH, or null where there is none - and always on Windows.</summary>
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";
}

View File

@@ -43,6 +43,16 @@ public sealed class SettingsPaths
public string InstallationConfigPath() => _paths.FromRoot(ConfigFileName);
/// <summary>
/// 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.
/// </summary>
public string VehicleCacheDirectory() => Path.Combine(UserConfigDirectory(), "vehicles");
/// <summary>The sceneries starred in the list.</summary>
public string FavoritesPath() => Path.Combine(UserConfigDirectory(), "favorites.txt");
public string UserConfigDirectory() =>
Path.GetDirectoryName(UserConfigPath()) ?? _environment.BaseDirectory;
}

View File

@@ -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);

View File

@@ -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<string, VehiclePhysics?> _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;

View File

@@ -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)
{

View File

@@ -8,6 +8,9 @@ public sealed class TexturesTxtParser
{
private const string DeriveCategory = "*";
/// <summary>Goes into the vehicle cache stamp, so a change to what is parsed rebuilds the cache.</summary>
public const int Version = 2;
public VehicleEntry? Parse(string directory, IReadOnlyList<string> 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) ||

View File

@@ -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)
/// <summary>
/// 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.
/// </summary>
public IEnumerable<string> 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<string> Sorted(IEnumerable<string> directories) =>
directories.OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase);
/// <summary>Reads one textures.txt; a file that will not parse is logged and skipped.</summary>
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<string> 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) ?? "";

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class VehicleCacheRepository : IVehicleRepository
{
private const string DatabaseFileName = "vehicles.json";
/// <summary>What the copy was built from, and what makes it stale.</summary>
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;
}
/// <summary>What the last load did, for the developer tab to report.</summary>
public VehicleCacheStatus Status { get; private set; } = VehicleCacheStatus.None;
public int Load(VehicleCatalog catalog) => Load(catalog, useCopy: true);
/// <summary>Throws the copy away and reads the installation again.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<VehicleEntry>? 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<VehicleEntry> Migrate(string stamp)
{
var entries = new List<VehicleEntry>();
foreach (string path in _legacy.Sources())
if (_legacy.Read(path) is { } entry)
entries.Add(entry);
Store(entries, stamp);
return entries;
}
/// <summary>
/// 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.
/// </summary>
private void Store(List<VehicleEntry> 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);
}
/// <summary>
/// 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.
/// </summary>
public sealed record VehicleCacheStatus(bool ServedFromCopy, int Sources, DateTime? Written, string Directory)
{
public static readonly VehicleCacheStatus None = new(false, 0, null, "");
}

View File

@@ -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<VehicleGroupDocument>? Groups { get; set; }
[JsonPropertyName("textures")] public List<VehicleTextureDocument>? Textures { get; set; }
[JsonPropertyName("sets")] public List<VehicleSetDocument>? Sets { get; set; }
[JsonPropertyName("unknown")] public List<string>? Unknown { get; set; }
}
/// <summary>Root of a merged database holding many entries in one file.</summary>
public sealed class VehicleCollectionDocument
{
[JsonPropertyName("vehicles")] public List<VehicleEntryDocument>? 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<VehicleAliasDocument>? 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<string>? 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<string>? 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
{
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
private IEnumerable<string> 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<VehicleEntry> Listed(VehicleEntry? entry) =>
entry is null ? new List<VehicleEntry>() : new List<VehicleEntry> { entry };
}

View File

@@ -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;
/// <summary>
/// Reads and writes <see cref="VehicleEntry"/> in the JSON database format,
/// mapping it on and off the document types that define the format.
/// </summary>
public sealed class VehicleJsonSerializer
{
/// <summary>The format this build writes, and the newest one it can read.</summary>
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<VehicleEntryDocument> EntryInfo =
(JsonTypeInfo<VehicleEntryDocument>)Options.GetTypeInfo(typeof(VehicleEntryDocument));
private static readonly JsonTypeInfo<VehicleCollectionDocument> CollectionInfo =
(JsonTypeInfo<VehicleCollectionDocument>)Options.GetTypeInfo(typeof(VehicleCollectionDocument));
public string Write(VehicleEntry entry) => JsonSerializer.Serialize(ToDocument(entry), EntryInfo);
public string WriteCollection(IEnumerable<VehicleEntry> entries) =>
JsonSerializer.Serialize(
new VehicleCollectionDocument { Vehicles = entries.Select(ToDocument).ToList() },
CollectionInfo);
/// <summary>One entry per file, the form a package is authored in.</summary>
public VehicleEntry? Read(string json)
{
var document = JsonSerializer.Deserialize(json, EntryInfo);
return document is null ? null : FromDocument(document);
}
/// <summary>Many entries in one file, the form a whole database is shipped in.</summary>
public List<VehicleEntry> ReadCollection(string json)
{
var document = JsonSerializer.Deserialize(json, CollectionInfo);
if (document?.Vehicles is null)
return new List<VehicleEntry>();
return document.Vehicles.Select(FromDocument).ToList();
}
/// <summary>
/// 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.
/// </summary>
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<VehicleGroupDocument>())
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<VehicleTextureDocument>())
entry.Textures.Add(FromDocument(texture));
foreach (var set in document.Sets ?? Enumerable.Empty<VehicleSetDocument>())
entry.Sets.Add(new VehicleSet
{
Uuid = set.Uuid,
Mode = set.Mode,
Count = set.Count,
TextureRefs = set.TextureRefs ?? new List<string>()
});
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<VehicleAliasDocument>())
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<T>? OrNull<T>(List<T> items) => items.Count > 0 ? items : null;
}

View File

@@ -37,6 +37,10 @@
Tag="settings"
IsCheckedChanged="Nav_OnCheckedChanged"
Content="{Binding [NavSettings], Source={x:Static starterNg:App.Loc}}" />
<RadioButton x:Name="NavDeveloper" GroupName="nav" Theme="{StaticResource NavTab}"
Tag="developer" IsVisible="False"
IsCheckedChanged="Nav_OnCheckedChanged"
Content="{Binding [NavDeveloper], Source={x:Static starterNg:App.Loc}}" />
<Button Classes="Basic" Click="openLastLogClick" Width="48" Cursor="Hand"
HorizontalContentAlignment="Center"
ToolTip.Tip="{Binding [ShowLatestLogTooltip], Source={x:Static starterNg:App.Loc}}">
@@ -93,6 +97,7 @@
<views:Scenarios x:Name="ScenariosView" />
<views:Depot x:Name="DepotView" IsVisible="False" />
<views:Settings x:Name="SettingsView" IsVisible="False" />
<views:Developer x:Name="DeveloperView" IsVisible="False" />
</Panel>
</DockPanel>
</Window>

View File

@@ -56,6 +56,8 @@ public partial class MainWindow : Window
startButton.AddHandler(PointerReleasedEvent, StartButton_OnRightClick,
RoutingStrategies.Tunnel);
SettingsView.DeveloperToolsChanged += ApplyDeveloperTab;
SettingsView.ThumbnailSizeChanged += () =>
{
DepotView.RefreshConsistView();
@@ -172,7 +174,7 @@ public partial class MainWindow : Window
if (sender is not RadioButton { IsChecked: true } rb)
return;
if (ScenariosView is null || DepotView is null || SettingsView is null)
if (ScenariosView is null || DepotView is null || SettingsView is null || DeveloperView is null)
return;
ShowPage(rb.Tag as string);
@@ -184,11 +186,31 @@ public partial class MainWindow : Window
ScenariosView.IsVisible = page == "scenarios";
DepotView.IsVisible = page == "depot";
SettingsView.IsVisible = page == "settings";
DeveloperView.IsVisible = page == "developer";
if (DeveloperView.IsVisible)
DeveloperView.Refresh();
}
/// <summary>
/// Shows or hides the developer tab. Switching it off while it is the open one
/// hands the window back to the scenarios, so the top bar never ends up with
/// nothing selected.
/// </summary>
private void ApplyDeveloperTab()
{
bool wanted = AppServices.Current.Settings.DeveloperTools;
NavDeveloper.IsVisible = wanted;
if (!wanted && NavDeveloper.IsChecked == true)
NavScenarios.IsChecked = true;
}
private void ApplyInitialNav()
{
foreach (var rb in new[] { NavScenarios, NavDepot, NavSettings })
ApplyDeveloperTab();
foreach (var rb in new[] { NavScenarios, NavDepot, NavSettings, NavDeveloper })
if (rb.IsChecked == true)
{
ShowPage(rb.Tag as string);
@@ -381,16 +403,30 @@ public partial class MainWindow : Window
return trainset.Vehicles.Any(CanStart);
}
private Task<bool> ShowExeProblem(ExeProblem problem, string exe)
{
string key = problem switch
{
ExeProblem.NotExecutable => "ExeNotExecutable",
ExeProblem.WrongPlatform => "ExeWrongPlatform",
_ => "ExeNotFound"
};
return MessageBox.Show(this, $"{exe}\n\n{App.Loc[key]}",
private Task<bool> ShowExeProblem(ExeProblem problem, string exe) =>
MessageBox.Show(this, $"{exe}\n\n{App.Loc[ExeProblemText.Key(problem)]}",
App.Loc["ExeLaunchFailed"], MessageBoxButtons.Ok);
/// <summary>
/// Asks for the simulator when the installation holds none, and keeps the pick
/// straight away, so the question is not back on the next start.
/// </summary>
public async Task OfferExecutablePickAsync()
{
var services = AppServices.Current;
string paragraph = Environment.NewLine + Environment.NewLine;
string message = string.Format(App.Loc["FaultNoExe"], services.Executables.CanonicalName, services.Paths.Root) +
paragraph + App.Loc["FaultNoExePick"];
if (!services.Environment.IsWindows)
message += paragraph + App.Loc["FaultNoExeWine"];
if (!await MessageBox.Show(this, message, App.Loc["FaultNoExeTitle"], MessageBoxButtons.YesNo,
App.Loc["FaultNoExePickNow"], App.Loc["NotNow"]))
return;
NavSettings.IsChecked = true;
if (await SettingsView.BrowseForExecutableAsync())
SettingsView.Save();
}
private async void StartButton_OnClick(object? sender, RoutedEventArgs e) =>
@@ -411,6 +447,15 @@ public partial class MainWindow : Window
freeFly.Click += async (_, _) => await LaunchAsync(saveSettings: true, freeFly: true);
menu.Items.Add(freeFly);
menu.Items.Add(new Separator());
var editor = new MenuItem
{
Header = App.Loc["StartEditor"],
IsEnabled = AppServices.Current.State.CurrentScenery is not null
};
editor.Click += async (_, _) => await LaunchEditorAsync();
menu.Items.Add(editor);
menu.Open(startButton);
}
@@ -432,7 +477,28 @@ public partial class MainWindow : Window
LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
var result = AppServices.Current.StartSimulation.Execute(freeFly, saveSettings);
await FollowStartAsync(AppServices.Current.StartSimulation.Execute(freeFly, saveSettings));
}
/// <summary>
/// The scenery editor, which only some simulator builds have - said up front,
/// since one without it simply does not open it.
/// </summary>
private async Task LaunchEditorAsync()
{
if (AppServices.Current.State.CurrentScenery is not { } scenery)
return;
if (!await MessageBox.Show(this, string.Format(App.Loc["StartEditorWarning"], Path.GetFileName(scenery.Path)),
App.Loc["StartEditor"], MessageBoxButtons.YesNo))
return;
await FollowStartAsync(AppServices.Current.StartSimulation.ExecuteEditor());
}
/// <summary>Reports a start that failed, or steps aside for the simulator that started.</summary>
private async Task FollowStartAsync(SimulationStartResult result)
{
switch (result.Outcome)
{
case SimulationStartOutcome.NothingSelected:
@@ -464,15 +530,16 @@ public partial class MainWindow : Window
GC.Collect();
GC.WaitForPendingFinalizers();
WatchSimulator(result.Process);
WatchSimulator(result.Process, result.ExecutablePath);
}
/// <summary>
/// Brings the starter back when the simulator exits. Adopts an already
/// running simulator when we did not start it ourselves.
/// </summary>
private void WatchSimulator(IProcessHandle? simulator)
private void WatchSimulator(IProcessHandle? simulator, string executable)
{
var started = DateTime.UtcNow;
simulator ??= AppServices.Current.Processes.FindRunning(
Path.GetFileNameWithoutExtension(AppServices.Current.SettingsStore.ResolveExecutable()));
@@ -482,8 +549,56 @@ public partial class MainWindow : Window
return;
}
_ = simulator.WaitForExitAsync().ContinueWith(
_ => Dispatcher.UIThread.Post(RestoreFromSimulator), TaskScheduler.Default);
_ = simulator.WaitForExitAsync().ContinueWith(_ => Dispatcher.UIThread.Post(() =>
{
RestoreFromSimulator();
ReportIfCrashed(simulator, executable, started);
}), TaskScheduler.Default);
}
/// <summary>
/// Says why the simulator is gone when it did not end the way a session does:
/// an error code, or a close so soon after the start that nobody played - a
/// window that only flashed. Its own words come from the error stream, or from
/// its log when that stayed silent, and a minidump it left is pointed at.
/// </summary>
private async void ReportIfCrashed(IProcessHandle simulator, string executable, DateTime startedUtc)
{
if (simulator.ExitCode is not { } code)
return;
var runTime = DateTime.UtcNow - startedUtc;
var exit = new SimulatorExit(code, runTime, simulator.ErrorTail);
if (!exit.Failed)
return;
var words = exit.CleanErrors.Count > 0
? exit.CleanErrors
: AppServices.Current.Aftermath.SimulatorLogTail(12);
var message = new StringBuilder();
message.AppendLine(string.Format(App.Loc["SimExitCode"], exit.DescribeCode()));
if (exit.Quick)
message.AppendLine(string.Format(App.Loc["SimExitQuick"], runTime.TotalSeconds));
if (words.Count > 0)
{
message.AppendLine().AppendLine(App.Loc["SimExitMessages"]);
foreach (string line in words)
message.AppendLine(line);
}
message.AppendLine().Append(App.Loc["SimExitSeeLog"]);
string? dump = AppServices.Current.Aftermath.CrashDumpsSince(startedUtc, executable).FirstOrDefault();
if (dump is not null)
message.AppendLine().AppendLine().AppendLine(string.Format(App.Loc["SimExitDump"], dump))
.Append(App.Loc["SimExitOpenDump"]);
Diagnostics.Log(message.ToString().Replace(Environment.NewLine, " | "));
bool open = await MessageBox.Show(this, message.ToString(), App.Loc["SimExitTitle"],
dump is null ? MessageBoxButtons.Ok : MessageBoxButtons.YesNo);
if (open && dump is not null && Path.GetDirectoryName(Path.GetFullPath(dump)) is { } folder)
AppServices.Current.Processes.OpenInShell(folder);
}
private async void CheckExternalSettings()

View File

@@ -20,12 +20,20 @@ public sealed class SceneryTreeBuilder
_translations = translations;
}
public const string Star = "\u2605 ";
/// <summary>
/// The tree of the list: groups and loose sceneries by name, and the starred ones
/// gathered once more in a group of their own on top, starred wherever they appear.
/// </summary>
public IReadOnlyList<SceneryTreeNode> Build(IReadOnlyList<Scenery> sceneries, bool includeArchival,
string langCode)
string langCode, Func<Scenery, bool>? isFavorite = null,
string favoritesLabel = "")
{
var groups = new Dictionary<string, List<SceneryTreeNode>>(StringComparer.Ordinal);
var groupLabels = new Dictionary<string, string>(StringComparer.Ordinal);
var topLevel = new List<SceneryTreeNode>();
var favorites = new List<SceneryTreeNode>();
for (int i = 0; i < sceneries.Count; i++)
{
@@ -35,7 +43,11 @@ public sealed class SceneryTreeBuilder
_translations.LoadFor(scenery, langCode);
var node = new SceneryTreeNode(scenery.DisplayName, i, Array.Empty<SceneryTreeNode>());
bool favorite = isFavorite?.Invoke(scenery) == true;
var node = new SceneryTreeNode((favorite ? Star : "") + scenery.DisplayName, i,
Array.Empty<SceneryTreeNode>());
if (favorite)
favorites.Add(node);
if (string.IsNullOrEmpty(scenery.Group))
{
@@ -55,7 +67,10 @@ public sealed class SceneryTreeBuilder
foreach (var (group, members) in groups)
topLevel.Add(new SceneryTreeNode(groupLabels[group], -1, Sorted(members)));
return Sorted(topLevel);
var tree = Sorted(topLevel);
if (favorites.Count > 0)
tree.Insert(0, new SceneryTreeNode(Star + favoritesLabel, -1, Sorted(favorites)));
return tree;
}
private static List<SceneryTreeNode> Sorted(IEnumerable<SceneryTreeNode> nodes) =>

View File

@@ -10,8 +10,9 @@
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\starter.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AssemblyVersion>1.4.0.0</AssemblyVersion>
<FileVersion>1.4.0.0</FileVersion>
<!-- Major and minor only: CI adds the patch, the number of commits, so every
build of main is a higher version without anyone bumping it. -->
<VersionPrefix>1.4</VersionPrefix>
<AssemblyName>Starter</AssemblyName>
<Company>eu07.pl</Company>
</PropertyGroup>

View File

@@ -30,55 +30,61 @@
<Border Grid.Column="0" Grid.Row="0" TabIndex="2" Background="{StaticResource CarbonPanelBg}"
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="1"
CornerRadius="2" Padding="6">
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto">
<DockPanel Grid.Row="0" Margin="0,0,0,6" LastChildFill="True">
<CheckBox Name="hideArchivalCheck" DockPanel.Dock="Right" FontSize="12"
VerticalAlignment="Center" Margin="8,0,0,0"
Content="{Binding [HideArchivalVehicles], Source={x:Static starterNg:App.Loc}}"
IsCheckedChanged="HideArchivalCheck_OnChanged" />
<TextBox Name="searchBox" VerticalAlignment="Center"
PlaceholderText="{Binding [Search], Source={x:Static starterNg:App.Loc}}"
TextChanged="SearchBox_OnTextChanged" />
</DockPanel>
<Grid Grid.Row="1" RowDefinitions="Auto,Auto" RowSpacing="6" Margin="0,0,0,6">
<DockPanel Grid.Row="0" LastChildFill="True">
<Button Name="textureBaseButton" DockPanel.Dock="Right" Classes="Flat"
Width="30" Margin="4,0,0,0" Cursor="Hand"
HorizontalContentAlignment="Center"
Click="TextureBaseButton_OnClick"
ToolTip.Tip="{Binding [OpenTextureBase], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="Magnify" Width="15" Height="15" />
</Button>
<ComboBox Name="categoryCombo" HorizontalAlignment="Stretch"
PlaceholderText="{Binding [Category], Source={x:Static starterNg:App.Loc}}"
PointerWheelChanged="Combo_OnPointerWheel"
SelectionChanged="CategoryCombo_OnSelectionChanged" />
</DockPanel>
<ComboBox Name="classCombo" Grid.Row="1" HorizontalAlignment="Stretch"
PlaceholderText="{Binding [Class], Source={x:Static starterNg:App.Loc}}"
<!-- The old starter's column, top to bottom: the archival switch, the category
with the catalogue beside it, the class with its thumbnail, then the
vehicles. The search, which it kept in a window of its own, sits above
the list it fills. -->
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto">
<CheckBox Name="hideArchivalCheck" Grid.Row="0" Margin="0,0,0,6" FontSize="12"
Content="{Binding [HideArchivalVehicles], Source={x:Static starterNg:App.Loc}}"
IsCheckedChanged="HideArchivalCheck_OnChanged" />
<DockPanel Grid.Row="1" Margin="0,0,0,6" LastChildFill="True">
<Button Name="textureBaseButton" DockPanel.Dock="Right" Classes="Flat"
Width="30" Margin="4,0,0,0" Cursor="Hand"
HorizontalContentAlignment="Center"
Click="TextureBaseButton_OnClick"
ToolTip.Tip="{Binding [OpenTextureBase], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="Magnify" Width="15" Height="15" />
</Button>
<ComboBox Name="categoryCombo" HorizontalAlignment="Stretch"
PlaceholderText="{Binding [Category], Source={x:Static starterNg:App.Loc}}"
PointerWheelChanged="Combo_OnPointerWheel"
SelectionChanged="ClassCombo_OnSelectionChanged" />
</Grid>
SelectionChanged="CategoryCombo_OnSelectionChanged" />
</DockPanel>
<Border Name="miniPreviewPanel" Grid.Row="3" Height="46"
Margin="0,6,0,0"
Background="{StaticResource CarbonInputBg}"
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="1"
CornerRadius="2" Cursor="Hand"
PointerPressed="MiniPreview_OnPointerPressed">
<Image Name="miniPreview" Stretch="Uniform" Margin="6" />
</Border>
<ComboBox Name="classCombo" Grid.Row="2" Margin="0,0,0,6" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
PlaceholderText="{Binding [Class], Source={x:Static starterNg:App.Loc}}"
PointerWheelChanged="Combo_OnPointerWheel"
SelectionChanged="ClassCombo_OnSelectionChanged" />
<Button Name="addVehicleButton" Grid.Row="4" Classes="Flat Accent"
Margin="0,8,0,0" Cursor="Hand" IsEnabled="False"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Click="AddVehicleButton_OnClick"
Content="{Binding [AddVehicle], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="searchBox" Grid.Row="3" Margin="0,0,0,6"
PlaceholderText="{Binding [SearchAllVehicles], Source={x:Static starterNg:App.Loc}}"
TextChanged="SearchBox_OnTextChanged" />
<ListBox Name="vehicleListBox" Grid.Row="2" Classes="Compact"
<ListBox Name="vehicleListBox" Grid.Row="4" Classes="Compact" MinHeight="60"
SelectionChanged="VehicleListBox_OnSelectionChanged"
DoubleTapped="VehicleListBox_OnDoubleTapped"
PointerPressed="VehicleListBox_OnPointerPressed" />
<!-- The thumbnail - drag it onto the consist - and Add share a line, so
the list keeps the height. -->
<Grid Grid.Row="5" ColumnDefinitions="*,Auto" ColumnSpacing="6" Margin="0,6,0,0">
<Border Name="miniPreviewPanel" Height="34"
Background="{StaticResource CarbonInputBg}"
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="1"
CornerRadius="2" Cursor="Hand"
PointerPressed="MiniPreview_OnPointerPressed">
<Image Name="miniPreview" Stretch="Uniform" Margin="3" />
</Border>
<Button Name="addVehicleButton" Grid.Column="1" Classes="Flat Accent"
MinWidth="80" Cursor="Hand" IsEnabled="False"
VerticalAlignment="Stretch" HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Click="AddVehicleButton_OnClick"
Content="{Binding [AddVehicle], Source={x:Static starterNg:App.Loc}}" />
</Grid>
</Grid>
</Border>

View File

@@ -56,7 +56,7 @@ public partial class Depot : UserControl
public Depot()
{
_info = new VehicleInfo(_db);
_info = new VehicleInfo(_db, AppServices.Current.Physics);
_consist = new Consist(_db, _info);
_cargo = new Cargo(_info, _rng);
_consist.Changed += RebuildConsist;
@@ -71,14 +71,20 @@ public partial class Depot : UserControl
_browser = new VehicleBrowser(categoryCombo, classCombo, vehicleListBox, searchBox,
hideArchivalCheck, miniPreview, addVehicleButton, _db, _minis,
() => TextureBaseButton_OnClick(null, null!));
() => TextureBaseButton_OnClick(null, null!))
{
AddMany = (texture, count) => AddTexture(texture, count)
};
_drag = new ConsistDragging(consistStack, consistScroll, consistOverlay,
consistDropTarget, vehicleListBox, miniPreviewPanel,
_consist, () => _browser.Selected, InsertTextureAt, _browser.SelectInBrowser);
_cards = new VehicleCards(consistStack, _db, _consist, _cargo, _minis, _hand,
RebuildConsist, () => _details.Refresh(), _browser.SelectInBrowser, _drag.ArmCardDrag);
RebuildConsist, () => _details.Refresh(), _browser.SelectInBrowser, _drag.ArmCardDrag)
{
AddCopies = AddCopiesAfter
};
// Hidden still scrolls - it only drops the bar itself.
MiniTextures.Sharp(miniPreview);
@@ -186,7 +192,12 @@ public partial class Depot : UserControl
}
/// <summary>Redraws the consist cards, e.g. after the thumbnail size changed.</summary>
public void RefreshConsistView() => RebuildConsist();
/// <summary>Redraws what depends on the thumbnail size: the consist and the class list.</summary>
public void RefreshConsistView()
{
_browser.InitClassComboTemplates();
RebuildConsist();
}
private void RebuildConsist()
{
@@ -213,9 +224,10 @@ public partial class Depot : UserControl
(_consist.SelectedCar is null || !moved.Cars.Contains(_consist.SelectedCar)))
_cards.Activate(moved);
// The stats are read off the trainset, so it is written back first.
WriteBackToScenery();
_details.Refresh();
UpdateTrainStats();
WriteBackToScenery();
// The cards are thrown away and rebuilt on every change, so a keyboard-driven
// edit has to be handed its card back or focus escapes the strip.
@@ -242,7 +254,7 @@ public partial class Depot : UserControl
StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields(
_consist.EditingTrainset,
_info.PhysicsFor,
car => _db.TextureForSkin(car.SkinFile) is { } t ? VehicleInfo.CategoryOf(t) : null,
car => _db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile) is { } t ? VehicleInfo.CategoryOf(t) : null,
AppServices.Current.LoadWeights.Table.WeightOf));
}
@@ -572,17 +584,15 @@ public partial class Depot : UserControl
var item = new ConsistItem
{
Cars = unit.Select(NewVehicle).ToList(),
Grouped = unit.Count > 1,
Driver = _consist.Selected.Driver,
Flipped = _consist.Selected.Flipped
Grouped = unit.Count > 1
};
_consist[i] = item;
_consist.Replace(i, item);
_consist.Selected = item;
_consist.AutoConnectAll();
RebuildConsist();
}
private void AddTexture(VehicleTexture texture)
private void AddTexture(VehicleTexture texture, int count = 1)
{
int at = _consist.Count;
if (_consist.Selected != null)
@@ -590,53 +600,47 @@ public partial class Depot : UserControl
int si = _consist.IndexOf(_consist.Selected);
if (si >= 0) at = si + 1;
}
InsertTextureAt(texture, at);
InsertTexturesAt(texture, at, count);
}
private void InsertTextureAt(VehicleTexture texture, int at)
/// <summary>Copies of the vehicle on a card - a whole unit for one of a set - right after it.</summary>
private void AddCopiesAfter(ConsistItem item, int count)
{
int index = _consist.IndexOf(item);
if (index < 0 || item.Cars.Count == 0 || _db.TextureFor(item.Cars[0].DataFolder, item.Cars[0].SkinFile, item.Cars[0].MmdFile) is not { } texture)
return;
InsertTexturesAt(texture, index + 1, count);
}
private void InsertTextureAt(VehicleTexture texture, int at) => InsertTexturesAt(texture, at, 1);
/// <summary>The vehicle count times in a row from at - each staffed as added - and one redraw.</summary>
private void InsertTexturesAt(VehicleTexture texture, int at, int count)
{
var set = _db.ResolveSet(texture);
var unit = set ?? new List<VehicleTexture> { texture };
var item = new ConsistItem
{
Cars = unit.Select(NewVehicle).ToList(),
Grouped = unit.Count > 1,
Driver = eDriverType.Nobody,
Flipped = false
};
at = Math.Clamp(at, 0, _consist.Count);
MatchOccupancy(item, at);
_consist.Insert(at, item);
_consist.Selected = item;
for (int n = 0; n < count; n++, at++)
{
var item = new ConsistItem
{
Cars = unit.Select(NewVehicle).ToList(),
Grouped = unit.Count > 1,
Driver = eDriverType.Nobody,
Flipped = false
};
_consist.Staff(item, at);
_consist.Insert(at, item);
_consist.Selected = item;
}
_consist.AutoConnectAll();
RebuildConsist();
}
private void MatchOccupancy(ConsistItem item, int position)
{
item.Driver = eDriverType.Nobody;
var lead = item.Cars.FirstOrDefault();
if (lead is null) return;
foreach (var car in item.Cars)
car.DriverType = eDriverType.Nobody;
var tex = _db.TextureForSkin(lead.SkinFile);
string? cat = tex != null ? VehicleInfo.CategoryOf(tex) : null;
if (!VehicleInfo.IsPoweredCategory(cat))
return;
bool staffed = _consist.Any(i =>
i.Driver is eDriverType.Headdriver or eDriverType.Reardriver);
if (position == 0 || !staffed)
{
item.Driver = eDriverType.Headdriver;
lead.DriverType = eDriverType.Headdriver;
}
}
private async void TextureBaseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (TopLevel.GetTopLevel(this) is not Window owner) return;

View File

@@ -0,0 +1,47 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
namespace StarterNG.Views;
/// <summary>Asks how many times to add a vehicle, for the browser and the consist alike.</summary>
internal static class AddManyFlyout
{
public const int Max = 100;
public static void Show(Control anchor, PlacementMode placement, Action<int> add)
{
var count = new NumericUpDown
{
Minimum = 1, Maximum = Max, Increment = 1, FormatString = "0", Value = 2, MinWidth = 120
};
var confirm = new Button { Content = App.Loc["AddVehicle"], HorizontalAlignment = HorizontalAlignment.Right };
confirm.Classes.Add("Flat");
confirm.Classes.Add("Accent");
var panel = new StackPanel { Spacing = 6, Margin = new Thickness(8), MinWidth = 160 };
panel.Children.Add(new TextBlock { Text = App.Loc["AddManyCount"], FontWeight = FontWeight.Bold, FontSize = 12 });
panel.Children.Add(count);
panel.Children.Add(confirm);
var flyout = new Flyout { Content = panel, Placement = placement };
void Commit()
{
flyout.Hide();
add(Math.Clamp((int)(count.Value ?? 1), 1, Max));
}
confirm.Click += (_, _) => Commit();
count.KeyDown += (_, e) =>
{
if (e.Key != Key.Enter) return;
e.Handled = true;
Commit();
};
flyout.ShowAt(anchor);
count.Focus();
}
}

View File

@@ -5,6 +5,7 @@ using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.VisualTree;
using StarterNG.Classes;
using StarterNG.Domain;
@@ -103,9 +104,9 @@ public sealed class ConsistDragging
public void MiniPreviewPressed(object? sender, PointerPressedEventArgs e)
{
if (_browserSelected() is null || !e.GetCurrentPoint(miniPreviewPanel).Properties.IsLeftButtonPressed)
if (_browserSelected() is not { } texture || !e.GetCurrentPoint(miniPreviewPanel).Properties.IsLeftButtonPressed)
return;
ArmVehicleDrag(e, miniPreviewPanel, _browserSelected());
ArmVehicleDrag(e, miniPreviewPanel, texture);
}
public void VehicleListPressed(object? sender, PointerPressedEventArgs e)
@@ -113,10 +114,7 @@ public sealed class ConsistDragging
if (!e.GetCurrentPoint(vehicleListBox).Properties.IsLeftButtonPressed)
return;
var hit = e.Source as Control;
while (hit != null && hit is not ListBoxItem)
hit = hit.Parent as Control;
if (hit is ListBoxItem { Tag: VehicleTexture texture })
if ((e.Source as Visual).FindAncestorOfType<ListBoxItem>(includeSelf: true) is { Tag: VehicleTexture texture })
ArmVehicleDrag(e, vehicleListBox, texture);
}

View File

@@ -0,0 +1,171 @@
using System.Collections.Generic;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Material.Icons;
using StarterNG.Classes;
namespace StarterNG.Views;
public enum CouplerState
{
/// <summary>Nothing connected between the two vehicles.</summary>
Free,
/// <summary>Coupled the ordinary way: mechanically, the brake pipe and so on.</summary>
Coupled,
/// <summary>Coupled with multiple-unit control.</summary>
MultipleUnit,
/// <summary>Not to be parted in shunting: workshop lock, or a locked coupler.</summary>
Permanent,
/// <summary>The back of the train at 0 - the simulator puts up the end signals.</summary>
EndSignals,
/// <summary>The back of the train at anything but 0 - no end signals, and nothing else.</summary>
NoEndSignals
}
/// <summary>
/// How a coupler is drawn on the consist strip. Under an icon for the coupler as a
/// whole sits a block of squares, one place and colour per connection, so what is
/// coupled reads at a glance; the coupler editor shows the same colours as its
/// legend.
/// </summary>
public static class CouplerLook
{
private static readonly IBrush Steel = Solid("#C3CBD3");
private static readonly IBrush Dim = Solid("#6B737B");
private static readonly IBrush Red = Solid("#FF5A5A");
private static readonly IBrush Amber = Solid("#F0A030");
/// <summary>One per bit of <see cref="CouplingBits.BitKeys"/>, in the same order.</summary>
private static readonly IBrush[] BitBrushes =
{
Solid("#000000"), // mechanical - black, outlined to show on the dark strip
Solid("#FF3B3B"), // brake pipe - red
Solid("#3D9BFF"), // multiple-unit control - blue
Solid("#FF8C1A"), // high voltage - orange
Solid("#1FD67A"), // gangway - green, as an exit sign
Solid("#FFD60A"), // auxiliary air - yellow
Solid("#F2F2F2"), // heating - white, as steam
Solid("#9AA3AC") // workshop lock - grey
};
private static readonly IBrush Outline = Solid("#C3CBD3");
private static readonly IBrush EmptySlot = Solid("#4A525A");
private static IBrush Solid(string color) => new SolidColorBrush(Color.Parse(color));
public static CouplerState StateOf(Coupling coupling, bool trailing)
{
int flags = coupling.AbsFlags;
if (trailing)
return flags == 0 ? CouplerState.EndSignals : CouplerState.NoEndSignals;
if (flags == 0)
return CouplerState.Free;
if (coupling.Locked || (flags & Coupling.WorkshopLock) != 0)
return CouplerState.Permanent;
return (flags & Coupling.ControlMU) != 0 ? CouplerState.MultipleUnit : CouplerState.Coupled;
}
public static MaterialIconKind Icon(CouplerState state) => state switch
{
CouplerState.Free => MaterialIconKind.LinkVariantOff,
CouplerState.Permanent => MaterialIconKind.LinkLock,
CouplerState.EndSignals => MaterialIconKind.RecordCircle,
CouplerState.NoEndSignals => MaterialIconKind.AlertCircleOutline,
_ => MaterialIconKind.LinkVariant
};
public static IBrush Brush(CouplerState state) => state switch
{
CouplerState.Free => Dim,
CouplerState.EndSignals => Red,
CouplerState.NoEndSignals => Amber,
_ => Steel
};
/// <summary>The bits that are set, as indexes into <see cref="CouplingBits.BitKeys"/>.</summary>
public static IEnumerable<int> SetBits(Coupling coupling) =>
Enumerable.Range(0, CouplingBits.BitKeys.Length).Where(i => coupling.Has(1 << i));
/// <summary>
/// The connections as a 2 by 4 block of squares laid out like the coupler
/// editor's checkboxes, so a square's place says what it is as much as its
/// colour; an empty place is a dot.
/// </summary>
public static void FillSlots(Grid slots, Coupling coupling)
{
slots.Children.Clear();
for (int i = 0; i < CouplingBits.BitKeys.Length; i++)
{
Control slot = coupling.Has(1 << i)
? Swatch(i, 10)
: new Border
{
Width = 4, Height = 4, CornerRadius = new CornerRadius(2), Background = EmptySlot,
HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center
};
Grid.SetRow(slot, i / 2);
Grid.SetColumn(slot, i % 2);
slots.Children.Add(slot);
}
}
public static Grid Slots() => new()
{
ColumnDefinitions = new ColumnDefinitions("12,12"),
RowDefinitions = new RowDefinitions("12,12,12,12"),
HorizontalAlignment = HorizontalAlignment.Center
};
/// <summary>A connection's name with its colour beside it, for the editor's checkboxes.</summary>
public static Control BitLabel(int index) => new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 6,
Children =
{
Swatch(index, 10),
new TextBlock { Text = App.Loc[CouplingBits.BitKeys[index]], VerticalAlignment = VerticalAlignment.Center }
}
};
private static Border Swatch(int index, double size) => new()
{
Width = size, Height = size, CornerRadius = new CornerRadius(1.5),
Background = BitBrushes[index],
// Black needs an edge to show on the dark strip; the rest have one in their own colour.
BorderBrush = index == 0 ? Outline : BitBrushes[index],
BorderThickness = new Thickness(1),
HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center
};
/// <summary>What the coupler connects, by the names the coupler editor uses.</summary>
public static IReadOnlyList<string> Connections(Coupling coupling) =>
SetBits(coupling).Select(i => App.Loc[CouplingBits.BitKeys[i]]).ToList();
/// <summary>The tooltip: the code as the scenery has it, then what it means.</summary>
public static string Describe(Coupling coupling, bool trailing)
{
string connections = string.Join(", ", Connections(coupling));
string meaning = StateOf(coupling, trailing) switch
{
CouplerState.Free => App.Loc["CouplerFree"],
CouplerState.EndSignals => App.Loc["CouplerEndSignals"],
CouplerState.NoEndSignals => App.Loc["CouplerNoEndSignals"],
_ => string.Format(App.Loc["CouplerConnects"], connections)
};
var lines = new List<string> { string.Format(App.Loc["CouplerCode"], coupling.Flags), meaning };
if (coupling.Locked && coupling.AbsFlags != 0)
lines.Add(App.Loc["CouplerLocked"]);
lines.Add(App.Loc["CouplerClickToEdit"]);
return string.Join("\n", lines);
}
}

View File

@@ -11,6 +11,7 @@ using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Avalonia.VisualTree;
using StarterNG.Classes;
using StarterNG.Domain;
@@ -51,6 +52,9 @@ public sealed class VehicleBrowser
public Action<VehicleTexture>? TextureSelected { get; set; }
/// <summary>Adds the vehicle to the consist the given number of times.</summary>
public Action<VehicleTexture, int>? AddMany { get; set; }
private bool _suppress;
private bool _syncingCombos;
@@ -90,24 +94,15 @@ public sealed class VehicleBrowser
private const int MiniPreviewHeight = 54;
private const int CompactThumbHeight = 24;
// In the old starter's order: traction first, then the wagons, then the rest.
private static readonly (string LocKey, Func<string?, bool> Match)[] CategoryDefs =
{
("CatElectricLoco", c => c == "e"),
("CatDieselLoco", c => c == "s"),
("CatSteamLoco", c => c == "p"),
("CatEMU", c => c == "z"),
("CatRailbus", c => c == "a"),
("CatDraisine", c => c == "d"),
("CatWork", c => c == "r"),
("CatPrototype", c => c == "x"),
("CatTram", c => c == "t"),
("CatCar", c => c == "o"),
("CatBus", c => c == "b"),
("CatTruck", c => c == "c"),
("CatPeople", c => c == "h"),
("CatAnimals", c => c == "f"),
("CatEMU", c => c == "z"),
("CatWagonsA", c => c == "A"),
("CatWagonsB", c => c == "B"),
("CatWagonsC", c => c == "C"),
@@ -133,6 +128,15 @@ public sealed class VehicleBrowser
("CatWagonsX", c => c == "X"),
("CatWagonsY", c => c == "Y"),
("CatWagonsZ", c => c == "Z"),
("CatWork", c => c == "r"),
("CatDraisine", c => c == "d"),
("CatTram", c => c == "t"),
("CatCar", c => c == "o"),
("CatBus", c => c == "b"),
("CatTruck", c => c == "c"),
("CatPeople", c => c == "h"),
("CatAnimals", c => c == "f"),
("CatPrototype", c => c == "x"),
("CatOther", IsOtherCat),
};
@@ -144,41 +148,76 @@ public sealed class VehicleBrowser
string.IsNullOrEmpty(c) ||
(c is { Length: 1 } && char.IsLower(c[0]) && !NamedLowerCats.Contains(c));
/// <summary>
/// Only the categories something is listed in - with the archival ones hidden, the
/// ones holding nothing else go too - keeping the pick and its class where they stay.
/// </summary>
public void PopulateCategoryCombo()
{
int keep = categoryCombo.SelectedIndex;
var keep = (categoryCombo.SelectedItem as ComboBoxItem)?.Tag;
string? keepClass = _classFilter;
var present = _db.Textures
.Where(Listable)
.Select(VehicleInfo.CategoryOf)
.Distinct()
.ToList();
_suppress = true;
categoryCombo.Items.Clear();
foreach (var (locKey, match) in CategoryDefs)
categoryCombo.Items.Add(new ComboBoxItem { Content = App.Loc[locKey], Tag = match });
categoryCombo.SelectedIndex = keep >= 0 && keep < categoryCombo.Items.Count ? keep : -1;
if (present.Any(match))
categoryCombo.Items.Add(new ComboBoxItem { Content = App.Loc[locKey], Tag = match });
categoryCombo.SelectedItem = categoryCombo.Items.OfType<ComboBoxItem>()
.FirstOrDefault(item => ReferenceEquals(item.Tag, keep));
_suppress = false;
_categoryFilter = categoryCombo.SelectedItem is ComboBoxItem { Tag: Func<string?, bool> f }
? f : null;
RebuildClassCombo();
RebuildClassCombo(keepClass);
}
private void RebuildClassCombo()
/// <summary>
/// Refills the classes. Within a category one is always picked - the one kept, or
/// else the first - so the list shows its vehicles straight away, as the old starter
/// did, instead of every vehicle of the category at once.
/// </summary>
private void RebuildClassCombo(string? keepClass = null)
{
_suppress = true;
FillClassCombo();
classCombo.SelectedIndex = -1;
var classes = classCombo.Items.OfType<string>().ToList();
classCombo.SelectedItem =
classes.FirstOrDefault(cls => string.Equals(cls, keepClass, StringComparison.OrdinalIgnoreCase))
?? (_categoryFilter is not null ? classes.FirstOrDefault() : null);
_suppress = false;
_classFilter = null;
_classFilter = classCombo.SelectedItem as string;
Rebuild();
}
/// <summary>A texture the browser lists at all: no set follower, no hidden archival one.</summary>
private bool Listable(VehicleTexture texture) =>
!_db.IsSetFollower(texture) &&
!(AppServices.Current.Settings.HideArchivalVehicles && texture.ResolvedArchived);
/// <summary>
/// The classes with their thumbnail above the name: in the open list at the size
/// the consist cards use, called again when that size changes; in the closed box
/// at a fixed modest one, so the class picked is seen, as in the old starter,
/// without taking the height the vehicle list needs.
/// </summary>
public void InitClassComboTemplates()
{
int height = VehicleCardStyle.ThumbHeight;
classCombo.ItemTemplate = new FuncDataTemplate<string>(
(cls, _) => ClassComboContent(cls, large: true), false);
(cls, _) => ClassComboContent(cls, height, stacked: true), false);
classCombo.SelectionBoxItemTemplate = new FuncDataTemplate<string>(
(cls, _) => ClassComboContent(cls, large: false), false);
(cls, _) => ClassComboContent(cls, PickedClassThumbHeight, stacked: true), false);
}
private const int PickedClassThumbHeight = 30;
private void FillClassCombo()
{
classCombo.Items.Clear();
@@ -216,14 +255,13 @@ public sealed class VehicleBrowser
_db.Textures.FirstOrDefault(t => string.Equals(VehicleInfo.ClassOf(t), cls, StringComparison.OrdinalIgnoreCase))
is { } t ? VehicleInfo.CategoryOf(t) : null;
private Control ClassComboContent(string cls, bool large)
private Control ClassComboContent(string cls, int height, bool stacked)
{
int height = large ? MiniPreviewHeight : CompactThumbHeight;
var cell = new StackPanel
{
Orientation = large ? Orientation.Vertical : Orientation.Horizontal,
Spacing = large ? 2 : 8,
HorizontalAlignment = large ? HorizontalAlignment.Center : HorizontalAlignment.Left,
Orientation = stacked ? Orientation.Vertical : Orientation.Horizontal,
Spacing = stacked ? 2 : 8,
HorizontalAlignment = stacked ? HorizontalAlignment.Center : HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center
};
var bmp = _minis.Get(cls, height);
@@ -238,7 +276,7 @@ public sealed class VehicleBrowser
cell.Children.Add(new TextBlock
{
Text = cls,
HorizontalAlignment = large ? HorizontalAlignment.Center : HorizontalAlignment.Left,
HorizontalAlignment = stacked ? HorizontalAlignment.Center : HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
TextAlignment = TextAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis
@@ -248,9 +286,8 @@ public sealed class VehicleBrowser
private IEnumerable<string> ClassesForCategory(Func<string?, bool>? category) =>
_db.Textures
.Where(Listable)
.Where(t => category == null || category(VehicleInfo.CategoryOf(t)))
.Where(t => !_db.IsSetFollower(t))
.Where(t => !AppServices.Current.Settings.HideArchivalVehicles || !t.ResolvedArchived)
.Select(VehicleInfo.ClassOf)
.Where(s => !string.IsNullOrEmpty(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
@@ -310,7 +347,10 @@ public sealed class VehicleBrowser
bool hasSearch = search.Length > 0;
if (_categoryFilter == null && _classFilter == null && !hasSearch)
{
AddNote(App.Loc["BrowserHint"]);
return;
}
var matched = _db.Textures
.Where(t => PassesFilters(t, search, hasSearch))
@@ -329,13 +369,16 @@ public sealed class VehicleBrowser
}
if (vehicleListBox.Items.Count == 0)
vehicleListBox.Items.Add(new ListBoxItem
{
Content = App.Loc["NoVehicles"],
IsEnabled = false
});
AddNote(hasSearch ? string.Format(App.Loc["BrowserNoMatch"], search) : App.Loc["NoVehicles"]);
}
private void AddNote(string text) =>
vehicleListBox.Items.Add(new ListBoxItem
{
Content = new TextBlock { Text = text, TextWrapping = TextWrapping.Wrap, Opacity = 0.7 },
IsEnabled = false
});
public void VehicleListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (vehicleListBox.SelectedItem is ListBoxItem { Tag: VehicleTexture texture })
@@ -360,20 +403,24 @@ public sealed class VehicleBrowser
private void List_OnContextRequested(object? sender, ContextRequestedEventArgs e)
{
var hit = e.Source as Control;
while (hit != null && hit is not ListBoxItem)
hit = hit.Parent as Control;
if (hit is not ListBoxItem { Tag: VehicleTexture texture })
if ((e.Source as Visual).FindAncestorOfType<ListBoxItem>(includeSelf: true) is not
{ Tag: VehicleTexture texture } hit)
return;
BuildBrowserMenu(texture).Open(hit);
BuildBrowserMenu(texture, hit).Open(hit);
e.Handled = true;
}
private ContextMenu BuildBrowserMenu(VehicleTexture texture)
private ContextMenu BuildBrowserMenu(VehicleTexture texture, Control anchor)
{
var menu = new ContextMenu();
var addMany = new MenuItem { Header = App.Loc["AddMany"], IsEnabled = AddMany is not null };
addMany.Click += (_, _) =>
AddManyFlyout.Show(anchor, PlacementMode.Right, count => AddMany?.Invoke(texture, count));
menu.Items.Add(addMany);
menu.Items.Add(new Separator());
var copy = new MenuItem { Header = App.Loc["CopyTextureName"] };
copy.Click += async (_, _) =>
{
@@ -476,23 +523,19 @@ public sealed class VehicleBrowser
private bool PassesFilters(VehicleTexture t, string search, bool hasSearch)
{
if (!Listable(t))
return false;
// A search looks through everything; the category and the class narrow the
// browsing, not the finding.
if (hasSearch)
return Matches(t, search);
if (_categoryFilter != null && !_categoryFilter(VehicleInfo.CategoryOf(t)))
return false;
if (_classFilter != null &&
!string.Equals(VehicleInfo.ClassOf(t), _classFilter, StringComparison.OrdinalIgnoreCase))
return false;
if (_db.IsSetFollower(t))
return false;
if (AppServices.Current.Settings.HideArchivalVehicles && t.ResolvedArchived)
return false;
if (hasSearch && !Matches(t, search))
return false;
return true;
return _classFilter == null ||
string.Equals(VehicleInfo.ClassOf(t), _classFilter, StringComparison.OrdinalIgnoreCase);
}
private bool Matches(VehicleTexture t, string f)
@@ -514,15 +557,20 @@ public sealed class VehicleBrowser
// called a vehicle something none of the other panels did.
private static string BrowserName(VehicleTexture texture) => Consist.Base(texture.Skinfile);
private string BrowserLabel(VehicleTexture texture, IReadOnlyList<VehicleTexture>? set)
// The file name alone, as the old starter listed it - the operator is in the
// texture panel - with a faint count for a set of cars.
private static Control BrowserLabel(VehicleTexture texture, IReadOnlyList<VehicleTexture>? set)
{
string name = BrowserName(texture);
var name = new TextBlock { Text = BrowserName(texture) };
if (set is not { Count: > 1 })
return name;
if (!string.IsNullOrEmpty(texture.Meta?.Operator))
name += $" · {texture.Meta!.Operator}";
if (set is { Count: > 1 })
name += $" [{set.Count}]";
return name;
return new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 6,
Children = { name, new TextBlock { Text = $"×{set.Count}", Opacity = 0.55 } }
};
}
public void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e)
@@ -537,12 +585,12 @@ public sealed class VehicleBrowser
if (_suppress) return;
AppServices.Current.Settings.HideArchivalVehicles = hideArchivalCheck.IsChecked ?? true;
AppServices.Current.SettingsStore.Save();
Rebuild();
PopulateCategoryCombo();
}
public void SelectInBrowser(Dynamic car)
{
var texture = _db.TextureForSkin(car.SkinFile);
var texture = _db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile);
if (texture is null)
return;
@@ -587,6 +635,7 @@ public sealed class VehicleBrowser
foreach (var obj in vehicleListBox.Items)
if (obj is ListBoxItem { Tag: VehicleTexture t } entry &&
string.Equals(t.Skinfile, texture.Skinfile, StringComparison.OrdinalIgnoreCase) &&
string.Equals(t.Model, texture.Model, StringComparison.OrdinalIgnoreCase) &&
string.Equals(t.Directory, texture.Directory, StringComparison.OrdinalIgnoreCase))
{
vehicleListBox.SelectedItem = entry;

View File

@@ -33,6 +33,9 @@ public sealed class VehicleCards
private readonly List<(Border Frame, ConsistItem Item, Dynamic Car)> _memberChrome = new();
/// <summary>Puts copies of the vehicle on a card right after it, the given number of times.</summary>
public Action<ConsistItem, int>? AddCopies { get; set; }
private readonly List<(Button Badge, ConsistItem Item)> _driverChrome = new();
public Dynamic? PressedCar { get; set; }
@@ -403,40 +406,67 @@ public sealed class VehicleCards
flyout.ShowAt(anchor);
}
/// <summary>
/// The coupler after a unit, drawn by its state - free, coupled, multiple-unit,
/// permanent, and at the back of the train whether the end signals go up.
/// </summary>
public Control BuildCoupler(ConsistItem item, bool trailing = false)
{
var glyph = new TextBlock
{
Text = trailing ? "╡" : "≣",
FontSize = 18,
Opacity = trailing ? 0.55 : 0.7,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center
};
var coupling = Consist.TailCar(item).Coupling;
var icon = new MaterialIcon { Width = 18, Height = 18, HorizontalAlignment = HorizontalAlignment.Center };
var slots = CouplerLook.Slots();
var coupler = new Button
{
Padding = new Thickness(4, 6, 4, 6),
Padding = new Thickness(3, 6, 3, 6),
MinWidth = 0,
Cursor = _hand,
VerticalAlignment = VerticalAlignment.Center,
Content = glyph,
Flyout = new Flyout
{
Content = BuildCouplingBox(item),
Placement = PlacementMode.Top
}
Content = new StackPanel { Spacing = 4, Children = { icon, slots } }
};
coupler.Classes.Add("Basic");
ToolTip.SetTip(coupler, $"{App.Loc["Coupling"]} — {App.Loc["TipReorder"]}");
// Between vehicles the squares say it all; the icon is for the back of the train.
icon.IsVisible = trailing;
void Restyle()
{
var state = CouplerLook.StateOf(coupling, trailing);
icon.Kind = CouplerLook.Icon(state);
icon.Foreground = CouplerLook.Brush(state);
CouplerLook.FillSlots(slots, coupling);
ToolTip.SetTip(coupler, CouplerLook.Describe(coupling, trailing));
}
Restyle();
coupler.Flyout = new Flyout
{
Content = BuildCouplingBox(item, Restyle),
Placement = PlacementMode.Top
};
return coupler;
}
private Control BuildCouplingBox(ConsistItem item)
/// <summary>
/// Edits the coupler in place, so the flyout stays open while bits are
/// ticked; <paramref name="changed"/> redraws the coupler it hangs off.
/// </summary>
private Control BuildCouplingBox(ConsistItem item, Action changed)
{
var d = Consist.TailCar(item);
int unitIdx = _consist.IndexOf(item);
var panel = new StackPanel { Spacing = 2 };
var checks = new List<(CheckBox Check, int Bit)>();
bool syncing = false;
void Changed()
{
d.CouplerSetByHand = true;
changed();
_refreshDetails();
}
var copy = new Button
{
@@ -453,7 +483,11 @@ public sealed class VehicleCards
{
if (unitIdx <= 0) return;
d.Coupling.Flags = Consist.TailCar(_consist[unitIdx - 1]).Coupling.Flags;
_redraw();
syncing = true;
foreach (var (check, bit) in checks)
check.IsChecked = d.Coupling.Has(bit);
syncing = false;
Changed();
};
var header = new DockPanel { Margin = new Thickness(0, 0, 0, 2) };
@@ -482,26 +516,25 @@ public sealed class VehicleCards
int bit = 1 << i;
var check = new CheckBox
{
Content = App.Loc[CouplingBits.BitKeys[i]],
Content = CouplerLook.BitLabel(i),
IsChecked = d.Coupling.Has(bit),
FontSize = 12
};
check.IsCheckedChanged += (_, _) => d.Coupling.Set(bit, check.IsChecked == true);
check.IsCheckedChanged += (_, _) =>
{
if (syncing) return;
d.Coupling.Set(bit, check.IsChecked == true);
Changed();
};
checks.Add((check, bit));
Grid.SetRow(check, i / 2);
Grid.SetColumn(check, i % 2);
grid.Children.Add(check);
}
panel.Children.Add(grid);
var thermo = new CheckBox
{
Content = App.Loc["ThermoAmbient"],
IsChecked = d.Coupling.ThermoDynamic,
FontSize = 12,
Margin = new Thickness(0, 2, 0, 0)
};
thermo.IsCheckedChanged += (_, _) => d.Coupling.ThermoDynamic = thermo.IsChecked == true;
panel.Children.Add(thermo);
// The ambient coolant flag rides in the coupling token too, but it is the
// vehicle's: it sits in the vehicle panel, beside the crew and the number.
return new Border { Padding = new Thickness(8), Child = panel };
}
@@ -565,9 +598,7 @@ public sealed class VehicleCards
private void PaintDriverButton(Button btn, ConsistItem item)
{
var type = ReferenceEquals(item, _consist.Selected) && item.Cars.Count > 1
? _consist.ActiveCar(item).DriverType
: item.Driver;
var type = item.Driver;
(string glyph, string color) = type switch
{
@@ -588,6 +619,12 @@ public sealed class VehicleCards
{
menu.Items.Clear();
var addMany = new MenuItem { Header = App.Loc["AddMany"], IsEnabled = AddCopies is not null };
addMany.Click += (_, _) =>
AddManyFlyout.Show(anchor, PlacementMode.Top, count => AddCopies?.Invoke(item, count));
menu.Items.Add(addMany);
menu.Items.Add(new Separator());
var num = new MenuItem { Header = App.Loc["WagonNumber"] };
num.Click += (_, _) => ShowWagonNumberFlyout(anchor, item.Cars[0]);
menu.Items.Add(num);

View File

@@ -97,7 +97,7 @@ public sealed class VehicleDetails
BuildGeneralInfo(_consist.Selected);
if (_db.TextureForSkin(_consist.ActiveCar(_consist.Selected).SkinFile) is { } tex)
if (_db.TextureFor(_consist.ActiveCar(_consist.Selected).DataFolder, _consist.ActiveCar(_consist.Selected).SkinFile, _consist.ActiveCar(_consist.Selected).MmdFile) is { } tex)
_showTextureInfo(tex, consistTexturePanel);
else
consistTexturePanel.Children.Add(PanelNote(App.Loc["NoTextureInfo"]));
@@ -178,8 +178,6 @@ public sealed class VehicleDetails
if (unitIdx < 0)
return;
bool isLast = unitIdx >= _consist.Count - 1;
var d = Consist.TailCar(item);
// Two columns: the tab is wide enough and the list is short enough
@@ -198,13 +196,14 @@ public sealed class VehicleDetails
int bit = 1 << i;
var check = new CheckBox
{
Content = App.Loc[CouplingBits.BitKeys[i]],
Content = CouplerLook.BitLabel(i),
IsChecked = d.Coupling.Has(bit),
Classes = { "Checklist" }
};
check.IsCheckedChanged += (_, _) =>
{
d.Coupling.Set(bit, check.IsChecked == true);
d.CouplerSetByHand = true;
_redraw();
};
Grid.SetRow(check, i / 2);
@@ -227,6 +226,7 @@ public sealed class VehicleDetails
{
if (unitIdx <= 0) return;
d.Coupling.Flags = Consist.TailCar(_consist[unitIdx - 1]).Coupling.Flags;
d.CouplerSetByHand = true;
_redraw();
Refresh();
};
@@ -238,13 +238,13 @@ public sealed class VehicleDetails
Cursor = _hand,
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Center,
IsEnabled = _consist.Count > 1 && !isLast
IsEnabled = _consist.Count > 0
};
auto.Classes.Add("Flat");
ToolTip.SetTip(auto, App.Loc["TipAutoCoupler"]);
auto.Click += (_, _) =>
{
_consist.AutoConnectAll();
_consist.AutoCouple();
_redraw();
Refresh();
};
@@ -262,17 +262,16 @@ public sealed class VehicleDetails
}
/// <summary>
/// The vehicle's own settings. Every write path is unchanged: crew and wagon number
/// go through the unit's active car, the coolant flag to its first car only.
/// The vehicle's own settings: the crew is the unit's and sits where its cab is,
/// the wagon number goes through the unit's active car, the coolant flag to its
/// first car only.
/// </summary>
private Control BuildVehicleSection(ConsistItem item)
{
var driver = new ComboBox { FontSize = 12, MinWidth = 0 };
foreach (string key in new[] { "DriverHead", "DriverRear", "DriverPassenger", "DriverNobody" })
driver.Items.Add(new ComboBoxItem { Content = App.Loc[key] });
var crewCar = _consist.ActiveCar(item);
driver.SelectedIndex = crewCar.DriverType switch
driver.SelectedIndex = item.Driver switch
{
eDriverType.Headdriver => 0,
eDriverType.Reardriver => 1,
@@ -288,14 +287,9 @@ public sealed class VehicleDetails
2 => eDriverType.Passenger,
_ => eDriverType.Nobody
};
if (picked == crewCar.DriverType) return;
if (picked == item.Driver) return;
crewCar.DriverType = picked;
item.Driver = Consist.UnitDriver(item.Cars);
if (ReferenceEquals(_consist.Selected, item))
_consist.SyncStartingVehicle();
_redraw();
AppServices.Current.State.NotifyChanged();
_consist.SetCrew(item, picked);
};
driver.MinWidth = 0;
driver.HorizontalAlignment = HorizontalAlignment.Stretch;
@@ -559,6 +553,8 @@ public sealed class VehicleDetails
var tools = Stack(
LoadToolButton(App.Loc["ConsistRandomType"],
() => { _cargo.RandomTypes(_consist); _redraw(); }, secondary: true),
LoadToolButton(App.Loc["ConsistMaxAmount"],
() => { _cargo.MaxAmounts(_consist); _redraw(); }, secondary: true),
LoadToolButton(App.Loc["ConsistRandomAmount"],
() => { _cargo.RandomAmounts(_consist); _redraw(); }, secondary: true));
@@ -566,7 +562,7 @@ public sealed class VehicleDetails
var count = new TextBlock
{
Text = $"{App.Loc["VehicleCount"]}: {_consist.Count}",
Text = $"{App.Loc["VehicleCount"]}: {_consist.VehicleCount}",
FontSize = 12,
Foreground = DimBrush,
VerticalAlignment = VerticalAlignment.Center
@@ -591,9 +587,7 @@ public sealed class VehicleDetails
? over == ConsistReadyOverride.AlwaysReady
: trainset?.ReadyToGo ?? false
};
ToolTip.SetTip(ready, overridden
? App.Loc["ConsistReadyOverridden"]
: App.Loc["ConsistReadyDesc"]);
ToolTip.SetTip(ready, App.Loc["ConsistReadyDesc"]);
ready.IsCheckedChanged += (_, _) =>
{
@@ -603,7 +597,12 @@ public sealed class VehicleDetails
_redraw();
};
return Row(content: ready);
// A disabled box shows no tooltip, so why it is greyed out is said beside it.
if (!overridden)
return Row(content: ready);
var why = PanelNote(App.Loc["ConsistReadyOverridden"]);
why.Margin = new Thickness(0);
return Row(content: new StackPanel { Spacing = 2, Children = { ready, why } });
}
private Button LoadToolButton(string text, Action onClick, bool secondary = false)

View File

@@ -0,0 +1,65 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:starterNg="clr-namespace:StarterNG"
x:CompileBindings="True"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="700"
x:Class="StarterNG.Views.Developer">
<!-- Read-only view of what the starter made of the installation: the counts a
package author needs before slicing anything out of it, and the state of
the JSON copy those packages will be generated from. -->
<StackPanel Margin="3" Spacing="3">
<Border Background="{StaticResource CarbonPanelBg}" BorderBrush="{StaticResource CarbonBorderLight}"
BorderThickness="1" CornerRadius="2" Padding="10" HorizontalAlignment="Left" MinWidth="440">
<StackPanel Spacing="8">
<TextBlock Text="{Binding [DevDatabase], Source={x:Static starterNg:App.Loc}}"
FontWeight="Bold" FontSize="14" />
<Grid ColumnDefinitions="190,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto"
RowSpacing="4">
<TextBlock Grid.Row="0" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevVehicles], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="VehiclesValue" />
<TextBlock Grid.Row="1" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevLiveries], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="LiveriesValue" />
<TextBlock Grid.Row="2" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevGroups], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="GroupsValue" />
<TextBlock Grid.Row="3" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevSets], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="3" Grid.Column="1" x:Name="SetsValue" />
<TextBlock Grid.Row="4" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevFiles], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="4" Grid.Column="1" x:Name="FilesValue" />
<TextBlock Grid.Row="5" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevSource], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="5" Grid.Column="1" x:Name="SourceValue" />
<TextBlock Grid.Row="6" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevWritten], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="6" Grid.Column="1" x:Name="WrittenValue" />
<TextBlock Grid.Row="7" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevLocation], Source={x:Static starterNg:App.Loc}}"
VerticalAlignment="Top" />
<SelectableTextBlock Grid.Row="7" Grid.Column="1" x:Name="LocationValue"
TextWrapping="Wrap" FontSize="11" />
</Grid>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="RebuildButton" Click="RebuildButton_OnClick" Cursor="Hand"
Content="{Binding [DevRebuild], Source={x:Static starterNg:App.Loc}}" />
<TextBlock x:Name="RebuildStatus" VerticalAlignment="Center"
Foreground="{StaticResource CarbonFgDim}" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,78 @@
using System;
using System.Globalization;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using StarterNG.Application;
using StarterNG.Domain.Vehicles;
namespace StarterNG.Views;
/// <summary>
/// The developer tab: what the starter read out of the installation, and the state
/// of the JSON copy it keeps of it. Read-only apart from rebuilding that copy.
/// </summary>
public partial class Developer : UserControl
{
public Developer()
{
InitializeComponent();
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
/// <summary>Fills the panel from the catalogue as it stands right now.</summary>
public void Refresh()
{
var catalog = AppServices.Current.Library.Vehicles;
var status = AppServices.Current.VehicleCache.Status;
VehiclesValue.Text = Count(catalog.TextureByUuid.Values
.Select(texture => texture.Directory)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count());
LiveriesValue.Text = Wrecks(catalog);
GroupsValue.Text = Count(catalog.GroupsById.Count);
SetsValue.Text = Count(catalog.Sets.Count);
FilesValue.Text = Count(status.Sources);
SourceValue.Text = App.Loc[status.ServedFromCopy ? "DevSourceCopy" : "DevSourceTextures"];
WrittenValue.Text = status.Written is { } written
? written.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)
: "-";
LocationValue.Text = status.Directory;
RebuildStatus.Text = "";
}
/// <summary>
/// Rebuilds the copy on disk without disturbing the catalogue the other tabs
/// are already drawn from - the vehicles on screen keep pointing at the entries
/// they were built with.
/// </summary>
private void RebuildButton_OnClick(object? sender, RoutedEventArgs e)
{
RebuildButton.IsEnabled = false;
try
{
int liveries = AppServices.Current.VehicleCache
.Rebuild(new VehicleCatalog(AppServices.Current.MiniTextures));
Refresh();
RebuildStatus.Text = $"{App.Loc["DevRebuilt"]} ({liveries})";
}
finally
{
RebuildButton.IsEnabled = true;
}
}
/// <summary>Liveries offered, saying so when wrecks are being held back.</summary>
private static string Wrecks(VehicleCatalog catalog)
{
int wrecks = catalog.TextureByUuid.Count - catalog.Textures.Count;
return wrecks > 0
? $"{Count(catalog.Textures.Count)} (+{Count(wrecks)} {App.Loc["DevWrecks"]})"
: Count(catalog.Textures.Count);
}
private static string Count(int value) => value.ToString(CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,14 @@
using StarterNG.Domain.Settings;
namespace StarterNG.Views;
public static class ExeProblemText
{
/// <summary>The language key explaining why a simulator binary cannot be used as it is.</summary>
public static string Key(ExeProblem problem) => problem switch
{
ExeProblem.NotExecutable => "ExeNotExecutable",
ExeProblem.WrongPlatform => "ExeWrongPlatform",
_ => "ExeNotFound"
};
}

View File

@@ -13,7 +13,10 @@ public enum MessageBoxButtons
public static class MessageBox
{
public static async Task<bool> Show(Window owner, string message, string title, MessageBoxButtons buttons)
/// <param name="yesText">A label that says what the answer does, in place of "Yes".</param>
/// <param name="noText">Likewise in place of "No".</param>
public static async Task<bool> Show(Window owner, string message, string title, MessageBoxButtons buttons,
string? yesText = null, string? noText = null)
{
var win = new Window
{
@@ -33,6 +36,11 @@ public static class MessageBox
noBtn.Classes.Add("Flat");
noBtn.Click += (_, _) => win.Close();
if (yesText is not null)
yesBtn.Content = yesText;
if (noText is not null)
noBtn.Content = noText;
if (buttons == MessageBoxButtons.Ok)
{
yesBtn.Content = App.Loc["Ok"];

View File

@@ -7,7 +7,7 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="StarterNG.Views.Scenarios"
x:CompileBindings="True">
<Grid Margin="0" ColumnSpacing="0" RowSpacing="3">
<Grid Name="scenariosGrid" Margin="0" ColumnSpacing="0" RowSpacing="3">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MinWidth="200" MaxWidth="460" />
<ColumnDefinition Width="Auto" />
@@ -15,8 +15,9 @@
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<!-- Measures itself, so a whole card fits at either thumbnail size. -->
<RowDefinition Height="Auto" MinHeight="120" MaxHeight="200" />
<!-- Measures itself, so a whole card fits at either thumbnail size; the
floor follows the thumbnail size, see SyncConsistRowHeight. -->
<RowDefinition Height="Auto" MaxHeight="200" />
</Grid.RowDefinitions>
<HeaderedContentControl Grid.Column="0" Grid.Row="0" Theme="{StaticResource GroupBox}"
@@ -28,9 +29,12 @@
IsCheckedChanged="ArchivalSwitch_OnChanged"
Content="{Binding [HideArchivalSceneries], Source={x:Static starterNg:App.Loc}}" />
<TreeView Name="sceneryList" Classes="Compact"
SelectionChanged="SceneryList_OnSelectionChanged">
SelectionChanged="SceneryList_OnSelectionChanged"
ContextRequested="SceneryList_OnContextRequested">
<TreeView.ContextMenu>
<ContextMenu>
<ContextMenu Opening="SceneryMenu_OnOpening">
<MenuItem Name="favoriteMenuItem" Click="ToggleFavorite_OnClick" />
<Separator />
<MenuItem Click="ReloadSceneries_OnClick"
Header="{Binding [ReloadSceneries], Source={x:Static starterNg:App.Loc}}" />
<MenuItem Click="OpenSceneryDir_OnClick"

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
@@ -15,6 +16,7 @@ using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using Avalonia.VisualTree;
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Application;
@@ -39,6 +41,7 @@ public partial class Scenarios : UserControl
public Scenarios()
{
InitializeComponent();
SyncConsistRowHeight();
Sceneries = AppServices.Current.Library.Sceneries;
@@ -70,8 +73,9 @@ public partial class Scenarios : UserControl
bool includeArchival = archivalSwitch.IsChecked != true;
bool expandGroups = AppServices.Current.Settings.AutoExpandSceneryTree;
var favorites = AppServices.Current.Favorites;
var nodes = new SceneryTreeBuilder(AppServices.Current.SceneryTexts)
.Build(Sceneries, includeArchival, App.Loc.CurrentLangCode);
.Build(Sceneries, includeArchival, App.Loc.CurrentLangCode, favorites.Contains, App.Loc["Favorites"]);
foreach (var node in nodes)
sceneryList.Items.Add(ToTreeItem(node, expandGroups));
@@ -92,6 +96,47 @@ public partial class Scenarios : UserControl
return item;
}
private Scenery? SelectedScenery =>
sceneryList.SelectedItem is TreeViewItem { Tag: int index } && index < Sceneries.Count
? Sceneries[index]
: null;
// A right click picks the scenery under it, so the menu speaks of that one. A
// group has nothing to star, and the menu is not opened on it at all - it would
// otherwise act on whatever scenery was selected before.
private void SceneryList_OnContextRequested(object? sender, ContextRequestedEventArgs e)
{
switch ((e.Source as Visual).FindAncestorOfType<TreeViewItem>(includeSelf: true))
{
case { Tag: int } item:
sceneryList.SelectedItem = item;
break;
case not null:
e.Handled = true;
break;
}
}
private void SceneryMenu_OnOpening(object? sender, CancelEventArgs e)
{
var scenery = SelectedScenery;
favoriteMenuItem.IsEnabled = scenery is not null;
favoriteMenuItem.Header = App.Loc[scenery is not null && AppServices.Current.Favorites.Contains(scenery)
? "FavoriteRemove"
: "FavoriteAdd"];
}
private void ToggleFavorite_OnClick(object? sender, RoutedEventArgs e)
{
if (SelectedScenery is not { } scenery)
return;
AppServices.Current.Favorites.Toggle(scenery);
BuildSceneryTree();
AppServices.Current.Settings.LastScenery = Path.GetFileNameWithoutExtension(scenery.Path);
RestoreLastScenery();
}
private void RestoreLastScenery()
{
string last = AppServices.Current.Settings.LastScenery;
@@ -318,14 +363,14 @@ public partial class Scenarios : UserControl
foreach (var v in ts.Vehicles)
{
if (string.IsNullOrEmpty(v.SkinFile)) continue;
if (db.TextureForSkin(v.SkinFile) is null)
if (db.TextureFor(v.DataFolder, v.SkinFile, v.MmdFile) is null)
lines.Add($"# unknown texture: {v.Name} ({v.SkinFile})");
}
}
foreach (var v in scenery.LooseVehicles)
{
if (db.TextureForSkin(v.SkinFile) is null)
if (db.TextureFor(v.DataFolder, v.SkinFile, v.MmdFile) is null)
lines.Add($"# unknown loose texture: {v.Name} ({v.SkinFile})");
}
@@ -418,8 +463,22 @@ public partial class Scenarios : UserControl
RandomizeTexturesRequested?.Invoke(scenery);
}
/// <summary>
/// The floor of the consist row, so the small thumbnails do not sit in the
/// height the large ones need.
/// </summary>
private const double SmallConsistRowHeight = 84;
private const double LargeConsistRowHeight = 120;
private void SyncConsistRowHeight() =>
scenariosGrid.RowDefinitions[1].MinHeight = AppServices.Current.Settings.LargeThumbnails
? LargeConsistRowHeight
: SmallConsistRowHeight;
public void RefreshConsistView()
{
SyncConsistRowHeight();
RefreshVehicleLabels();
if (AppServices.Current.State.CurrentTrainset is { } trainset)
ShowConsist(trainset);
@@ -709,7 +768,7 @@ public partial class Scenarios : UserControl
try
{
timetableContent.Text = File.ReadAllText(path, LegacyText.CodePage1250);
timetableContent.Text = LegacyText.Decode(File.ReadAllBytes(path));
UpdateTimetableTab(true);
}
catch
@@ -801,7 +860,7 @@ public partial class Scenarios : UserControl
foreach (var train in trainset.Vehicles)
{
string miniName = db.MiniForSkin(train.SkinFile) ?? train.SkinFile;
string miniName = db.MiniFor(train.DataFolder, train.SkinFile, train.MmdFile) ?? train.SkinFile;
if (!AppServices.Current.MiniTextures.Has(miniName) && AppServices.Current.MiniTextures.Has(train.SkinFile))
miniName = train.SkinFile;
int thumbH = AppServices.Current.Settings.LargeThumbnails ? 64 : 32;
@@ -1102,10 +1161,10 @@ public partial class Scenarios : UserControl
var db = AppServices.Current.Library.Vehicles;
StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields(
trainset,
car => AppServices.Current.Physics.For(car.DataFolder, db.TextureForSkin(car.SkinFile)?.Model)
car => AppServices.Current.Physics.For(car.DataFolder, db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile)?.Model)
?? AppServices.Current.Physics.For(car.DataFolder, car.MmdFile)
?? AppServices.Current.Physics.For(car.DataFolder, car.SkinFile),
car => db.TextureForSkin(car.SkinFile)?.ResolvedCategory));
car => db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile)?.ResolvedCategory));
}
private void RefreshSelectedConsist()

View File

@@ -133,7 +133,12 @@
<ctl:SettingsSection Header="{Binding [Others], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<ctl:SettingRow ConfigKey="starter.exe.path" Label="{Binding [SelectEXE], Source={x:Static starterNg:App.Loc}}">
<ComboBox x:Name="SelectExeCb" SelectedIndex="0" />
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="6">
<ComboBox x:Name="SelectExeCb" SelectedIndex="0" HorizontalAlignment="Stretch" />
<Button x:Name="BrowseExeButton" Grid.Column="1" Classes="Flat" Cursor="Hand"
Click="BrowseExeButton_OnClick"
Content="{Binding [SelectEXEBrowse], Source={x:Static starterNg:App.Loc}}" />
</Grid>
</ctl:SettingRow>
<ctl:SettingRow Description="{Binding [DebugModeDesc], Source={x:Static starterNg:App.Loc}}" ConfigKey="debugmode">
<CheckBox x:Name="DebugModeCb" Content="{Binding [DebugMode], Source={x:Static starterNg:App.Loc}}" />
@@ -144,6 +149,9 @@
<ctl:SettingRow ConfigKey="starter.logmissingvehicles" Description="{Binding [LogMissingVehicleFilesDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="LogMissingVehicleFilesCb" Content="{Binding [LogMissingVehicleFiles], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="starter.developertools" Description="{Binding [DeveloperToolsDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="DeveloperToolsCb" Content="{Binding [DeveloperTools], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
</StackPanel>
</ctl:SettingsSection>
</StackPanel>
@@ -220,14 +228,20 @@
<StackPanel>
<ctl:SettingRow ConfigKey="gfxrenderer" Label="{Binding [RenderEngine], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [RenderEngineDesc], Source={x:Static starterNg:App.Loc}}">
<ComboBox x:Name="RenderEngineCb" SelectedIndex="0">
<ComboBoxItem Content="{Binding [REFull], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [REOld], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [RESimpShader], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [RESimp], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [REOff], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [REExperimental], Source={x:Static starterNg:App.Loc}}" />
</ComboBox>
<StackPanel Spacing="6">
<ComboBox x:Name="RenderEngineCb" SelectedIndex="0"
SelectionChanged="RenderEngineCb_OnSelectionChanged">
<ComboBoxItem Content="{Binding [REFull], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [REOld], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [RESimpShader], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [RESimp], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [REOff], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [REExperimental], Source={x:Static starterNg:App.Loc}}" />
</ComboBox>
<TextBlock x:Name="RenderEngineWarning" IsVisible="False" TextWrapping="Wrap"
Foreground="{StaticResource CarbonAccentBright}"
Text="{Binding [REExperimentalWarning], Source={x:Static starterNg:App.Loc}}" />
</StackPanel>
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.angleplatform" Description="{Binding [RenderAngleVulkanDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="RenderAngleVulkanCb" Content="{Binding [RenderAngleVulkan], Source={x:Static starterNg:App.Loc}}" />

View File

@@ -4,6 +4,7 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
@@ -13,6 +14,7 @@ using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.Markup.Xaml.Styling;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using StarterNG.Classes;
using StarterNG.Controls;
using StarterNG.Infrastructure;
@@ -34,9 +36,15 @@ public partial class Settings : UserControl, ISettingsCapture
/// </summary>
public event Action? ThumbnailSizeChanged;
/// <summary>Raised when the developer tab is switched on or off, so the top bar can follow.</summary>
public event Action? DeveloperToolsChanged;
/// <summary>Thumbnail size the other tabs were last drawn at.</summary>
private bool _drawnThumbs = AppServices.Current.Settings.LargeThumbnails;
/// <summary>Whether the top bar was last drawn with the developer tab on it.</summary>
private bool _shownDeveloperTools = AppServices.Current.Settings.DeveloperTools;
public Settings()
{
InitializeComponent();
@@ -158,6 +166,58 @@ public partial class Settings : UserControl, ISettingsCapture
}
}
private async void BrowseExeButton_OnClick(object? sender, RoutedEventArgs e) =>
await BrowseForExecutableAsync();
/// <summary>
/// Picks the simulator from anywhere, such as a build tree kept beside the data,
/// where the automatic search - the working directory only - cannot see it.
/// True when a file was picked; like any other choice here, it waits for Save.
/// </summary>
public async Task<bool> BrowseForExecutableAsync()
{
if (TopLevel.GetTopLevel(this) is not Window owner)
return false;
var picked = await owner.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = App.Loc["SelectEXEBrowseTitle"],
AllowMultiple = false
});
if (picked.Count == 0 || picked[0].TryGetLocalPath() is not { } path)
return false;
var problem = AppServices.Current.Executables.Validate(path);
string message = $"{path}\n\n{App.Loc[ExeProblemText.Key(problem)]}";
if (problem == ExeProblem.WrongPlatform)
{
// Not a dead end: Wine or Proton runs a Windows build on Linux.
if (!await MessageBox.Show(owner, $"{message}\n\n{App.Loc["SelectEXEUseAnyway"]}",
App.Loc["SelectEXE"], MessageBoxButtons.YesNo))
return false;
}
else if (problem != ExeProblem.None)
{
await MessageBox.Show(owner, message, App.Loc["SelectEXE"], MessageBoxButtons.Ok);
return false;
}
// Inside the installation the bare name is enough, and matches the list.
string root = Path.GetFullPath(AppServices.Current.Paths.Root);
string entry = string.Equals(Path.GetDirectoryName(path), root, StringComparison.Ordinal)
? Path.GetFileName(path)
: path;
int index = FindComboIndexByContent(SelectExeCb, entry);
if (index < 0)
{
SelectExeCb.Items.Add(new ComboBoxItem { Content = entry });
index = SelectExeCb.Items.Count - 1;
}
SelectExeCb.SelectedIndex = index;
return true;
}
public void ReloadFromSettings() => ApplyToUi();
private void ApplyToUi()
@@ -199,6 +259,7 @@ public partial class Settings : UserControl, ISettingsCapture
DebugModeCb.IsChecked = s.DebugMode;
VirtualShuntingCb.IsChecked = s.VirtualShunting;
LogMissingVehicleFilesCb.IsChecked = s.LogMissingVehicleFiles;
DeveloperToolsCb.IsChecked = s.DeveloperTools;
RenderEngineCb.SelectedIndex = s.RenderEngine;
SelectResolution(s.Width, s.Height);
@@ -297,6 +358,7 @@ public partial class Settings : UserControl, ISettingsCapture
s.DebugMode = IsChecked(DebugModeCb);
s.VirtualShunting = IsChecked(VirtualShuntingCb);
s.LogMissingVehicleFiles = IsChecked(LogMissingVehicleFilesCb);
s.DeveloperTools = IsChecked(DeveloperToolsCb);
s.RenderEngine = Math.Max(0, RenderEngineCb.SelectedIndex);
ReadResolution(s);
@@ -367,18 +429,38 @@ public partial class Settings : UserControl, ISettingsCapture
new UartWindow().ShowDialog(owner);
}
private void SaveButton_OnClick(object? sender, RoutedEventArgs e)
private void SaveButton_OnClick(object? sender, RoutedEventArgs e) => Save();
public void Save()
{
CaptureInto(AppServices.Current.Settings);
AppServices.Current.SettingsStore.Save();
KeyboardConfig.Instance.Save();
RedrawThumbnailsIfNeeded();
RedrawNavIfNeeded();
_dirty = false;
UpdateSaveState();
if (SaveStatus is not null)
SaveStatus.Text = App.Loc["SettingsSaved"];
}
private void RenderEngineCb_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (RenderEngineWarning is not null)
RenderEngineWarning.IsVisible =
SimulatorSettings.RenderEngines.ElementAtOrDefault(RenderEngineCb.SelectedIndex) == "experimental";
}
private void RedrawNavIfNeeded()
{
bool developer = AppServices.Current.Settings.DeveloperTools;
if (developer == _shownDeveloperTools)
return;
_shownDeveloperTools = developer;
DeveloperToolsChanged?.Invoke();
}
private void RedrawThumbnailsIfNeeded()
{
bool large = AppServices.Current.Settings.LargeThumbnails;
@@ -722,6 +804,7 @@ public partial class Settings : UserControl, ISettingsCapture
private static readonly IBrush KeyPlainBrush = new SolidColorBrush(Color.Parse("#2E9E1F"));
private static readonly IBrush KeyShiftBrush = new SolidColorBrush(Color.Parse("#C9A227"));
private static readonly IBrush KeyCtrlBrush = new SolidColorBrush(Color.Parse("#2D7FD3"));
private static readonly IBrush KeyShiftCtrlBrush = new SolidColorBrush(Color.Parse("#C8372D"));
private static readonly IBrush KeyBorderBrush = new SolidColorBrush(Color.Parse("#3A424A"));
private static readonly IBrush FgBrush = new SolidColorBrush(Color.Parse("#E6E8EA"));
private static readonly IBrush FgDimBrush = new SolidColorBrush(Color.Parse("#9098A0"));
@@ -803,7 +886,7 @@ public partial class Settings : UserControl, ISettingsCapture
private static bool MatchesFilter(KeyBinding b, string filter) =>
TextMatch.Contains(b.Command, filter) ||
TextMatch.Contains(b.Description, filter);
TextMatch.Contains(KeyboardConfig.Instance.DescriptionOf(b), filter);
private Control BuildBindingRow(KeyBinding b, HashSet<string> conflicts)
{
@@ -924,6 +1007,8 @@ public partial class Settings : UserControl, ISettingsCapture
_capturing.Shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
_capturing.Ctrl = e.KeyModifiers.HasFlag(KeyModifiers.Control);
_capturing.Key = token;
// As the key flyout does: the combination moves here, not doubled up.
ReleaseCombo(token, _capturing.Shift, _capturing.Ctrl, _capturing);
KeyboardConfig.Instance.Dirty = true;
MarkDirty();
e.Handled = true;
@@ -1035,6 +1120,7 @@ public partial class Settings : UserControl, ISettingsCapture
if (state is { Plain: true }) colours.Add(KeyPlainBrush);
if (state is { Shift: true }) colours.Add(KeyShiftBrush);
if (state is { Ctrl: true }) colours.Add(KeyCtrlBrush);
if (state is { ShiftCtrl: true }) colours.Add(KeyShiftCtrlBrush);
if (colours.Count == 0)
return new Border { Background = KeyUnassignedBrush };
@@ -1062,12 +1148,12 @@ public partial class Settings : UserControl, ISettingsCapture
foreach (var c in commands)
items.Add(CommandLabel(c));
var slots = new (bool shift, bool ctrl, string labelKey)[]
var slots = new (bool shift, bool ctrl, string labelKey, IBrush colour)[]
{
(false, false, "BindNoMod"),
(true, false, "BindShift"),
(false, true, "BindCtrl"),
(true, true, "BindShiftCtrl"),
(false, false, "BindNoMod", KeyPlainBrush),
(true, false, "BindShift", KeyShiftBrush),
(false, true, "BindCtrl", KeyCtrlBrush),
(true, true, "BindShiftCtrl", KeyShiftCtrlBrush),
};
var combos = new ComboBox[slots.Length];
@@ -1079,14 +1165,24 @@ public partial class Settings : UserControl, ISettingsCapture
for (int i = 0; i < slots.Length; i++)
{
var (shift, ctrl, labelKey) = slots[i];
var (shift, ctrl, labelKey, colour) = slots[i];
var lbl = new TextBlock
// The swatch ties the row to the key colours on the keyboard above.
var lbl = new StackPanel
{
Text = App.Loc[labelKey],
Foreground = FgBrush,
Orientation = Orientation.Horizontal,
Spacing = 6,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 4, 12, 4)
Margin = new Thickness(0, 4, 12, 4),
Children =
{
new Border
{
Width = 10, Height = 10, CornerRadius = new CornerRadius(2),
Background = colour, VerticalAlignment = VerticalAlignment.Center
},
new TextBlock { Text = App.Loc[labelKey], Foreground = FgBrush, VerticalAlignment = VerticalAlignment.Center }
}
};
Grid.SetRow(lbl, i);
Grid.SetColumn(lbl, 0);
@@ -1153,17 +1249,7 @@ public partial class Settings : UserControl, ISettingsCapture
private void AssignSlot(string token, bool shift, bool ctrl, int selectedIndex, List<KeyBinding> commands)
{
KeyBinding? chosen = selectedIndex <= 0 ? null : commands[selectedIndex - 1];
foreach (var b in KeyboardConfig.Instance.Bindings)
{
if (!ReferenceEquals(b, chosen) && b.IsAssigned &&
string.Equals(b.Key, token, StringComparison.OrdinalIgnoreCase) &&
b.Shift == shift && b.Ctrl == ctrl)
{
b.Shift = b.Ctrl = false;
b.Key = "none";
}
}
ReleaseCombo(token, shift, ctrl, chosen);
if (chosen is not null)
{
@@ -1176,6 +1262,25 @@ public partial class Settings : UserControl, ISettingsCapture
MarkDirty();
}
/// <summary>
/// Frees a key combination for <paramref name="keeper"/>: the simulator maps a
/// combination to one command - in driving and outside the cab alike, the
/// editor has its own fixed keys - so of two, one would silently never fire.
/// </summary>
private static void ReleaseCombo(string token, bool shift, bool ctrl, KeyBinding? keeper)
{
foreach (var b in KeyboardConfig.Instance.Bindings)
{
if (!ReferenceEquals(b, keeper) && b.IsAssigned &&
string.Equals(b.Key, token, StringComparison.OrdinalIgnoreCase) &&
b.Shift == shift && b.Ctrl == ctrl)
{
b.Shift = b.Ctrl = false;
b.Key = "none";
}
}
}
private static KeyBinding? CommandInSlot(string token, bool shift, bool ctrl) =>
KeyboardConfig.Instance.Bindings.FirstOrDefault(b =>
b.IsAssigned &&
@@ -1189,6 +1294,7 @@ public partial class Settings : UserControl, ISettingsCapture
legend.Children.Add(LegendItem(KeyPlainBrush, App.Loc["KbAssigned"]));
legend.Children.Add(LegendItem(KeyShiftBrush, App.Loc["KbShift"]));
legend.Children.Add(LegendItem(KeyCtrlBrush, App.Loc["KbCtrl"]));
legend.Children.Add(LegendItem(KeyShiftCtrlBrush, App.Loc["KbShiftCtrl"]));
return legend;
}
@@ -1220,6 +1326,7 @@ public partial class Settings : UserControl, ISettingsCapture
public bool Plain;
public bool Shift;
public bool Ctrl;
public bool ShiftCtrl;
public readonly List<string> Tips = new();
}
@@ -1234,9 +1341,13 @@ public partial class Settings : UserControl, ISettingsCapture
if (!map.TryGetValue(key, out var state))
map[key] = state = new KeyState();
if (!b.Shift && !b.Ctrl) state.Plain = true;
if (b.Shift) state.Shift = true;
if (b.Ctrl) state.Ctrl = true;
switch (b.Shift, b.Ctrl)
{
case (false, false): state.Plain = true; break;
case (true, false): state.Shift = true; break;
case (false, true): state.Ctrl = true; break;
default: state.ShiftCtrl = true; break;
}
state.Tips.Add($"{ComboText(b)} — {CommandLabel(b)}");
}
@@ -1273,7 +1384,7 @@ public partial class Settings : UserControl, ISettingsCapture
}
private static string CommandLabel(KeyBinding b) =>
string.IsNullOrEmpty(b.Description) ? b.Command : Capitalize(b.Description);
KeyboardConfig.Instance.DescriptionOf(b) is { Length: > 0 } description ? Capitalize(description) : b.Command;
private static string Capitalize(string s) =>
string.IsNullOrEmpty(s) ? s : char.ToUpperInvariant(s[0]) + s.Substring(1);

View File

@@ -50,6 +50,7 @@ public partial class TextureBaseWindow : Window
modelList.SelectionChanged += (_, _) => Refresh();
textureList.ContextRequested += TextureList_OnContextRequested;
textureList.AddHandler(PointerReleasedEvent, TextureList_OnPointerReleased, handledEventsToo: true);
Refresh();
}
@@ -133,7 +134,16 @@ public partial class TextureBaseWindow : Window
{
if (textureList.SelectedItem is not TextureRow row) return;
Picked = row.Texture;
Close();
}
// DoubleTapped is raised from the second press, while the list still holds the
// pointer. Closing there leaves the capture on a dead window and on Windows the
// owner stops taking clicks. The release ends the gesture and drops the capture,
// so that is where the window goes.
private void TextureList_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (Picked != null)
Close();
}
private void TextureList_OnContextRequested(object? sender, ContextRequestedEventArgs e)

View File

@@ -7,6 +7,7 @@
<String key="NavScenarios">Scénáře</String>
<String key="NavDepot">Depo</String>
<String key="NavSettings">Nastavení</String>
<String key="NavDeveloper">Developer</String>
<String key="NavControls">Ovládání</String>
<String key="Help">Nápověda</String>
<String key="HelpWiki">Wiki MaSzyna</String>
@@ -65,6 +66,9 @@
<String key="StartNoStaffConfirm">Tato souprava nemá osádku ani cestujícího — nebude co řídit. Přesto spustit?</String>
<String key="RestoreWeather">Obnovit počasí</String>
<String key="ReloadSceneries">Načíst znovu</String>
<String key="Favorites">Oblíbené</String>
<String key="FavoriteAdd">Přidat do oblíbených</String>
<String key="FavoriteRemove">Odebrat z oblíbených</String>
<String key="OpenSceneryFolder">Otevřít složku</String>
<String key="OptionalIncludes">Volitelné prvky</String>
<String key="OpenTextureFolder">Otevřít složku s texturami</String>
@@ -91,6 +95,9 @@
<String key="Crew">s osádkou</String>
<String key="Reversed">otočeno</String>
<String key="NoVehicles">Ve složce dynamic/**/textures.txt nebyla nalezena žádná vozidla.</String>
<String key="BrowserHint">Vyberte kategorii nebo napište název vozidla.</String>
<String key="BrowserNoMatch">Nic neodpovídá „{0}“.</String>
<String key="SearchAllVehicles">Hledat ve všech vozidlech…</String>
<String key="ConsistEmpty">Přidejte vozidla z databáze vlevo nebo načtěte existující soupravu ze souboru scenérie.</String>
<String key="TipMoveLeft">Posunout doleva</String>
<String key="TipMoveRight">Posunout doprava</String>
@@ -103,21 +110,21 @@
<String key="All">Vše</String>
<String key="CatDiesel">Motorová</String>
<String key="CatElectric">Elektrická</String>
<String key="CatEMU">Elektrická jednotka</String>
<String key="CatEMU">Elektrické jednotky</String>
<String key="CatDMU">Motorová jednotka</String>
<String key="CatCarriagesA">Vozy A</String>
<String key="CatCarriagesB">Vozy B</String>
<String key="CatElectricLoco">Elektrická lokomotiva</String>
<String key="CatDieselLoco">Motorová lokomotiva</String>
<String key="CatSteamLoco">Parní lokomotiva</String>
<String key="CatRailbus">Motorový vůz</String>
<String key="CatDraisine">Drezína</String>
<String key="CatWork">Pracovní vozidla</String>
<String key="CatPrototype">Prototyp</String>
<String key="CatTram">Tramvaj</String>
<String key="CatCar">Automobil</String>
<String key="CatBus">Autobus</String>
<String key="CatTruck">Nákladní auto</String>
<String key="CatElectricLoco">Elektrické lokomotivy</String>
<String key="CatDieselLoco">Motorové lokomotivy</String>
<String key="CatSteamLoco">Parní lokomotivy</String>
<String key="CatRailbus">Motorové vozy</String>
<String key="CatDraisine">Drezíny</String>
<String key="CatWork">Pracovní vozy</String>
<String key="CatPrototype">Prototypy</String>
<String key="CatTram">Tramvaje</String>
<String key="CatCar">Automobily</String>
<String key="CatBus">Autobusy</String>
<String key="CatTruck">Nákladní automobily</String>
<String key="CatPeople">Lidé</String>
<String key="CatAnimals">Zvířata</String>
<String key="CatWagonsA">Vagony A</String>
@@ -223,6 +230,7 @@
<String key="ReadyOverrideReady">Always ready</String>
<String key="ConsistReadyOverridden">Overridden by "Force the consist state on launch" in Settings.</String>
<String key="ConsistRandomType">Náhodný druh</String>
<String key="ConsistMaxAmount">Maximální množství</String>
<String key="ConsistRandomAmount">Náhodné množství</String>
<String key="Set">Nastavit</String>
<String key="Length">Délka</String>
@@ -251,7 +259,14 @@
<String key="ExeLaunchFailed">Nepodařilo se spustit simulátor</String>
<String key="ExeNotFound">Spustitelný soubor simulátoru nebyl nalezen.</String>
<String key="ExeNotExecutable">Soubor není označen jako spustitelný (chmod +x).</String>
<String key="ExeWrongPlatform">Tento binární soubor je pro jiný operační systém.</String>
<String key="ExeWrongPlatform">Tento binární soubor je pro jiný operační systém. Na Linuxu může verze pro Windows běžet přes Wine nebo Proton - pokud je používáte, nejde o chybu.</String>
<String key="SimExitTitle">Simulátor se neočekávaně ukončil</String>
<String key="SimExitCode">Návratový kód: {0}</String>
<String key="SimExitQuick">Ukončil se {0:0.0} s po spuštění.</String>
<String key="SimExitMessages">Poslední zprávy:</String>
<String key="SimExitSeeLog">Celý průběh je v souboru log.txt ve složce simulátoru.</String>
<String key="SimExitDump">Simulátor uložil výpis paměti: {0}</String>
<String key="SimExitOpenDump">Otevřít složku s výpisem? Přiložte jej k hlášení chyby.</String>
<String key="TabInfo">Informace</String>
<String key="SettingsFileChangedTitle">Nastavení</String>
<String key="SettingsFileChanged">Soubor eu07.ini byl změněn mimo starter. Načíst jej znovu?</String>
@@ -271,8 +286,16 @@
<String key="CouplingUnitRear">Spřáhlo na konci jednotky</String>
<String key="Join">Spojit</String>
<String key="TipJoin">Spojit toto vozidlo s následujícím do jedné jednotky</String>
<String key="TipReorder">Přetáhněte pro změnu pořadí</String>
<String key="CouplerCode">Kód spřáhla: {0}</String>
<String key="CouplerFree">Vozidla nejsou spojena.</String>
<String key="CouplerConnects">Spojeno: {0}</String>
<String key="CouplerEndSignals">Konec soupravy — simulátor vyvěsí koncovky.</String>
<String key="CouplerNoEndSignals">Jiný kód než 0 na konci soupravy — simulátor nevyvěsí koncovky.</String>
<String key="CouplerLocked">Zablokováno — při posunu se nerozpojí.</String>
<String key="CouplerClickToEdit">Klikněte pro změnu.</String>
<String key="AddVehicle">Přidat</String>
<String key="AddMany">Přidat vícekrát…</String>
<String key="AddManyCount">Kolikrát vozidlo přidat</String>
<String key="TipAddVehicle">Přidat toto vozidlo do soupravy</String>
<String key="TipAddUnit">Přidat celou jednotku do soupravy</String>
@@ -331,9 +354,13 @@
<String key="Others">Ostatní</String>
<String key="SelectEXE">Vybrat exe</String>
<String key="SelectEXEAuto">Vybrat automaticky</String>
<String key="SelectEXEBrowse">Vybrat soubor…</String>
<String key="SelectEXEBrowseTitle">Vyberte spustitelný soubor simulátoru</String>
<String key="SelectEXEUseAnyway">Přesto jej použít?</String>
<String key="DebugMode">Režim ladění (testovací režim)</String>
<String key="VirtualShunting">Virtuální posun</String>
<String key="LogMissingVehicleFiles">Zaznamenat chybějící soubory vozidel při exportu</String>
<String key="DeveloperTools">Show the package author's tab</String>
<String key="Graphic">Grafika</String>
<String key="RenderEngine">Vykreslovací engine</String>
@@ -343,6 +370,7 @@
<String key="RESimp">Zjednodušený</String>
<String key="REOff">Vypnout vizuální vrstvu</String>
<String key="REExperimental">Experimentální</String>
<String key="REExperimentalWarning">Upozornění: experimentální renderer je ve vývoji. Může být nestabilní, nezobrazovat části scény nebo se na některých grafických kartách nespustit. Pokud se simulátor nespustí nebo vypadá špatně, vraťte se k rendereru „Plný“.</String>
<String key="Resolution">Rozlišení</String>
<String key="BufforResolution">Rozlišení bufferu</String>
@@ -459,6 +487,7 @@
<String key="KbAssigned">Přiřazeno</String>
<String key="KbShift">Se Shiftem</String>
<String key="KbCtrl">S Ctrl</String>
<String key="KbShiftCtrl">S Ctrl + Shift</String>
<String key="KbReserved">Vyhrazeno simulátorem</String>
<String key="BindKeyTitle">Klávesa:</String>
<String key="BindKeyHint">Přiřaďte příkaz ke každé kombinaci modifikátorů této klávesy.</String>
@@ -485,7 +514,12 @@
<String key="FaultNoScenery">Nebyly nalezeny žádné scénáře.</String>
<String key="FaultNoVehicles">Nebyla nalezena žádná vozidla.</String>
<String key="FaultNoPhysics">Nebyla nalezena data vozového parku.</String>
<String key="FaultNoExe">Spustitelný soubor simulátoru nebyl nalezen.</String>
<String key="FaultNoExeTitle">Simulátor nebyl nalezen</String>
<String key="FaultNoExe">Starter spouští simulátor ({0}) ze složky, ze které byl sám spuštěn:&#10;{1}&#10;&#10;Simulátor tam není, takže Start nebude fungovat.</String>
<String key="FaultNoExePick">Pokud je simulátor jinde, např. jako vlastní build, vyberte jej. Volbu můžete později změnit v Nastavení, v sekci Ostatní. Pokud to není složka MaSzyny, spusťte starter ze správné složky.</String>
<String key="FaultNoExeWine">Na Linuxu můžete vybrat i verzi pro Windows (eu07.exe) — poběží přes Wine nebo Proton.</String>
<String key="FaultNoExePickNow">Vybrat simulátor…</String>
<String key="NotNow">Teď ne</String>
<!-- About -->
<String key="AboutTitle">O programu</String>
@@ -528,7 +562,8 @@
<String key="WarehouseImportFile">Přidat soupravy ze souboru...</String>
<String key="StartWithoutSaving">Spustit bez uložení nastavení</String>
<String key="StartFreeFly">Spustit bez vozidla</String>
<String key="VolumePaused">Hlasitost při pauze</String>
<String key="StartEditor">Spustit editor scenérie</String>
<String key="StartEditorWarning">Editor scenérie (eu07 -edit) mají jen některé verze simulátoru. Pokud ho vybraný simulátor nepodporuje, editor se neotevře. Editor pracuje přímo se souborem {0}, ne s kopií od starteru, takže uložené změny se zapíší do scenérie. Spustit?</String>
<String key="DebugLogVisible">Zobrazovat průběh simulace</String>
<String key="PyScreenPriority">Frekvence obnovení doplňkových obrazovek</String>
<String key="PyPriorityNormal">Standardní</String>
@@ -584,4 +619,20 @@
<String key="ShortcutMember">Vybrat člen ve spojené jednotce</String>
<String key="ShortcutMoveVehicle">Posunout vozidlo v soupravě</String>
<String key="ShortcutCardMenu">Otevřít nabídku vozidla</String>
<!-- Package author's tab - awaiting translation -->
<String key="DevDatabase">Vehicle database</String>
<String key="DevVehicles">Vehicle folders</String>
<String key="DevLiveries">Liveries</String>
<String key="DevWrecks">wrecks</String>
<String key="DevGroups">Groups</String>
<String key="DevSets">Fixed sets</String>
<String key="DevFiles">textures.txt files</String>
<String key="DevSource">Read from</String>
<String key="DevSourceCopy">the starter's copy</String>
<String key="DevSourceTextures">textures.txt files</String>
<String key="DevWritten">Copy written</String>
<String key="DevLocation">Copy folder</String>
<String key="DevRebuild">Rebuild copy</String>
<String key="DevRebuilt">Rebuilt</String>
</Language>

View File

@@ -7,6 +7,7 @@
<String key="NavScenarios">Scenarios</String>
<String key="NavDepot">Depot</String>
<String key="NavSettings">Settings</String>
<String key="NavDeveloper">Developer</String>
<String key="NavControls">Controls</String>
<String key="Help">Help</String>
<String key="HelpWiki">MaSzyna wiki</String>
@@ -65,6 +66,9 @@
<String key="StartNoStaffConfirm">This consist has no crew and no passenger — there will be nothing to drive. Start anyway?</String>
<String key="RestoreWeather">Restore weather</String>
<String key="ReloadSceneries">Reload</String>
<String key="Favorites">Favourites</String>
<String key="FavoriteAdd">Add to favourites</String>
<String key="FavoriteRemove">Remove from favourites</String>
<String key="OpenSceneryFolder">Open folder</String>
<String key="OptionalIncludes">Optional elements</String>
<String key="OpenTextureFolder">Open texture folder</String>
@@ -91,6 +95,9 @@
<String key="Crew">with crew</String>
<String key="Reversed">reversed</String>
<String key="NoVehicles">No vehicles found in dynamic/**/textures.txt.</String>
<String key="BrowserHint">Pick a category or type a vehicle's name.</String>
<String key="BrowserNoMatch">Nothing matches “{0}”.</String>
<String key="SearchAllVehicles">Search all vehicles…</String>
<String key="ConsistEmpty">Add vehicles from the database on the left, or load an existing consist from a scenery file.</String>
<String key="TipMoveLeft">Move left</String>
<String key="TipMoveRight">Move right</String>
@@ -107,45 +114,45 @@
<String key="CatDMU">DMU</String>
<String key="CatCarriagesA">Carriages A</String>
<String key="CatCarriagesB">Carriages B</String>
<String key="CatElectricLoco">Electric locomotive</String>
<String key="CatDieselLoco">Diesel locomotive</String>
<String key="CatSteamLoco">Steam locomotive</String>
<String key="CatRailbus">Railbus</String>
<String key="CatDraisine">Draisine</String>
<String key="CatWork">Work vehicles</String>
<String key="CatPrototype">Prototype</String>
<String key="CatElectricLoco">Electric</String>
<String key="CatDieselLoco">Diesel</String>
<String key="CatSteamLoco">Steam</String>
<String key="CatRailbus">Railcar</String>
<String key="CatDraisine">Push-trolley</String>
<String key="CatWork">Work train</String>
<String key="CatPrototype">Prototypes</String>
<String key="CatTram">Tram</String>
<String key="CatCar">Car</String>
<String key="CatCar">Motorcar</String>
<String key="CatBus">Bus</String>
<String key="CatTruck">Truck</String>
<String key="CatPeople">People</String>
<String key="CatAnimals">Animals</String>
<String key="CatWagonsA">Wagons A</String>
<String key="CatWagonsB">Wagons B</String>
<String key="CatWagonsC">Wagons C</String>
<String key="CatWagonsD">Wagons D</String>
<String key="CatWagonsE">Wagons E</String>
<String key="CatWagonsF">Wagons F</String>
<String key="CatWagonsG">Wagons G</String>
<String key="CatWagonsH">Wagons H</String>
<String key="CatWagonsI">Wagons I</String>
<String key="CatWagonsJ">Wagons J</String>
<String key="CatWagonsK">Wagons K</String>
<String key="CatWagonsL">Wagons L</String>
<String key="CatWagonsM">Wagons M</String>
<String key="CatWagonsN">Wagons N</String>
<String key="CatWagonsO">Wagons O</String>
<String key="CatWagonsP">Wagons P</String>
<String key="CatWagonsQ">Wagons Q</String>
<String key="CatWagonsR">Wagons R</String>
<String key="CatWagonsS">Wagons S</String>
<String key="CatWagonsT">Wagons T</String>
<String key="CatWagonsU">Wagons U</String>
<String key="CatWagonsV">Wagons V</String>
<String key="CatWagonsW">Wagons W</String>
<String key="CatWagonsX">Wagons X</String>
<String key="CatWagonsY">Wagons Y</String>
<String key="CatWagonsZ">Wagons Z</String>
<String key="CatWagonsA">Car A</String>
<String key="CatWagonsB">Car B</String>
<String key="CatWagonsC">Car C</String>
<String key="CatWagonsD">Car D</String>
<String key="CatWagonsE">Car E</String>
<String key="CatWagonsF">Car F</String>
<String key="CatWagonsG">Car G</String>
<String key="CatWagonsH">Car H</String>
<String key="CatWagonsI">Car I</String>
<String key="CatWagonsJ">Car J</String>
<String key="CatWagonsK">Car K</String>
<String key="CatWagonsL">Car L</String>
<String key="CatWagonsM">Car M</String>
<String key="CatWagonsN">Car N</String>
<String key="CatWagonsO">Car O</String>
<String key="CatWagonsP">Car P</String>
<String key="CatWagonsQ">Car Q</String>
<String key="CatWagonsR">Car R</String>
<String key="CatWagonsS">Car S</String>
<String key="CatWagonsT">Car T</String>
<String key="CatWagonsU">Car U</String>
<String key="CatWagonsV">Car V</String>
<String key="CatWagonsW">Car W</String>
<String key="CatWagonsX">Car X</String>
<String key="CatWagonsY">Car Y</String>
<String key="CatWagonsZ">Car Z</String>
<String key="CatOther">Other</String>
<String key="Brakes">Brakes</String>
<String key="Loads">Loads</String>
@@ -223,6 +230,7 @@
<String key="ReadyOverrideReady">Always ready</String>
<String key="ConsistReadyOverridden">Overridden by "Force the consist state on launch" in Settings.</String>
<String key="ConsistRandomType">Random type</String>
<String key="ConsistMaxAmount">Maximum amount</String>
<String key="ConsistRandomAmount">Random amount</String>
<String key="Set">Set</String>
<String key="Length">Length</String>
@@ -251,7 +259,14 @@
<String key="ExeLaunchFailed">Could not start the simulator</String>
<String key="ExeNotFound">The simulator executable was not found.</String>
<String key="ExeNotExecutable">The file is not marked as executable (chmod +x).</String>
<String key="ExeWrongPlatform">This binary is built for a different operating system.</String>
<String key="ExeWrongPlatform">This binary is built for a different operating system. On Linux a Windows build can still run through Wine or Proton - if you use them, this is not an error.</String>
<String key="SimExitTitle">The simulator ended unexpectedly</String>
<String key="SimExitCode">Exit code: {0}</String>
<String key="SimExitQuick">It closed {0:0.0} s after starting.</String>
<String key="SimExitMessages">Last messages:</String>
<String key="SimExitSeeLog">The full run is in log.txt in the simulator folder.</String>
<String key="SimExitDump">The simulator saved a memory dump: {0}</String>
<String key="SimExitOpenDump">Open the folder with the dump? Attach it to the bug report.</String>
<String key="TabInfo">Info</String>
<String key="SettingsFileChangedTitle">Settings</String>
<String key="SettingsFileChanged">eu07.ini was changed outside the starter. Reload it?</String>
@@ -271,8 +286,16 @@
<String key="CouplingUnitRear">Coupler at the rear of the unit</String>
<String key="Join">Join</String>
<String key="TipJoin">Couple this vehicle with the next one into a single unit</String>
<String key="TipReorder">Drag to reorder</String>
<String key="CouplerCode">Coupler code: {0}</String>
<String key="CouplerFree">The vehicles are not coupled.</String>
<String key="CouplerConnects">Connected: {0}</String>
<String key="CouplerEndSignals">End of the train — the simulator puts up the end signals.</String>
<String key="CouplerNoEndSignals">Anything but 0 at the back of the train — the simulator puts up no end signals.</String>
<String key="CouplerLocked">Locked — not uncoupled in shunting.</String>
<String key="CouplerClickToEdit">Click to change.</String>
<String key="AddVehicle">Add</String>
<String key="AddMany">Add several…</String>
<String key="AddManyCount">How many times to add it</String>
<String key="TipAddVehicle">Add this vehicle to the consist</String>
<String key="TipAddUnit">Add the whole unit to the consist</String>
@@ -334,12 +357,17 @@
<String key="Others">Other</String>
<String key="SelectEXE">Select exe</String>
<String key="SelectEXEAuto">Select automatically</String>
<String key="SelectEXEBrowse">Choose file…</String>
<String key="SelectEXEBrowseTitle">Pick the simulator executable</String>
<String key="SelectEXEUseAnyway">Use it anyway?</String>
<String key="DebugMode">Debug mode</String>
<String key="DebugModeDesc">Unlocks debug menus, extra panels and test shortcuts.</String>
<String key="VirtualShunting">Virtual shunting</String>
<String key="VirtualShuntingDesc">Enables an AI assistant for coupling, uncoupling and simple shunting moves while the vehicle is stopped.</String>
<String key="LogMissingVehicleFiles">Log missing vehicle files during export</String>
<String key="LogMissingVehicleFilesDesc">Writes vehicles the starter could not find into the log.</String>
<String key="DeveloperTools">Show the package author's tab</String>
<String key="DeveloperToolsDesc">Adds a tab with the state of the vehicle database, for preparing rolling stock packages.</String>
<String key="Graphic">Graphics</String>
<String key="RenderEngine">Render engine</String>
@@ -350,6 +378,7 @@
<String key="RESimp">Simplified</String>
<String key="REOff">Disable visual layer</String>
<String key="REExperimental">Experimental</String>
<String key="REExperimentalWarning">Note: the experimental renderer is still being developed. It may be unstable, miss parts of the scene or not start on some graphics cards. If the simulator does not start or looks wrong, go back to the “Full” renderer.</String>
<String key="Resolution">Resolution</String>
<String key="BufforResolution">Buffer resolution</String>
@@ -493,6 +522,7 @@
<String key="KbAssigned">Assigned</String>
<String key="KbShift">With Shift</String>
<String key="KbCtrl">With Ctrl</String>
<String key="KbShiftCtrl">With Ctrl + Shift</String>
<String key="KbReserved">Reserved by the simulator</String>
<String key="BindKeyTitle">Key:</String>
<String key="BindKeyHint">Assign a command to each modifier combination for this key.</String>
@@ -519,7 +549,12 @@
<String key="FaultNoScenery">No scenarios found.</String>
<String key="FaultNoVehicles">No vehicles found.</String>
<String key="FaultNoPhysics">No rolling stock data found.</String>
<String key="FaultNoExe">Simulator executable not found.</String>
<String key="FaultNoExeTitle">Simulator not found</String>
<String key="FaultNoExe">The starter runs the simulator ({0}) from the folder it was started in:&#10;{1}&#10;&#10;There is no simulator there, so Start will not work.</String>
<String key="FaultNoExePick">If the simulator lives elsewhere, such as your own build, point to it. You can change the choice later in Settings, under Other. If this is not the MaSzyna folder, start the starter from the right one.</String>
<String key="FaultNoExeWine">On Linux you can also point to the Windows build (eu07.exe) — it runs through Wine or Proton.</String>
<String key="FaultNoExePickNow">Point to the simulator…</String>
<String key="NotNow">Not now</String>
<!-- About -->
<String key="AboutTitle">About</String>
@@ -562,7 +597,8 @@
<String key="WarehouseImportFile">Add consists from file...</String>
<String key="StartWithoutSaving">Start without saving settings</String>
<String key="StartFreeFly">Start with no vehicle</String>
<String key="VolumePaused">Volume while paused</String>
<String key="StartEditor">Open the scenery editor</String>
<String key="StartEditorWarning">Only some simulator builds have the scenery editor (eu07 -edit). If the selected simulator does not, the editor will not open. It works on {0} itself, not on the starter's copy, so what you save goes into the scenery. Start it?</String>
<String key="DebugLogVisible">Show log in console</String>
<String key="PyScreenPriority">Extra screen update delay</String>
<String key="PyScreenPriorityDesc">Minimum time between Python cab screen updates in milliseconds. Higher value = less frequent updates.</String>
@@ -619,4 +655,20 @@
<String key="ShortcutMember">Pick a car inside a coupled unit</String>
<String key="ShortcutMoveVehicle">Move the vehicle along the consist</String>
<String key="ShortcutCardMenu">Open the vehicle menu</String>
<!-- Package author's tab -->
<String key="DevDatabase">Vehicle database</String>
<String key="DevVehicles">Vehicle folders</String>
<String key="DevLiveries">Liveries</String>
<String key="DevWrecks">wrecks</String>
<String key="DevGroups">Groups</String>
<String key="DevSets">Fixed sets</String>
<String key="DevFiles">textures.txt files</String>
<String key="DevSource">Read from</String>
<String key="DevSourceCopy">the starter's copy</String>
<String key="DevSourceTextures">textures.txt files</String>
<String key="DevWritten">Copy written</String>
<String key="DevLocation">Copy folder</String>
<String key="DevRebuild">Rebuild copy</String>
<String key="DevRebuilt">Rebuilt</String>
</Language>

View File

@@ -7,6 +7,7 @@
<String key="NavScenarios">Forgatókönyvek</String>
<String key="NavDepot">Fűtőház</String>
<String key="NavSettings">Beállítások</String>
<String key="NavDeveloper">Developer</String>
<String key="NavControls">Irányítás</String>
<String key="Help">Súgó</String>
<String key="HelpWiki">MaSzyna wiki</String>
@@ -65,6 +66,9 @@
<String key="StartNoStaffConfirm">Ebben a szerelvényben nincs személyzet és utas sem — nem lesz mit vezetni. Mégis elindítja?</String>
<String key="RestoreWeather">Időjárás visszaállítása</String>
<String key="ReloadSceneries">Újratöltés</String>
<String key="Favorites">Kedvencek</String>
<String key="FavoriteAdd">Hozzáadás a kedvencekhez</String>
<String key="FavoriteRemove">Eltávolítás a kedvencek közül</String>
<String key="OpenSceneryFolder">Mappa megnyitása</String>
<String key="OptionalIncludes">Opcionális elemek</String>
<String key="OpenTextureFolder">Textúramappa megnyitása</String>
@@ -91,6 +95,9 @@
<String key="Crew">személyzettel</String>
<String key="Reversed">megfordítva</String>
<String key="NoVehicles">A dynamic/**/textures.txt fájlokban nem található jármű.</String>
<String key="BrowserHint">Válassz kategóriát, vagy írd be a jármű nevét.</String>
<String key="BrowserNoMatch">Semmi sem felel meg ennek: „{0}”.</String>
<String key="SearchAllVehicles">Keresés az összes járműben…</String>
<String key="ConsistEmpty">Adjon hozzá járműveket a bal oldali adatbázisból, vagy töltsön be meglévő szerelvényt egy pályafájlból.</String>
<String key="TipMoveLeft">Mozgatás balra</String>
<String key="TipMoveRight">Mozgatás jobbra</String>
@@ -103,49 +110,49 @@
<String key="All">Mind</String>
<String key="CatDiesel">Dízel</String>
<String key="CatElectric">Villamos</String>
<String key="CatEMU">Villamos motorvonat</String>
<String key="CatEMU">Motorvonat</String>
<String key="CatDMU">Dízel motorvonat</String>
<String key="CatCarriagesA">Kocsik A</String>
<String key="CatCarriagesB">Kocsik B</String>
<String key="CatElectricLoco">Villamos mozdony</String>
<String key="CatDieselLoco">Dízelmozdony</String>
<String key="CatSteamLoco">Gőzmozdony</String>
<String key="CatRailbus">Motorkocsi</String>
<String key="CatDieselLoco">Dízel</String>
<String key="CatSteamLoco">Gőz</String>
<String key="CatRailbus">Vasúti kocsi</String>
<String key="CatDraisine">Hajtány</String>
<String key="CatWork">Munkagépek</String>
<String key="CatPrototype">Prototípus</String>
<String key="CatWork">Üzemi vonat</String>
<String key="CatPrototype">Prototípusok</String>
<String key="CatTram">Villamos</String>
<String key="CatCar">Autó</String>
<String key="CatBus">Autóbusz</String>
<String key="CatTruck">Tehergépkocsi</String>
<String key="CatBus">Busz</String>
<String key="CatTruck">Teherautó</String>
<String key="CatPeople">Emberek</String>
<String key="CatAnimals">Állatok</String>
<String key="CatWagonsA">Vagonok A</String>
<String key="CatWagonsB">Vagonok B</String>
<String key="CatWagonsC">Vagonok C</String>
<String key="CatWagonsD">Vagonok D</String>
<String key="CatWagonsE">Vagonok E</String>
<String key="CatWagonsF">Vagonok F</String>
<String key="CatWagonsG">Vagonok G</String>
<String key="CatWagonsH">Vagonok H</String>
<String key="CatWagonsI">Vagonok I</String>
<String key="CatWagonsJ">Vagonok J</String>
<String key="CatWagonsK">Vagonok K</String>
<String key="CatWagonsL">Vagonok L</String>
<String key="CatWagonsM">Vagonok M</String>
<String key="CatWagonsN">Vagonok N</String>
<String key="CatWagonsO">Vagonok O</String>
<String key="CatWagonsP">Vagonok P</String>
<String key="CatWagonsQ">Vagonok Q</String>
<String key="CatWagonsR">Vagonok R</String>
<String key="CatWagonsS">Vagonok S</String>
<String key="CatWagonsT">Vagonok T</String>
<String key="CatWagonsU">Vagonok U</String>
<String key="CatWagonsV">Vagonok V</String>
<String key="CatWagonsW">Vagonok W</String>
<String key="CatWagonsX">Vagonok X</String>
<String key="CatWagonsY">Vagonok Y</String>
<String key="CatWagonsZ">Vagonok Z</String>
<String key="CatWagonsA">Kocsi A</String>
<String key="CatWagonsB">Kocsi B</String>
<String key="CatWagonsC">Kocsi C</String>
<String key="CatWagonsD">Kocsi D</String>
<String key="CatWagonsE">Kocsi E</String>
<String key="CatWagonsF">Kocsi F</String>
<String key="CatWagonsG">Kocsi G</String>
<String key="CatWagonsH">Kocsi H</String>
<String key="CatWagonsI">Kocsi I</String>
<String key="CatWagonsJ">Kocsi J</String>
<String key="CatWagonsK">Kocsi K</String>
<String key="CatWagonsL">Kocsi L</String>
<String key="CatWagonsM">Kocsi M</String>
<String key="CatWagonsN">Kocsi N</String>
<String key="CatWagonsO">Kocsi O</String>
<String key="CatWagonsP">Kocsi P</String>
<String key="CatWagonsQ">Kocsi Q</String>
<String key="CatWagonsR">Kocsi R</String>
<String key="CatWagonsS">Kocsi S</String>
<String key="CatWagonsT">Kocsi T</String>
<String key="CatWagonsU">Kocsi U</String>
<String key="CatWagonsV">Kocsi V</String>
<String key="CatWagonsW">Kocsi W</String>
<String key="CatWagonsX">Kocsi X</String>
<String key="CatWagonsY">Kocsi Y</String>
<String key="CatWagonsZ">Kocsi Z</String>
<String key="CatOther">Egyéb</String>
<String key="Brakes">Fékek</String>
<String key="Loads">Rakomány</String>
@@ -223,6 +230,7 @@
<String key="ReadyOverrideReady">Always ready</String>
<String key="ConsistReadyOverridden">Overridden by "Force the consist state on launch" in Settings.</String>
<String key="ConsistRandomType">Véletlen típus</String>
<String key="ConsistMaxAmount">Legnagyobb mennyiség</String>
<String key="ConsistRandomAmount">Véletlen mennyiség</String>
<String key="Set">Beállítás</String>
<String key="Length">Hossz</String>
@@ -251,7 +259,14 @@
<String key="ExeLaunchFailed">A szimulátor indítása sikertelen</String>
<String key="ExeNotFound">A szimulátor futtatható fájlja nem található.</String>
<String key="ExeNotExecutable">A fájl nincs futtathatóként megjelölve (chmod +x).</String>
<String key="ExeWrongPlatform">Ez a bináris másik operációs rendszerhez készült.</String>
<String key="ExeWrongPlatform">Ez a bináris másik operációs rendszerhez készült. Linuxon a Windows-verzió Wine vagy Proton segítségével futtatható - ha ezeket használod, ez nem hiba.</String>
<String key="SimExitTitle">A szimulátor váratlanul leállt</String>
<String key="SimExitCode">Kilépési kód: {0}</String>
<String key="SimExitQuick">{0:0.0} másodperccel az indítás után bezárult.</String>
<String key="SimExitMessages">Utolsó üzenetek:</String>
<String key="SimExitSeeLog">A teljes futás a szimulátor mappájában lévő log.txt fájlban található.</String>
<String key="SimExitDump">A szimulátor memóriaképet mentett: {0}</String>
<String key="SimExitOpenDump">Megnyitod a memóriaképet tartalmazó mappát? Csatold a hibajelentéshez.</String>
<String key="TabInfo">Információ</String>
<String key="SettingsFileChangedTitle">Beállítások</String>
<String key="SettingsFileChanged">Az eu07.ini fájl a starteren kívül módosult. Újratölti?</String>
@@ -271,8 +286,16 @@
<String key="CouplingUnitRear">Az egység végén lévő kapcsolókészülék</String>
<String key="Join">Összekapcsolás</String>
<String key="TipJoin">A jármű összekapcsolása a következővel egy egységgé</String>
<String key="TipReorder">Húzza az átrendezéshez</String>
<String key="CouplerCode">Kapcsolat kódja: {0}</String>
<String key="CouplerFree">A járművek nincsenek összekapcsolva.</String>
<String key="CouplerConnects">Összekapcsolva: {0}</String>
<String key="CouplerEndSignals">A szerelvény vége — a szimulátor kiteszi a zárjelzőket.</String>
<String key="CouplerNoEndSignals">A szerelvény végén 0-tól eltérő kód — a szimulátor nem teszi ki a zárjelzőket.</String>
<String key="CouplerLocked">Zárolva — tolatáskor nem bontható.</String>
<String key="CouplerClickToEdit">Kattints a módosításhoz.</String>
<String key="AddVehicle">Hozzáadás</String>
<String key="AddMany">Hozzáadás többször…</String>
<String key="AddManyCount">Hányszor add hozzá a járművet</String>
<String key="TipAddVehicle">Jármű hozzáadása a szerelvényhez</String>
<String key="TipAddUnit">A teljes egység hozzáadása a szerelvényhez</String>
@@ -331,9 +354,13 @@
<String key="Others">Egyéb</String>
<String key="SelectEXE">Exe kiválasztása</String>
<String key="SelectEXEAuto">Automatikus kiválasztás</String>
<String key="SelectEXEBrowse">Fájl kiválasztása…</String>
<String key="SelectEXEBrowseTitle">Válaszd ki a szimulátor futtatható fájlját</String>
<String key="SelectEXEUseAnyway">Mégis használod?</String>
<String key="DebugMode">Hibakeresési mód (teszt mód)</String>
<String key="VirtualShunting">Virtuális tolatás</String>
<String key="LogMissingVehicleFiles">Hiányzó járműfájlok naplózása exportáláskor</String>
<String key="DeveloperTools">Show the package author's tab</String>
<String key="Graphic">Grafika</String>
<String key="RenderEngine">Megjelenítő motor</String>
@@ -343,6 +370,7 @@
<String key="RESimp">Egyszerűsített</String>
<String key="REOff">Vizuális réteg kikapcsolása</String>
<String key="REExperimental">Kísérleti</String>
<String key="REExperimentalWarning">Figyelem: a kísérleti renderelő még fejlesztés alatt áll. Instabil lehet, a jelenet egyes részei hiányozhatnak, vagy egyes videokártyákon el sem indul. Ha a szimulátor nem indul el vagy hibásan jelenik meg, válts vissza a „Teljes” renderelőre.</String>
<String key="Resolution">Felbontás</String>
<String key="BufforResolution">Puffer felbontása</String>
@@ -459,6 +487,7 @@
<String key="KbAssigned">Hozzárendelve</String>
<String key="KbShift">Shift-tel</String>
<String key="KbCtrl">Ctrl-lal</String>
<String key="KbShiftCtrl">Ctrl + Shift-tel</String>
<String key="KbReserved">A szimulátor számára fenntartva</String>
<String key="BindKeyTitle">Billentyű:</String>
<String key="BindKeyHint">Rendeljen parancsot a billentyű minden módosító-kombinációjához.</String>
@@ -485,7 +514,12 @@
<String key="FaultNoScenery">Nem található forgatókönyv.</String>
<String key="FaultNoVehicles">Nem található jármű.</String>
<String key="FaultNoPhysics">Nem található járműadat.</String>
<String key="FaultNoExe">A szimulátor futtatható fájlja nem található.</String>
<String key="FaultNoExeTitle">A szimulátor nem található</String>
<String key="FaultNoExe">A starter abból a mappából indítja a szimulátort ({0}), ahonnan őt magát elindították:&#10;{1}&#10;&#10;Ott nincs szimulátor, így az Indítás nem fog működni.</String>
<String key="FaultNoExePick">Ha a szimulátor máshol van, például saját build, válaszd ki. A választást később a Beállítások Egyéb szakaszában módosíthatod. Ha ez nem a MaSzyna mappája, a megfelelő mappából indítsd a startert.</String>
<String key="FaultNoExeWine">Linuxon a Windows-verziót (eu07.exe) is kiválaszthatod — Wine vagy Proton segítségével fut.</String>
<String key="FaultNoExePickNow">Szimulátor kiválasztása…</String>
<String key="NotNow">Most nem</String>
<!-- About -->
<String key="AboutTitle">Névjegy</String>
@@ -528,7 +562,8 @@
<String key="WarehouseImportFile">Szerelvények hozzáadása fájlból...</String>
<String key="StartWithoutSaving">Indítás a beállítások mentése nélkül</String>
<String key="StartFreeFly">Indítás jármű nélkül</String>
<String key="VolumePaused">Hangerő szünet alatt</String>
<String key="StartEditor">Pályaszerkesztő indítása</String>
<String key="StartEditorWarning">A pályaszerkesztő (eu07 -edit) csak a szimulátor egyes verzióiban érhető el. Ha a kiválasztott szimulátor nem támogatja, a szerkesztő nem nyílik meg. Közvetlenül a(z) {0} fájlon dolgozik, nem a starter másolatán, így a mentett változások a pályába kerülnek. Elindítod?</String>
<String key="DebugLogVisible">Szimuláció menetének megjelenítése</String>
<String key="PyScreenPriority">Kiegészítő képernyők frissítési gyakorisága</String>
<String key="PyPriorityNormal">Normál</String>
@@ -584,4 +619,20 @@
<String key="ShortcutMember">Tag kiválasztása kapcsolt egységen belül</String>
<String key="ShortcutMoveVehicle">Jármű mozgatása a szerelvényben</String>
<String key="ShortcutCardMenu">Jármű menüjének megnyitása</String>
<!-- Package author's tab - awaiting translation -->
<String key="DevDatabase">Vehicle database</String>
<String key="DevVehicles">Vehicle folders</String>
<String key="DevLiveries">Liveries</String>
<String key="DevWrecks">wrecks</String>
<String key="DevGroups">Groups</String>
<String key="DevSets">Fixed sets</String>
<String key="DevFiles">textures.txt files</String>
<String key="DevSource">Read from</String>
<String key="DevSourceCopy">the starter's copy</String>
<String key="DevSourceTextures">textures.txt files</String>
<String key="DevWritten">Copy written</String>
<String key="DevLocation">Copy folder</String>
<String key="DevRebuild">Rebuild copy</String>
<String key="DevRebuilt">Rebuilt</String>
</Language>

View File

@@ -7,6 +7,7 @@
<String key="NavScenarios">Scenariusze</String>
<String key="NavDepot">Zajezdnia</String>
<String key="NavSettings">Ustawienia</String>
<String key="NavDeveloper">Deweloper</String>
<String key="NavControls">Sterowanie</String>
<String key="Help">Pomoc</String>
<String key="HelpWiki">Wiki MaSzyna</String>
@@ -65,6 +66,9 @@
<String key="StartNoStaffConfirm">Skład nie ma obsady ani pasażera — nie będzie czym kierować. Uruchomić mimo to?</String>
<String key="RestoreWeather">Przywróć pogodę</String>
<String key="ReloadSceneries">Przeładuj</String>
<String key="Favorites">Ulubione</String>
<String key="FavoriteAdd">Dodaj do ulubionych</String>
<String key="FavoriteRemove">Usuń z ulubionych</String>
<String key="OpenSceneryFolder">Otwórz folder</String>
<String key="OptionalIncludes">Elementy opcjonalne</String>
<String key="OpenTextureFolder">Otwórz folder tekstury</String>
@@ -91,6 +95,9 @@
<String key="Crew">z obsadą</String>
<String key="Reversed">Obrócony</String>
<String key="NoVehicles">Nie znaleziono pojazdów w dynamic/**/textures.txt.</String>
<String key="BrowserHint">Wybierz kategorię albo wpisz nazwę pojazdu.</String>
<String key="BrowserNoMatch">Nic nie pasuje do „{0}”.</String>
<String key="SearchAllVehicles">Szukaj we wszystkich pojazdach…</String>
<String key="ConsistEmpty">Dodaj pojazdy z bazy po lewej stronie lub wczytaj istniejący skład z pliku scenerii.</String>
<String key="TipMoveLeft">Przesuń w lewo</String>
<String key="TipMoveRight">Przesuń w prawo</String>
@@ -107,17 +114,17 @@
<String key="CatDMU">SZT</String>
<String key="CatCarriagesA">Wagony A</String>
<String key="CatCarriagesB">Wagony B</String>
<String key="CatElectricLoco">Lokomotywa elektryczna</String>
<String key="CatDieselLoco">Lokomotywa spalinowa</String>
<String key="CatSteamLoco">Parowóz</String>
<String key="CatRailbus">Szynobus</String>
<String key="CatDraisine">Drezyna</String>
<String key="CatWork">Pojazdy robocze</String>
<String key="CatPrototype">Prototyp</String>
<String key="CatTram">Tramwaj</String>
<String key="CatCar">Samochód osobowy</String>
<String key="CatBus">Autobus</String>
<String key="CatTruck">Samochód ciężarowy</String>
<String key="CatElectricLoco">Elektrowozy</String>
<String key="CatDieselLoco">Spalinowozy</String>
<String key="CatSteamLoco">Parowozy</String>
<String key="CatRailbus">Szynobusy</String>
<String key="CatDraisine">Drezyny</String>
<String key="CatWork">Robocze</String>
<String key="CatPrototype">Prototypy</String>
<String key="CatTram">Tramwaje</String>
<String key="CatCar">Samochody</String>
<String key="CatBus">Autobusy</String>
<String key="CatTruck">Ciężarówki</String>
<String key="CatPeople">Ludzie</String>
<String key="CatAnimals">Zwierzęta</String>
<String key="CatWagonsA">Wagony A</String>
@@ -223,6 +230,7 @@
<String key="ReadyOverrideReady">Zawsze gotowy</String>
<String key="ConsistReadyOverridden">Nadpisane opcją „Wymuś stan składu przy starcie” w Ustawieniach.</String>
<String key="ConsistRandomType">Losowy rodzaj</String>
<String key="ConsistMaxAmount">Maksymalna ilość</String>
<String key="ConsistRandomAmount">Losowa ilość</String>
<String key="Set">Ustaw</String>
<String key="Length">Długość</String>
@@ -251,7 +259,14 @@
<String key="ExeLaunchFailed">Nie udało się uruchomić symulatora</String>
<String key="ExeNotFound">Nie znaleziono pliku wykonywalnego symulatora.</String>
<String key="ExeNotExecutable">Plik nie ma prawa wykonywania (chmod +x).</String>
<String key="ExeWrongPlatform">To jest plik wykonywalny dla innego systemu niż uruchomiony.</String>
<String key="ExeWrongPlatform">To jest plik wykonywalny dla innego systemu niż uruchomiony. Na Linuksie wersja dla Windows może działać przez Wine lub Proton - jeśli ich używasz, to nie jest błąd.</String>
<String key="SimExitTitle">Symulator zakończył pracę nieoczekiwanie</String>
<String key="SimExitCode">Kod wyjścia: {0}</String>
<String key="SimExitQuick">Zamknął się {0:0.0} s po uruchomieniu.</String>
<String key="SimExitMessages">Ostatnie komunikaty:</String>
<String key="SimExitSeeLog">Pełny przebieg jest w pliku log.txt w katalogu symulatora.</String>
<String key="SimExitDump">Symulator zapisał zrzut pamięci: {0}</String>
<String key="SimExitOpenDump">Otworzyć folder ze zrzutem? Dołącz go do zgłoszenia błędu.</String>
<String key="TabInfo">Informacje</String>
<String key="SettingsFileChangedTitle">Ustawienia</String>
<String key="SettingsFileChanged">Plik eu07.ini został zmieniony poza starterem. Wczytać ponownie?</String>
@@ -271,8 +286,16 @@
<String key="CouplingUnitRear">Sprzęg na końcu zespołu</String>
<String key="Join">Złącz</String>
<String key="TipJoin">Połącz ten pojazd z następnym w jeden zespół</String>
<String key="TipReorder">Przeciągnij, aby zmienić kolejność</String>
<String key="CouplerCode">Kod sprzęgu: {0}</String>
<String key="CouplerFree">Pojazdy nie są połączone.</String>
<String key="CouplerConnects">Połączone: {0}</String>
<String key="CouplerEndSignals">Koniec składu — symulator wystawi końcówki.</String>
<String key="CouplerNoEndSignals">Kod inny niż 0 na końcu składu — symulator nie wystawi końcówek.</String>
<String key="CouplerLocked">Zablokowany — nie rozprzęga się przy manewrach.</String>
<String key="CouplerClickToEdit">Kliknij, aby zmienić.</String>
<String key="AddVehicle">Dodaj</String>
<String key="AddMany">Dodaj wiele…</String>
<String key="AddManyCount">Ile razy dodać pojazd</String>
<String key="TipAddVehicle">Dodaj ten pojazd do składu</String>
<String key="TipAddUnit">Dodaj cały zespół do składu</String>
@@ -334,12 +357,17 @@
<String key="Others">Inne</String>
<String key="SelectEXE">Wybór exe</String>
<String key="SelectEXEAuto">Wybierz automatycznie</String>
<String key="SelectEXEBrowse">Wybierz plik…</String>
<String key="SelectEXEBrowseTitle">Wskaż plik wykonywalny symulatora</String>
<String key="SelectEXEUseAnyway">Użyć go mimo to?</String>
<String key="DebugMode">Tryb testowy</String>
<String key="DebugModeDesc">Odblokowuje menu debug, dodatkowe panele i skróty testowe.</String>
<String key="VirtualShunting">Wirtualny manewrowy</String>
<String key="VirtualShuntingDesc">Włącza asystenta AI do sprzęgania, rozprzęgania i prostych manewrów, gdy pojazd stoi.</String>
<String key="LogMissingVehicleFiles">Zapisuj w logu brakujące pliki pojazdów podczas eksportu</String>
<String key="LogMissingVehicleFilesDesc">Zapisuje w logu pojazdy, których starter nie znalazł.</String>
<String key="DeveloperTools">Pokaż zakładkę dla twórców paczek</String>
<String key="DeveloperToolsDesc">Dodaje zakładkę ze stanem bazy pojazdów, przydatną przy przygotowywaniu paczek z taborem.</String>
<String key="Graphic">Grafika</String>
<String key="RenderEngine">Silnik renderowania</String>
@@ -350,6 +378,7 @@
<String key="RESimp">Uproszczony</String>
<String key="REOff">Wyłącz warstwę wizualną</String>
<String key="REExperimental">Eksperymentalny</String>
<String key="REExperimentalWarning">Uwaga: renderer eksperymentalny jest w trakcie rozwoju. Może działać niestabilnie, nie pokazywać części obiektów albo nie uruchomić się na niektórych kartach graficznych. Gdy symulator nie startuje lub wygląda źle, wróć do renderera „Pełny”.</String>
<String key="Resolution">Rozdzielczość</String>
<String key="BufforResolution">Rozdzielczość bufora</String>
@@ -493,6 +522,7 @@
<String key="KbAssigned">Przypisany</String>
<String key="KbShift">Z klawiszem Shift</String>
<String key="KbCtrl">Z klawiszem Ctrl</String>
<String key="KbShiftCtrl">Z Ctrl + Shift</String>
<String key="KbReserved">Zarezerwowany przez symulator</String>
<String key="BindKeyTitle">Klawisz:</String>
<String key="BindKeyHint">Przypisz komendę do każdej kombinacji modyfikatorów dla tego klawisza.</String>
@@ -519,7 +549,12 @@
<String key="FaultNoScenery">Nie znaleziono scenariuszy.</String>
<String key="FaultNoVehicles">Nie znaleziono pojazdów.</String>
<String key="FaultNoPhysics">Nie znaleziono danych taboru.</String>
<String key="FaultNoExe">Nie znaleziono pliku wykonywalnego symulatora.</String>
<String key="FaultNoExeTitle">Nie znaleziono symulatora</String>
<String key="FaultNoExe">Starter uruchamia symulator ({0}) z folderu, z którego sam został uruchomiony:&#10;{1}&#10;&#10;Nie ma tam symulatora, więc Start nie zadziała.</String>
<String key="FaultNoExePick">Jeśli symulator leży gdzie indziej, np. jako własny build, wskaż go. Wybór zmienisz potem w Ustawieniach, w sekcji Inne. Jeśli to nie jest folder MaSzyny, uruchom starter z właściwego folderu.</String>
<String key="FaultNoExeWine">Na Linuksie możesz wskazać też wersję dla Windows (eu07.exe) — uruchomi się przez Wine lub Proton.</String>
<String key="FaultNoExePickNow">Wskaż symulator…</String>
<String key="NotNow">Nie teraz</String>
<!-- About -->
<String key="AboutTitle">O programie</String>
@@ -562,7 +597,8 @@
<String key="WarehouseImportFile">Dodaj składy z pliku...</String>
<String key="StartWithoutSaving">Uruchom bez zapisu ustawień</String>
<String key="StartFreeFly">Uruchom bez pojazdu</String>
<String key="VolumePaused">Głośność podczas pauzy</String>
<String key="StartEditor">Uruchom edytor scenerii</String>
<String key="StartEditorWarning">Edytor scenerii (eu07 -edit) mają tylko niektóre wersje symulatora. Jeśli wybrany plik symulatora go nie obsługuje, edytor się nie otworzy. Edytor pracuje na samym pliku {0}, a nie na kopii przygotowanej przez starter, więc zapisane zmiany trafią do scenerii. Uruchomić?</String>
<String key="DebugLogVisible">Wyświetlaj log w konsoli</String>
<String key="PyScreenPriority">Opóźnienie odświeżania ekranów dodatkowych</String>
<String key="PyScreenPriorityDesc">Minimalny czas między aktualizacjami ekranów Python w kabinach (ms). Wyższa wartość = rzadsze odświeżanie.</String>
@@ -619,4 +655,20 @@
<String key="ShortcutMember">Wybierz człon w zespolonej jednostce</String>
<String key="ShortcutMoveVehicle">Przesuń pojazd w składzie</String>
<String key="ShortcutCardMenu">Otwórz menu pojazdu</String>
<!-- Zakladka dla tworcow paczek -->
<String key="DevDatabase">Baza pojazdów</String>
<String key="DevVehicles">Katalogi pojazdów</String>
<String key="DevLiveries">Malowania</String>
<String key="DevWrecks">wraki</String>
<String key="DevGroups">Grupy</String>
<String key="DevSets">Zespoły stałe</String>
<String key="DevFiles">Pliki textures.txt</String>
<String key="DevSource">Wczytano z</String>
<String key="DevSourceCopy">kopii startera</String>
<String key="DevSourceTextures">plików textures.txt</String>
<String key="DevWritten">Kopia zapisana</String>
<String key="DevLocation">Katalog kopii</String>
<String key="DevRebuild">Odbuduj kopię</String>
<String key="DevRebuilt">Odbudowano</String>
</Language>

View File

@@ -7,6 +7,7 @@
<String key="NavScenarios">Сценарии</String>
<String key="NavDepot">Депо</String>
<String key="NavSettings">Настройки</String>
<String key="NavDeveloper">Developer</String>
<String key="NavControls">Управление</String>
<String key="Help">Справка</String>
<String key="HelpWiki">Вики MaSzyna</String>
@@ -65,6 +66,9 @@
<String key="StartNoStaffConfirm">В этом составе нет ни бригады, ни пассажира — управлять будет нечем. Всё равно запустить?</String>
<String key="RestoreWeather">Восстановить погоду</String>
<String key="ReloadSceneries">Перезагрузить</String>
<String key="Favorites">Избранное</String>
<String key="FavoriteAdd">Добавить в избранное</String>
<String key="FavoriteRemove">Удалить из избранного</String>
<String key="OpenSceneryFolder">Открыть папку</String>
<String key="OptionalIncludes">Дополнительные элементы</String>
<String key="OpenTextureFolder">Открыть папку с текстурами</String>
@@ -91,6 +95,9 @@
<String key="Crew">с бригадой</String>
<String key="Reversed">развёрнут</String>
<String key="NoVehicles">В dynamic/**/textures.txt подвижной состав не найден.</String>
<String key="BrowserHint">Выберите категорию или введите название единицы.</String>
<String key="BrowserNoMatch">Ничего не найдено по запросу «{0}».</String>
<String key="SearchAllVehicles">Искать во всех единицах…</String>
<String key="ConsistEmpty">Добавьте единицы из базы слева или загрузите готовый состав из файла сценерии.</String>
<String key="TipMoveLeft">Сдвинуть влево</String>
<String key="TipMoveRight">Сдвинуть вправо</String>
@@ -103,21 +110,21 @@
<String key="All">Все</String>
<String key="CatDiesel">Тепловозная</String>
<String key="CatElectric">Электрическая</String>
<String key="CatEMU">Электропоезд</String>
<String key="CatEMU">Электропоезда</String>
<String key="CatDMU">Дизель-поезд</String>
<String key="CatCarriagesA">Вагоны A</String>
<String key="CatCarriagesB">Вагоны B</String>
<String key="CatElectricLoco">Электровоз</String>
<String key="CatDieselLoco">Тепловоз</String>
<String key="CatSteamLoco">Паровоз</String>
<String key="CatRailbus">Рельсовый автобус</String>
<String key="CatDraisine">Дрезина</String>
<String key="CatElectricLoco">Электровозы</String>
<String key="CatDieselLoco">Тепловозы</String>
<String key="CatSteamLoco">Паровозы</String>
<String key="CatRailbus">Рельсовые автобусы</String>
<String key="CatDraisine">Дрезины</String>
<String key="CatWork">Путевая техника</String>
<String key="CatPrototype">Прототип</String>
<String key="CatTram">Трамвай</String>
<String key="CatCar">Автомобиль</String>
<String key="CatBus">Автобус</String>
<String key="CatTruck">Грузовик</String>
<String key="CatPrototype">Прототипы</String>
<String key="CatTram">Трамваи</String>
<String key="CatCar">Автомобили</String>
<String key="CatBus">Автобусы</String>
<String key="CatTruck">Грузовики</String>
<String key="CatPeople">Люди</String>
<String key="CatAnimals">Животные</String>
<String key="CatWagonsA">Вагоны A</String>
@@ -223,6 +230,7 @@
<String key="ReadyOverrideReady">Always ready</String>
<String key="ConsistReadyOverridden">Overridden by "Force the consist state on launch" in Settings.</String>
<String key="ConsistRandomType">Случайный тип</String>
<String key="ConsistMaxAmount">Максимальное количество</String>
<String key="ConsistRandomAmount">Случайное количество</String>
<String key="Set">Задать</String>
<String key="Length">Длина</String>
@@ -251,7 +259,14 @@
<String key="ExeLaunchFailed">Не удалось запустить симулятор</String>
<String key="ExeNotFound">Исполняемый файл симулятора не найден.</String>
<String key="ExeNotExecutable">Файл не помечен как исполняемый (chmod +x).</String>
<String key="ExeWrongPlatform">Этот исполняемый файл собран для другой операционной системы.</String>
<String key="ExeWrongPlatform">Этот исполняемый файл собран для другой операционной системы. В Linux версия для Windows может работать через Wine или Proton - если вы их используете, это не ошибка.</String>
<String key="SimExitTitle">Симулятор неожиданно завершил работу</String>
<String key="SimExitCode">Код выхода: {0}</String>
<String key="SimExitQuick">Он закрылся через {0:0.0} с после запуска.</String>
<String key="SimExitMessages">Последние сообщения:</String>
<String key="SimExitSeeLog">Полный журнал находится в файле log.txt в папке симулятора.</String>
<String key="SimExitDump">Симулятор сохранил дамп памяти: {0}</String>
<String key="SimExitOpenDump">Открыть папку с дампом? Приложите его к сообщению об ошибке.</String>
<String key="TabInfo">Информация</String>
<String key="SettingsFileChangedTitle">Настройки</String>
<String key="SettingsFileChanged">Файл eu07.ini изменён вне стартера. Загрузить заново?</String>
@@ -271,8 +286,16 @@
<String key="CouplingUnitRear">Сцепка в конце состава</String>
<String key="Join">Соединить</String>
<String key="TipJoin">Соединить этот вагон со следующим в один состав</String>
<String key="TipReorder">Перетащите для изменения порядка</String>
<String key="CouplerCode">Код сцепки: {0}</String>
<String key="CouplerFree">Вагоны не сцеплены.</String>
<String key="CouplerConnects">Соединено: {0}</String>
<String key="CouplerEndSignals">Хвост поезда — симулятор вывесит хвостовые сигналы.</String>
<String key="CouplerNoEndSignals">Код, отличный от 0, в хвосте поезда — симулятор не вывесит хвостовые сигналы.</String>
<String key="CouplerLocked">Заблокирована — не расцепляется при маневрах.</String>
<String key="CouplerClickToEdit">Нажмите, чтобы изменить.</String>
<String key="AddVehicle">Добавить</String>
<String key="AddMany">Добавить несколько…</String>
<String key="AddManyCount">Сколько раз добавить единицу</String>
<String key="TipAddVehicle">Добавить эту единицу в состав</String>
<String key="TipAddUnit">Добавить весь сцеп в состав</String>
@@ -331,9 +354,13 @@
<String key="Others">Прочее</String>
<String key="SelectEXE">Выбрать exe</String>
<String key="SelectEXEAuto">Выбрать автоматически</String>
<String key="SelectEXEBrowse">Выбрать файл…</String>
<String key="SelectEXEBrowseTitle">Укажите исполняемый файл симулятора</String>
<String key="SelectEXEUseAnyway">Использовать его всё равно?</String>
<String key="DebugMode">Режим отладки (тестовый режим)</String>
<String key="VirtualShunting">Виртуальный манёвр</String>
<String key="LogMissingVehicleFiles">Записывать в журнал отсутствующие файлы состава при экспорте</String>
<String key="DeveloperTools">Show the package author's tab</String>
<String key="Graphic">Графика</String>
<String key="RenderEngine">Движок отрисовки</String>
@@ -343,6 +370,7 @@
<String key="RESimp">Упрощённый</String>
<String key="REOff">Отключить визуальный слой</String>
<String key="REExperimental">Экспериментальный</String>
<String key="REExperimentalWarning">Внимание: экспериментальный рендерер находится в разработке. Он может работать нестабильно, не показывать части сцены или не запускаться на некоторых видеокартах. Если симулятор не запускается или выглядит неправильно, вернитесь к рендереру «Полный».</String>
<String key="Resolution">Разрешение</String>
<String key="BufforResolution">Разрешение буфера</String>
@@ -459,6 +487,7 @@
<String key="KbAssigned">Назначено</String>
<String key="KbShift">С Shift</String>
<String key="KbCtrl">С Ctrl</String>
<String key="KbShiftCtrl">С Ctrl + Shift</String>
<String key="KbReserved">Зарезервировано симулятором</String>
<String key="BindKeyTitle">Клавиша:</String>
<String key="BindKeyHint">Назначьте команду каждому сочетанию модификаторов для этой клавиши.</String>
@@ -485,7 +514,12 @@
<String key="FaultNoScenery">Сценарии не найдены.</String>
<String key="FaultNoVehicles">Транспортные средства не найдены.</String>
<String key="FaultNoPhysics">Данные подвижного состава не найдены.</String>
<String key="FaultNoExe">Исполняемый файл симулятора не найден.</String>
<String key="FaultNoExeTitle">Симулятор не найден</String>
<String key="FaultNoExe">Стартер запускает симулятор ({0}) из папки, из которой запущен сам:&#10;{1}&#10;&#10;Симулятора там нет, поэтому «Старт» не сработает.</String>
<String key="FaultNoExePick">Если симулятор находится в другом месте, например ваша собственная сборка, укажите его. Выбор можно потом изменить в Настройках, в разделе «Прочее». Если это не папка MaSzyna, запустите стартер из правильной папки.</String>
<String key="FaultNoExeWine">В Linux можно указать и версию для Windows (eu07.exe) — она запустится через Wine или Proton.</String>
<String key="FaultNoExePickNow">Указать симулятор…</String>
<String key="NotNow">Не сейчас</String>
<!-- About -->
<String key="AboutTitle">О программе</String>
@@ -528,7 +562,8 @@
<String key="WarehouseImportFile">Добавить составы из файла...</String>
<String key="StartWithoutSaving">Запустить без сохранения настроек</String>
<String key="StartFreeFly">Запустить без транспорта</String>
<String key="VolumePaused">Громкость на паузе</String>
<String key="StartEditor">Запустить редактор сценария</String>
<String key="StartEditorWarning">Редактор сценария (eu07 -edit) есть только в некоторых версиях симулятора. Если выбранный симулятор его не поддерживает, редактор не откроется. Он работает с самим файлом {0}, а не с копией стартера, поэтому сохранённые изменения попадут в сценарий. Запустить?</String>
<String key="DebugLogVisible">Показывать ход симуляции</String>
<String key="PyScreenPriority">Частота обновления дополнительных экранов</String>
<String key="PyPriorityNormal">Стандартная</String>
@@ -584,4 +619,20 @@
<String key="ShortcutMember">Выбрать секцию в сцепленной единице</String>
<String key="ShortcutMoveVehicle">Переместить вагон в составе</String>
<String key="ShortcutCardMenu">Открыть меню вагона</String>
<!-- Package author's tab - awaiting translation -->
<String key="DevDatabase">Vehicle database</String>
<String key="DevVehicles">Vehicle folders</String>
<String key="DevLiveries">Liveries</String>
<String key="DevWrecks">wrecks</String>
<String key="DevGroups">Groups</String>
<String key="DevSets">Fixed sets</String>
<String key="DevFiles">textures.txt files</String>
<String key="DevSource">Read from</String>
<String key="DevSourceCopy">the starter's copy</String>
<String key="DevSourceTextures">textures.txt files</String>
<String key="DevWritten">Copy written</String>
<String key="DevLocation">Copy folder</String>
<String key="DevRebuild">Rebuild copy</String>
<String key="DevRebuilt">Rebuilt</String>
</Language>