mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-10-16 23:49:19 +02:00
14
.github/dependabot.yml
vendored
Normal file
14
.github/dependabot.yml
vendored
Normal 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
|
||||||
185
.github/workflows/dotnet.yml
vendored
185
.github/workflows/dotnet.yml
vendored
@@ -13,6 +13,9 @@ on:
|
|||||||
|
|
||||||
workflow_dispatch:
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -20,11 +23,98 @@ env:
|
|||||||
DOTNET_CLI_TELEMETRY_OPTOUT: 1
|
DOTNET_CLI_TELEMETRY_OPTOUT: 1
|
||||||
DOTNET_NOLOGO: true
|
DOTNET_NOLOGO: true
|
||||||
PROJECT_PATH: StarterNG/StarterNG.csproj
|
PROJECT_PATH: StarterNG/StarterNG.csproj
|
||||||
|
TEST_PROJECT_PATH: StarterNG.Tests/StarterNG.Tests.csproj
|
||||||
|
|
||||||
jobs:
|
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:
|
publish:
|
||||||
name: ${{ matrix.name }}
|
name: ${{ matrix.name }}
|
||||||
|
|
||||||
|
# A failing test stops the packages, and with them the release.
|
||||||
|
needs: [test, version]
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
@@ -43,13 +133,16 @@ jobs:
|
|||||||
package: artifacts/StarterNG-linux-x64.tar.gz
|
package: artifacts/StarterNG-linux-x64.tar.gz
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup .NET 9
|
- name: Setup .NET 9
|
||||||
uses: actions/setup-dotnet@v6
|
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: 9.0.x
|
dotnet-version: 9.0.x
|
||||||
|
|
||||||
@@ -68,6 +161,9 @@ jobs:
|
|||||||
-r "${{ matrix.rid }}"
|
-r "${{ matrix.rid }}"
|
||||||
|
|
||||||
- name: Publish NativeAOT
|
- name: Publish NativeAOT
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.version.outputs.number }}
|
||||||
run: >
|
run: >
|
||||||
dotnet publish
|
dotnet publish
|
||||||
"${{ env.PROJECT_PATH }}"
|
"${{ env.PROJECT_PATH }}"
|
||||||
@@ -77,6 +173,7 @@ jobs:
|
|||||||
--no-restore
|
--no-restore
|
||||||
-p:PublishAot=true
|
-p:PublishAot=true
|
||||||
-p:PublishTrimmed=true
|
-p:PublishTrimmed=true
|
||||||
|
-p:Version="$VERSION"
|
||||||
-o "publish/${{ matrix.rid }}"
|
-o "publish/${{ matrix.rid }}"
|
||||||
|
|
||||||
- name: Package Windows build
|
- name: Package Windows build
|
||||||
@@ -100,8 +197,11 @@ jobs:
|
|||||||
-czf "${{ matrix.package }}" \
|
-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
|
- 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:
|
with:
|
||||||
name: ${{ matrix.artifact }}
|
name: ${{ matrix.artifact }}
|
||||||
path: ${{ matrix.package }}
|
path: ${{ matrix.package }}
|
||||||
@@ -109,51 +209,96 @@ jobs:
|
|||||||
archive: false
|
archive: false
|
||||||
|
|
||||||
# Workflow artifacts are only reachable for signed-in users with repository
|
# Workflow artifacts are only reachable for signed-in users with repository
|
||||||
# access, so every build of a release branch also lands in a rolling
|
# access, so every build of a release branch also lands in a release, whose
|
||||||
# pre-release, whose assets anyone can download:
|
# assets anyone can download. main gets a release of its own per build, tagged
|
||||||
# https://github.com/${{ github.repository }}/releases/download/<tag>/<file>
|
# with its version - the newest is what the starter checks itself against:
|
||||||
# main -> "latest", staging -> "staging"; the two never overwrite each other.
|
# 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:
|
release:
|
||||||
name: Rolling pre-release
|
name: Release
|
||||||
needs: publish
|
needs: [publish, version]
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
runs-on: ubuntu-latest
|
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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
env:
|
|
||||||
RELEASE_TAG: ${{ github.ref_name == 'main' && 'latest' || 'staging' }}
|
|
||||||
RELEASE_NAME: ${{ github.ref_name == 'main' && 'Latest build' || 'Staging build' }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# v8, not v7: the packages are uploaded with archive: false, and only v8
|
# 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
|
# 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
|
# Windows .zip a file - unpacked, its contents would end up in the release
|
||||||
# instead of the package.
|
# instead of the package.
|
||||||
- name: Collect the packages
|
- name: Collect the packages
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
path: dist
|
path: dist
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
skip-decompress: true
|
skip-decompress: true
|
||||||
|
|
||||||
# Recreated rather than edited, so the tag follows its branch and no asset
|
- name: Checksum the packages
|
||||||
# from an older build is left behind.
|
working-directory: dist
|
||||||
- name: Replace the rolling pre-release
|
run: sha256sum StarterNG-windows-x64.zip StarterNG-linux-x64.tar.gz | tee SHA256SUMS.txt
|
||||||
|
|
||||||
|
- name: Publish the release
|
||||||
|
if: github.ref_name == 'main'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: v${{ needs.version.outputs.number }}
|
||||||
|
VERSION: ${{ needs.version.outputs.number }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ls -l dist
|
ls -l dist
|
||||||
|
|
||||||
gh release delete "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag || true
|
# A re-run of the same commit finds its release already there and refreshes
|
||||||
gh release create "$RELEASE_TAG" \
|
# 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-windows-x64.zip \
|
||||||
dist/StarterNG-linux-x64.tar.gz \
|
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" \
|
--repo "$GITHUB_REPOSITORY" \
|
||||||
--target "$GITHUB_SHA" \
|
--target "$GITHUB_SHA" \
|
||||||
--prerelease \
|
--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')."
|
--notes "Automatic build of $GITHUB_REF_NAME (${GITHUB_SHA::7}), $(date -u '+%Y-%m-%d %H:%M UTC')."
|
||||||
|
|||||||
176
StarterNG.Tests/ConsistCrewTests.cs
Normal file
176
StarterNG.Tests/ConsistCrewTests.cs
Normal 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) } };
|
||||||
|
}
|
||||||
114
StarterNG.Tests/ConsistOrientationTests.cs
Normal file
114
StarterNG.Tests/ConsistOrientationTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
StarterNG.Tests/ConsistTailTests.cs
Normal file
119
StarterNG.Tests/ConsistTailTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
StarterNG.Tests/CouplerLookTests.cs
Normal file
25
StarterNG.Tests/CouplerLookTests.cs
Normal 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));
|
||||||
|
}
|
||||||
@@ -68,6 +68,31 @@ public class ExecutableLocatorTests
|
|||||||
Assert.Equal(ExeProblem.None, problem);
|
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]
|
[Fact]
|
||||||
public void Data_files_beside_the_binary_are_never_offered_as_candidates()
|
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());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ public sealed class InMemoryFileSystem : IFileSystem
|
|||||||
public string[] ReadAllLines(string path) =>
|
public string[] ReadAllLines(string path) =>
|
||||||
ReadAllText(path).Split('\n').Select(line => line.TrimEnd('\r')).ToArray();
|
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 Stream OpenRead(string path) => new MemoryStream(Bytes(path), writable: false);
|
||||||
|
|
||||||
public void WriteAllText(string path, string contents) => WithFile(path, contents);
|
public void WriteAllText(string path, string contents) => WithFile(path, contents);
|
||||||
@@ -132,6 +134,8 @@ public sealed class InMemoryFileSystem : IFileSystem
|
|||||||
public DateTime GetLastWriteTimeUtc(string path) => LastWriteTimeUtc;
|
public DateTime GetLastWriteTimeUtc(string path) => LastWriteTimeUtc;
|
||||||
|
|
||||||
/// <summary>Files added through <see cref="WithExecutable"/> carry the execute bit.</summary>
|
/// <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 bool IsExecutable(string path) => _executable.Contains(Normalize(path));
|
||||||
|
|
||||||
public InMemoryFileSystem WithExecutable(string path, string contents = "\u007fELF")
|
public InMemoryFileSystem WithExecutable(string path, string contents = "\u007fELF")
|
||||||
|
|||||||
@@ -26,12 +26,15 @@ public sealed class TestInstallation
|
|||||||
MiniTextures = new MiniTextureIndex(Files, Paths);
|
MiniTextures = new MiniTextureIndex(Files, Paths);
|
||||||
Physics = new FizPhysicsRepository(Files, Paths, Log);
|
Physics = new FizPhysicsRepository(Files, Paths, Log);
|
||||||
Vehicles = new TexturesTxtVehicleRepository(Files, Paths, Log, new TexturesTxtParser());
|
Vehicles = new TexturesTxtVehicleRepository(Files, Paths, Log, new TexturesTxtParser());
|
||||||
|
VehicleJson = new VehicleJsonSerializer();
|
||||||
Sceneries = new SceneryRepository(Files, Paths, Log, new SceneryParser(Clock, Random));
|
Sceneries = new SceneryRepository(Files, Paths, Log, new SceneryParser(Clock, Random));
|
||||||
MissingAssets = new MissingAssetScanner(Files, Paths);
|
MissingAssets = new MissingAssetScanner(Files, Paths);
|
||||||
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, Log);
|
|
||||||
|
|
||||||
Environment = new FakeEnvironment();
|
Environment = new FakeEnvironment();
|
||||||
SettingsPaths = new SettingsPaths(Environment, Paths);
|
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);
|
Executables = new ExecutableLocator(Files, Paths, Environment, Log);
|
||||||
SettingsStore = new SettingsStore(Files, Clock, Log, SettingsPaths, new SettingsSerializer(), Executables);
|
SettingsStore = new SettingsStore(Files, Clock, Log, SettingsPaths, new SettingsSerializer(), Executables);
|
||||||
}
|
}
|
||||||
@@ -45,6 +48,8 @@ public sealed class TestInstallation
|
|||||||
public MiniTextureIndex MiniTextures { get; }
|
public MiniTextureIndex MiniTextures { get; }
|
||||||
public FizPhysicsRepository Physics { get; }
|
public FizPhysicsRepository Physics { get; }
|
||||||
public TexturesTxtVehicleRepository Vehicles { get; }
|
public TexturesTxtVehicleRepository Vehicles { get; }
|
||||||
|
public VehicleJsonSerializer VehicleJson { get; }
|
||||||
|
public VehicleCacheRepository VehicleCache { get; }
|
||||||
public SceneryRepository Sceneries { get; }
|
public SceneryRepository Sceneries { get; }
|
||||||
public MissingAssetScanner MissingAssets { get; }
|
public MissingAssetScanner MissingAssets { get; }
|
||||||
public GameLibrary Library { get; }
|
public GameLibrary Library { get; }
|
||||||
|
|||||||
38
StarterNG.Tests/FavoriteSceneriesTests.cs
Normal file
38
StarterNG.Tests/FavoriteSceneriesTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,4 +40,38 @@ public class InstallationCheckTests
|
|||||||
Assert.DoesNotContain(faults, fault => fault.StartsWith("missing "));
|
Assert.DoesNotContain(faults, fault => fault.StartsWith("missing "));
|
||||||
Assert.DoesNotContain("FaultNoWeights", faults);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
10
StarterNG.Tests/KeyboardConfigTests.cs
Normal file
10
StarterNG.Tests/KeyboardConfigTests.cs
Normal 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"));
|
||||||
|
}
|
||||||
90
StarterNG.Tests/LegacyDecodingTests.cs
Normal file
90
StarterNG.Tests/LegacyDecodingTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,36 @@ public class SceneryTreeBuilderTests
|
|||||||
return (new SceneryTreeBuilder(new SceneryTranslations(files, installation.Log)), sceneries);
|
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]
|
[Fact]
|
||||||
public void Scenarios_declaring_a_group_are_gathered_under_it()
|
public void Scenarios_declaring_a_group_are_gathered_under_it()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ namespace StarterNG.Tests;
|
|||||||
|
|
||||||
public class SettingsStoreTests
|
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)
|
private static TestInstallation WithHome(InMemoryFileSystem? files = null)
|
||||||
{
|
{
|
||||||
|
|||||||
39
StarterNG.Tests/SimulatorAftermathTests.cs
Normal file
39
StarterNG.Tests/SimulatorAftermathTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
StarterNG.Tests/SimulatorExitTests.cs
Normal file
65
StarterNG.Tests/SimulatorExitTests.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
159
StarterNG.Tests/StartSimulationTests.cs
Normal file
159
StarterNG.Tests/StartSimulationTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
StarterNG.Tests/VehicleCacheTests.cs
Normal file
111
StarterNG.Tests/VehicleCacheTests.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,4 +117,48 @@ public class VehicleCatalogTests
|
|||||||
/// <summary>Builds an entry through the real parser, for catalogue-level tests.</summary>
|
/// <summary>Builds an entry through the real parser, for catalogue-level tests.</summary>
|
||||||
private static VehicleEntry ParsedEntry(string directory, params string[] lines) =>
|
private static VehicleEntry ParsedEntry(string directory, params string[] lines) =>
|
||||||
new StarterNG.Infrastructure.Vehicles.TexturesTxtParser().Parse(directory, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
199
StarterNG.Tests/VehicleJsonTests.cs
Normal file
199
StarterNG.Tests/VehicleJsonTests.cs
Normal 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}"));
|
||||||
|
}
|
||||||
@@ -176,13 +176,17 @@ public partial class App : Avalonia.Application
|
|||||||
$"{Loc["FaultLoadData"]}{Environment.NewLine}{Environment.NewLine}{Loc["FaultDetail"]} {inner.Message}");
|
$"{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)
|
if (faults.Count > 0)
|
||||||
{
|
{
|
||||||
await Diagnostics.ReportAsync(
|
await Diagnostics.ReportAsync(
|
||||||
string.Join(Environment.NewLine, faults) + Environment.NewLine + Environment.NewLine +
|
string.Join(Environment.NewLine, faults) + Environment.NewLine + Environment.NewLine +
|
||||||
Loc["FaultBadInstall"]);
|
Loc["FaultBadInstall"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (check.ExecutableMissing())
|
||||||
|
await main.OfferExecutablePickAsync();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public interface IFileSystem
|
|||||||
|
|
||||||
string[] ReadAllLines(string path);
|
string[] ReadAllLines(string path);
|
||||||
|
|
||||||
|
byte[] ReadAllBytes(string path);
|
||||||
|
|
||||||
Stream OpenRead(string path);
|
Stream OpenRead(string path);
|
||||||
|
|
||||||
void WriteAllText(string path, string contents);
|
void WriteAllText(string path, string contents);
|
||||||
@@ -41,5 +43,7 @@ public interface IFileSystem
|
|||||||
|
|
||||||
DateTime GetLastWriteTimeUtc(string path);
|
DateTime GetLastWriteTimeUtc(string path);
|
||||||
|
|
||||||
|
long GetFileSize(string path);
|
||||||
|
|
||||||
bool IsExecutable(string path);
|
bool IsExecutable(string path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ public interface IGamePaths
|
|||||||
|
|
||||||
string Data { get; }
|
string Data { get; }
|
||||||
|
|
||||||
|
/// <summary>Where the JSON vehicle database and the packages layered on it live.</summary>
|
||||||
|
string VehicleDatabase { get; }
|
||||||
|
|
||||||
string StarterConfig { get; }
|
string StarterConfig { get; }
|
||||||
|
|
||||||
string DiagnosticsLog { get; }
|
string DiagnosticsLog { get; }
|
||||||
|
|||||||
@@ -8,13 +8,24 @@ public interface IProcessHandle
|
|||||||
{
|
{
|
||||||
bool HasExited { get; }
|
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);
|
Task WaitForExitAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IProcessLauncher
|
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,
|
IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
|
||||||
out string? error);
|
bool captureErrors, out string? error);
|
||||||
|
|
||||||
bool OpenInShell(string pathOrUrl);
|
bool OpenInShell(string pathOrUrl);
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,16 @@ public sealed class AppServices
|
|||||||
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random));
|
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random));
|
||||||
SceneryImages = new SceneryImageLocator(files);
|
SceneryImages = new SceneryImageLocator(files);
|
||||||
MiniTextures = new MiniTextureIndex(files, paths);
|
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);
|
Physics = new FizPhysicsRepository(files, paths, log);
|
||||||
MissingAssets = new MissingAssetScanner(files, paths);
|
MissingAssets = new MissingAssetScanner(files, paths);
|
||||||
SceneryTexts = new SceneryTranslations(files, log);
|
SceneryTexts = new SceneryTranslations(files, log);
|
||||||
@@ -40,12 +49,13 @@ public sealed class AppServices
|
|||||||
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
|
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
|
||||||
State = new AppState();
|
State = new AppState();
|
||||||
|
|
||||||
var settingsPaths = new SettingsPaths(environment, paths);
|
|
||||||
Executables = new ExecutableLocator(files, paths, environment, log);
|
Executables = new ExecutableLocator(files, paths, environment, log);
|
||||||
SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(), Executables);
|
SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(), Executables);
|
||||||
SettingsPaths = settingsPaths;
|
|
||||||
MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, MissingAssets, Library, log);
|
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 =>
|
public static AppServices Current =>
|
||||||
@@ -85,6 +95,14 @@ public sealed class AppServices
|
|||||||
|
|
||||||
public IVehicleRepository Vehicles { get; }
|
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 IPhysicsRepository Physics { get; }
|
||||||
|
|
||||||
public MissingAssetScanner MissingAssets { get; }
|
public MissingAssetScanner MissingAssets { get; }
|
||||||
@@ -109,7 +127,11 @@ public sealed class AppServices
|
|||||||
|
|
||||||
public MissingVehicleLog MissingVehicleLog { get; }
|
public MissingVehicleLog MissingVehicleLog { get; }
|
||||||
|
|
||||||
|
public FavoriteSceneries Favorites { get; }
|
||||||
|
|
||||||
public StartSimulation StartSimulation { get; }
|
public StartSimulation StartSimulation { get; }
|
||||||
|
|
||||||
|
public SimulatorAftermath Aftermath { get; }
|
||||||
|
|
||||||
public LocalizationService Localization { get; }
|
public LocalizationService Localization { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
72
StarterNG/Application/FavoriteSceneries.cs
Normal file
72
StarterNG/Application/FavoriteSceneries.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
StarterNG/Application/SimulatorAftermath.cs
Normal file
81
StarterNG/Application/SimulatorAftermath.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
StarterNG/Application/SimulatorExit.cs
Normal file
58
StarterNG/Application/SimulatorExit.cs
Normal 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");
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -6,7 +7,9 @@ using StarterNG.Application.Abstractions;
|
|||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
using StarterNG.Domain;
|
using StarterNG.Domain;
|
||||||
using StarterNG.Domain.Settings;
|
using StarterNG.Domain.Settings;
|
||||||
|
using StarterNG.Domain.Vehicles;
|
||||||
using StarterNG.Infrastructure.Adapters;
|
using StarterNG.Infrastructure.Adapters;
|
||||||
|
using StarterNG.Infrastructure.Settings;
|
||||||
|
|
||||||
namespace StarterNG.Application;
|
namespace StarterNG.Application;
|
||||||
|
|
||||||
@@ -37,17 +40,24 @@ public sealed class StartSimulation
|
|||||||
|
|
||||||
private readonly AppState _state;
|
private readonly AppState _state;
|
||||||
private readonly SettingsStore _settings;
|
private readonly SettingsStore _settings;
|
||||||
|
private readonly VehicleCatalog _vehicles;
|
||||||
|
private readonly ExecutableLocator _executables;
|
||||||
private readonly IFileSystem _files;
|
private readonly IFileSystem _files;
|
||||||
|
private readonly IGamePaths _paths;
|
||||||
private readonly IProcessLauncher _processes;
|
private readonly IProcessLauncher _processes;
|
||||||
private readonly IRandomSource _random;
|
private readonly IRandomSource _random;
|
||||||
private readonly IDiagnosticsLog _log;
|
private readonly IDiagnosticsLog _log;
|
||||||
|
|
||||||
public StartSimulation(AppState state, SettingsStore settings, IFileSystem files, IProcessLauncher processes,
|
public StartSimulation(AppState state, SettingsStore settings, VehicleCatalog vehicles,
|
||||||
IRandomSource random, IDiagnosticsLog log)
|
ExecutableLocator executables, IFileSystem files, IGamePaths paths,
|
||||||
|
IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log)
|
||||||
{
|
{
|
||||||
_state = state;
|
_state = state;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
_vehicles = vehicles;
|
||||||
|
_executables = executables;
|
||||||
_files = files;
|
_files = files;
|
||||||
|
_paths = paths;
|
||||||
_processes = processes;
|
_processes = processes;
|
||||||
_random = random;
|
_random = random;
|
||||||
_log = log;
|
_log = log;
|
||||||
@@ -91,7 +101,7 @@ public sealed class StartSimulation
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName)
|
string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles)
|
||||||
?? StartableVehicle(trainset, _state.StartingVehicleName);
|
?? StartableVehicle(trainset, _state.StartingVehicleName);
|
||||||
_state.StartingVehicleName = vehicle;
|
_state.StartingVehicleName = vehicle;
|
||||||
|
|
||||||
@@ -115,16 +125,42 @@ public sealed class StartSimulation
|
|||||||
if (saveSettings)
|
if (saveSettings)
|
||||||
_settings.CaptureAndSave();
|
_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)
|
string[] arguments = freeFly || string.IsNullOrEmpty(vehicle)
|
||||||
? new[] { "-s", exportName }
|
? new[] { "-s", exportName }
|
||||||
: new[] { "-s", exportName, "-v", vehicle };
|
: 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)
|
if (process is null)
|
||||||
return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable,
|
return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable,
|
||||||
Detail: error);
|
Detail: error);
|
||||||
|
|||||||
@@ -86,9 +86,65 @@ public sealed class KeyboardConfig
|
|||||||
_savePath = UserConfigPath();
|
_savePath = UserConfigPath();
|
||||||
string source = FirstExisting(_savePath, WorkingDirDefaultPath(), BundledDefaultPath());
|
string source = FirstExisting(_savePath, WorkingDirDefaultPath(), BundledDefaultPath());
|
||||||
ParseSource(source);
|
ParseSource(source);
|
||||||
|
LoadPolishDescriptions();
|
||||||
Dirty = false;
|
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()
|
public void LoadDefaults()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_savePath))
|
if (string.IsNullOrEmpty(_savePath))
|
||||||
|
|||||||
@@ -235,6 +235,12 @@ public class Dynamic
|
|||||||
|
|
||||||
public bool Flipped;
|
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) =>
|
public static bool IsPantStateType(string? type) =>
|
||||||
string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase);
|
string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
@@ -302,7 +308,8 @@ public class Dynamic
|
|||||||
LoadCount = LoadCount,
|
LoadCount = LoadCount,
|
||||||
LoadType = LoadType,
|
LoadType = LoadType,
|
||||||
MiniName = MiniName,
|
MiniName = MiniName,
|
||||||
Flipped = Flipped
|
Flipped = Flipped,
|
||||||
|
CouplerSetByHand = CouplerSetByHand
|
||||||
};
|
};
|
||||||
|
|
||||||
public string ToTrainsetNode()
|
public string ToTrainsetNode()
|
||||||
|
|||||||
@@ -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)
|
public void RandomAmounts(IReadOnlyList<ConsistItem> consist)
|
||||||
{
|
{
|
||||||
foreach (var c in consist.SelectMany(u => u.Cars))
|
foreach (var c in consist.SelectMany(u => u.Cars))
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
|
|
||||||
public int Count => _items.Count;
|
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]
|
public ConsistItem this[int index]
|
||||||
{
|
{
|
||||||
get => _items[index];
|
get => _items[index];
|
||||||
@@ -57,7 +60,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
var cars = trainset.Vehicles.Select(v =>
|
var cars = trainset.Vehicles.Select(v =>
|
||||||
{
|
{
|
||||||
var c = v.Clone();
|
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;
|
return c;
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
@@ -83,7 +86,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
}
|
}
|
||||||
|
|
||||||
VehicleSet? set = null;
|
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);
|
_db.SetByTextureUuid.TryGetValue(uuid, out set);
|
||||||
|
|
||||||
if (set?.TextureRefs is { Count: > 1 })
|
if (set?.TextureRefs is { Count: > 1 })
|
||||||
@@ -94,7 +97,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
int j = i;
|
int j = i;
|
||||||
while (j < cars.Count && group.Count < set.TextureRefs.Count)
|
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))
|
&& members.Contains(u))
|
||||||
group.Add(cars[j++]);
|
group.Add(cars[j++]);
|
||||||
else
|
else
|
||||||
@@ -152,17 +155,11 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
Raise();
|
Raise();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Dynamic> Flatten()
|
public List<Dynamic> Flatten() => _items.SelectMany(RunningOrder).ToList();
|
||||||
{
|
|
||||||
var flat = new List<Dynamic>();
|
/// <summary>A card's cars in the order they run, which is the order the export writes.</summary>
|
||||||
foreach (var item in _items)
|
public static IEnumerable<Dynamic> RunningOrder(ConsistItem item) =>
|
||||||
{
|
item.Flipped ? Enumerable.Reverse(item.Cars) : item.Cars;
|
||||||
var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse().ToList() : item.Cars;
|
|
||||||
foreach (var car in cars)
|
|
||||||
flat.Add(car);
|
|
||||||
}
|
|
||||||
return flat;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SyncStartingVehicle()
|
public void SyncStartingVehicle()
|
||||||
{
|
{
|
||||||
@@ -236,45 +233,139 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
public void Insert(int at, ConsistItem item) =>
|
public void Insert(int at, ConsistItem item) =>
|
||||||
_items.Insert(Math.Clamp(at, 0, _items.Count), 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)
|
public void MoveLeft(ConsistItem item)
|
||||||
{
|
{
|
||||||
int i = _items.IndexOf(item);
|
int i = _items.IndexOf(item);
|
||||||
if (i > 0)
|
if (i > 0)
|
||||||
{
|
Move(i, i - 1);
|
||||||
(_items[i - 1], _items[i]) = (_items[i], _items[i - 1]);
|
|
||||||
Raise();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MoveRight(ConsistItem item)
|
public void MoveRight(ConsistItem item)
|
||||||
{
|
{
|
||||||
int i = _items.IndexOf(item);
|
int i = _items.IndexOf(item);
|
||||||
if (i >= 0 && i < _items.Count - 1)
|
if (i >= 0 && i < _items.Count - 1)
|
||||||
{
|
Move(i, i + 1);
|
||||||
(_items[i + 1], _items[i]) = (_items[i], _items[i + 1]);
|
|
||||||
Raise();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Move(int from, int to)
|
public void Move(int from, int to)
|
||||||
{
|
{
|
||||||
if (from < 0 || from >= _items.Count) return;
|
if (from < 0 || from >= _items.Count) return;
|
||||||
|
|
||||||
|
var previousHead = _items[0];
|
||||||
var item = _items[from];
|
var item = _items[from];
|
||||||
_items.RemoveAt(from);
|
_items.RemoveAt(from);
|
||||||
to = Math.Clamp(to, 0, _items.Count);
|
to = Math.Clamp(to, 0, _items.Count);
|
||||||
_items.Insert(to, item);
|
_items.Insert(to, item);
|
||||||
|
|
||||||
|
HandOverLead(previousHead);
|
||||||
Selected = item;
|
Selected = item;
|
||||||
AutoConnectAll();
|
AutoConnectAll();
|
||||||
Raise();
|
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)
|
public void Remove(ConsistItem item)
|
||||||
{
|
{
|
||||||
int gap = _items.IndexOf(item);
|
int gap = _items.IndexOf(item);
|
||||||
_items.Remove(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))
|
if (ReferenceEquals(Selected, item))
|
||||||
Selected = _items.Count == 0
|
Selected = _items.Count == 0
|
||||||
? null
|
? null
|
||||||
@@ -288,6 +379,16 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
int index = _items.IndexOf(item);
|
int index = _items.IndexOf(item);
|
||||||
if (index < 0) return;
|
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;
|
int first = index;
|
||||||
while (first > 0 && HoldsTail(_items[first - 1]))
|
while (first > 0 && HoldsTail(_items[first - 1]))
|
||||||
first--;
|
first--;
|
||||||
@@ -296,6 +397,17 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
while (last + 1 < _items.Count && HoldsTail(_items[last]))
|
while (last + 1 < _items.Count && HoldsTail(_items[last]))
|
||||||
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++)
|
for (int k = first; k <= last; k++)
|
||||||
{
|
{
|
||||||
var card = _items[k];
|
var card = _items[k];
|
||||||
@@ -306,36 +418,23 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
|
|
||||||
if (last > first)
|
if (last > first)
|
||||||
_items.Reverse(first, last - first + 1);
|
_items.Reverse(first, last - first + 1);
|
||||||
|
|
||||||
AutoConnectAll();
|
|
||||||
Raise();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CycleDriver(ConsistItem item)
|
public void CycleDriver(ConsistItem item) =>
|
||||||
{
|
SetCrew(item, item.Driver switch
|
||||||
var car = ActiveCar(item);
|
|
||||||
car.DriverType = car.DriverType switch
|
|
||||||
{
|
{
|
||||||
eDriverType.Nobody => eDriverType.Headdriver,
|
eDriverType.Nobody => eDriverType.Headdriver,
|
||||||
eDriverType.Headdriver => eDriverType.Reardriver,
|
eDriverType.Headdriver => eDriverType.Reardriver,
|
||||||
eDriverType.Reardriver => eDriverType.Passenger,
|
eDriverType.Reardriver => eDriverType.Passenger,
|
||||||
_ => eDriverType.Nobody
|
_ => eDriverType.Nobody
|
||||||
};
|
});
|
||||||
item.Driver = UnitDriver(item.Cars);
|
|
||||||
if (ReferenceEquals(Selected, item))
|
|
||||||
SyncStartingVehicle();
|
|
||||||
Raise();
|
|
||||||
AppServices.Current.State.NotifyChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Split(ConsistItem item)
|
public void Split(ConsistItem item)
|
||||||
{
|
{
|
||||||
int i = _items.IndexOf(item);
|
int i = _items.IndexOf(item);
|
||||||
if (i < 0) return;
|
if (i < 0) return;
|
||||||
|
|
||||||
var order = item.Flipped
|
var order = RunningOrder(item).ToList();
|
||||||
? Enumerable.Reverse(item.Cars).ToList()
|
|
||||||
: item.Cars;
|
|
||||||
|
|
||||||
_items.RemoveAt(i);
|
_items.RemoveAt(i);
|
||||||
for (int c = 0; c < order.Count; c++)
|
for (int c = 0; c < order.Count; c++)
|
||||||
@@ -344,7 +443,8 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
{
|
{
|
||||||
Cars = new List<Dynamic> { order[c] },
|
Cars = new List<Dynamic> { order[c] },
|
||||||
Grouped = false,
|
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
|
Driver = order[c].DriverType
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -377,13 +477,17 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
|
|
||||||
if (last == first) return;
|
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 cars = new List<Dynamic>();
|
||||||
var driver = eDriverType.Nobody;
|
var driver = eDriverType.Nobody;
|
||||||
for (int k = first; k <= last; k++)
|
for (int k = first; k <= last; k++)
|
||||||
{
|
{
|
||||||
cars.AddRange(_items[k].Cars);
|
var card = _items[k];
|
||||||
|
cars.AddRange(RunningOrder(card));
|
||||||
if (driver == eDriverType.Nobody)
|
if (driver == eDriverType.Nobody)
|
||||||
driver = _items[k].Driver;
|
driver = card.Driver;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool flipped = _items[first].Flipped;
|
bool flipped = _items[first].Flipped;
|
||||||
@@ -441,28 +545,42 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
if (start < 0) start = 0;
|
if (start < 0) start = 0;
|
||||||
if (start >= _items.Count) return;
|
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)
|
if (rng.Next(2) == 0)
|
||||||
_items[i].Flipped = !_items[i].Flipped;
|
Turn(first, last);
|
||||||
|
i = last + 1;
|
||||||
|
}
|
||||||
|
|
||||||
AutoConnectAll();
|
AutoConnectAll();
|
||||||
Raise();
|
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()
|
public void AutoConnectAll()
|
||||||
{
|
{
|
||||||
var flat = new List<(Dynamic car, bool flipped)>();
|
var flat = Flatten();
|
||||||
foreach (var item in _items)
|
|
||||||
{
|
|
||||||
var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse() : item.Cars;
|
|
||||||
foreach (var c in cars)
|
|
||||||
flat.Add((c, item.Flipped));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < flat.Count - 1; i++)
|
for (int i = 0; i < flat.Count - 1; i++)
|
||||||
{
|
{
|
||||||
var (left, lf) = flat[i];
|
var left = flat[i];
|
||||||
var (right, rf) = flat[i + 1];
|
var right = flat[i + 1];
|
||||||
|
bool lf = IsTurned(left);
|
||||||
|
bool rf = IsTurned(right);
|
||||||
|
|
||||||
var lp = _info.PhysicsFor(left);
|
var lp = _info.PhysicsFor(left);
|
||||||
var rp = _info.PhysicsFor(right);
|
var rp = _info.PhysicsFor(right);
|
||||||
@@ -478,7 +596,10 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
common &= ~Coupling.ControlMU;
|
common &= ~Coupling.ControlMU;
|
||||||
|
|
||||||
left.Coupling.Flags = left.Coupling.Locked ? -common : common;
|
left.Coupling.Flags = left.Coupling.Locked ? -common : common;
|
||||||
|
left.CouplerSetByHand = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FreeTail();
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool CanFormUnit(ConsistItem left, ConsistItem right)
|
public bool CanFormUnit(ConsistItem left, ConsistItem right)
|
||||||
@@ -491,8 +612,8 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
|
|
||||||
var lp = _info.PhysicsFor(tail);
|
var lp = _info.PhysicsFor(tail);
|
||||||
var rp = _info.PhysicsFor(head);
|
var rp = _info.PhysicsFor(head);
|
||||||
int leftMax = lp == null ? 3 : (left.Flipped ? lp.AllowedFlagA : lp.AllowedFlagB);
|
int leftMax = lp == null ? 3 : (IsTurned(tail) ? lp.AllowedFlagA : lp.AllowedFlagB);
|
||||||
int rightMax = rp == null ? 3 : (right.Flipped ? rp.AllowedFlagB : rp.AllowedFlagA);
|
int rightMax = rp == null ? 3 : (IsTurned(head) ? rp.AllowedFlagB : rp.AllowedFlagA);
|
||||||
|
|
||||||
return (leftMax & rightMax & Coupling.WorkshopLock) != 0 || SameSet(tail, head);
|
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))
|
if (IsUnitCar(a) && IsUnitCar(b) && UnitKey(a) == UnitKey(b))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
string? ua = _db.TextureForSkin(a.SkinFile)?.Uuid;
|
string? ua = _db.TextureFor(a.DataFolder, a.SkinFile, a.MmdFile)?.Uuid;
|
||||||
string? ub = _db.TextureForSkin(b.SkinFile)?.Uuid;
|
string? ub = _db.TextureFor(b.DataFolder, b.SkinFile, b.MmdFile)?.Uuid;
|
||||||
if (string.IsNullOrEmpty(ua) || string.IsNullOrEmpty(ub))
|
if (string.IsNullOrEmpty(ua) || string.IsNullOrEmpty(ub))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -526,6 +647,29 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
public static Dynamic HeadCar(ConsistItem item) =>
|
public static Dynamic HeadCar(ConsistItem item) =>
|
||||||
item.Flipped ? item.Cars[^1] : item.Cars[0];
|
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) =>
|
public static bool HoldsTail(ConsistItem item) =>
|
||||||
item.Cars.Count > 0 && HoldsNext(TailCar(item));
|
item.Cars.Count > 0 && HoldsNext(TailCar(item));
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public sealed class SimulatorSettings
|
|||||||
public bool DebugMode;
|
public bool DebugMode;
|
||||||
public bool VirtualShunting = true;
|
public bool VirtualShunting = true;
|
||||||
public bool LogMissingVehicleFiles;
|
public bool LogMissingVehicleFiles;
|
||||||
|
public bool DeveloperTools;
|
||||||
|
|
||||||
public int RenderEngine;
|
public int RenderEngine;
|
||||||
public int Width = 1280;
|
public int Width = 1280;
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ public sealed class TexRandomizer
|
|||||||
var item = consist[i];
|
var item = consist[i];
|
||||||
if (item.Cars.Count == 0) continue;
|
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))
|
if (current != null && _db.IsSetFollower(current))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -128,13 +128,13 @@ public sealed class TexRandomizer
|
|||||||
|
|
||||||
if (index > 0)
|
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))
|
if (prev != null && !string.IsNullOrEmpty(prev.ResolvedClass))
|
||||||
return prev.ResolvedClass;
|
return prev.ResolvedClass;
|
||||||
}
|
}
|
||||||
if (index + 1 < consist.Count)
|
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))
|
if (next != null && !string.IsNullOrEmpty(next.ResolvedClass))
|
||||||
return next.ResolvedClass;
|
return next.ResolvedClass;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,14 +67,14 @@ public static class TrainsetDisplay
|
|||||||
|
|
||||||
public static string VehicleLabel(Dynamic v, bool head = false)
|
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? cls = string.IsNullOrEmpty(tex?.ResolvedClass) ? null : tex!.ResolvedClass;
|
||||||
string? name = string.IsNullOrWhiteSpace(v.Name) ? null : v.Name;
|
string? name = string.IsNullOrWhiteSpace(v.Name) ? null : v.Name;
|
||||||
return (head ? name ?? cls : cls ?? name) ?? BaseSkin(v);
|
return (head ? name ?? cls : cls ?? name) ?? BaseSkin(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool IsPowered(Dynamic 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";
|
is "e" or "s" or "p" or "z" or "a";
|
||||||
|
|
||||||
private static string BaseSkin(Dynamic v) =>
|
private static string BaseSkin(Dynamic v) =>
|
||||||
@@ -199,14 +199,14 @@ public static class TrainsetDisplay
|
|||||||
return p.LoadAccepted.Contains(loadType, StringComparison.OrdinalIgnoreCase);
|
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 used = CollectNames(scenery);
|
||||||
var db = AppServices.Current.Library.Vehicles;
|
|
||||||
|
|
||||||
bool mu = trainset.Vehicles.Any(v =>
|
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";
|
return cat is "z" or "a";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,18 +221,18 @@ public static class TrainsetDisplay
|
|||||||
if (mu)
|
if (mu)
|
||||||
{
|
{
|
||||||
foreach (var v in trainset.Vehicles)
|
foreach (var v in trainset.Vehicles)
|
||||||
Rename(v, used);
|
Rename(v, used, db);
|
||||||
return target?.Name;
|
return target?.Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target != null)
|
if (target != null)
|
||||||
Rename(target, used);
|
Rename(target, used, db);
|
||||||
return target?.Name;
|
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)
|
if (tex != null)
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -294,11 +294,11 @@ public static class TrainsetDisplay
|
|||||||
return (n, "");
|
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);
|
var (_, number) = SplitWagonNumber(v.Name);
|
||||||
if (!string.IsNullOrEmpty(v.Name))
|
if (!string.IsNullOrEmpty(v.Name))
|
||||||
used.Remove(v.Name!);
|
used.Remove(v.Name!);
|
||||||
v.Name = EnsureUnique(PreferredBaseName(v), used) + number;
|
v.Name = EnsureUnique(PreferredBaseName(v, db), used) + number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
using StarterNG.Domain.Vehicles;
|
using StarterNG.Domain.Vehicles;
|
||||||
using StarterNG.Application;
|
using StarterNG.Application.Abstractions;
|
||||||
|
|
||||||
namespace StarterNG.Domain;
|
namespace StarterNG.Domain;
|
||||||
|
|
||||||
public sealed class VehicleInfo
|
public sealed class VehicleInfo
|
||||||
{
|
{
|
||||||
private readonly VehicleCatalog _db;
|
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;
|
public static string ClassOf(VehicleTexture t) => t.ResolvedClass;
|
||||||
|
|
||||||
@@ -17,20 +22,20 @@ public sealed class VehicleInfo
|
|||||||
public static bool IsPoweredCategory(string? c) =>
|
public static bool IsPoweredCategory(string? c) =>
|
||||||
c is "e" or "s" or "p" or "z" or "a";
|
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) =>
|
public string? CategoryOf(Dynamic car) =>
|
||||||
TextureFor(car) is { } t ? CategoryOf(t) : null;
|
TextureFor(car) is { } t ? CategoryOf(t) : null;
|
||||||
|
|
||||||
public VehiclePhysics? PhysicsFor(VehicleTexture texture) =>
|
public VehiclePhysics? PhysicsFor(VehicleTexture texture) =>
|
||||||
AppServices.Current.Physics.For(texture.Directory, texture.Model)
|
_physics.For(texture.Directory, texture.Model)
|
||||||
?? AppServices.Current.Physics.For(texture.Directory, texture.Skinfile);
|
?? _physics.For(texture.Directory, texture.Skinfile);
|
||||||
|
|
||||||
public VehiclePhysics? PhysicsFor(Dynamic car)
|
public VehiclePhysics? PhysicsFor(Dynamic car)
|
||||||
{
|
{
|
||||||
string? dbModel = TextureFor(car)?.Model;
|
string? dbModel = TextureFor(car)?.Model;
|
||||||
return AppServices.Current.Physics.For(car.DataFolder, dbModel)
|
return _physics.For(car.DataFolder, dbModel)
|
||||||
?? AppServices.Current.Physics.For(car.DataFolder, car.MmdFile)
|
?? _physics.For(car.DataFolder, car.MmdFile)
|
||||||
?? AppServices.Current.Physics.For(car.DataFolder, car.SkinFile);
|
?? _physics.For(car.DataFolder, car.SkinFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ public sealed class VehicleCatalog
|
|||||||
|
|
||||||
public Dictionary<string, VehicleTexture> TextureBySkin { get; } = new();
|
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()
|
public void BeginLoad()
|
||||||
{
|
{
|
||||||
Textures.Clear();
|
Textures.Clear();
|
||||||
@@ -35,6 +46,9 @@ public sealed class VehicleCatalog
|
|||||||
SetByTextureUuid.Clear();
|
SetByTextureUuid.Clear();
|
||||||
TextureByUuid.Clear();
|
TextureByUuid.Clear();
|
||||||
TextureBySkin.Clear();
|
TextureBySkin.Clear();
|
||||||
|
_textureByFolderSkinModel.Clear();
|
||||||
|
_textureByFolderSkin.Clear();
|
||||||
|
_textureBySkinModel.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Ingest(VehicleEntry entry)
|
public void Ingest(VehicleEntry entry)
|
||||||
@@ -49,7 +63,12 @@ public sealed class VehicleCatalog
|
|||||||
TextureByUuid[texture.Uuid] = texture;
|
TextureByUuid[texture.Uuid] = texture;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(texture.Skinfile))
|
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)
|
if (texture.Wreck)
|
||||||
continue;
|
continue;
|
||||||
@@ -105,18 +124,48 @@ public sealed class VehicleCatalog
|
|||||||
return texture.TextureMini;
|
return texture.TextureMini;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? MiniForSkin(string? skinFile) =>
|
public string? MiniFor(string? dataFolder, string? skinFile, string? model) =>
|
||||||
TextureForSkin(skinFile) is { } texture ? ResolveMiniName(texture) : null;
|
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)
|
public VehicleTexture? TextureForSkin(string? skinFile)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(skinFile))
|
if (string.IsNullOrEmpty(skinFile))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant();
|
return TextureBySkin.TryGetValue(Key(skinFile), out var texture) ? texture : null;
|
||||||
return TextureBySkin.TryGetValue(key, 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)
|
public string GroupHeader(string? groupId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(groupId) || !GroupsById.TryGetValue(groupId, out var group))
|
if (string.IsNullOrEmpty(groupId) || !GroupsById.TryGetValue(groupId, out var group))
|
||||||
|
|||||||
@@ -5,9 +5,25 @@ namespace StarterNG.Domain.Vehicles;
|
|||||||
public sealed class VehicleEntry
|
public sealed class VehicleEntry
|
||||||
{
|
{
|
||||||
public string? Uuid { get; set; }
|
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<VehicleGroup> Groups { get; } = new();
|
||||||
public List<VehicleTexture> Textures { get; } = new();
|
public List<VehicleTexture> Textures { get; } = new();
|
||||||
public List<VehicleSet> Sets { 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
|
public sealed class VehicleGroup
|
||||||
@@ -17,6 +33,13 @@ public sealed class VehicleGroup
|
|||||||
public string? Mini { get; set; }
|
public string? Mini { get; set; }
|
||||||
|
|
||||||
public bool Archived { 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
|
public sealed class VehicleTexture
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ public sealed class GamePaths : IGamePaths
|
|||||||
|
|
||||||
public string Data => Path.Combine(Root, "data");
|
public string Data => Path.Combine(Root, "data");
|
||||||
|
|
||||||
|
public string VehicleDatabase => Path.Combine(Root, "databases", "vehicles");
|
||||||
|
|
||||||
public string StarterConfig => Path.Combine(Root, "starter");
|
public string StarterConfig => Path.Combine(Root, "starter");
|
||||||
|
|
||||||
public string DiagnosticsLog => Path.Combine(StarterConfig, "bledy.txt");
|
public string DiagnosticsLog => Path.Combine(StarterConfig, "bledy.txt");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Unicode;
|
||||||
|
|
||||||
namespace StarterNG.Infrastructure.Adapters;
|
namespace StarterNG.Infrastructure.Adapters;
|
||||||
|
|
||||||
@@ -21,6 +22,25 @@ public static class LegacyText
|
|||||||
|
|
||||||
public static bool IsFallback { get; private set; }
|
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()
|
private static Encoding Resolve()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ public sealed class PhysicalFileSystem : IFileSystem
|
|||||||
|
|
||||||
public string[] ReadAllLines(string path) => File.ReadAllLines(path);
|
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 Stream OpenRead(string path) => File.OpenRead(path);
|
||||||
|
|
||||||
public void WriteAllText(string path, string contents) => File.WriteAllText(path, contents);
|
public void WriteAllText(string path, string contents) => File.WriteAllText(path, contents);
|
||||||
@@ -49,6 +51,8 @@ public sealed class PhysicalFileSystem : IFileSystem
|
|||||||
|
|
||||||
public DateTime GetLastWriteTimeUtc(string path) => File.GetLastWriteTimeUtc(path);
|
public DateTime GetLastWriteTimeUtc(string path) => File.GetLastWriteTimeUtc(path);
|
||||||
|
|
||||||
|
public long GetFileSize(string path) => new FileInfo(path).Length;
|
||||||
|
|
||||||
public bool IsExecutable(string path)
|
public bool IsExecutable(string path)
|
||||||
{
|
{
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
|
|||||||
@@ -18,29 +18,38 @@ public sealed class SystemProcessLauncher : IProcessLauncher
|
|||||||
}
|
}
|
||||||
|
|
||||||
public IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
|
public IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
|
||||||
out string? error)
|
bool captureErrors, out string? error)
|
||||||
{
|
{
|
||||||
var info = new ProcessStartInfo
|
var info = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = executablePath,
|
FileName = executablePath,
|
||||||
WorkingDirectory = workingDirectory ?? string.Empty,
|
WorkingDirectory = workingDirectory ?? string.Empty,
|
||||||
UseShellExecute = false
|
UseShellExecute = false,
|
||||||
|
RedirectStandardError = captureErrors
|
||||||
};
|
};
|
||||||
foreach (string argument in arguments)
|
foreach (string argument in arguments)
|
||||||
info.ArgumentList.Add(argument);
|
info.ArgumentList.Add(argument);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var process = Process.Start(info);
|
var process = new Process { StartInfo = info };
|
||||||
if (process is null)
|
var handle = new ProcessHandle(process, started: true);
|
||||||
|
if (captureErrors)
|
||||||
|
process.ErrorDataReceived += (_, e) => handle.AddError(e.Data);
|
||||||
|
|
||||||
|
if (!process.Start())
|
||||||
{
|
{
|
||||||
error = executablePath;
|
error = executablePath;
|
||||||
_log.Log($"start {executablePath}: process not created");
|
_log.Log($"start {executablePath}: process not created");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drained as it comes: a full pipe would stall the simulator.
|
||||||
|
if (captureErrors)
|
||||||
|
process.BeginErrorReadLine();
|
||||||
|
|
||||||
error = null;
|
error = null;
|
||||||
return new ProcessHandle(process);
|
return handle;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -69,7 +78,7 @@ public sealed class SystemProcessLauncher : IProcessLauncher
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var process = Process.GetProcessesByName(processName).FirstOrDefault();
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -80,11 +89,16 @@ public sealed class SystemProcessLauncher : IProcessLauncher
|
|||||||
|
|
||||||
private sealed class ProcessHandle : IProcessHandle
|
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;
|
_process = process;
|
||||||
|
_started = started;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool HasExited
|
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) =>
|
public Task WaitForExitAsync(CancellationToken cancellationToken = default) =>
|
||||||
_process.WaitForExitAsync(cancellationToken);
|
_process.WaitForExitAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,13 +53,11 @@ public static class Diagnostics
|
|||||||
Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title));
|
Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<string> CheckInstallation() =>
|
public static InstallationCheck InstallationCheck() =>
|
||||||
new InstallationCheck(
|
new(AppServices.Current.Paths,
|
||||||
AppServices.Current.Paths,
|
AppServices.Current.Files,
|
||||||
AppServices.Current.Files,
|
AppServices.Current.Localization,
|
||||||
AppServices.Current.Localization,
|
AppServices.Current.Physics,
|
||||||
AppServices.Current.Physics,
|
AppServices.Current.Library,
|
||||||
AppServices.Current.Library,
|
AppServices.Current.SettingsStore);
|
||||||
AppServices.Current.SettingsStore)
|
|
||||||
.Run();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,10 +49,17 @@ public sealed class InstallationCheck
|
|||||||
if (_physics.IndexedCount == 0)
|
if (_physics.IndexedCount == 0)
|
||||||
faults.Add(_strings["FaultNoPhysics"]);
|
faults.Add(_strings["FaultNoPhysics"]);
|
||||||
|
|
||||||
_settings.ResolveExecutable(out var problem);
|
|
||||||
if (problem == ExeProblem.NotFound)
|
|
||||||
faults.Add(_strings["FaultNoExe"]);
|
|
||||||
|
|
||||||
return faults;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
private readonly IGamePaths _paths;
|
private readonly IGamePaths _paths;
|
||||||
private readonly IDiagnosticsLog _log;
|
private readonly IDiagnosticsLog _log;
|
||||||
private readonly SceneryParser _parser;
|
private readonly SceneryParser _parser;
|
||||||
private readonly Encoding _encoding;
|
|
||||||
|
|
||||||
public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser)
|
public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser)
|
||||||
{
|
{
|
||||||
@@ -28,7 +27,6 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
_paths = paths;
|
_paths = paths;
|
||||||
_log = log;
|
_log = log;
|
||||||
_parser = parser;
|
_parser = parser;
|
||||||
_encoding = LegacyText.CodePage1250;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null)
|
public IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null)
|
||||||
@@ -60,7 +58,7 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
if (!_files.FileExists(path))
|
if (!_files.FileExists(path))
|
||||||
return null;
|
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"));
|
scenery.HasCompanionTimetable = _files.FileExists(Path.ChangeExtension(path, ".sbt"));
|
||||||
return scenery;
|
return scenery;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ public sealed class ExecutableLocator
|
|||||||
private readonly IEnvironment _environment;
|
private readonly IEnvironment _environment;
|
||||||
private readonly IDiagnosticsLog _log;
|
private readonly IDiagnosticsLog _log;
|
||||||
|
|
||||||
|
private string? _wine;
|
||||||
|
private bool _wineLookedUp;
|
||||||
|
private string? _missingPickLogged;
|
||||||
|
|
||||||
public ExecutableLocator(IFileSystem files, IGamePaths paths, IEnvironment environment, IDiagnosticsLog log)
|
public ExecutableLocator(IFileSystem files, IGamePaths paths, IEnvironment environment, IDiagnosticsLog log)
|
||||||
{
|
{
|
||||||
_files = files;
|
_files = files;
|
||||||
@@ -29,21 +33,47 @@ public sealed class ExecutableLocator
|
|||||||
|
|
||||||
public string CanonicalName => _environment.IsWindows ? "eu07.exe" : "eu07";
|
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)
|
public string Resolve(SimulatorSettings settings, out ExeProblem problem)
|
||||||
{
|
{
|
||||||
if (!settings.SelectExeAutomatically && !string.IsNullOrWhiteSpace(settings.ExecutablePath))
|
if (!settings.SelectExeAutomatically && !string.IsNullOrWhiteSpace(settings.ExecutablePath))
|
||||||
{
|
{
|
||||||
problem = Validate(FullPath(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;
|
string? fallback = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var candidates = _files.GetFiles(_paths.Root, "eu07*")
|
var candidates = _files.GetFiles(_paths.Root, "eu07*")
|
||||||
.Where(IsCandidate)
|
.Where(IsCandidate)
|
||||||
.OrderBy(path => string.Equals(Path.GetFileName(path), CanonicalName,
|
.OrderBy(path => IsExeOnLinux(path) ? 1 : 0)
|
||||||
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
.ThenBy(path => string.Equals(Path.GetFileName(path), CanonicalName,
|
||||||
|
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
||||||
.ThenBy(path => Path.GetFileName(path).Length);
|
.ThenBy(path => Path.GetFileName(path).Length);
|
||||||
|
|
||||||
foreach (string path in candidates)
|
foreach (string path in candidates)
|
||||||
@@ -94,10 +124,16 @@ public sealed class ExecutableLocator
|
|||||||
if (!_files.FileExists(path))
|
if (!_files.FileExists(path))
|
||||||
return ExeProblem.NotFound;
|
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))
|
if (!_environment.IsWindows && !_files.IsExecutable(path))
|
||||||
return ExeProblem.NotExecutable;
|
return ExeProblem.NotExecutable;
|
||||||
|
|
||||||
return MatchesPlatform(path) ? ExeProblem.None : ExeProblem.WrongPlatform;
|
return foreign ? ExeProblem.WrongPlatform : ExeProblem.None;
|
||||||
}
|
}
|
||||||
catch (Exception)
|
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];
|
byte[] head = new byte[4];
|
||||||
using var stream = _files.OpenRead(path);
|
using var stream = _files.OpenRead(path);
|
||||||
if (stream.Read(head, 0, 4) != 4)
|
if (stream.Read(head, 0, 4) != 4)
|
||||||
return true;
|
return BinaryFormat.Unknown;
|
||||||
|
|
||||||
bool portableExecutable = head[0] == 0x4D && head[1] == 0x5A;
|
if (head[0] == 0x4D && head[1] == 0x5A)
|
||||||
bool elf = head[0] == 0x7F && head[1] == 0x45 && head[2] == 0x4C && head[3] == 0x46;
|
return BinaryFormat.PortableExecutable;
|
||||||
|
if (head[0] == 0x7F && head[1] == 0x45 && head[2] == 0x4C && head[3] == 0x46)
|
||||||
|
return BinaryFormat.Elf;
|
||||||
|
|
||||||
if (_environment.IsWindows && elf)
|
return BinaryFormat.Unknown;
|
||||||
return false;
|
|
||||||
if (_environment.IsLinux && portableExecutable)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsExeOnLinux(string path) =>
|
||||||
|
_environment.IsLinux && string.Equals(Path.GetExtension(path), ".exe", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private bool IsCandidate(string path)
|
private bool IsCandidate(string path)
|
||||||
{
|
{
|
||||||
string extension = Path.GetExtension(path).ToLowerInvariant();
|
string extension = Path.GetExtension(path).ToLowerInvariant();
|
||||||
@@ -133,8 +237,11 @@ public sealed class ExecutableLocator
|
|||||||
if (_environment.IsWindows)
|
if (_environment.IsWindows)
|
||||||
return extension == ".exe";
|
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)
|
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";
|
return extension.Length == 0 || extension is ".exe" or ".x86_64" or ".run" or ".appimage";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ public sealed class SettingsPaths
|
|||||||
|
|
||||||
public string InstallationConfigPath() => _paths.FromRoot(ConfigFileName);
|
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() =>
|
public string UserConfigDirectory() =>
|
||||||
Path.GetDirectoryName(UserConfigPath()) ?? _environment.BaseDirectory;
|
Path.GetDirectoryName(UserConfigPath()) ?? _environment.BaseDirectory;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ public sealed class SettingsSerializer
|
|||||||
s.DebugMode = c.GetBool("debugmode", false);
|
s.DebugMode = c.GetBool("debugmode", false);
|
||||||
s.VirtualShunting = c.GetBool("ai.trainman", true);
|
s.VirtualShunting = c.GetBool("ai.trainman", true);
|
||||||
s.LogMissingVehicleFiles = c.GetBool("starter.logmissingvehicles", false);
|
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.RenderEngine = IndexOf(SimulatorSettings.RenderEngines, c.GetString("gfxrenderer", "full"), 0);
|
||||||
s.Width = c.GetInt("width", 1280);
|
s.Width = c.GetInt("width", 1280);
|
||||||
@@ -195,6 +196,7 @@ public sealed class SettingsSerializer
|
|||||||
c.SetBool("debugmode", s.DebugMode);
|
c.SetBool("debugmode", s.DebugMode);
|
||||||
c.SetBool("ai.trainman", s.VirtualShunting);
|
c.SetBool("ai.trainman", s.VirtualShunting);
|
||||||
c.SetBool("starter.logmissingvehicles", s.LogMissingVehicleFiles);
|
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.Set("gfxrenderer", SimulatorSettings.RenderEngines[Clamp(s.RenderEngine, 0, SimulatorSettings.RenderEngines.Length - 1)]);
|
||||||
c.SetInt("width", s.Width);
|
c.SetInt("width", s.Width);
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
|
|||||||
private readonly IFileSystem _files;
|
private readonly IFileSystem _files;
|
||||||
private readonly IGamePaths _paths;
|
private readonly IGamePaths _paths;
|
||||||
private readonly IDiagnosticsLog _log;
|
private readonly IDiagnosticsLog _log;
|
||||||
private readonly Encoding _encoding;
|
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, VehiclePhysics?> _cache =
|
private readonly ConcurrentDictionary<string, VehiclePhysics?> _cache =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -38,7 +37,6 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
|
|||||||
_files = files;
|
_files = files;
|
||||||
_paths = paths;
|
_paths = paths;
|
||||||
_log = log;
|
_log = log;
|
||||||
_encoding = LegacyText.CodePage1250;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Preload() => Index();
|
public void Preload() => Index();
|
||||||
@@ -118,7 +116,7 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
|
|||||||
if (depth > MaxIncludeDepth || !_files.FileExists(path))
|
if (depth > MaxIncludeDepth || !_files.FileExists(path))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var tokens = Tokenize(_files.ReadAllText(path, _encoding));
|
var tokens = Tokenize(LegacyText.Decode(_files.ReadAllBytes(path)));
|
||||||
|
|
||||||
string section = "";
|
string section = "";
|
||||||
string? pendingKey = null;
|
string? pendingKey = null;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public sealed class LoadWeightsRepository
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return new LoadWeightsTable(ParsePairs(_files.ReadAllText(path, LegacyText.CodePage1250)));
|
return new LoadWeightsTable(ParsePairs(LegacyText.Decode(_files.ReadAllBytes(path))));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ public sealed class TexturesTxtParser
|
|||||||
{
|
{
|
||||||
private const string DeriveCategory = "*";
|
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)
|
public VehicleEntry? Parse(string directory, IReadOnlyList<string> lines)
|
||||||
{
|
{
|
||||||
var entry = new VehicleEntry { Uuid = "legacy:" + directory };
|
var entry = new VehicleEntry { Uuid = "legacy:" + directory };
|
||||||
@@ -25,7 +28,10 @@ public sealed class TexturesTxtParser
|
|||||||
|
|
||||||
char first = line[0];
|
char first = line[0];
|
||||||
if (first is '#' or '@' or '*')
|
if (first is '#' or '@' or '*')
|
||||||
|
{
|
||||||
|
entry.Unknown.Add(line);
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (line.StartsWith("$a", StringComparison.OrdinalIgnoreCase))
|
if (line.StartsWith("$a", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -47,11 +53,17 @@ public sealed class TexturesTxtParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (line.IndexOf('=') < 0 || line.StartsWith("//", StringComparison.Ordinal))
|
if (line.IndexOf('=') < 0 || line.StartsWith("//", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
entry.Unknown.Add(line);
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var texture = ParseLivery(line, directory, categorySign, archived, entry);
|
var texture = ParseLivery(line, directory, categorySign, archived, entry);
|
||||||
if (texture is null)
|
if (texture is null)
|
||||||
|
{
|
||||||
|
entry.Unknown.Add(line);
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
entry.Textures.Add(texture);
|
entry.Textures.Add(texture);
|
||||||
|
|
||||||
@@ -120,12 +132,20 @@ public sealed class TexturesTxtParser
|
|||||||
Id = groupId,
|
Id = groupId,
|
||||||
Category = categorySign,
|
Category = categorySign,
|
||||||
Mini = lead.Mini,
|
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
|
var texture = new VehicleTexture
|
||||||
{
|
{
|
||||||
Uuid = $"legacy:{directory}{skin}",
|
Uuid = uuid,
|
||||||
Directory = directory,
|
Directory = directory,
|
||||||
Skinfile = skin,
|
Skinfile = skin,
|
||||||
Wreck = skin.Contains("wreck", StringComparison.OrdinalIgnoreCase) ||
|
Wreck = skin.Contains("wreck", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using StarterNG.Application.Abstractions;
|
using StarterNG.Application.Abstractions;
|
||||||
using StarterNG.Domain.Vehicles;
|
using StarterNG.Domain.Vehicles;
|
||||||
@@ -16,7 +17,6 @@ public sealed class TexturesTxtVehicleRepository : IVehicleRepository
|
|||||||
private readonly IGamePaths _paths;
|
private readonly IGamePaths _paths;
|
||||||
private readonly IDiagnosticsLog _log;
|
private readonly IDiagnosticsLog _log;
|
||||||
private readonly TexturesTxtParser _parser;
|
private readonly TexturesTxtParser _parser;
|
||||||
private readonly Encoding _encoding;
|
|
||||||
|
|
||||||
public TexturesTxtVehicleRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log,
|
public TexturesTxtVehicleRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log,
|
||||||
TexturesTxtParser parser)
|
TexturesTxtParser parser)
|
||||||
@@ -25,7 +25,6 @@ public sealed class TexturesTxtVehicleRepository : IVehicleRepository
|
|||||||
_paths = paths;
|
_paths = paths;
|
||||||
_log = log;
|
_log = log;
|
||||||
_parser = parser;
|
_parser = parser;
|
||||||
_encoding = LegacyText.CodePage1250;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public int Load(VehicleCatalog catalog)
|
public int Load(VehicleCatalog catalog)
|
||||||
@@ -33,9 +32,9 @@ public sealed class TexturesTxtVehicleRepository : IVehicleRepository
|
|||||||
catalog.BeginLoad();
|
catalog.BeginLoad();
|
||||||
|
|
||||||
int liveries = 0;
|
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)
|
if (entry is null)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -47,11 +46,31 @@ public sealed class TexturesTxtVehicleRepository : IVehicleRepository
|
|||||||
return liveries;
|
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
|
try
|
||||||
{
|
{
|
||||||
string[] lines = _files.ReadAllText(path, _encoding).Split('\n');
|
string[] lines = LegacyText.Decode(_files.ReadAllBytes(path)).Split('\n');
|
||||||
return _parser.Parse(RelativeDirectory(path), lines);
|
return _parser.Parse(RelativeDirectory(path), lines);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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)
|
private string RelativeDirectory(string texturesPath)
|
||||||
{
|
{
|
||||||
string directory = Path.GetDirectoryName(texturesPath) ?? "";
|
string directory = Path.GetDirectoryName(texturesPath) ?? "";
|
||||||
|
|||||||
190
StarterNG/Infrastructure/Vehicles/VehicleCacheRepository.cs
Normal file
190
StarterNG/Infrastructure/Vehicles/VehicleCacheRepository.cs
Normal 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, "");
|
||||||
|
}
|
||||||
124
StarterNG/Infrastructure/Vehicles/VehicleJsonDocument.cs
Normal file
124
StarterNG/Infrastructure/Vehicles/VehicleJsonDocument.cs
Normal 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
|
||||||
|
{
|
||||||
|
}
|
||||||
101
StarterNG/Infrastructure/Vehicles/VehicleJsonRepository.cs
Normal file
101
StarterNG/Infrastructure/Vehicles/VehicleJsonRepository.cs
Normal 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 };
|
||||||
|
}
|
||||||
215
StarterNG/Infrastructure/Vehicles/VehicleJsonSerializer.cs
Normal file
215
StarterNG/Infrastructure/Vehicles/VehicleJsonSerializer.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@
|
|||||||
Tag="settings"
|
Tag="settings"
|
||||||
IsCheckedChanged="Nav_OnCheckedChanged"
|
IsCheckedChanged="Nav_OnCheckedChanged"
|
||||||
Content="{Binding [NavSettings], Source={x:Static starterNg:App.Loc}}" />
|
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"
|
<Button Classes="Basic" Click="openLastLogClick" Width="48" Cursor="Hand"
|
||||||
HorizontalContentAlignment="Center"
|
HorizontalContentAlignment="Center"
|
||||||
ToolTip.Tip="{Binding [ShowLatestLogTooltip], Source={x:Static starterNg:App.Loc}}">
|
ToolTip.Tip="{Binding [ShowLatestLogTooltip], Source={x:Static starterNg:App.Loc}}">
|
||||||
@@ -93,6 +97,7 @@
|
|||||||
<views:Scenarios x:Name="ScenariosView" />
|
<views:Scenarios x:Name="ScenariosView" />
|
||||||
<views:Depot x:Name="DepotView" IsVisible="False" />
|
<views:Depot x:Name="DepotView" IsVisible="False" />
|
||||||
<views:Settings x:Name="SettingsView" IsVisible="False" />
|
<views:Settings x:Name="SettingsView" IsVisible="False" />
|
||||||
|
<views:Developer x:Name="DeveloperView" IsVisible="False" />
|
||||||
</Panel>
|
</Panel>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ public partial class MainWindow : Window
|
|||||||
startButton.AddHandler(PointerReleasedEvent, StartButton_OnRightClick,
|
startButton.AddHandler(PointerReleasedEvent, StartButton_OnRightClick,
|
||||||
RoutingStrategies.Tunnel);
|
RoutingStrategies.Tunnel);
|
||||||
|
|
||||||
|
SettingsView.DeveloperToolsChanged += ApplyDeveloperTab;
|
||||||
|
|
||||||
SettingsView.ThumbnailSizeChanged += () =>
|
SettingsView.ThumbnailSizeChanged += () =>
|
||||||
{
|
{
|
||||||
DepotView.RefreshConsistView();
|
DepotView.RefreshConsistView();
|
||||||
@@ -172,7 +174,7 @@ public partial class MainWindow : Window
|
|||||||
if (sender is not RadioButton { IsChecked: true } rb)
|
if (sender is not RadioButton { IsChecked: true } rb)
|
||||||
return;
|
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;
|
return;
|
||||||
|
|
||||||
ShowPage(rb.Tag as string);
|
ShowPage(rb.Tag as string);
|
||||||
@@ -184,11 +186,31 @@ public partial class MainWindow : Window
|
|||||||
ScenariosView.IsVisible = page == "scenarios";
|
ScenariosView.IsVisible = page == "scenarios";
|
||||||
DepotView.IsVisible = page == "depot";
|
DepotView.IsVisible = page == "depot";
|
||||||
SettingsView.IsVisible = page == "settings";
|
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()
|
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)
|
if (rb.IsChecked == true)
|
||||||
{
|
{
|
||||||
ShowPage(rb.Tag as string);
|
ShowPage(rb.Tag as string);
|
||||||
@@ -381,16 +403,30 @@ public partial class MainWindow : Window
|
|||||||
return trainset.Vehicles.Any(CanStart);
|
return trainset.Vehicles.Any(CanStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<bool> ShowExeProblem(ExeProblem problem, string exe)
|
private Task<bool> ShowExeProblem(ExeProblem problem, string exe) =>
|
||||||
{
|
MessageBox.Show(this, $"{exe}\n\n{App.Loc[ExeProblemText.Key(problem)]}",
|
||||||
string key = problem switch
|
|
||||||
{
|
|
||||||
ExeProblem.NotExecutable => "ExeNotExecutable",
|
|
||||||
ExeProblem.WrongPlatform => "ExeWrongPlatform",
|
|
||||||
_ => "ExeNotFound"
|
|
||||||
};
|
|
||||||
return MessageBox.Show(this, $"{exe}\n\n{App.Loc[key]}",
|
|
||||||
App.Loc["ExeLaunchFailed"], MessageBoxButtons.Ok);
|
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) =>
|
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);
|
freeFly.Click += async (_, _) => await LaunchAsync(saveSettings: true, freeFly: true);
|
||||||
menu.Items.Add(freeFly);
|
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);
|
menu.Open(startButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +477,28 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
|
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)
|
switch (result.Outcome)
|
||||||
{
|
{
|
||||||
case SimulationStartOutcome.NothingSelected:
|
case SimulationStartOutcome.NothingSelected:
|
||||||
@@ -464,15 +530,16 @@ public partial class MainWindow : Window
|
|||||||
GC.Collect();
|
GC.Collect();
|
||||||
GC.WaitForPendingFinalizers();
|
GC.WaitForPendingFinalizers();
|
||||||
|
|
||||||
WatchSimulator(result.Process);
|
WatchSimulator(result.Process, result.ExecutablePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Brings the starter back when the simulator exits. Adopts an already
|
/// Brings the starter back when the simulator exits. Adopts an already
|
||||||
/// running simulator when we did not start it ourselves.
|
/// running simulator when we did not start it ourselves.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void WatchSimulator(IProcessHandle? simulator)
|
private void WatchSimulator(IProcessHandle? simulator, string executable)
|
||||||
{
|
{
|
||||||
|
var started = DateTime.UtcNow;
|
||||||
simulator ??= AppServices.Current.Processes.FindRunning(
|
simulator ??= AppServices.Current.Processes.FindRunning(
|
||||||
Path.GetFileNameWithoutExtension(AppServices.Current.SettingsStore.ResolveExecutable()));
|
Path.GetFileNameWithoutExtension(AppServices.Current.SettingsStore.ResolveExecutable()));
|
||||||
|
|
||||||
@@ -482,8 +549,56 @@ public partial class MainWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = simulator.WaitForExitAsync().ContinueWith(
|
_ = simulator.WaitForExitAsync().ContinueWith(_ => Dispatcher.UIThread.Post(() =>
|
||||||
_ => Dispatcher.UIThread.Post(RestoreFromSimulator), TaskScheduler.Default);
|
{
|
||||||
|
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()
|
private async void CheckExternalSettings()
|
||||||
|
|||||||
@@ -20,12 +20,20 @@ public sealed class SceneryTreeBuilder
|
|||||||
_translations = translations;
|
_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,
|
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 groups = new Dictionary<string, List<SceneryTreeNode>>(StringComparer.Ordinal);
|
||||||
var groupLabels = new Dictionary<string, string>(StringComparer.Ordinal);
|
var groupLabels = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
var topLevel = new List<SceneryTreeNode>();
|
var topLevel = new List<SceneryTreeNode>();
|
||||||
|
var favorites = new List<SceneryTreeNode>();
|
||||||
|
|
||||||
for (int i = 0; i < sceneries.Count; i++)
|
for (int i = 0; i < sceneries.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -35,7 +43,11 @@ public sealed class SceneryTreeBuilder
|
|||||||
|
|
||||||
_translations.LoadFor(scenery, langCode);
|
_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))
|
if (string.IsNullOrEmpty(scenery.Group))
|
||||||
{
|
{
|
||||||
@@ -55,7 +67,10 @@ public sealed class SceneryTreeBuilder
|
|||||||
foreach (var (group, members) in groups)
|
foreach (var (group, members) in groups)
|
||||||
topLevel.Add(new SceneryTreeNode(groupLabels[group], -1, Sorted(members)));
|
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) =>
|
private static List<SceneryTreeNode> Sorted(IEnumerable<SceneryTreeNode> nodes) =>
|
||||||
|
|||||||
@@ -10,8 +10,9 @@
|
|||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<ApplicationIcon>Assets\starter.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\starter.ico</ApplicationIcon>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
<AssemblyVersion>1.4.0.0</AssemblyVersion>
|
<!-- Major and minor only: CI adds the patch, the number of commits, so every
|
||||||
<FileVersion>1.4.0.0</FileVersion>
|
build of main is a higher version without anyone bumping it. -->
|
||||||
|
<VersionPrefix>1.4</VersionPrefix>
|
||||||
<AssemblyName>Starter</AssemblyName>
|
<AssemblyName>Starter</AssemblyName>
|
||||||
<Company>eu07.pl</Company>
|
<Company>eu07.pl</Company>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -30,55 +30,61 @@
|
|||||||
<Border Grid.Column="0" Grid.Row="0" TabIndex="2" Background="{StaticResource CarbonPanelBg}"
|
<Border Grid.Column="0" Grid.Row="0" TabIndex="2" Background="{StaticResource CarbonPanelBg}"
|
||||||
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="1"
|
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="1"
|
||||||
CornerRadius="2" Padding="6">
|
CornerRadius="2" Padding="6">
|
||||||
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto">
|
<!-- The old starter's column, top to bottom: the archival switch, the category
|
||||||
<DockPanel Grid.Row="0" Margin="0,0,0,6" LastChildFill="True">
|
with the catalogue beside it, the class with its thumbnail, then the
|
||||||
<CheckBox Name="hideArchivalCheck" DockPanel.Dock="Right" FontSize="12"
|
vehicles. The search, which it kept in a window of its own, sits above
|
||||||
VerticalAlignment="Center" Margin="8,0,0,0"
|
the list it fills. -->
|
||||||
Content="{Binding [HideArchivalVehicles], Source={x:Static starterNg:App.Loc}}"
|
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto">
|
||||||
IsCheckedChanged="HideArchivalCheck_OnChanged" />
|
<CheckBox Name="hideArchivalCheck" Grid.Row="0" Margin="0,0,0,6" FontSize="12"
|
||||||
<TextBox Name="searchBox" VerticalAlignment="Center"
|
Content="{Binding [HideArchivalVehicles], Source={x:Static starterNg:App.Loc}}"
|
||||||
PlaceholderText="{Binding [Search], Source={x:Static starterNg:App.Loc}}"
|
IsCheckedChanged="HideArchivalCheck_OnChanged" />
|
||||||
TextChanged="SearchBox_OnTextChanged" />
|
|
||||||
</DockPanel>
|
<DockPanel Grid.Row="1" Margin="0,0,0,6" LastChildFill="True">
|
||||||
<Grid Grid.Row="1" RowDefinitions="Auto,Auto" RowSpacing="6" Margin="0,0,0,6">
|
<Button Name="textureBaseButton" DockPanel.Dock="Right" Classes="Flat"
|
||||||
<DockPanel Grid.Row="0" LastChildFill="True">
|
Width="30" Margin="4,0,0,0" Cursor="Hand"
|
||||||
<Button Name="textureBaseButton" DockPanel.Dock="Right" Classes="Flat"
|
HorizontalContentAlignment="Center"
|
||||||
Width="30" Margin="4,0,0,0" Cursor="Hand"
|
Click="TextureBaseButton_OnClick"
|
||||||
HorizontalContentAlignment="Center"
|
ToolTip.Tip="{Binding [OpenTextureBase], Source={x:Static starterNg:App.Loc}}">
|
||||||
Click="TextureBaseButton_OnClick"
|
<materialIcons:MaterialIcon Kind="Magnify" Width="15" Height="15" />
|
||||||
ToolTip.Tip="{Binding [OpenTextureBase], Source={x:Static starterNg:App.Loc}}">
|
</Button>
|
||||||
<materialIcons:MaterialIcon Kind="Magnify" Width="15" Height="15" />
|
<ComboBox Name="categoryCombo" HorizontalAlignment="Stretch"
|
||||||
</Button>
|
PlaceholderText="{Binding [Category], Source={x:Static starterNg:App.Loc}}"
|
||||||
<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}}"
|
|
||||||
PointerWheelChanged="Combo_OnPointerWheel"
|
PointerWheelChanged="Combo_OnPointerWheel"
|
||||||
SelectionChanged="ClassCombo_OnSelectionChanged" />
|
SelectionChanged="CategoryCombo_OnSelectionChanged" />
|
||||||
</Grid>
|
</DockPanel>
|
||||||
|
|
||||||
<Border Name="miniPreviewPanel" Grid.Row="3" Height="46"
|
<ComboBox Name="classCombo" Grid.Row="2" Margin="0,0,0,6" HorizontalAlignment="Stretch"
|
||||||
Margin="0,6,0,0"
|
HorizontalContentAlignment="Center"
|
||||||
Background="{StaticResource CarbonInputBg}"
|
PlaceholderText="{Binding [Class], Source={x:Static starterNg:App.Loc}}"
|
||||||
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="1"
|
PointerWheelChanged="Combo_OnPointerWheel"
|
||||||
CornerRadius="2" Cursor="Hand"
|
SelectionChanged="ClassCombo_OnSelectionChanged" />
|
||||||
PointerPressed="MiniPreview_OnPointerPressed">
|
|
||||||
<Image Name="miniPreview" Stretch="Uniform" Margin="6" />
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Button Name="addVehicleButton" Grid.Row="4" Classes="Flat Accent"
|
<TextBox Name="searchBox" Grid.Row="3" Margin="0,0,0,6"
|
||||||
Margin="0,8,0,0" Cursor="Hand" IsEnabled="False"
|
PlaceholderText="{Binding [SearchAllVehicles], Source={x:Static starterNg:App.Loc}}"
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
TextChanged="SearchBox_OnTextChanged" />
|
||||||
Click="AddVehicleButton_OnClick"
|
|
||||||
Content="{Binding [AddVehicle], Source={x:Static starterNg:App.Loc}}" />
|
|
||||||
|
|
||||||
<ListBox Name="vehicleListBox" Grid.Row="2" Classes="Compact"
|
<ListBox Name="vehicleListBox" Grid.Row="4" Classes="Compact" MinHeight="60"
|
||||||
SelectionChanged="VehicleListBox_OnSelectionChanged"
|
SelectionChanged="VehicleListBox_OnSelectionChanged"
|
||||||
DoubleTapped="VehicleListBox_OnDoubleTapped"
|
DoubleTapped="VehicleListBox_OnDoubleTapped"
|
||||||
PointerPressed="VehicleListBox_OnPointerPressed" />
|
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>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ public partial class Depot : UserControl
|
|||||||
|
|
||||||
public Depot()
|
public Depot()
|
||||||
{
|
{
|
||||||
_info = new VehicleInfo(_db);
|
_info = new VehicleInfo(_db, AppServices.Current.Physics);
|
||||||
_consist = new Consist(_db, _info);
|
_consist = new Consist(_db, _info);
|
||||||
_cargo = new Cargo(_info, _rng);
|
_cargo = new Cargo(_info, _rng);
|
||||||
_consist.Changed += RebuildConsist;
|
_consist.Changed += RebuildConsist;
|
||||||
@@ -71,14 +71,20 @@ public partial class Depot : UserControl
|
|||||||
|
|
||||||
_browser = new VehicleBrowser(categoryCombo, classCombo, vehicleListBox, searchBox,
|
_browser = new VehicleBrowser(categoryCombo, classCombo, vehicleListBox, searchBox,
|
||||||
hideArchivalCheck, miniPreview, addVehicleButton, _db, _minis,
|
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,
|
_drag = new ConsistDragging(consistStack, consistScroll, consistOverlay,
|
||||||
consistDropTarget, vehicleListBox, miniPreviewPanel,
|
consistDropTarget, vehicleListBox, miniPreviewPanel,
|
||||||
_consist, () => _browser.Selected, InsertTextureAt, _browser.SelectInBrowser);
|
_consist, () => _browser.Selected, InsertTextureAt, _browser.SelectInBrowser);
|
||||||
|
|
||||||
_cards = new VehicleCards(consistStack, _db, _consist, _cargo, _minis, _hand,
|
_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.
|
// Hidden still scrolls - it only drops the bar itself.
|
||||||
MiniTextures.Sharp(miniPreview);
|
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>
|
/// <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()
|
private void RebuildConsist()
|
||||||
{
|
{
|
||||||
@@ -213,9 +224,10 @@ public partial class Depot : UserControl
|
|||||||
(_consist.SelectedCar is null || !moved.Cars.Contains(_consist.SelectedCar)))
|
(_consist.SelectedCar is null || !moved.Cars.Contains(_consist.SelectedCar)))
|
||||||
_cards.Activate(moved);
|
_cards.Activate(moved);
|
||||||
|
|
||||||
|
// The stats are read off the trainset, so it is written back first.
|
||||||
|
WriteBackToScenery();
|
||||||
_details.Refresh();
|
_details.Refresh();
|
||||||
UpdateTrainStats();
|
UpdateTrainStats();
|
||||||
WriteBackToScenery();
|
|
||||||
|
|
||||||
// The cards are thrown away and rebuilt on every change, so a keyboard-driven
|
// 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.
|
// 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(
|
StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields(
|
||||||
_consist.EditingTrainset,
|
_consist.EditingTrainset,
|
||||||
_info.PhysicsFor,
|
_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));
|
AppServices.Current.LoadWeights.Table.WeightOf));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,17 +584,15 @@ public partial class Depot : UserControl
|
|||||||
var item = new ConsistItem
|
var item = new ConsistItem
|
||||||
{
|
{
|
||||||
Cars = unit.Select(NewVehicle).ToList(),
|
Cars = unit.Select(NewVehicle).ToList(),
|
||||||
Grouped = unit.Count > 1,
|
Grouped = unit.Count > 1
|
||||||
Driver = _consist.Selected.Driver,
|
|
||||||
Flipped = _consist.Selected.Flipped
|
|
||||||
};
|
};
|
||||||
_consist[i] = item;
|
_consist.Replace(i, item);
|
||||||
_consist.Selected = item;
|
_consist.Selected = item;
|
||||||
_consist.AutoConnectAll();
|
_consist.AutoConnectAll();
|
||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddTexture(VehicleTexture texture)
|
private void AddTexture(VehicleTexture texture, int count = 1)
|
||||||
{
|
{
|
||||||
int at = _consist.Count;
|
int at = _consist.Count;
|
||||||
if (_consist.Selected != null)
|
if (_consist.Selected != null)
|
||||||
@@ -590,53 +600,47 @@ public partial class Depot : UserControl
|
|||||||
int si = _consist.IndexOf(_consist.Selected);
|
int si = _consist.IndexOf(_consist.Selected);
|
||||||
if (si >= 0) at = si + 1;
|
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 set = _db.ResolveSet(texture);
|
||||||
var unit = set ?? new List<VehicleTexture> { 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);
|
at = Math.Clamp(at, 0, _consist.Count);
|
||||||
MatchOccupancy(item, at);
|
for (int n = 0; n < count; n++, at++)
|
||||||
_consist.Insert(at, item);
|
{
|
||||||
_consist.Selected = item;
|
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();
|
_consist.AutoConnectAll();
|
||||||
RebuildConsist();
|
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)
|
private async void TextureBaseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (TopLevel.GetTopLevel(this) is not Window owner) return;
|
if (TopLevel.GetTopLevel(this) is not Window owner) return;
|
||||||
|
|||||||
47
StarterNG/Views/Depot/AddManyFlyout.cs
Normal file
47
StarterNG/Views/Depot/AddManyFlyout.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
|
||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
using StarterNG.Domain;
|
using StarterNG.Domain;
|
||||||
@@ -103,9 +104,9 @@ public sealed class ConsistDragging
|
|||||||
|
|
||||||
public void MiniPreviewPressed(object? sender, PointerPressedEventArgs e)
|
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;
|
return;
|
||||||
ArmVehicleDrag(e, miniPreviewPanel, _browserSelected());
|
ArmVehicleDrag(e, miniPreviewPanel, texture);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void VehicleListPressed(object? sender, PointerPressedEventArgs e)
|
public void VehicleListPressed(object? sender, PointerPressedEventArgs e)
|
||||||
@@ -113,10 +114,7 @@ public sealed class ConsistDragging
|
|||||||
if (!e.GetCurrentPoint(vehicleListBox).Properties.IsLeftButtonPressed)
|
if (!e.GetCurrentPoint(vehicleListBox).Properties.IsLeftButtonPressed)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var hit = e.Source as Control;
|
if ((e.Source as Visual).FindAncestorOfType<ListBoxItem>(includeSelf: true) is { Tag: VehicleTexture texture })
|
||||||
while (hit != null && hit is not ListBoxItem)
|
|
||||||
hit = hit.Parent as Control;
|
|
||||||
if (hit is ListBoxItem { Tag: VehicleTexture texture })
|
|
||||||
ArmVehicleDrag(e, vehicleListBox, texture);
|
ArmVehicleDrag(e, vehicleListBox, texture);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
171
StarterNG/Views/Depot/CouplerLook.cs
Normal file
171
StarterNG/Views/Depot/CouplerLook.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ using Avalonia.Layout;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
|
||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
using StarterNG.Domain;
|
using StarterNG.Domain;
|
||||||
@@ -51,6 +52,9 @@ public sealed class VehicleBrowser
|
|||||||
|
|
||||||
public Action<VehicleTexture>? TextureSelected { get; set; }
|
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 _suppress;
|
||||||
private bool _syncingCombos;
|
private bool _syncingCombos;
|
||||||
|
|
||||||
@@ -90,24 +94,15 @@ public sealed class VehicleBrowser
|
|||||||
|
|
||||||
private const int MiniPreviewHeight = 54;
|
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 =
|
private static readonly (string LocKey, Func<string?, bool> Match)[] CategoryDefs =
|
||||||
{
|
{
|
||||||
("CatElectricLoco", c => c == "e"),
|
("CatElectricLoco", c => c == "e"),
|
||||||
("CatDieselLoco", c => c == "s"),
|
("CatDieselLoco", c => c == "s"),
|
||||||
("CatSteamLoco", c => c == "p"),
|
("CatSteamLoco", c => c == "p"),
|
||||||
("CatEMU", c => c == "z"),
|
|
||||||
("CatRailbus", c => c == "a"),
|
("CatRailbus", c => c == "a"),
|
||||||
("CatDraisine", c => c == "d"),
|
("CatEMU", c => c == "z"),
|
||||||
("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"),
|
|
||||||
("CatWagonsA", c => c == "A"),
|
("CatWagonsA", c => c == "A"),
|
||||||
("CatWagonsB", c => c == "B"),
|
("CatWagonsB", c => c == "B"),
|
||||||
("CatWagonsC", c => c == "C"),
|
("CatWagonsC", c => c == "C"),
|
||||||
@@ -133,6 +128,15 @@ public sealed class VehicleBrowser
|
|||||||
("CatWagonsX", c => c == "X"),
|
("CatWagonsX", c => c == "X"),
|
||||||
("CatWagonsY", c => c == "Y"),
|
("CatWagonsY", c => c == "Y"),
|
||||||
("CatWagonsZ", c => c == "Z"),
|
("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),
|
("CatOther", IsOtherCat),
|
||||||
};
|
};
|
||||||
@@ -144,41 +148,76 @@ public sealed class VehicleBrowser
|
|||||||
string.IsNullOrEmpty(c) ||
|
string.IsNullOrEmpty(c) ||
|
||||||
(c is { Length: 1 } && char.IsLower(c[0]) && !NamedLowerCats.Contains(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()
|
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;
|
_suppress = true;
|
||||||
categoryCombo.Items.Clear();
|
categoryCombo.Items.Clear();
|
||||||
foreach (var (locKey, match) in CategoryDefs)
|
foreach (var (locKey, match) in CategoryDefs)
|
||||||
categoryCombo.Items.Add(new ComboBoxItem { Content = App.Loc[locKey], Tag = match });
|
if (present.Any(match))
|
||||||
categoryCombo.SelectedIndex = keep >= 0 && keep < categoryCombo.Items.Count ? keep : -1;
|
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;
|
_suppress = false;
|
||||||
|
|
||||||
_categoryFilter = categoryCombo.SelectedItem is ComboBoxItem { Tag: Func<string?, bool> f }
|
_categoryFilter = categoryCombo.SelectedItem is ComboBoxItem { Tag: Func<string?, bool> f }
|
||||||
? f : null;
|
? 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;
|
_suppress = true;
|
||||||
FillClassCombo();
|
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;
|
_suppress = false;
|
||||||
|
|
||||||
_classFilter = null;
|
_classFilter = classCombo.SelectedItem as string;
|
||||||
Rebuild();
|
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()
|
public void InitClassComboTemplates()
|
||||||
{
|
{
|
||||||
|
int height = VehicleCardStyle.ThumbHeight;
|
||||||
classCombo.ItemTemplate = new FuncDataTemplate<string>(
|
classCombo.ItemTemplate = new FuncDataTemplate<string>(
|
||||||
(cls, _) => ClassComboContent(cls, large: true), false);
|
(cls, _) => ClassComboContent(cls, height, stacked: true), false);
|
||||||
classCombo.SelectionBoxItemTemplate = new FuncDataTemplate<string>(
|
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()
|
private void FillClassCombo()
|
||||||
{
|
{
|
||||||
classCombo.Items.Clear();
|
classCombo.Items.Clear();
|
||||||
@@ -216,14 +255,13 @@ public sealed class VehicleBrowser
|
|||||||
_db.Textures.FirstOrDefault(t => string.Equals(VehicleInfo.ClassOf(t), cls, StringComparison.OrdinalIgnoreCase))
|
_db.Textures.FirstOrDefault(t => string.Equals(VehicleInfo.ClassOf(t), cls, StringComparison.OrdinalIgnoreCase))
|
||||||
is { } t ? VehicleInfo.CategoryOf(t) : null;
|
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
|
var cell = new StackPanel
|
||||||
{
|
{
|
||||||
Orientation = large ? Orientation.Vertical : Orientation.Horizontal,
|
Orientation = stacked ? Orientation.Vertical : Orientation.Horizontal,
|
||||||
Spacing = large ? 2 : 8,
|
Spacing = stacked ? 2 : 8,
|
||||||
HorizontalAlignment = large ? HorizontalAlignment.Center : HorizontalAlignment.Left,
|
HorizontalAlignment = stacked ? HorizontalAlignment.Center : HorizontalAlignment.Left,
|
||||||
VerticalAlignment = VerticalAlignment.Center
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
};
|
};
|
||||||
var bmp = _minis.Get(cls, height);
|
var bmp = _minis.Get(cls, height);
|
||||||
@@ -238,7 +276,7 @@ public sealed class VehicleBrowser
|
|||||||
cell.Children.Add(new TextBlock
|
cell.Children.Add(new TextBlock
|
||||||
{
|
{
|
||||||
Text = cls,
|
Text = cls,
|
||||||
HorizontalAlignment = large ? HorizontalAlignment.Center : HorizontalAlignment.Left,
|
HorizontalAlignment = stacked ? HorizontalAlignment.Center : HorizontalAlignment.Left,
|
||||||
VerticalAlignment = VerticalAlignment.Center,
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
TextAlignment = TextAlignment.Center,
|
TextAlignment = TextAlignment.Center,
|
||||||
TextTrimming = TextTrimming.CharacterEllipsis
|
TextTrimming = TextTrimming.CharacterEllipsis
|
||||||
@@ -248,9 +286,8 @@ public sealed class VehicleBrowser
|
|||||||
|
|
||||||
private IEnumerable<string> ClassesForCategory(Func<string?, bool>? category) =>
|
private IEnumerable<string> ClassesForCategory(Func<string?, bool>? category) =>
|
||||||
_db.Textures
|
_db.Textures
|
||||||
|
.Where(Listable)
|
||||||
.Where(t => category == null || category(VehicleInfo.CategoryOf(t)))
|
.Where(t => category == null || category(VehicleInfo.CategoryOf(t)))
|
||||||
.Where(t => !_db.IsSetFollower(t))
|
|
||||||
.Where(t => !AppServices.Current.Settings.HideArchivalVehicles || !t.ResolvedArchived)
|
|
||||||
.Select(VehicleInfo.ClassOf)
|
.Select(VehicleInfo.ClassOf)
|
||||||
.Where(s => !string.IsNullOrEmpty(s))
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -310,7 +347,10 @@ public sealed class VehicleBrowser
|
|||||||
bool hasSearch = search.Length > 0;
|
bool hasSearch = search.Length > 0;
|
||||||
|
|
||||||
if (_categoryFilter == null && _classFilter == null && !hasSearch)
|
if (_categoryFilter == null && _classFilter == null && !hasSearch)
|
||||||
|
{
|
||||||
|
AddNote(App.Loc["BrowserHint"]);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var matched = _db.Textures
|
var matched = _db.Textures
|
||||||
.Where(t => PassesFilters(t, search, hasSearch))
|
.Where(t => PassesFilters(t, search, hasSearch))
|
||||||
@@ -329,13 +369,16 @@ public sealed class VehicleBrowser
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (vehicleListBox.Items.Count == 0)
|
if (vehicleListBox.Items.Count == 0)
|
||||||
vehicleListBox.Items.Add(new ListBoxItem
|
AddNote(hasSearch ? string.Format(App.Loc["BrowserNoMatch"], search) : App.Loc["NoVehicles"]);
|
||||||
{
|
|
||||||
Content = App.Loc["NoVehicles"],
|
|
||||||
IsEnabled = false
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
public void VehicleListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (vehicleListBox.SelectedItem is ListBoxItem { Tag: VehicleTexture texture })
|
if (vehicleListBox.SelectedItem is ListBoxItem { Tag: VehicleTexture texture })
|
||||||
@@ -360,20 +403,24 @@ public sealed class VehicleBrowser
|
|||||||
|
|
||||||
private void List_OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
private void List_OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||||
{
|
{
|
||||||
var hit = e.Source as Control;
|
if ((e.Source as Visual).FindAncestorOfType<ListBoxItem>(includeSelf: true) is not
|
||||||
while (hit != null && hit is not ListBoxItem)
|
{ Tag: VehicleTexture texture } hit)
|
||||||
hit = hit.Parent as Control;
|
|
||||||
|
|
||||||
if (hit is not ListBoxItem { Tag: VehicleTexture texture })
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
BuildBrowserMenu(texture).Open(hit);
|
BuildBrowserMenu(texture, hit).Open(hit);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ContextMenu BuildBrowserMenu(VehicleTexture texture)
|
private ContextMenu BuildBrowserMenu(VehicleTexture texture, Control anchor)
|
||||||
{
|
{
|
||||||
var menu = new ContextMenu();
|
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"] };
|
var copy = new MenuItem { Header = App.Loc["CopyTextureName"] };
|
||||||
copy.Click += async (_, _) =>
|
copy.Click += async (_, _) =>
|
||||||
{
|
{
|
||||||
@@ -476,23 +523,19 @@ public sealed class VehicleBrowser
|
|||||||
|
|
||||||
private bool PassesFilters(VehicleTexture t, string search, bool hasSearch)
|
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)))
|
if (_categoryFilter != null && !_categoryFilter(VehicleInfo.CategoryOf(t)))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (_classFilter != null &&
|
return _classFilter == null ||
|
||||||
!string.Equals(VehicleInfo.ClassOf(t), _classFilter, StringComparison.OrdinalIgnoreCase))
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool Matches(VehicleTexture t, string f)
|
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.
|
// called a vehicle something none of the other panels did.
|
||||||
private static string BrowserName(VehicleTexture texture) => Consist.Base(texture.Skinfile);
|
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))
|
return new StackPanel
|
||||||
name += $" · {texture.Meta!.Operator}";
|
{
|
||||||
if (set is { Count: > 1 })
|
Orientation = Orientation.Horizontal,
|
||||||
name += $" [{set.Count}]";
|
Spacing = 6,
|
||||||
return name;
|
Children = { name, new TextBlock { Text = $"×{set.Count}", Opacity = 0.55 } }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e)
|
public void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e)
|
||||||
@@ -537,12 +585,12 @@ public sealed class VehicleBrowser
|
|||||||
if (_suppress) return;
|
if (_suppress) return;
|
||||||
AppServices.Current.Settings.HideArchivalVehicles = hideArchivalCheck.IsChecked ?? true;
|
AppServices.Current.Settings.HideArchivalVehicles = hideArchivalCheck.IsChecked ?? true;
|
||||||
AppServices.Current.SettingsStore.Save();
|
AppServices.Current.SettingsStore.Save();
|
||||||
Rebuild();
|
PopulateCategoryCombo();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SelectInBrowser(Dynamic car)
|
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)
|
if (texture is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -587,6 +635,7 @@ public sealed class VehicleBrowser
|
|||||||
foreach (var obj in vehicleListBox.Items)
|
foreach (var obj in vehicleListBox.Items)
|
||||||
if (obj is ListBoxItem { Tag: VehicleTexture t } entry &&
|
if (obj is ListBoxItem { Tag: VehicleTexture t } entry &&
|
||||||
string.Equals(t.Skinfile, texture.Skinfile, StringComparison.OrdinalIgnoreCase) &&
|
string.Equals(t.Skinfile, texture.Skinfile, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(t.Model, texture.Model, StringComparison.OrdinalIgnoreCase) &&
|
||||||
string.Equals(t.Directory, texture.Directory, StringComparison.OrdinalIgnoreCase))
|
string.Equals(t.Directory, texture.Directory, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
vehicleListBox.SelectedItem = entry;
|
vehicleListBox.SelectedItem = entry;
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ public sealed class VehicleCards
|
|||||||
|
|
||||||
private readonly List<(Border Frame, ConsistItem Item, Dynamic Car)> _memberChrome = new();
|
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();
|
private readonly List<(Button Badge, ConsistItem Item)> _driverChrome = new();
|
||||||
|
|
||||||
public Dynamic? PressedCar { get; set; }
|
public Dynamic? PressedCar { get; set; }
|
||||||
@@ -403,40 +406,67 @@ public sealed class VehicleCards
|
|||||||
flyout.ShowAt(anchor);
|
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)
|
public Control BuildCoupler(ConsistItem item, bool trailing = false)
|
||||||
{
|
{
|
||||||
var glyph = new TextBlock
|
var coupling = Consist.TailCar(item).Coupling;
|
||||||
{
|
var icon = new MaterialIcon { Width = 18, Height = 18, HorizontalAlignment = HorizontalAlignment.Center };
|
||||||
Text = trailing ? "╡" : "≣",
|
var slots = CouplerLook.Slots();
|
||||||
FontSize = 18,
|
|
||||||
Opacity = trailing ? 0.55 : 0.7,
|
|
||||||
VerticalAlignment = VerticalAlignment.Center,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Center
|
|
||||||
};
|
|
||||||
var coupler = new Button
|
var coupler = new Button
|
||||||
{
|
{
|
||||||
Padding = new Thickness(4, 6, 4, 6),
|
Padding = new Thickness(3, 6, 3, 6),
|
||||||
MinWidth = 0,
|
MinWidth = 0,
|
||||||
Cursor = _hand,
|
Cursor = _hand,
|
||||||
VerticalAlignment = VerticalAlignment.Center,
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
Content = glyph,
|
Content = new StackPanel { Spacing = 4, Children = { icon, slots } }
|
||||||
Flyout = new Flyout
|
|
||||||
{
|
|
||||||
Content = BuildCouplingBox(item),
|
|
||||||
Placement = PlacementMode.Top
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
coupler.Classes.Add("Basic");
|
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;
|
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);
|
var d = Consist.TailCar(item);
|
||||||
int unitIdx = _consist.IndexOf(item);
|
int unitIdx = _consist.IndexOf(item);
|
||||||
|
|
||||||
var panel = new StackPanel { Spacing = 2 };
|
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
|
var copy = new Button
|
||||||
{
|
{
|
||||||
@@ -453,7 +483,11 @@ public sealed class VehicleCards
|
|||||||
{
|
{
|
||||||
if (unitIdx <= 0) return;
|
if (unitIdx <= 0) return;
|
||||||
d.Coupling.Flags = Consist.TailCar(_consist[unitIdx - 1]).Coupling.Flags;
|
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) };
|
var header = new DockPanel { Margin = new Thickness(0, 0, 0, 2) };
|
||||||
@@ -482,26 +516,25 @@ public sealed class VehicleCards
|
|||||||
int bit = 1 << i;
|
int bit = 1 << i;
|
||||||
var check = new CheckBox
|
var check = new CheckBox
|
||||||
{
|
{
|
||||||
Content = App.Loc[CouplingBits.BitKeys[i]],
|
Content = CouplerLook.BitLabel(i),
|
||||||
IsChecked = d.Coupling.Has(bit),
|
IsChecked = d.Coupling.Has(bit),
|
||||||
FontSize = 12
|
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.SetRow(check, i / 2);
|
||||||
Grid.SetColumn(check, i % 2);
|
Grid.SetColumn(check, i % 2);
|
||||||
grid.Children.Add(check);
|
grid.Children.Add(check);
|
||||||
}
|
}
|
||||||
panel.Children.Add(grid);
|
panel.Children.Add(grid);
|
||||||
|
|
||||||
var thermo = new CheckBox
|
// 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.
|
||||||
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);
|
|
||||||
|
|
||||||
return new Border { Padding = new Thickness(8), Child = panel };
|
return new Border { Padding = new Thickness(8), Child = panel };
|
||||||
}
|
}
|
||||||
@@ -565,9 +598,7 @@ public sealed class VehicleCards
|
|||||||
|
|
||||||
private void PaintDriverButton(Button btn, ConsistItem item)
|
private void PaintDriverButton(Button btn, ConsistItem item)
|
||||||
{
|
{
|
||||||
var type = ReferenceEquals(item, _consist.Selected) && item.Cars.Count > 1
|
var type = item.Driver;
|
||||||
? _consist.ActiveCar(item).DriverType
|
|
||||||
: item.Driver;
|
|
||||||
|
|
||||||
(string glyph, string color) = type switch
|
(string glyph, string color) = type switch
|
||||||
{
|
{
|
||||||
@@ -588,6 +619,12 @@ public sealed class VehicleCards
|
|||||||
{
|
{
|
||||||
menu.Items.Clear();
|
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"] };
|
var num = new MenuItem { Header = App.Loc["WagonNumber"] };
|
||||||
num.Click += (_, _) => ShowWagonNumberFlyout(anchor, item.Cars[0]);
|
num.Click += (_, _) => ShowWagonNumberFlyout(anchor, item.Cars[0]);
|
||||||
menu.Items.Add(num);
|
menu.Items.Add(num);
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ public sealed class VehicleDetails
|
|||||||
|
|
||||||
BuildGeneralInfo(_consist.Selected);
|
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);
|
_showTextureInfo(tex, consistTexturePanel);
|
||||||
else
|
else
|
||||||
consistTexturePanel.Children.Add(PanelNote(App.Loc["NoTextureInfo"]));
|
consistTexturePanel.Children.Add(PanelNote(App.Loc["NoTextureInfo"]));
|
||||||
@@ -178,8 +178,6 @@ public sealed class VehicleDetails
|
|||||||
if (unitIdx < 0)
|
if (unitIdx < 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
bool isLast = unitIdx >= _consist.Count - 1;
|
|
||||||
|
|
||||||
var d = Consist.TailCar(item);
|
var d = Consist.TailCar(item);
|
||||||
|
|
||||||
// Two columns: the tab is wide enough and the list is short enough
|
// 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;
|
int bit = 1 << i;
|
||||||
var check = new CheckBox
|
var check = new CheckBox
|
||||||
{
|
{
|
||||||
Content = App.Loc[CouplingBits.BitKeys[i]],
|
Content = CouplerLook.BitLabel(i),
|
||||||
IsChecked = d.Coupling.Has(bit),
|
IsChecked = d.Coupling.Has(bit),
|
||||||
Classes = { "Checklist" }
|
Classes = { "Checklist" }
|
||||||
};
|
};
|
||||||
check.IsCheckedChanged += (_, _) =>
|
check.IsCheckedChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
d.Coupling.Set(bit, check.IsChecked == true);
|
d.Coupling.Set(bit, check.IsChecked == true);
|
||||||
|
d.CouplerSetByHand = true;
|
||||||
_redraw();
|
_redraw();
|
||||||
};
|
};
|
||||||
Grid.SetRow(check, i / 2);
|
Grid.SetRow(check, i / 2);
|
||||||
@@ -227,6 +226,7 @@ public sealed class VehicleDetails
|
|||||||
{
|
{
|
||||||
if (unitIdx <= 0) return;
|
if (unitIdx <= 0) return;
|
||||||
d.Coupling.Flags = Consist.TailCar(_consist[unitIdx - 1]).Coupling.Flags;
|
d.Coupling.Flags = Consist.TailCar(_consist[unitIdx - 1]).Coupling.Flags;
|
||||||
|
d.CouplerSetByHand = true;
|
||||||
_redraw();
|
_redraw();
|
||||||
Refresh();
|
Refresh();
|
||||||
};
|
};
|
||||||
@@ -238,13 +238,13 @@ public sealed class VehicleDetails
|
|||||||
Cursor = _hand,
|
Cursor = _hand,
|
||||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
HorizontalContentAlignment = HorizontalAlignment.Center,
|
||||||
IsEnabled = _consist.Count > 1 && !isLast
|
IsEnabled = _consist.Count > 0
|
||||||
};
|
};
|
||||||
auto.Classes.Add("Flat");
|
auto.Classes.Add("Flat");
|
||||||
ToolTip.SetTip(auto, App.Loc["TipAutoCoupler"]);
|
ToolTip.SetTip(auto, App.Loc["TipAutoCoupler"]);
|
||||||
auto.Click += (_, _) =>
|
auto.Click += (_, _) =>
|
||||||
{
|
{
|
||||||
_consist.AutoConnectAll();
|
_consist.AutoCouple();
|
||||||
_redraw();
|
_redraw();
|
||||||
Refresh();
|
Refresh();
|
||||||
};
|
};
|
||||||
@@ -262,17 +262,16 @@ public sealed class VehicleDetails
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The vehicle's own settings. Every write path is unchanged: crew and wagon number
|
/// The vehicle's own settings: the crew is the unit's and sits where its cab is,
|
||||||
/// go through the unit's active car, the coolant flag to its first car only.
|
/// the wagon number goes through the unit's active car, the coolant flag to its
|
||||||
|
/// first car only.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private Control BuildVehicleSection(ConsistItem item)
|
private Control BuildVehicleSection(ConsistItem item)
|
||||||
{
|
{
|
||||||
var driver = new ComboBox { FontSize = 12, MinWidth = 0 };
|
var driver = new ComboBox { FontSize = 12, MinWidth = 0 };
|
||||||
foreach (string key in new[] { "DriverHead", "DriverRear", "DriverPassenger", "DriverNobody" })
|
foreach (string key in new[] { "DriverHead", "DriverRear", "DriverPassenger", "DriverNobody" })
|
||||||
driver.Items.Add(new ComboBoxItem { Content = App.Loc[key] });
|
driver.Items.Add(new ComboBoxItem { Content = App.Loc[key] });
|
||||||
var crewCar = _consist.ActiveCar(item);
|
driver.SelectedIndex = item.Driver switch
|
||||||
|
|
||||||
driver.SelectedIndex = crewCar.DriverType switch
|
|
||||||
{
|
{
|
||||||
eDriverType.Headdriver => 0,
|
eDriverType.Headdriver => 0,
|
||||||
eDriverType.Reardriver => 1,
|
eDriverType.Reardriver => 1,
|
||||||
@@ -288,14 +287,9 @@ public sealed class VehicleDetails
|
|||||||
2 => eDriverType.Passenger,
|
2 => eDriverType.Passenger,
|
||||||
_ => eDriverType.Nobody
|
_ => eDriverType.Nobody
|
||||||
};
|
};
|
||||||
if (picked == crewCar.DriverType) return;
|
if (picked == item.Driver) return;
|
||||||
|
|
||||||
crewCar.DriverType = picked;
|
_consist.SetCrew(item, picked);
|
||||||
item.Driver = Consist.UnitDriver(item.Cars);
|
|
||||||
if (ReferenceEquals(_consist.Selected, item))
|
|
||||||
_consist.SyncStartingVehicle();
|
|
||||||
_redraw();
|
|
||||||
AppServices.Current.State.NotifyChanged();
|
|
||||||
};
|
};
|
||||||
driver.MinWidth = 0;
|
driver.MinWidth = 0;
|
||||||
driver.HorizontalAlignment = HorizontalAlignment.Stretch;
|
driver.HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||||
@@ -559,6 +553,8 @@ public sealed class VehicleDetails
|
|||||||
var tools = Stack(
|
var tools = Stack(
|
||||||
LoadToolButton(App.Loc["ConsistRandomType"],
|
LoadToolButton(App.Loc["ConsistRandomType"],
|
||||||
() => { _cargo.RandomTypes(_consist); _redraw(); }, secondary: true),
|
() => { _cargo.RandomTypes(_consist); _redraw(); }, secondary: true),
|
||||||
|
LoadToolButton(App.Loc["ConsistMaxAmount"],
|
||||||
|
() => { _cargo.MaxAmounts(_consist); _redraw(); }, secondary: true),
|
||||||
LoadToolButton(App.Loc["ConsistRandomAmount"],
|
LoadToolButton(App.Loc["ConsistRandomAmount"],
|
||||||
() => { _cargo.RandomAmounts(_consist); _redraw(); }, secondary: true));
|
() => { _cargo.RandomAmounts(_consist); _redraw(); }, secondary: true));
|
||||||
|
|
||||||
@@ -566,7 +562,7 @@ public sealed class VehicleDetails
|
|||||||
|
|
||||||
var count = new TextBlock
|
var count = new TextBlock
|
||||||
{
|
{
|
||||||
Text = $"{App.Loc["VehicleCount"]}: {_consist.Count}",
|
Text = $"{App.Loc["VehicleCount"]}: {_consist.VehicleCount}",
|
||||||
FontSize = 12,
|
FontSize = 12,
|
||||||
Foreground = DimBrush,
|
Foreground = DimBrush,
|
||||||
VerticalAlignment = VerticalAlignment.Center
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
@@ -591,9 +587,7 @@ public sealed class VehicleDetails
|
|||||||
? over == ConsistReadyOverride.AlwaysReady
|
? over == ConsistReadyOverride.AlwaysReady
|
||||||
: trainset?.ReadyToGo ?? false
|
: trainset?.ReadyToGo ?? false
|
||||||
};
|
};
|
||||||
ToolTip.SetTip(ready, overridden
|
ToolTip.SetTip(ready, App.Loc["ConsistReadyDesc"]);
|
||||||
? App.Loc["ConsistReadyOverridden"]
|
|
||||||
: App.Loc["ConsistReadyDesc"]);
|
|
||||||
|
|
||||||
ready.IsCheckedChanged += (_, _) =>
|
ready.IsCheckedChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
@@ -603,7 +597,12 @@ public sealed class VehicleDetails
|
|||||||
_redraw();
|
_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)
|
private Button LoadToolButton(string text, Action onClick, bool secondary = false)
|
||||||
|
|||||||
65
StarterNG/Views/Developer.axaml
Normal file
65
StarterNG/Views/Developer.axaml
Normal 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>
|
||||||
78
StarterNG/Views/Developer.axaml.cs
Normal file
78
StarterNG/Views/Developer.axaml.cs
Normal 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);
|
||||||
|
}
|
||||||
14
StarterNG/Views/ExeProblemText.cs
Normal file
14
StarterNG/Views/ExeProblemText.cs
Normal 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,7 +13,10 @@ public enum MessageBoxButtons
|
|||||||
|
|
||||||
public static class MessageBox
|
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
|
var win = new Window
|
||||||
{
|
{
|
||||||
@@ -33,6 +36,11 @@ public static class MessageBox
|
|||||||
noBtn.Classes.Add("Flat");
|
noBtn.Classes.Add("Flat");
|
||||||
noBtn.Click += (_, _) => win.Close();
|
noBtn.Click += (_, _) => win.Close();
|
||||||
|
|
||||||
|
if (yesText is not null)
|
||||||
|
yesBtn.Content = yesText;
|
||||||
|
if (noText is not null)
|
||||||
|
noBtn.Content = noText;
|
||||||
|
|
||||||
if (buttons == MessageBoxButtons.Ok)
|
if (buttons == MessageBoxButtons.Ok)
|
||||||
{
|
{
|
||||||
yesBtn.Content = App.Loc["Ok"];
|
yesBtn.Content = App.Loc["Ok"];
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="StarterNG.Views.Scenarios"
|
x:Class="StarterNG.Views.Scenarios"
|
||||||
x:CompileBindings="True">
|
x:CompileBindings="True">
|
||||||
<Grid Margin="0" ColumnSpacing="0" RowSpacing="3">
|
<Grid Name="scenariosGrid" Margin="0" ColumnSpacing="0" RowSpacing="3">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" MinWidth="200" MaxWidth="460" />
|
<ColumnDefinition Width="*" MinWidth="200" MaxWidth="460" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
@@ -15,8 +15,9 @@
|
|||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition />
|
<RowDefinition />
|
||||||
<!-- Measures itself, so a whole card fits at either thumbnail size. -->
|
<!-- Measures itself, so a whole card fits at either thumbnail size; the
|
||||||
<RowDefinition Height="Auto" MinHeight="120" MaxHeight="200" />
|
floor follows the thumbnail size, see SyncConsistRowHeight. -->
|
||||||
|
<RowDefinition Height="Auto" MaxHeight="200" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<HeaderedContentControl Grid.Column="0" Grid.Row="0" Theme="{StaticResource GroupBox}"
|
<HeaderedContentControl Grid.Column="0" Grid.Row="0" Theme="{StaticResource GroupBox}"
|
||||||
@@ -28,9 +29,12 @@
|
|||||||
IsCheckedChanged="ArchivalSwitch_OnChanged"
|
IsCheckedChanged="ArchivalSwitch_OnChanged"
|
||||||
Content="{Binding [HideArchivalSceneries], Source={x:Static starterNg:App.Loc}}" />
|
Content="{Binding [HideArchivalSceneries], Source={x:Static starterNg:App.Loc}}" />
|
||||||
<TreeView Name="sceneryList" Classes="Compact"
|
<TreeView Name="sceneryList" Classes="Compact"
|
||||||
SelectionChanged="SceneryList_OnSelectionChanged">
|
SelectionChanged="SceneryList_OnSelectionChanged"
|
||||||
|
ContextRequested="SceneryList_OnContextRequested">
|
||||||
<TreeView.ContextMenu>
|
<TreeView.ContextMenu>
|
||||||
<ContextMenu>
|
<ContextMenu Opening="SceneryMenu_OnOpening">
|
||||||
|
<MenuItem Name="favoriteMenuItem" Click="ToggleFavorite_OnClick" />
|
||||||
|
<Separator />
|
||||||
<MenuItem Click="ReloadSceneries_OnClick"
|
<MenuItem Click="ReloadSceneries_OnClick"
|
||||||
Header="{Binding [ReloadSceneries], Source={x:Static starterNg:App.Loc}}" />
|
Header="{Binding [ReloadSceneries], Source={x:Static starterNg:App.Loc}}" />
|
||||||
<MenuItem Click="OpenSceneryDir_OnClick"
|
<MenuItem Click="OpenSceneryDir_OnClick"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -15,6 +16,7 @@ using Avalonia.Interactivity;
|
|||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
using StarterNG.Domain;
|
using StarterNG.Domain;
|
||||||
using StarterNG.Application;
|
using StarterNG.Application;
|
||||||
@@ -39,6 +41,7 @@ public partial class Scenarios : UserControl
|
|||||||
public Scenarios()
|
public Scenarios()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
SyncConsistRowHeight();
|
||||||
|
|
||||||
Sceneries = AppServices.Current.Library.Sceneries;
|
Sceneries = AppServices.Current.Library.Sceneries;
|
||||||
|
|
||||||
@@ -70,8 +73,9 @@ public partial class Scenarios : UserControl
|
|||||||
bool includeArchival = archivalSwitch.IsChecked != true;
|
bool includeArchival = archivalSwitch.IsChecked != true;
|
||||||
bool expandGroups = AppServices.Current.Settings.AutoExpandSceneryTree;
|
bool expandGroups = AppServices.Current.Settings.AutoExpandSceneryTree;
|
||||||
|
|
||||||
|
var favorites = AppServices.Current.Favorites;
|
||||||
var nodes = new SceneryTreeBuilder(AppServices.Current.SceneryTexts)
|
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)
|
foreach (var node in nodes)
|
||||||
sceneryList.Items.Add(ToTreeItem(node, expandGroups));
|
sceneryList.Items.Add(ToTreeItem(node, expandGroups));
|
||||||
@@ -92,6 +96,47 @@ public partial class Scenarios : UserControl
|
|||||||
return item;
|
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()
|
private void RestoreLastScenery()
|
||||||
{
|
{
|
||||||
string last = AppServices.Current.Settings.LastScenery;
|
string last = AppServices.Current.Settings.LastScenery;
|
||||||
@@ -318,14 +363,14 @@ public partial class Scenarios : UserControl
|
|||||||
foreach (var v in ts.Vehicles)
|
foreach (var v in ts.Vehicles)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(v.SkinFile)) continue;
|
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})");
|
lines.Add($"# unknown texture: {v.Name} ({v.SkinFile})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var v in scenery.LooseVehicles)
|
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})");
|
lines.Add($"# unknown loose texture: {v.Name} ({v.SkinFile})");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,8 +463,22 @@ public partial class Scenarios : UserControl
|
|||||||
RandomizeTexturesRequested?.Invoke(scenery);
|
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()
|
public void RefreshConsistView()
|
||||||
{
|
{
|
||||||
|
SyncConsistRowHeight();
|
||||||
RefreshVehicleLabels();
|
RefreshVehicleLabels();
|
||||||
if (AppServices.Current.State.CurrentTrainset is { } trainset)
|
if (AppServices.Current.State.CurrentTrainset is { } trainset)
|
||||||
ShowConsist(trainset);
|
ShowConsist(trainset);
|
||||||
@@ -709,7 +768,7 @@ public partial class Scenarios : UserControl
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
timetableContent.Text = File.ReadAllText(path, LegacyText.CodePage1250);
|
timetableContent.Text = LegacyText.Decode(File.ReadAllBytes(path));
|
||||||
UpdateTimetableTab(true);
|
UpdateTimetableTab(true);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -801,7 +860,7 @@ public partial class Scenarios : UserControl
|
|||||||
foreach (var train in trainset.Vehicles)
|
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))
|
if (!AppServices.Current.MiniTextures.Has(miniName) && AppServices.Current.MiniTextures.Has(train.SkinFile))
|
||||||
miniName = train.SkinFile;
|
miniName = train.SkinFile;
|
||||||
int thumbH = AppServices.Current.Settings.LargeThumbnails ? 64 : 32;
|
int thumbH = AppServices.Current.Settings.LargeThumbnails ? 64 : 32;
|
||||||
@@ -1102,10 +1161,10 @@ public partial class Scenarios : UserControl
|
|||||||
var db = AppServices.Current.Library.Vehicles;
|
var db = AppServices.Current.Library.Vehicles;
|
||||||
StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields(
|
StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields(
|
||||||
trainset,
|
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.MmdFile)
|
||||||
?? AppServices.Current.Physics.For(car.DataFolder, car.SkinFile),
|
?? 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()
|
private void RefreshSelectedConsist()
|
||||||
|
|||||||
@@ -133,7 +133,12 @@
|
|||||||
<ctl:SettingsSection Header="{Binding [Others], Source={x:Static starterNg:App.Loc}}">
|
<ctl:SettingsSection Header="{Binding [Others], Source={x:Static starterNg:App.Loc}}">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<ctl:SettingRow ConfigKey="starter.exe.path" Label="{Binding [SelectEXE], Source={x:Static starterNg:App.Loc}}">
|
<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>
|
||||||
<ctl:SettingRow Description="{Binding [DebugModeDesc], Source={x:Static starterNg:App.Loc}}" ConfigKey="debugmode">
|
<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}}" />
|
<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}}">
|
<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}}" />
|
<CheckBox x:Name="LogMissingVehicleFilesCb" Content="{Binding [LogMissingVehicleFiles], Source={x:Static starterNg:App.Loc}}" />
|
||||||
</ctl:SettingRow>
|
</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>
|
</StackPanel>
|
||||||
</ctl:SettingsSection>
|
</ctl:SettingsSection>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -220,14 +228,20 @@
|
|||||||
<StackPanel>
|
<StackPanel>
|
||||||
<ctl:SettingRow ConfigKey="gfxrenderer" Label="{Binding [RenderEngine], Source={x:Static starterNg:App.Loc}}"
|
<ctl:SettingRow ConfigKey="gfxrenderer" Label="{Binding [RenderEngine], Source={x:Static starterNg:App.Loc}}"
|
||||||
Description="{Binding [RenderEngineDesc], Source={x:Static starterNg:App.Loc}}">
|
Description="{Binding [RenderEngineDesc], Source={x:Static starterNg:App.Loc}}">
|
||||||
<ComboBox x:Name="RenderEngineCb" SelectedIndex="0">
|
<StackPanel Spacing="6">
|
||||||
<ComboBoxItem Content="{Binding [REFull], Source={x:Static starterNg:App.Loc}}" />
|
<ComboBox x:Name="RenderEngineCb" SelectedIndex="0"
|
||||||
<ComboBoxItem Content="{Binding [REOld], Source={x:Static starterNg:App.Loc}}" />
|
SelectionChanged="RenderEngineCb_OnSelectionChanged">
|
||||||
<ComboBoxItem Content="{Binding [RESimpShader], Source={x:Static starterNg:App.Loc}}" />
|
<ComboBoxItem Content="{Binding [REFull], Source={x:Static starterNg:App.Loc}}" />
|
||||||
<ComboBoxItem Content="{Binding [RESimp], Source={x:Static starterNg:App.Loc}}" />
|
<ComboBoxItem Content="{Binding [REOld], Source={x:Static starterNg:App.Loc}}" />
|
||||||
<ComboBoxItem Content="{Binding [REOff], Source={x:Static starterNg:App.Loc}}" />
|
<ComboBoxItem Content="{Binding [RESimpShader], Source={x:Static starterNg:App.Loc}}" />
|
||||||
<ComboBoxItem Content="{Binding [REExperimental], Source={x:Static starterNg:App.Loc}}" />
|
<ComboBoxItem Content="{Binding [RESimp], Source={x:Static starterNg:App.Loc}}" />
|
||||||
</ComboBox>
|
<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>
|
||||||
<ctl:SettingRow ConfigKey="gfx.angleplatform" Description="{Binding [RenderAngleVulkanDesc], Source={x:Static starterNg:App.Loc}}">
|
<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}}" />
|
<CheckBox x:Name="RenderAngleVulkanCb" Content="{Binding [RenderAngleVulkan], Source={x:Static starterNg:App.Loc}}" />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Globalization;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
@@ -13,6 +14,7 @@ using Avalonia.Layout;
|
|||||||
using Avalonia.LogicalTree;
|
using Avalonia.LogicalTree;
|
||||||
using Avalonia.Markup.Xaml.Styling;
|
using Avalonia.Markup.Xaml.Styling;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
using StarterNG.Controls;
|
using StarterNG.Controls;
|
||||||
using StarterNG.Infrastructure;
|
using StarterNG.Infrastructure;
|
||||||
@@ -34,9 +36,15 @@ public partial class Settings : UserControl, ISettingsCapture
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action? ThumbnailSizeChanged;
|
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>
|
/// <summary>Thumbnail size the other tabs were last drawn at.</summary>
|
||||||
private bool _drawnThumbs = AppServices.Current.Settings.LargeThumbnails;
|
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()
|
public Settings()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
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();
|
public void ReloadFromSettings() => ApplyToUi();
|
||||||
|
|
||||||
private void ApplyToUi()
|
private void ApplyToUi()
|
||||||
@@ -199,6 +259,7 @@ public partial class Settings : UserControl, ISettingsCapture
|
|||||||
DebugModeCb.IsChecked = s.DebugMode;
|
DebugModeCb.IsChecked = s.DebugMode;
|
||||||
VirtualShuntingCb.IsChecked = s.VirtualShunting;
|
VirtualShuntingCb.IsChecked = s.VirtualShunting;
|
||||||
LogMissingVehicleFilesCb.IsChecked = s.LogMissingVehicleFiles;
|
LogMissingVehicleFilesCb.IsChecked = s.LogMissingVehicleFiles;
|
||||||
|
DeveloperToolsCb.IsChecked = s.DeveloperTools;
|
||||||
|
|
||||||
RenderEngineCb.SelectedIndex = s.RenderEngine;
|
RenderEngineCb.SelectedIndex = s.RenderEngine;
|
||||||
SelectResolution(s.Width, s.Height);
|
SelectResolution(s.Width, s.Height);
|
||||||
@@ -297,6 +358,7 @@ public partial class Settings : UserControl, ISettingsCapture
|
|||||||
s.DebugMode = IsChecked(DebugModeCb);
|
s.DebugMode = IsChecked(DebugModeCb);
|
||||||
s.VirtualShunting = IsChecked(VirtualShuntingCb);
|
s.VirtualShunting = IsChecked(VirtualShuntingCb);
|
||||||
s.LogMissingVehicleFiles = IsChecked(LogMissingVehicleFilesCb);
|
s.LogMissingVehicleFiles = IsChecked(LogMissingVehicleFilesCb);
|
||||||
|
s.DeveloperTools = IsChecked(DeveloperToolsCb);
|
||||||
|
|
||||||
s.RenderEngine = Math.Max(0, RenderEngineCb.SelectedIndex);
|
s.RenderEngine = Math.Max(0, RenderEngineCb.SelectedIndex);
|
||||||
ReadResolution(s);
|
ReadResolution(s);
|
||||||
@@ -367,18 +429,38 @@ public partial class Settings : UserControl, ISettingsCapture
|
|||||||
new UartWindow().ShowDialog(owner);
|
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);
|
CaptureInto(AppServices.Current.Settings);
|
||||||
AppServices.Current.SettingsStore.Save();
|
AppServices.Current.SettingsStore.Save();
|
||||||
KeyboardConfig.Instance.Save();
|
KeyboardConfig.Instance.Save();
|
||||||
RedrawThumbnailsIfNeeded();
|
RedrawThumbnailsIfNeeded();
|
||||||
|
RedrawNavIfNeeded();
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
UpdateSaveState();
|
UpdateSaveState();
|
||||||
if (SaveStatus is not null)
|
if (SaveStatus is not null)
|
||||||
SaveStatus.Text = App.Loc["SettingsSaved"];
|
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()
|
private void RedrawThumbnailsIfNeeded()
|
||||||
{
|
{
|
||||||
bool large = AppServices.Current.Settings.LargeThumbnails;
|
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 KeyPlainBrush = new SolidColorBrush(Color.Parse("#2E9E1F"));
|
||||||
private static readonly IBrush KeyShiftBrush = new SolidColorBrush(Color.Parse("#C9A227"));
|
private static readonly IBrush KeyShiftBrush = new SolidColorBrush(Color.Parse("#C9A227"));
|
||||||
private static readonly IBrush KeyCtrlBrush = new SolidColorBrush(Color.Parse("#2D7FD3"));
|
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 KeyBorderBrush = new SolidColorBrush(Color.Parse("#3A424A"));
|
||||||
private static readonly IBrush FgBrush = new SolidColorBrush(Color.Parse("#E6E8EA"));
|
private static readonly IBrush FgBrush = new SolidColorBrush(Color.Parse("#E6E8EA"));
|
||||||
private static readonly IBrush FgDimBrush = new SolidColorBrush(Color.Parse("#9098A0"));
|
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) =>
|
private static bool MatchesFilter(KeyBinding b, string filter) =>
|
||||||
TextMatch.Contains(b.Command, 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)
|
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.Shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
|
||||||
_capturing.Ctrl = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
_capturing.Ctrl = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
||||||
_capturing.Key = token;
|
_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;
|
KeyboardConfig.Instance.Dirty = true;
|
||||||
MarkDirty();
|
MarkDirty();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
@@ -1035,6 +1120,7 @@ public partial class Settings : UserControl, ISettingsCapture
|
|||||||
if (state is { Plain: true }) colours.Add(KeyPlainBrush);
|
if (state is { Plain: true }) colours.Add(KeyPlainBrush);
|
||||||
if (state is { Shift: true }) colours.Add(KeyShiftBrush);
|
if (state is { Shift: true }) colours.Add(KeyShiftBrush);
|
||||||
if (state is { Ctrl: true }) colours.Add(KeyCtrlBrush);
|
if (state is { Ctrl: true }) colours.Add(KeyCtrlBrush);
|
||||||
|
if (state is { ShiftCtrl: true }) colours.Add(KeyShiftCtrlBrush);
|
||||||
|
|
||||||
if (colours.Count == 0)
|
if (colours.Count == 0)
|
||||||
return new Border { Background = KeyUnassignedBrush };
|
return new Border { Background = KeyUnassignedBrush };
|
||||||
@@ -1062,12 +1148,12 @@ public partial class Settings : UserControl, ISettingsCapture
|
|||||||
foreach (var c in commands)
|
foreach (var c in commands)
|
||||||
items.Add(CommandLabel(c));
|
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"),
|
(false, false, "BindNoMod", KeyPlainBrush),
|
||||||
(true, false, "BindShift"),
|
(true, false, "BindShift", KeyShiftBrush),
|
||||||
(false, true, "BindCtrl"),
|
(false, true, "BindCtrl", KeyCtrlBrush),
|
||||||
(true, true, "BindShiftCtrl"),
|
(true, true, "BindShiftCtrl", KeyShiftCtrlBrush),
|
||||||
};
|
};
|
||||||
|
|
||||||
var combos = new ComboBox[slots.Length];
|
var combos = new ComboBox[slots.Length];
|
||||||
@@ -1079,14 +1165,24 @@ public partial class Settings : UserControl, ISettingsCapture
|
|||||||
|
|
||||||
for (int i = 0; i < slots.Length; i++)
|
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],
|
Orientation = Orientation.Horizontal,
|
||||||
Foreground = FgBrush,
|
Spacing = 6,
|
||||||
VerticalAlignment = VerticalAlignment.Center,
|
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.SetRow(lbl, i);
|
||||||
Grid.SetColumn(lbl, 0);
|
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)
|
private void AssignSlot(string token, bool shift, bool ctrl, int selectedIndex, List<KeyBinding> commands)
|
||||||
{
|
{
|
||||||
KeyBinding? chosen = selectedIndex <= 0 ? null : commands[selectedIndex - 1];
|
KeyBinding? chosen = selectedIndex <= 0 ? null : commands[selectedIndex - 1];
|
||||||
|
ReleaseCombo(token, shift, ctrl, chosen);
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chosen is not null)
|
if (chosen is not null)
|
||||||
{
|
{
|
||||||
@@ -1176,6 +1262,25 @@ public partial class Settings : UserControl, ISettingsCapture
|
|||||||
MarkDirty();
|
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) =>
|
private static KeyBinding? CommandInSlot(string token, bool shift, bool ctrl) =>
|
||||||
KeyboardConfig.Instance.Bindings.FirstOrDefault(b =>
|
KeyboardConfig.Instance.Bindings.FirstOrDefault(b =>
|
||||||
b.IsAssigned &&
|
b.IsAssigned &&
|
||||||
@@ -1189,6 +1294,7 @@ public partial class Settings : UserControl, ISettingsCapture
|
|||||||
legend.Children.Add(LegendItem(KeyPlainBrush, App.Loc["KbAssigned"]));
|
legend.Children.Add(LegendItem(KeyPlainBrush, App.Loc["KbAssigned"]));
|
||||||
legend.Children.Add(LegendItem(KeyShiftBrush, App.Loc["KbShift"]));
|
legend.Children.Add(LegendItem(KeyShiftBrush, App.Loc["KbShift"]));
|
||||||
legend.Children.Add(LegendItem(KeyCtrlBrush, App.Loc["KbCtrl"]));
|
legend.Children.Add(LegendItem(KeyCtrlBrush, App.Loc["KbCtrl"]));
|
||||||
|
legend.Children.Add(LegendItem(KeyShiftCtrlBrush, App.Loc["KbShiftCtrl"]));
|
||||||
return legend;
|
return legend;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1220,6 +1326,7 @@ public partial class Settings : UserControl, ISettingsCapture
|
|||||||
public bool Plain;
|
public bool Plain;
|
||||||
public bool Shift;
|
public bool Shift;
|
||||||
public bool Ctrl;
|
public bool Ctrl;
|
||||||
|
public bool ShiftCtrl;
|
||||||
public readonly List<string> Tips = new();
|
public readonly List<string> Tips = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1234,9 +1341,13 @@ public partial class Settings : UserControl, ISettingsCapture
|
|||||||
if (!map.TryGetValue(key, out var state))
|
if (!map.TryGetValue(key, out var state))
|
||||||
map[key] = state = new KeyState();
|
map[key] = state = new KeyState();
|
||||||
|
|
||||||
if (!b.Shift && !b.Ctrl) state.Plain = true;
|
switch (b.Shift, b.Ctrl)
|
||||||
if (b.Shift) state.Shift = true;
|
{
|
||||||
if (b.Ctrl) state.Ctrl = true;
|
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)}");
|
state.Tips.Add($"{ComboText(b)} — {CommandLabel(b)}");
|
||||||
}
|
}
|
||||||
@@ -1273,7 +1384,7 @@ public partial class Settings : UserControl, ISettingsCapture
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static string CommandLabel(KeyBinding b) =>
|
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) =>
|
private static string Capitalize(string s) =>
|
||||||
string.IsNullOrEmpty(s) ? s : char.ToUpperInvariant(s[0]) + s.Substring(1);
|
string.IsNullOrEmpty(s) ? s : char.ToUpperInvariant(s[0]) + s.Substring(1);
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public partial class TextureBaseWindow : Window
|
|||||||
modelList.SelectionChanged += (_, _) => Refresh();
|
modelList.SelectionChanged += (_, _) => Refresh();
|
||||||
|
|
||||||
textureList.ContextRequested += TextureList_OnContextRequested;
|
textureList.ContextRequested += TextureList_OnContextRequested;
|
||||||
|
textureList.AddHandler(PointerReleasedEvent, TextureList_OnPointerReleased, handledEventsToo: true);
|
||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +134,16 @@ public partial class TextureBaseWindow : Window
|
|||||||
{
|
{
|
||||||
if (textureList.SelectedItem is not TextureRow row) return;
|
if (textureList.SelectedItem is not TextureRow row) return;
|
||||||
Picked = row.Texture;
|
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)
|
private void TextureList_OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<String key="NavScenarios">Scénáře</String>
|
<String key="NavScenarios">Scénáře</String>
|
||||||
<String key="NavDepot">Depo</String>
|
<String key="NavDepot">Depo</String>
|
||||||
<String key="NavSettings">Nastavení</String>
|
<String key="NavSettings">Nastavení</String>
|
||||||
|
<String key="NavDeveloper">Developer</String>
|
||||||
<String key="NavControls">Ovládání</String>
|
<String key="NavControls">Ovládání</String>
|
||||||
<String key="Help">Nápověda</String>
|
<String key="Help">Nápověda</String>
|
||||||
<String key="HelpWiki">Wiki MaSzyna</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="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="RestoreWeather">Obnovit počasí</String>
|
||||||
<String key="ReloadSceneries">Načíst znovu</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="OpenSceneryFolder">Otevřít složku</String>
|
||||||
<String key="OptionalIncludes">Volitelné prvky</String>
|
<String key="OptionalIncludes">Volitelné prvky</String>
|
||||||
<String key="OpenTextureFolder">Otevřít složku s texturami</String>
|
<String key="OpenTextureFolder">Otevřít složku s texturami</String>
|
||||||
@@ -91,6 +95,9 @@
|
|||||||
<String key="Crew">s osádkou</String>
|
<String key="Crew">s osádkou</String>
|
||||||
<String key="Reversed">otočeno</String>
|
<String key="Reversed">otočeno</String>
|
||||||
<String key="NoVehicles">Ve složce dynamic/**/textures.txt nebyla nalezena žádná vozidla.</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="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="TipMoveLeft">Posunout doleva</String>
|
||||||
<String key="TipMoveRight">Posunout doprava</String>
|
<String key="TipMoveRight">Posunout doprava</String>
|
||||||
@@ -103,21 +110,21 @@
|
|||||||
<String key="All">Vše</String>
|
<String key="All">Vše</String>
|
||||||
<String key="CatDiesel">Motorová</String>
|
<String key="CatDiesel">Motorová</String>
|
||||||
<String key="CatElectric">Elektrická</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="CatDMU">Motorová jednotka</String>
|
||||||
<String key="CatCarriagesA">Vozy A</String>
|
<String key="CatCarriagesA">Vozy A</String>
|
||||||
<String key="CatCarriagesB">Vozy B</String>
|
<String key="CatCarriagesB">Vozy B</String>
|
||||||
<String key="CatElectricLoco">Elektrická lokomotiva</String>
|
<String key="CatElectricLoco">Elektrické lokomotivy</String>
|
||||||
<String key="CatDieselLoco">Motorová lokomotiva</String>
|
<String key="CatDieselLoco">Motorové lokomotivy</String>
|
||||||
<String key="CatSteamLoco">Parní lokomotiva</String>
|
<String key="CatSteamLoco">Parní lokomotivy</String>
|
||||||
<String key="CatRailbus">Motorový vůz</String>
|
<String key="CatRailbus">Motorové vozy</String>
|
||||||
<String key="CatDraisine">Drezína</String>
|
<String key="CatDraisine">Drezíny</String>
|
||||||
<String key="CatWork">Pracovní vozidla</String>
|
<String key="CatWork">Pracovní vozy</String>
|
||||||
<String key="CatPrototype">Prototyp</String>
|
<String key="CatPrototype">Prototypy</String>
|
||||||
<String key="CatTram">Tramvaj</String>
|
<String key="CatTram">Tramvaje</String>
|
||||||
<String key="CatCar">Automobil</String>
|
<String key="CatCar">Automobily</String>
|
||||||
<String key="CatBus">Autobus</String>
|
<String key="CatBus">Autobusy</String>
|
||||||
<String key="CatTruck">Nákladní auto</String>
|
<String key="CatTruck">Nákladní automobily</String>
|
||||||
<String key="CatPeople">Lidé</String>
|
<String key="CatPeople">Lidé</String>
|
||||||
<String key="CatAnimals">Zvířata</String>
|
<String key="CatAnimals">Zvířata</String>
|
||||||
<String key="CatWagonsA">Vagony A</String>
|
<String key="CatWagonsA">Vagony A</String>
|
||||||
@@ -223,6 +230,7 @@
|
|||||||
<String key="ReadyOverrideReady">Always ready</String>
|
<String key="ReadyOverrideReady">Always ready</String>
|
||||||
<String key="ConsistReadyOverridden">Overridden by "Force the consist state on launch" in Settings.</String>
|
<String key="ConsistReadyOverridden">Overridden by "Force the consist state on launch" in Settings.</String>
|
||||||
<String key="ConsistRandomType">Náhodný druh</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="ConsistRandomAmount">Náhodné množství</String>
|
||||||
<String key="Set">Nastavit</String>
|
<String key="Set">Nastavit</String>
|
||||||
<String key="Length">Délka</String>
|
<String key="Length">Délka</String>
|
||||||
@@ -251,7 +259,14 @@
|
|||||||
<String key="ExeLaunchFailed">Nepodařilo se spustit simulátor</String>
|
<String key="ExeLaunchFailed">Nepodařilo se spustit simulátor</String>
|
||||||
<String key="ExeNotFound">Spustitelný soubor simulátoru nebyl nalezen.</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="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="TabInfo">Informace</String>
|
||||||
<String key="SettingsFileChangedTitle">Nastavení</String>
|
<String key="SettingsFileChangedTitle">Nastavení</String>
|
||||||
<String key="SettingsFileChanged">Soubor eu07.ini byl změněn mimo starter. Načíst jej znovu?</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="CouplingUnitRear">Spřáhlo na konci jednotky</String>
|
||||||
<String key="Join">Spojit</String>
|
<String key="Join">Spojit</String>
|
||||||
<String key="TipJoin">Spojit toto vozidlo s následujícím do jedné jednotky</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="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="TipAddVehicle">Přidat toto vozidlo do soupravy</String>
|
||||||
<String key="TipAddUnit">Přidat celou jednotku do soupravy</String>
|
<String key="TipAddUnit">Přidat celou jednotku do soupravy</String>
|
||||||
|
|
||||||
@@ -331,9 +354,13 @@
|
|||||||
<String key="Others">Ostatní</String>
|
<String key="Others">Ostatní</String>
|
||||||
<String key="SelectEXE">Vybrat exe</String>
|
<String key="SelectEXE">Vybrat exe</String>
|
||||||
<String key="SelectEXEAuto">Vybrat automaticky</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="DebugMode">Režim ladění (testovací režim)</String>
|
||||||
<String key="VirtualShunting">Virtuální posun</String>
|
<String key="VirtualShunting">Virtuální posun</String>
|
||||||
<String key="LogMissingVehicleFiles">Zaznamenat chybějící soubory vozidel při exportu</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="Graphic">Grafika</String>
|
||||||
<String key="RenderEngine">Vykreslovací engine</String>
|
<String key="RenderEngine">Vykreslovací engine</String>
|
||||||
@@ -343,6 +370,7 @@
|
|||||||
<String key="RESimp">Zjednodušený</String>
|
<String key="RESimp">Zjednodušený</String>
|
||||||
<String key="REOff">Vypnout vizuální vrstvu</String>
|
<String key="REOff">Vypnout vizuální vrstvu</String>
|
||||||
<String key="REExperimental">Experimentální</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="Resolution">Rozlišení</String>
|
||||||
<String key="BufforResolution">Rozlišení bufferu</String>
|
<String key="BufforResolution">Rozlišení bufferu</String>
|
||||||
@@ -459,6 +487,7 @@
|
|||||||
<String key="KbAssigned">Přiřazeno</String>
|
<String key="KbAssigned">Přiřazeno</String>
|
||||||
<String key="KbShift">Se Shiftem</String>
|
<String key="KbShift">Se Shiftem</String>
|
||||||
<String key="KbCtrl">S Ctrl</String>
|
<String key="KbCtrl">S Ctrl</String>
|
||||||
|
<String key="KbShiftCtrl">S Ctrl + Shift</String>
|
||||||
<String key="KbReserved">Vyhrazeno simulátorem</String>
|
<String key="KbReserved">Vyhrazeno simulátorem</String>
|
||||||
<String key="BindKeyTitle">Klávesa:</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>
|
<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="FaultNoScenery">Nebyly nalezeny žádné scénáře.</String>
|
||||||
<String key="FaultNoVehicles">Nebyla nalezena žádná vozidla.</String>
|
<String key="FaultNoVehicles">Nebyla nalezena žádná vozidla.</String>
|
||||||
<String key="FaultNoPhysics">Nebyla nalezena data vozového parku.</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: {1} 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 -->
|
<!-- About -->
|
||||||
<String key="AboutTitle">O programu</String>
|
<String key="AboutTitle">O programu</String>
|
||||||
@@ -528,7 +562,8 @@
|
|||||||
<String key="WarehouseImportFile">Přidat soupravy ze souboru...</String>
|
<String key="WarehouseImportFile">Přidat soupravy ze souboru...</String>
|
||||||
<String key="StartWithoutSaving">Spustit bez uložení nastavení</String>
|
<String key="StartWithoutSaving">Spustit bez uložení nastavení</String>
|
||||||
<String key="StartFreeFly">Spustit bez vozidla</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="DebugLogVisible">Zobrazovat průběh simulace</String>
|
||||||
<String key="PyScreenPriority">Frekvence obnovení doplňkových obrazovek</String>
|
<String key="PyScreenPriority">Frekvence obnovení doplňkových obrazovek</String>
|
||||||
<String key="PyPriorityNormal">Standardní</String>
|
<String key="PyPriorityNormal">Standardní</String>
|
||||||
@@ -584,4 +619,20 @@
|
|||||||
<String key="ShortcutMember">Vybrat člen ve spojené jednotce</String>
|
<String key="ShortcutMember">Vybrat člen ve spojené jednotce</String>
|
||||||
<String key="ShortcutMoveVehicle">Posunout vozidlo v soupravě</String>
|
<String key="ShortcutMoveVehicle">Posunout vozidlo v soupravě</String>
|
||||||
<String key="ShortcutCardMenu">Otevřít nabídku vozidla</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>
|
</Language>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<String key="NavScenarios">Scenarios</String>
|
<String key="NavScenarios">Scenarios</String>
|
||||||
<String key="NavDepot">Depot</String>
|
<String key="NavDepot">Depot</String>
|
||||||
<String key="NavSettings">Settings</String>
|
<String key="NavSettings">Settings</String>
|
||||||
|
<String key="NavDeveloper">Developer</String>
|
||||||
<String key="NavControls">Controls</String>
|
<String key="NavControls">Controls</String>
|
||||||
<String key="Help">Help</String>
|
<String key="Help">Help</String>
|
||||||
<String key="HelpWiki">MaSzyna wiki</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="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="RestoreWeather">Restore weather</String>
|
||||||
<String key="ReloadSceneries">Reload</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="OpenSceneryFolder">Open folder</String>
|
||||||
<String key="OptionalIncludes">Optional elements</String>
|
<String key="OptionalIncludes">Optional elements</String>
|
||||||
<String key="OpenTextureFolder">Open texture folder</String>
|
<String key="OpenTextureFolder">Open texture folder</String>
|
||||||
@@ -91,6 +95,9 @@
|
|||||||
<String key="Crew">with crew</String>
|
<String key="Crew">with crew</String>
|
||||||
<String key="Reversed">reversed</String>
|
<String key="Reversed">reversed</String>
|
||||||
<String key="NoVehicles">No vehicles found in dynamic/**/textures.txt.</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="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="TipMoveLeft">Move left</String>
|
||||||
<String key="TipMoveRight">Move right</String>
|
<String key="TipMoveRight">Move right</String>
|
||||||
@@ -107,45 +114,45 @@
|
|||||||
<String key="CatDMU">DMU</String>
|
<String key="CatDMU">DMU</String>
|
||||||
<String key="CatCarriagesA">Carriages A</String>
|
<String key="CatCarriagesA">Carriages A</String>
|
||||||
<String key="CatCarriagesB">Carriages B</String>
|
<String key="CatCarriagesB">Carriages B</String>
|
||||||
<String key="CatElectricLoco">Electric locomotive</String>
|
<String key="CatElectricLoco">Electric</String>
|
||||||
<String key="CatDieselLoco">Diesel locomotive</String>
|
<String key="CatDieselLoco">Diesel</String>
|
||||||
<String key="CatSteamLoco">Steam locomotive</String>
|
<String key="CatSteamLoco">Steam</String>
|
||||||
<String key="CatRailbus">Railbus</String>
|
<String key="CatRailbus">Railcar</String>
|
||||||
<String key="CatDraisine">Draisine</String>
|
<String key="CatDraisine">Push-trolley</String>
|
||||||
<String key="CatWork">Work vehicles</String>
|
<String key="CatWork">Work train</String>
|
||||||
<String key="CatPrototype">Prototype</String>
|
<String key="CatPrototype">Prototypes</String>
|
||||||
<String key="CatTram">Tram</String>
|
<String key="CatTram">Tram</String>
|
||||||
<String key="CatCar">Car</String>
|
<String key="CatCar">Motorcar</String>
|
||||||
<String key="CatBus">Bus</String>
|
<String key="CatBus">Bus</String>
|
||||||
<String key="CatTruck">Truck</String>
|
<String key="CatTruck">Truck</String>
|
||||||
<String key="CatPeople">People</String>
|
<String key="CatPeople">People</String>
|
||||||
<String key="CatAnimals">Animals</String>
|
<String key="CatAnimals">Animals</String>
|
||||||
<String key="CatWagonsA">Wagons A</String>
|
<String key="CatWagonsA">Car A</String>
|
||||||
<String key="CatWagonsB">Wagons B</String>
|
<String key="CatWagonsB">Car B</String>
|
||||||
<String key="CatWagonsC">Wagons C</String>
|
<String key="CatWagonsC">Car C</String>
|
||||||
<String key="CatWagonsD">Wagons D</String>
|
<String key="CatWagonsD">Car D</String>
|
||||||
<String key="CatWagonsE">Wagons E</String>
|
<String key="CatWagonsE">Car E</String>
|
||||||
<String key="CatWagonsF">Wagons F</String>
|
<String key="CatWagonsF">Car F</String>
|
||||||
<String key="CatWagonsG">Wagons G</String>
|
<String key="CatWagonsG">Car G</String>
|
||||||
<String key="CatWagonsH">Wagons H</String>
|
<String key="CatWagonsH">Car H</String>
|
||||||
<String key="CatWagonsI">Wagons I</String>
|
<String key="CatWagonsI">Car I</String>
|
||||||
<String key="CatWagonsJ">Wagons J</String>
|
<String key="CatWagonsJ">Car J</String>
|
||||||
<String key="CatWagonsK">Wagons K</String>
|
<String key="CatWagonsK">Car K</String>
|
||||||
<String key="CatWagonsL">Wagons L</String>
|
<String key="CatWagonsL">Car L</String>
|
||||||
<String key="CatWagonsM">Wagons M</String>
|
<String key="CatWagonsM">Car M</String>
|
||||||
<String key="CatWagonsN">Wagons N</String>
|
<String key="CatWagonsN">Car N</String>
|
||||||
<String key="CatWagonsO">Wagons O</String>
|
<String key="CatWagonsO">Car O</String>
|
||||||
<String key="CatWagonsP">Wagons P</String>
|
<String key="CatWagonsP">Car P</String>
|
||||||
<String key="CatWagonsQ">Wagons Q</String>
|
<String key="CatWagonsQ">Car Q</String>
|
||||||
<String key="CatWagonsR">Wagons R</String>
|
<String key="CatWagonsR">Car R</String>
|
||||||
<String key="CatWagonsS">Wagons S</String>
|
<String key="CatWagonsS">Car S</String>
|
||||||
<String key="CatWagonsT">Wagons T</String>
|
<String key="CatWagonsT">Car T</String>
|
||||||
<String key="CatWagonsU">Wagons U</String>
|
<String key="CatWagonsU">Car U</String>
|
||||||
<String key="CatWagonsV">Wagons V</String>
|
<String key="CatWagonsV">Car V</String>
|
||||||
<String key="CatWagonsW">Wagons W</String>
|
<String key="CatWagonsW">Car W</String>
|
||||||
<String key="CatWagonsX">Wagons X</String>
|
<String key="CatWagonsX">Car X</String>
|
||||||
<String key="CatWagonsY">Wagons Y</String>
|
<String key="CatWagonsY">Car Y</String>
|
||||||
<String key="CatWagonsZ">Wagons Z</String>
|
<String key="CatWagonsZ">Car Z</String>
|
||||||
<String key="CatOther">Other</String>
|
<String key="CatOther">Other</String>
|
||||||
<String key="Brakes">Brakes</String>
|
<String key="Brakes">Brakes</String>
|
||||||
<String key="Loads">Loads</String>
|
<String key="Loads">Loads</String>
|
||||||
@@ -223,6 +230,7 @@
|
|||||||
<String key="ReadyOverrideReady">Always ready</String>
|
<String key="ReadyOverrideReady">Always ready</String>
|
||||||
<String key="ConsistReadyOverridden">Overridden by "Force the consist state on launch" in Settings.</String>
|
<String key="ConsistReadyOverridden">Overridden by "Force the consist state on launch" in Settings.</String>
|
||||||
<String key="ConsistRandomType">Random type</String>
|
<String key="ConsistRandomType">Random type</String>
|
||||||
|
<String key="ConsistMaxAmount">Maximum amount</String>
|
||||||
<String key="ConsistRandomAmount">Random amount</String>
|
<String key="ConsistRandomAmount">Random amount</String>
|
||||||
<String key="Set">Set</String>
|
<String key="Set">Set</String>
|
||||||
<String key="Length">Length</String>
|
<String key="Length">Length</String>
|
||||||
@@ -251,7 +259,14 @@
|
|||||||
<String key="ExeLaunchFailed">Could not start the simulator</String>
|
<String key="ExeLaunchFailed">Could not start the simulator</String>
|
||||||
<String key="ExeNotFound">The simulator executable was not found.</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="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="TabInfo">Info</String>
|
||||||
<String key="SettingsFileChangedTitle">Settings</String>
|
<String key="SettingsFileChangedTitle">Settings</String>
|
||||||
<String key="SettingsFileChanged">eu07.ini was changed outside the starter. Reload it?</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="CouplingUnitRear">Coupler at the rear of the unit</String>
|
||||||
<String key="Join">Join</String>
|
<String key="Join">Join</String>
|
||||||
<String key="TipJoin">Couple this vehicle with the next one into a single unit</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="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="TipAddVehicle">Add this vehicle to the consist</String>
|
||||||
<String key="TipAddUnit">Add the whole unit 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="Others">Other</String>
|
||||||
<String key="SelectEXE">Select exe</String>
|
<String key="SelectEXE">Select exe</String>
|
||||||
<String key="SelectEXEAuto">Select automatically</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="DebugMode">Debug mode</String>
|
||||||
<String key="DebugModeDesc">Unlocks debug menus, extra panels and test shortcuts.</String>
|
<String key="DebugModeDesc">Unlocks debug menus, extra panels and test shortcuts.</String>
|
||||||
<String key="VirtualShunting">Virtual shunting</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="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="LogMissingVehicleFiles">Log missing vehicle files during export</String>
|
||||||
<String key="LogMissingVehicleFilesDesc">Writes vehicles the starter could not find into the log.</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="Graphic">Graphics</String>
|
||||||
<String key="RenderEngine">Render engine</String>
|
<String key="RenderEngine">Render engine</String>
|
||||||
@@ -350,6 +378,7 @@
|
|||||||
<String key="RESimp">Simplified</String>
|
<String key="RESimp">Simplified</String>
|
||||||
<String key="REOff">Disable visual layer</String>
|
<String key="REOff">Disable visual layer</String>
|
||||||
<String key="REExperimental">Experimental</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="Resolution">Resolution</String>
|
||||||
<String key="BufforResolution">Buffer resolution</String>
|
<String key="BufforResolution">Buffer resolution</String>
|
||||||
@@ -493,6 +522,7 @@
|
|||||||
<String key="KbAssigned">Assigned</String>
|
<String key="KbAssigned">Assigned</String>
|
||||||
<String key="KbShift">With Shift</String>
|
<String key="KbShift">With Shift</String>
|
||||||
<String key="KbCtrl">With Ctrl</String>
|
<String key="KbCtrl">With Ctrl</String>
|
||||||
|
<String key="KbShiftCtrl">With Ctrl + Shift</String>
|
||||||
<String key="KbReserved">Reserved by the simulator</String>
|
<String key="KbReserved">Reserved by the simulator</String>
|
||||||
<String key="BindKeyTitle">Key:</String>
|
<String key="BindKeyTitle">Key:</String>
|
||||||
<String key="BindKeyHint">Assign a command to each modifier combination for this 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="FaultNoScenery">No scenarios found.</String>
|
||||||
<String key="FaultNoVehicles">No vehicles found.</String>
|
<String key="FaultNoVehicles">No vehicles found.</String>
|
||||||
<String key="FaultNoPhysics">No rolling stock data 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: {1} 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 -->
|
<!-- About -->
|
||||||
<String key="AboutTitle">About</String>
|
<String key="AboutTitle">About</String>
|
||||||
@@ -562,7 +597,8 @@
|
|||||||
<String key="WarehouseImportFile">Add consists from file...</String>
|
<String key="WarehouseImportFile">Add consists from file...</String>
|
||||||
<String key="StartWithoutSaving">Start without saving settings</String>
|
<String key="StartWithoutSaving">Start without saving settings</String>
|
||||||
<String key="StartFreeFly">Start with no vehicle</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="DebugLogVisible">Show log in console</String>
|
||||||
<String key="PyScreenPriority">Extra screen update delay</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>
|
<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="ShortcutMember">Pick a car inside a coupled unit</String>
|
||||||
<String key="ShortcutMoveVehicle">Move the vehicle along the consist</String>
|
<String key="ShortcutMoveVehicle">Move the vehicle along the consist</String>
|
||||||
<String key="ShortcutCardMenu">Open the vehicle menu</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>
|
</Language>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<String key="NavScenarios">Forgatókönyvek</String>
|
<String key="NavScenarios">Forgatókönyvek</String>
|
||||||
<String key="NavDepot">Fűtőház</String>
|
<String key="NavDepot">Fűtőház</String>
|
||||||
<String key="NavSettings">Beállítások</String>
|
<String key="NavSettings">Beállítások</String>
|
||||||
|
<String key="NavDeveloper">Developer</String>
|
||||||
<String key="NavControls">Irányítás</String>
|
<String key="NavControls">Irányítás</String>
|
||||||
<String key="Help">Súgó</String>
|
<String key="Help">Súgó</String>
|
||||||
<String key="HelpWiki">MaSzyna wiki</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="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="RestoreWeather">Időjárás visszaállítása</String>
|
||||||
<String key="ReloadSceneries">Újratöltés</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="OpenSceneryFolder">Mappa megnyitása</String>
|
||||||
<String key="OptionalIncludes">Opcionális elemek</String>
|
<String key="OptionalIncludes">Opcionális elemek</String>
|
||||||
<String key="OpenTextureFolder">Textúramappa megnyitása</String>
|
<String key="OpenTextureFolder">Textúramappa megnyitása</String>
|
||||||
@@ -91,6 +95,9 @@
|
|||||||
<String key="Crew">személyzettel</String>
|
<String key="Crew">személyzettel</String>
|
||||||
<String key="Reversed">megfordítva</String>
|
<String key="Reversed">megfordítva</String>
|
||||||
<String key="NoVehicles">A dynamic/**/textures.txt fájlokban nem található jármű.</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="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="TipMoveLeft">Mozgatás balra</String>
|
||||||
<String key="TipMoveRight">Mozgatás jobbra</String>
|
<String key="TipMoveRight">Mozgatás jobbra</String>
|
||||||
@@ -103,49 +110,49 @@
|
|||||||
<String key="All">Mind</String>
|
<String key="All">Mind</String>
|
||||||
<String key="CatDiesel">Dízel</String>
|
<String key="CatDiesel">Dízel</String>
|
||||||
<String key="CatElectric">Villamos</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="CatDMU">Dízel motorvonat</String>
|
||||||
<String key="CatCarriagesA">Kocsik A</String>
|
<String key="CatCarriagesA">Kocsik A</String>
|
||||||
<String key="CatCarriagesB">Kocsik B</String>
|
<String key="CatCarriagesB">Kocsik B</String>
|
||||||
<String key="CatElectricLoco">Villamos mozdony</String>
|
<String key="CatElectricLoco">Villamos mozdony</String>
|
||||||
<String key="CatDieselLoco">Dízelmozdony</String>
|
<String key="CatDieselLoco">Dízel</String>
|
||||||
<String key="CatSteamLoco">Gőzmozdony</String>
|
<String key="CatSteamLoco">Gőz</String>
|
||||||
<String key="CatRailbus">Motorkocsi</String>
|
<String key="CatRailbus">Vasúti kocsi</String>
|
||||||
<String key="CatDraisine">Hajtány</String>
|
<String key="CatDraisine">Hajtány</String>
|
||||||
<String key="CatWork">Munkagépek</String>
|
<String key="CatWork">Üzemi vonat</String>
|
||||||
<String key="CatPrototype">Prototípus</String>
|
<String key="CatPrototype">Prototípusok</String>
|
||||||
<String key="CatTram">Villamos</String>
|
<String key="CatTram">Villamos</String>
|
||||||
<String key="CatCar">Autó</String>
|
<String key="CatCar">Autó</String>
|
||||||
<String key="CatBus">Autóbusz</String>
|
<String key="CatBus">Busz</String>
|
||||||
<String key="CatTruck">Tehergépkocsi</String>
|
<String key="CatTruck">Teherautó</String>
|
||||||
<String key="CatPeople">Emberek</String>
|
<String key="CatPeople">Emberek</String>
|
||||||
<String key="CatAnimals">Állatok</String>
|
<String key="CatAnimals">Állatok</String>
|
||||||
<String key="CatWagonsA">Vagonok A</String>
|
<String key="CatWagonsA">Kocsi A</String>
|
||||||
<String key="CatWagonsB">Vagonok B</String>
|
<String key="CatWagonsB">Kocsi B</String>
|
||||||
<String key="CatWagonsC">Vagonok C</String>
|
<String key="CatWagonsC">Kocsi C</String>
|
||||||
<String key="CatWagonsD">Vagonok D</String>
|
<String key="CatWagonsD">Kocsi D</String>
|
||||||
<String key="CatWagonsE">Vagonok E</String>
|
<String key="CatWagonsE">Kocsi E</String>
|
||||||
<String key="CatWagonsF">Vagonok F</String>
|
<String key="CatWagonsF">Kocsi F</String>
|
||||||
<String key="CatWagonsG">Vagonok G</String>
|
<String key="CatWagonsG">Kocsi G</String>
|
||||||
<String key="CatWagonsH">Vagonok H</String>
|
<String key="CatWagonsH">Kocsi H</String>
|
||||||
<String key="CatWagonsI">Vagonok I</String>
|
<String key="CatWagonsI">Kocsi I</String>
|
||||||
<String key="CatWagonsJ">Vagonok J</String>
|
<String key="CatWagonsJ">Kocsi J</String>
|
||||||
<String key="CatWagonsK">Vagonok K</String>
|
<String key="CatWagonsK">Kocsi K</String>
|
||||||
<String key="CatWagonsL">Vagonok L</String>
|
<String key="CatWagonsL">Kocsi L</String>
|
||||||
<String key="CatWagonsM">Vagonok M</String>
|
<String key="CatWagonsM">Kocsi M</String>
|
||||||
<String key="CatWagonsN">Vagonok N</String>
|
<String key="CatWagonsN">Kocsi N</String>
|
||||||
<String key="CatWagonsO">Vagonok O</String>
|
<String key="CatWagonsO">Kocsi O</String>
|
||||||
<String key="CatWagonsP">Vagonok P</String>
|
<String key="CatWagonsP">Kocsi P</String>
|
||||||
<String key="CatWagonsQ">Vagonok Q</String>
|
<String key="CatWagonsQ">Kocsi Q</String>
|
||||||
<String key="CatWagonsR">Vagonok R</String>
|
<String key="CatWagonsR">Kocsi R</String>
|
||||||
<String key="CatWagonsS">Vagonok S</String>
|
<String key="CatWagonsS">Kocsi S</String>
|
||||||
<String key="CatWagonsT">Vagonok T</String>
|
<String key="CatWagonsT">Kocsi T</String>
|
||||||
<String key="CatWagonsU">Vagonok U</String>
|
<String key="CatWagonsU">Kocsi U</String>
|
||||||
<String key="CatWagonsV">Vagonok V</String>
|
<String key="CatWagonsV">Kocsi V</String>
|
||||||
<String key="CatWagonsW">Vagonok W</String>
|
<String key="CatWagonsW">Kocsi W</String>
|
||||||
<String key="CatWagonsX">Vagonok X</String>
|
<String key="CatWagonsX">Kocsi X</String>
|
||||||
<String key="CatWagonsY">Vagonok Y</String>
|
<String key="CatWagonsY">Kocsi Y</String>
|
||||||
<String key="CatWagonsZ">Vagonok Z</String>
|
<String key="CatWagonsZ">Kocsi Z</String>
|
||||||
<String key="CatOther">Egyéb</String>
|
<String key="CatOther">Egyéb</String>
|
||||||
<String key="Brakes">Fékek</String>
|
<String key="Brakes">Fékek</String>
|
||||||
<String key="Loads">Rakomány</String>
|
<String key="Loads">Rakomány</String>
|
||||||
@@ -223,6 +230,7 @@
|
|||||||
<String key="ReadyOverrideReady">Always ready</String>
|
<String key="ReadyOverrideReady">Always ready</String>
|
||||||
<String key="ConsistReadyOverridden">Overridden by "Force the consist state on launch" in Settings.</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="ConsistRandomType">Véletlen típus</String>
|
||||||
|
<String key="ConsistMaxAmount">Legnagyobb mennyiség</String>
|
||||||
<String key="ConsistRandomAmount">Véletlen mennyiség</String>
|
<String key="ConsistRandomAmount">Véletlen mennyiség</String>
|
||||||
<String key="Set">Beállítás</String>
|
<String key="Set">Beállítás</String>
|
||||||
<String key="Length">Hossz</String>
|
<String key="Length">Hossz</String>
|
||||||
@@ -251,7 +259,14 @@
|
|||||||
<String key="ExeLaunchFailed">A szimulátor indítása sikertelen</String>
|
<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="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="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="TabInfo">Információ</String>
|
||||||
<String key="SettingsFileChangedTitle">Beállítások</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>
|
<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="CouplingUnitRear">Az egység végén lévő kapcsolókészülék</String>
|
||||||
<String key="Join">Összekapcsolás</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="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="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="TipAddVehicle">Jármű hozzáadása a szerelvényhez</String>
|
||||||
<String key="TipAddUnit">A teljes egység 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="Others">Egyéb</String>
|
||||||
<String key="SelectEXE">Exe kiválasztása</String>
|
<String key="SelectEXE">Exe kiválasztása</String>
|
||||||
<String key="SelectEXEAuto">Automatikus kiválasztás</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="DebugMode">Hibakeresési mód (teszt mód)</String>
|
||||||
<String key="VirtualShunting">Virtuális tolatás</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="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="Graphic">Grafika</String>
|
||||||
<String key="RenderEngine">Megjelenítő motor</String>
|
<String key="RenderEngine">Megjelenítő motor</String>
|
||||||
@@ -343,6 +370,7 @@
|
|||||||
<String key="RESimp">Egyszerűsített</String>
|
<String key="RESimp">Egyszerűsített</String>
|
||||||
<String key="REOff">Vizuális réteg kikapcsolása</String>
|
<String key="REOff">Vizuális réteg kikapcsolása</String>
|
||||||
<String key="REExperimental">Kísérleti</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="Resolution">Felbontás</String>
|
||||||
<String key="BufforResolution">Puffer felbontása</String>
|
<String key="BufforResolution">Puffer felbontása</String>
|
||||||
@@ -459,6 +487,7 @@
|
|||||||
<String key="KbAssigned">Hozzárendelve</String>
|
<String key="KbAssigned">Hozzárendelve</String>
|
||||||
<String key="KbShift">Shift-tel</String>
|
<String key="KbShift">Shift-tel</String>
|
||||||
<String key="KbCtrl">Ctrl-lal</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="KbReserved">A szimulátor számára fenntartva</String>
|
||||||
<String key="BindKeyTitle">Billentyű:</String>
|
<String key="BindKeyTitle">Billentyű:</String>
|
||||||
<String key="BindKeyHint">Rendeljen parancsot a billentyű minden módosító-kombinációjához.</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="FaultNoScenery">Nem található forgatókönyv.</String>
|
||||||
<String key="FaultNoVehicles">Nem található jármű.</String>
|
<String key="FaultNoVehicles">Nem található jármű.</String>
|
||||||
<String key="FaultNoPhysics">Nem található járműadat.</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: {1} 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 -->
|
<!-- About -->
|
||||||
<String key="AboutTitle">Névjegy</String>
|
<String key="AboutTitle">Névjegy</String>
|
||||||
@@ -528,7 +562,8 @@
|
|||||||
<String key="WarehouseImportFile">Szerelvények hozzáadása fájlból...</String>
|
<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="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="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="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="PyScreenPriority">Kiegészítő képernyők frissítési gyakorisága</String>
|
||||||
<String key="PyPriorityNormal">Normál</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="ShortcutMember">Tag kiválasztása kapcsolt egységen belül</String>
|
||||||
<String key="ShortcutMoveVehicle">Jármű mozgatása a szerelvényben</String>
|
<String key="ShortcutMoveVehicle">Jármű mozgatása a szerelvényben</String>
|
||||||
<String key="ShortcutCardMenu">Jármű menüjének megnyitása</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>
|
</Language>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<String key="NavScenarios">Scenariusze</String>
|
<String key="NavScenarios">Scenariusze</String>
|
||||||
<String key="NavDepot">Zajezdnia</String>
|
<String key="NavDepot">Zajezdnia</String>
|
||||||
<String key="NavSettings">Ustawienia</String>
|
<String key="NavSettings">Ustawienia</String>
|
||||||
|
<String key="NavDeveloper">Deweloper</String>
|
||||||
<String key="NavControls">Sterowanie</String>
|
<String key="NavControls">Sterowanie</String>
|
||||||
<String key="Help">Pomoc</String>
|
<String key="Help">Pomoc</String>
|
||||||
<String key="HelpWiki">Wiki MaSzyna</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="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="RestoreWeather">Przywróć pogodę</String>
|
||||||
<String key="ReloadSceneries">Przeładuj</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="OpenSceneryFolder">Otwórz folder</String>
|
||||||
<String key="OptionalIncludes">Elementy opcjonalne</String>
|
<String key="OptionalIncludes">Elementy opcjonalne</String>
|
||||||
<String key="OpenTextureFolder">Otwórz folder tekstury</String>
|
<String key="OpenTextureFolder">Otwórz folder tekstury</String>
|
||||||
@@ -91,6 +95,9 @@
|
|||||||
<String key="Crew">z obsadą</String>
|
<String key="Crew">z obsadą</String>
|
||||||
<String key="Reversed">Obrócony</String>
|
<String key="Reversed">Obrócony</String>
|
||||||
<String key="NoVehicles">Nie znaleziono pojazdów w dynamic/**/textures.txt.</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="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="TipMoveLeft">Przesuń w lewo</String>
|
||||||
<String key="TipMoveRight">Przesuń w prawo</String>
|
<String key="TipMoveRight">Przesuń w prawo</String>
|
||||||
@@ -107,17 +114,17 @@
|
|||||||
<String key="CatDMU">SZT</String>
|
<String key="CatDMU">SZT</String>
|
||||||
<String key="CatCarriagesA">Wagony A</String>
|
<String key="CatCarriagesA">Wagony A</String>
|
||||||
<String key="CatCarriagesB">Wagony B</String>
|
<String key="CatCarriagesB">Wagony B</String>
|
||||||
<String key="CatElectricLoco">Lokomotywa elektryczna</String>
|
<String key="CatElectricLoco">Elektrowozy</String>
|
||||||
<String key="CatDieselLoco">Lokomotywa spalinowa</String>
|
<String key="CatDieselLoco">Spalinowozy</String>
|
||||||
<String key="CatSteamLoco">Parowóz</String>
|
<String key="CatSteamLoco">Parowozy</String>
|
||||||
<String key="CatRailbus">Szynobus</String>
|
<String key="CatRailbus">Szynobusy</String>
|
||||||
<String key="CatDraisine">Drezyna</String>
|
<String key="CatDraisine">Drezyny</String>
|
||||||
<String key="CatWork">Pojazdy robocze</String>
|
<String key="CatWork">Robocze</String>
|
||||||
<String key="CatPrototype">Prototyp</String>
|
<String key="CatPrototype">Prototypy</String>
|
||||||
<String key="CatTram">Tramwaj</String>
|
<String key="CatTram">Tramwaje</String>
|
||||||
<String key="CatCar">Samochód osobowy</String>
|
<String key="CatCar">Samochody</String>
|
||||||
<String key="CatBus">Autobus</String>
|
<String key="CatBus">Autobusy</String>
|
||||||
<String key="CatTruck">Samochód ciężarowy</String>
|
<String key="CatTruck">Ciężarówki</String>
|
||||||
<String key="CatPeople">Ludzie</String>
|
<String key="CatPeople">Ludzie</String>
|
||||||
<String key="CatAnimals">Zwierzęta</String>
|
<String key="CatAnimals">Zwierzęta</String>
|
||||||
<String key="CatWagonsA">Wagony A</String>
|
<String key="CatWagonsA">Wagony A</String>
|
||||||
@@ -223,6 +230,7 @@
|
|||||||
<String key="ReadyOverrideReady">Zawsze gotowy</String>
|
<String key="ReadyOverrideReady">Zawsze gotowy</String>
|
||||||
<String key="ConsistReadyOverridden">Nadpisane opcją „Wymuś stan składu przy starcie” w Ustawieniach.</String>
|
<String key="ConsistReadyOverridden">Nadpisane opcją „Wymuś stan składu przy starcie” w Ustawieniach.</String>
|
||||||
<String key="ConsistRandomType">Losowy rodzaj</String>
|
<String key="ConsistRandomType">Losowy rodzaj</String>
|
||||||
|
<String key="ConsistMaxAmount">Maksymalna ilość</String>
|
||||||
<String key="ConsistRandomAmount">Losowa ilość</String>
|
<String key="ConsistRandomAmount">Losowa ilość</String>
|
||||||
<String key="Set">Ustaw</String>
|
<String key="Set">Ustaw</String>
|
||||||
<String key="Length">Długość</String>
|
<String key="Length">Długość</String>
|
||||||
@@ -251,7 +259,14 @@
|
|||||||
<String key="ExeLaunchFailed">Nie udało się uruchomić symulatora</String>
|
<String key="ExeLaunchFailed">Nie udało się uruchomić symulatora</String>
|
||||||
<String key="ExeNotFound">Nie znaleziono pliku wykonywalnego symulatora.</String>
|
<String key="ExeNotFound">Nie znaleziono pliku wykonywalnego symulatora.</String>
|
||||||
<String key="ExeNotExecutable">Plik nie ma prawa wykonywania (chmod +x).</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="TabInfo">Informacje</String>
|
||||||
<String key="SettingsFileChangedTitle">Ustawienia</String>
|
<String key="SettingsFileChangedTitle">Ustawienia</String>
|
||||||
<String key="SettingsFileChanged">Plik eu07.ini został zmieniony poza starterem. Wczytać ponownie?</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="CouplingUnitRear">Sprzęg na końcu zespołu</String>
|
||||||
<String key="Join">Złącz</String>
|
<String key="Join">Złącz</String>
|
||||||
<String key="TipJoin">Połącz ten pojazd z następnym w jeden zespół</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="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="TipAddVehicle">Dodaj ten pojazd do składu</String>
|
||||||
<String key="TipAddUnit">Dodaj cały zespół 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="Others">Inne</String>
|
||||||
<String key="SelectEXE">Wybór exe</String>
|
<String key="SelectEXE">Wybór exe</String>
|
||||||
<String key="SelectEXEAuto">Wybierz automatycznie</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="DebugMode">Tryb testowy</String>
|
||||||
<String key="DebugModeDesc">Odblokowuje menu debug, dodatkowe panele i skróty testowe.</String>
|
<String key="DebugModeDesc">Odblokowuje menu debug, dodatkowe panele i skróty testowe.</String>
|
||||||
<String key="VirtualShunting">Wirtualny manewrowy</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="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="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="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="Graphic">Grafika</String>
|
||||||
<String key="RenderEngine">Silnik renderowania</String>
|
<String key="RenderEngine">Silnik renderowania</String>
|
||||||
@@ -350,6 +378,7 @@
|
|||||||
<String key="RESimp">Uproszczony</String>
|
<String key="RESimp">Uproszczony</String>
|
||||||
<String key="REOff">Wyłącz warstwę wizualną</String>
|
<String key="REOff">Wyłącz warstwę wizualną</String>
|
||||||
<String key="REExperimental">Eksperymentalny</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="Resolution">Rozdzielczość</String>
|
||||||
<String key="BufforResolution">Rozdzielczość bufora</String>
|
<String key="BufforResolution">Rozdzielczość bufora</String>
|
||||||
@@ -493,6 +522,7 @@
|
|||||||
<String key="KbAssigned">Przypisany</String>
|
<String key="KbAssigned">Przypisany</String>
|
||||||
<String key="KbShift">Z klawiszem Shift</String>
|
<String key="KbShift">Z klawiszem Shift</String>
|
||||||
<String key="KbCtrl">Z klawiszem Ctrl</String>
|
<String key="KbCtrl">Z klawiszem Ctrl</String>
|
||||||
|
<String key="KbShiftCtrl">Z Ctrl + Shift</String>
|
||||||
<String key="KbReserved">Zarezerwowany przez symulator</String>
|
<String key="KbReserved">Zarezerwowany przez symulator</String>
|
||||||
<String key="BindKeyTitle">Klawisz:</String>
|
<String key="BindKeyTitle">Klawisz:</String>
|
||||||
<String key="BindKeyHint">Przypisz komendę do każdej kombinacji modyfikatorów dla tego klawisza.</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="FaultNoScenery">Nie znaleziono scenariuszy.</String>
|
||||||
<String key="FaultNoVehicles">Nie znaleziono pojazdów.</String>
|
<String key="FaultNoVehicles">Nie znaleziono pojazdów.</String>
|
||||||
<String key="FaultNoPhysics">Nie znaleziono danych taboru.</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: {1} 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 -->
|
<!-- About -->
|
||||||
<String key="AboutTitle">O programie</String>
|
<String key="AboutTitle">O programie</String>
|
||||||
@@ -562,7 +597,8 @@
|
|||||||
<String key="WarehouseImportFile">Dodaj składy z pliku...</String>
|
<String key="WarehouseImportFile">Dodaj składy z pliku...</String>
|
||||||
<String key="StartWithoutSaving">Uruchom bez zapisu ustawień</String>
|
<String key="StartWithoutSaving">Uruchom bez zapisu ustawień</String>
|
||||||
<String key="StartFreeFly">Uruchom bez pojazdu</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="DebugLogVisible">Wyświetlaj log w konsoli</String>
|
||||||
<String key="PyScreenPriority">Opóźnienie odświeżania ekranów dodatkowych</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>
|
<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="ShortcutMember">Wybierz człon w zespolonej jednostce</String>
|
||||||
<String key="ShortcutMoveVehicle">Przesuń pojazd w składzie</String>
|
<String key="ShortcutMoveVehicle">Przesuń pojazd w składzie</String>
|
||||||
<String key="ShortcutCardMenu">Otwórz menu pojazdu</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>
|
</Language>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<String key="NavScenarios">Сценарии</String>
|
<String key="NavScenarios">Сценарии</String>
|
||||||
<String key="NavDepot">Депо</String>
|
<String key="NavDepot">Депо</String>
|
||||||
<String key="NavSettings">Настройки</String>
|
<String key="NavSettings">Настройки</String>
|
||||||
|
<String key="NavDeveloper">Developer</String>
|
||||||
<String key="NavControls">Управление</String>
|
<String key="NavControls">Управление</String>
|
||||||
<String key="Help">Справка</String>
|
<String key="Help">Справка</String>
|
||||||
<String key="HelpWiki">Вики MaSzyna</String>
|
<String key="HelpWiki">Вики MaSzyna</String>
|
||||||
@@ -65,6 +66,9 @@
|
|||||||
<String key="StartNoStaffConfirm">В этом составе нет ни бригады, ни пассажира — управлять будет нечем. Всё равно запустить?</String>
|
<String key="StartNoStaffConfirm">В этом составе нет ни бригады, ни пассажира — управлять будет нечем. Всё равно запустить?</String>
|
||||||
<String key="RestoreWeather">Восстановить погоду</String>
|
<String key="RestoreWeather">Восстановить погоду</String>
|
||||||
<String key="ReloadSceneries">Перезагрузить</String>
|
<String key="ReloadSceneries">Перезагрузить</String>
|
||||||
|
<String key="Favorites">Избранное</String>
|
||||||
|
<String key="FavoriteAdd">Добавить в избранное</String>
|
||||||
|
<String key="FavoriteRemove">Удалить из избранного</String>
|
||||||
<String key="OpenSceneryFolder">Открыть папку</String>
|
<String key="OpenSceneryFolder">Открыть папку</String>
|
||||||
<String key="OptionalIncludes">Дополнительные элементы</String>
|
<String key="OptionalIncludes">Дополнительные элементы</String>
|
||||||
<String key="OpenTextureFolder">Открыть папку с текстурами</String>
|
<String key="OpenTextureFolder">Открыть папку с текстурами</String>
|
||||||
@@ -91,6 +95,9 @@
|
|||||||
<String key="Crew">с бригадой</String>
|
<String key="Crew">с бригадой</String>
|
||||||
<String key="Reversed">развёрнут</String>
|
<String key="Reversed">развёрнут</String>
|
||||||
<String key="NoVehicles">В dynamic/**/textures.txt подвижной состав не найден.</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="ConsistEmpty">Добавьте единицы из базы слева или загрузите готовый состав из файла сценерии.</String>
|
||||||
<String key="TipMoveLeft">Сдвинуть влево</String>
|
<String key="TipMoveLeft">Сдвинуть влево</String>
|
||||||
<String key="TipMoveRight">Сдвинуть вправо</String>
|
<String key="TipMoveRight">Сдвинуть вправо</String>
|
||||||
@@ -103,21 +110,21 @@
|
|||||||
<String key="All">Все</String>
|
<String key="All">Все</String>
|
||||||
<String key="CatDiesel">Тепловозная</String>
|
<String key="CatDiesel">Тепловозная</String>
|
||||||
<String key="CatElectric">Электрическая</String>
|
<String key="CatElectric">Электрическая</String>
|
||||||
<String key="CatEMU">Электропоезд</String>
|
<String key="CatEMU">Электропоезда</String>
|
||||||
<String key="CatDMU">Дизель-поезд</String>
|
<String key="CatDMU">Дизель-поезд</String>
|
||||||
<String key="CatCarriagesA">Вагоны A</String>
|
<String key="CatCarriagesA">Вагоны A</String>
|
||||||
<String key="CatCarriagesB">Вагоны B</String>
|
<String key="CatCarriagesB">Вагоны B</String>
|
||||||
<String key="CatElectricLoco">Электровоз</String>
|
<String key="CatElectricLoco">Электровозы</String>
|
||||||
<String key="CatDieselLoco">Тепловоз</String>
|
<String key="CatDieselLoco">Тепловозы</String>
|
||||||
<String key="CatSteamLoco">Паровоз</String>
|
<String key="CatSteamLoco">Паровозы</String>
|
||||||
<String key="CatRailbus">Рельсовый автобус</String>
|
<String key="CatRailbus">Рельсовые автобусы</String>
|
||||||
<String key="CatDraisine">Дрезина</String>
|
<String key="CatDraisine">Дрезины</String>
|
||||||
<String key="CatWork">Путевая техника</String>
|
<String key="CatWork">Путевая техника</String>
|
||||||
<String key="CatPrototype">Прототип</String>
|
<String key="CatPrototype">Прототипы</String>
|
||||||
<String key="CatTram">Трамвай</String>
|
<String key="CatTram">Трамваи</String>
|
||||||
<String key="CatCar">Автомобиль</String>
|
<String key="CatCar">Автомобили</String>
|
||||||
<String key="CatBus">Автобус</String>
|
<String key="CatBus">Автобусы</String>
|
||||||
<String key="CatTruck">Грузовик</String>
|
<String key="CatTruck">Грузовики</String>
|
||||||
<String key="CatPeople">Люди</String>
|
<String key="CatPeople">Люди</String>
|
||||||
<String key="CatAnimals">Животные</String>
|
<String key="CatAnimals">Животные</String>
|
||||||
<String key="CatWagonsA">Вагоны A</String>
|
<String key="CatWagonsA">Вагоны A</String>
|
||||||
@@ -223,6 +230,7 @@
|
|||||||
<String key="ReadyOverrideReady">Always ready</String>
|
<String key="ReadyOverrideReady">Always ready</String>
|
||||||
<String key="ConsistReadyOverridden">Overridden by "Force the consist state on launch" in Settings.</String>
|
<String key="ConsistReadyOverridden">Overridden by "Force the consist state on launch" in Settings.</String>
|
||||||
<String key="ConsistRandomType">Случайный тип</String>
|
<String key="ConsistRandomType">Случайный тип</String>
|
||||||
|
<String key="ConsistMaxAmount">Максимальное количество</String>
|
||||||
<String key="ConsistRandomAmount">Случайное количество</String>
|
<String key="ConsistRandomAmount">Случайное количество</String>
|
||||||
<String key="Set">Задать</String>
|
<String key="Set">Задать</String>
|
||||||
<String key="Length">Длина</String>
|
<String key="Length">Длина</String>
|
||||||
@@ -251,7 +259,14 @@
|
|||||||
<String key="ExeLaunchFailed">Не удалось запустить симулятор</String>
|
<String key="ExeLaunchFailed">Не удалось запустить симулятор</String>
|
||||||
<String key="ExeNotFound">Исполняемый файл симулятора не найден.</String>
|
<String key="ExeNotFound">Исполняемый файл симулятора не найден.</String>
|
||||||
<String key="ExeNotExecutable">Файл не помечен как исполняемый (chmod +x).</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="TabInfo">Информация</String>
|
||||||
<String key="SettingsFileChangedTitle">Настройки</String>
|
<String key="SettingsFileChangedTitle">Настройки</String>
|
||||||
<String key="SettingsFileChanged">Файл eu07.ini изменён вне стартера. Загрузить заново?</String>
|
<String key="SettingsFileChanged">Файл eu07.ini изменён вне стартера. Загрузить заново?</String>
|
||||||
@@ -271,8 +286,16 @@
|
|||||||
<String key="CouplingUnitRear">Сцепка в конце состава</String>
|
<String key="CouplingUnitRear">Сцепка в конце состава</String>
|
||||||
<String key="Join">Соединить</String>
|
<String key="Join">Соединить</String>
|
||||||
<String key="TipJoin">Соединить этот вагон со следующим в один состав</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="AddVehicle">Добавить</String>
|
||||||
|
<String key="AddMany">Добавить несколько…</String>
|
||||||
|
<String key="AddManyCount">Сколько раз добавить единицу</String>
|
||||||
<String key="TipAddVehicle">Добавить эту единицу в состав</String>
|
<String key="TipAddVehicle">Добавить эту единицу в состав</String>
|
||||||
<String key="TipAddUnit">Добавить весь сцеп в состав</String>
|
<String key="TipAddUnit">Добавить весь сцеп в состав</String>
|
||||||
|
|
||||||
@@ -331,9 +354,13 @@
|
|||||||
<String key="Others">Прочее</String>
|
<String key="Others">Прочее</String>
|
||||||
<String key="SelectEXE">Выбрать exe</String>
|
<String key="SelectEXE">Выбрать exe</String>
|
||||||
<String key="SelectEXEAuto">Выбрать автоматически</String>
|
<String key="SelectEXEAuto">Выбрать автоматически</String>
|
||||||
|
<String key="SelectEXEBrowse">Выбрать файл…</String>
|
||||||
|
<String key="SelectEXEBrowseTitle">Укажите исполняемый файл симулятора</String>
|
||||||
|
<String key="SelectEXEUseAnyway">Использовать его всё равно?</String>
|
||||||
<String key="DebugMode">Режим отладки (тестовый режим)</String>
|
<String key="DebugMode">Режим отладки (тестовый режим)</String>
|
||||||
<String key="VirtualShunting">Виртуальный манёвр</String>
|
<String key="VirtualShunting">Виртуальный манёвр</String>
|
||||||
<String key="LogMissingVehicleFiles">Записывать в журнал отсутствующие файлы состава при экспорте</String>
|
<String key="LogMissingVehicleFiles">Записывать в журнал отсутствующие файлы состава при экспорте</String>
|
||||||
|
<String key="DeveloperTools">Show the package author's tab</String>
|
||||||
|
|
||||||
<String key="Graphic">Графика</String>
|
<String key="Graphic">Графика</String>
|
||||||
<String key="RenderEngine">Движок отрисовки</String>
|
<String key="RenderEngine">Движок отрисовки</String>
|
||||||
@@ -343,6 +370,7 @@
|
|||||||
<String key="RESimp">Упрощённый</String>
|
<String key="RESimp">Упрощённый</String>
|
||||||
<String key="REOff">Отключить визуальный слой</String>
|
<String key="REOff">Отключить визуальный слой</String>
|
||||||
<String key="REExperimental">Экспериментальный</String>
|
<String key="REExperimental">Экспериментальный</String>
|
||||||
|
<String key="REExperimentalWarning">Внимание: экспериментальный рендерер находится в разработке. Он может работать нестабильно, не показывать части сцены или не запускаться на некоторых видеокартах. Если симулятор не запускается или выглядит неправильно, вернитесь к рендереру «Полный».</String>
|
||||||
|
|
||||||
<String key="Resolution">Разрешение</String>
|
<String key="Resolution">Разрешение</String>
|
||||||
<String key="BufforResolution">Разрешение буфера</String>
|
<String key="BufforResolution">Разрешение буфера</String>
|
||||||
@@ -459,6 +487,7 @@
|
|||||||
<String key="KbAssigned">Назначено</String>
|
<String key="KbAssigned">Назначено</String>
|
||||||
<String key="KbShift">С Shift</String>
|
<String key="KbShift">С Shift</String>
|
||||||
<String key="KbCtrl">С Ctrl</String>
|
<String key="KbCtrl">С Ctrl</String>
|
||||||
|
<String key="KbShiftCtrl">С Ctrl + Shift</String>
|
||||||
<String key="KbReserved">Зарезервировано симулятором</String>
|
<String key="KbReserved">Зарезервировано симулятором</String>
|
||||||
<String key="BindKeyTitle">Клавиша:</String>
|
<String key="BindKeyTitle">Клавиша:</String>
|
||||||
<String key="BindKeyHint">Назначьте команду каждому сочетанию модификаторов для этой клавиши.</String>
|
<String key="BindKeyHint">Назначьте команду каждому сочетанию модификаторов для этой клавиши.</String>
|
||||||
@@ -485,7 +514,12 @@
|
|||||||
<String key="FaultNoScenery">Сценарии не найдены.</String>
|
<String key="FaultNoScenery">Сценарии не найдены.</String>
|
||||||
<String key="FaultNoVehicles">Транспортные средства не найдены.</String>
|
<String key="FaultNoVehicles">Транспортные средства не найдены.</String>
|
||||||
<String key="FaultNoPhysics">Данные подвижного состава не найдены.</String>
|
<String key="FaultNoPhysics">Данные подвижного состава не найдены.</String>
|
||||||
<String key="FaultNoExe">Исполняемый файл симулятора не найден.</String>
|
<String key="FaultNoExeTitle">Симулятор не найден</String>
|
||||||
|
<String key="FaultNoExe">Стартер запускает симулятор ({0}) из папки, из которой запущен сам: {1} Симулятора там нет, поэтому «Старт» не сработает.</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 -->
|
<!-- About -->
|
||||||
<String key="AboutTitle">О программе</String>
|
<String key="AboutTitle">О программе</String>
|
||||||
@@ -528,7 +562,8 @@
|
|||||||
<String key="WarehouseImportFile">Добавить составы из файла...</String>
|
<String key="WarehouseImportFile">Добавить составы из файла...</String>
|
||||||
<String key="StartWithoutSaving">Запустить без сохранения настроек</String>
|
<String key="StartWithoutSaving">Запустить без сохранения настроек</String>
|
||||||
<String key="StartFreeFly">Запустить без транспорта</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="DebugLogVisible">Показывать ход симуляции</String>
|
||||||
<String key="PyScreenPriority">Частота обновления дополнительных экранов</String>
|
<String key="PyScreenPriority">Частота обновления дополнительных экранов</String>
|
||||||
<String key="PyPriorityNormal">Стандартная</String>
|
<String key="PyPriorityNormal">Стандартная</String>
|
||||||
@@ -584,4 +619,20 @@
|
|||||||
<String key="ShortcutMember">Выбрать секцию в сцепленной единице</String>
|
<String key="ShortcutMember">Выбрать секцию в сцепленной единице</String>
|
||||||
<String key="ShortcutMoveVehicle">Переместить вагон в составе</String>
|
<String key="ShortcutMoveVehicle">Переместить вагон в составе</String>
|
||||||
<String key="ShortcutCardMenu">Открыть меню вагона</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>
|
</Language>
|
||||||
|
|||||||
Reference in New Issue
Block a user