71 Commits

Author SHA1 Message Date
maj00r
81c7e8ba95 Find an old starter's language file on Linux too
The list of languages holds codes in lower case, so lang-ZH.txt was not found
on a case-sensitive file system and its key descriptions stayed empty.
2026-11-01 16:16:39 +01:00
maj00r
b56a2a0ae6 Raise the version to 1.5 2026-11-01 16:12:02 +01:00
maj00r
80115b7a7e Set the start and editor button pairs apart
Each button stays close to its arrow, with a clear gap between the two pairs.
Both arrows are the same size.
2026-11-01 16:12:02 +01:00
maj00r
ceab05462a Fit the main window to small screens and wire up the new services
Pages scroll below the size they are laid out for and Start stays in view. The
crash window shows this session's log only.
2026-11-01 16:06:16 +01:00
maj00r
2ebff91316 Start the scenery editor from its own button and note launches
With no scenery selected the button starts a new one in the editor. Each launch
notes the scenery, the timetable number and the vehicles for the tapes. Tools
work on the selected scenery, and an emptied consist stays listed.
2026-11-01 16:06:16 +01:00
maj00r
6e45f5de59 Add Hasler tape viewer for the simulator's physics logs
Reads physicslog/*.dat and draws each trip as a Hasler tape or, for vehicles
with an electronic recorder, as an evaluation screen. Tapes are grouped by
launch, and a launch still running can be followed live. Includes whole-tape
PNG export and a summary of times and averages. With the log off, the page
offers to turn it on.
2026-11-01 16:06:16 +01:00
maj00r
82429010d4 Rework the depot, warehouse and vehicle base
A consist added to the warehouse gets a name there. The crew type stays through
loading a consist. The vehicle base window replaces the texture base. The depot
follows consists changed on the scenarios page, also with the warehouse tab
left open.
2026-11-01 16:06:16 +01:00
maj00r
f152a020e3 Rebuild the settings page
A sticky quality bar, a level and a reset per section, key bindings in their
own tab. UART moves into the page. A log and tools window replaces
AdvancedSettingsWindow, and the developer tab is gone.
2026-11-01 16:06:16 +01:00
maj00r
3b21583b53 Add graphics levels, screen choices and the rest of the simulator's keys
Six levels of detail climbing in even steps, High being where a player with no
settings of their own starts. Five parts of the picture can also take a level
of their own. Resolutions start from the monitors in the computer.

uartfeature with nothing selected writes "no no no no"; uart and uarttune are
written whole or not at all. On Linux the file's contents tell a Windows build,
a native one and a script from a note that merely has the execute bit.
2026-11-01 16:06:15 +01:00
maj00r
69aa075bfa Keep scenery files intact on save and follow them on disk
Saves keep comments and every byte they do not change. Faults in a scenery's
text are found and described. Files that change while the starter runs are
picked up without a manual reload.
2026-11-01 16:06:15 +01:00
maj00r
b6735fe345 Load languages from the player's own folders and add the new strings
Translations also come from folders of the player's, the old starter's
lang-XX.txt and the simulator's XX.po. Strings for the commits that follow in five
languages, shorter where they had grown.
2026-11-01 16:06:15 +01:00
maj00r
07987a60a9 Write settings, presets and favourites whole or not at all
A failed write leaves the old file in place. Settings that could not be read
are not saved over. UI exceptions reach the log. Started from a shortcut set
up elsewhere, the starter moves to its own folder when only that one holds a
game.
2026-11-01 16:06:15 +01:00
maj00r
6948b13503 Merge pull request #35 from dos1/fullscreen
Maximize the window automatically when running in gamescope or Steam Big Picture
2026-10-31 22:10:26 +01:00
Sebastian Krzyszkowiak
22c51ddf3c Maximize the window automatically when running in gamescope or Steam Big Picture 2026-10-28 07:08:29 +01:00
2befefb547 Merge pull request #26 from MaSzyna-EU07/staging
Staging
2026-10-06 20:29:38 +02:00
5929a4ef4f Merge pull request #24 from MaSzyna-EU07/refactor/solid-ddd
Refactor/solid ddd
2026-10-06 20:29:12 +02:00
maj00r
dcbd6078b9 Show key descriptions with Polish letters
The game's key file has its descriptions stripped of them. Where a
description is the bundled one without the letters, the bundled one is
shown; the file itself is left as it is.
2026-10-04 19:41:47 +02:00
maj00r
4da5991c13 Let a captured key take over its combination
Pressing a key for a command in the list doubled the combination up when
another command had it, and the simulator maps a combination to one command
only. It now moves over, as it already did from the key flyout.
2026-10-04 19:32:04 +02:00
maj00r
760dfb38da Show Ctrl+Shift bindings in red on the keyboard
They were drawn as a Shift and a Ctrl binding at once. The key flyout's rows
carry the same colours as the keys.
2026-10-04 19:27:25 +02:00
maj00r
5e5481c5f5 Heating in white instead of pink 2026-10-04 19:20:52 +02:00
maj00r
c77d8c31eb Gangway in green instead of purple 2026-10-04 19:19:13 +02:00
maj00r
a79f74b948 Bigger coupler squares, 8 atm air in yellow, icon only at the back 2026-10-04 19:18:01 +02:00
maj00r
5459f0e0dd Lay the coupler's connections out as a block, mechanical in black
Thin stripes ran together. Each connection now has its own square in a 2 by 4
block placed like the editor's checkboxes, so its place says what it is as
much as its colour, and an empty place is a dot. Mechanical is black, as the
hook is, with a light edge to show on the dark strip.
2026-10-04 19:15:24 +02:00
maj00r
868e07ff89 Show what a coupler connects as a stripe per connection
Under the coupler's icon runs a stripe for each connection in its own colour -
brake red, multiple-unit blue, high voltage yellow and so on - so the strip
tells the kinds of coupling apart, not just coupled from free. The editor's
checkboxes carry the same colours as the legend.

The ambient coolant flag leaves the coupler flyout: it is the vehicle's and
already sits in the vehicle panel, beside the crew and the wagon number.
2026-10-04 19:12:57 +02:00
maj00r
ee87055eed Seat a unit's crew in the car that has the cab
A driver in cab 1 goes into the first car of a unit and one in cab 2 into the
last - the ET42-A has only cab 1, the ET42-B only cab 2. The crew is the
unit's: the card, the details and replacing a vehicle all set it there,
rather than in whichever car happened to be selected.
2026-10-04 19:11:44 +02:00
maj00r
526ff91b3b Draw depot couplers as an icon and a link bar in the state's colour
Each state gets its own icon and colour, and a bar under it ties the two
cards: broken when free, thicker when permanent, absent at the back. Green
is gone - on the strip it already means selected or turned round.
2026-10-04 19:09:57 +02:00
maj00r
8c046cfed4 Match scenery vehicles by folder too, as the old starter did
The old starter looked a vehicle up by folder, skin and model. The folder was
left out here, and 39 skin names recur across folders - "red" is a Clio, an
Audi and a crane - so a vehicle could get another folder's livery.
2026-10-04 19:07:23 +02:00
maj00r
e5c46d23ea Tell apart the halves of a unit that share one skin
The ET42-026 dresses both halves in 112E-026 and the scenery tells them apart
by the model. Textures were keyed by the skin alone, so the B half became a
second A: not part of the set, and listed on its own. A shared skin now gets
the model in its id, vehicles are looked up by skin and model, and the
vehicle cache is rebuilt for the new ids.
2026-10-04 19:05:32 +02:00
maj00r
37dd038b96 Explain the missing-simulator prompt and name its buttons
It now says where the starter looks for the simulator and why it matters,
that starting from the wrong folder is the usual cause, and answers with
"Point to the simulator" and "Not now" instead of Yes and No.
2026-10-04 18:59:14 +02:00
maj00r
6a8750566e Look for the simulator when the picked one is not in this installation
The pick is kept in the per-user eu07.ini, which every installation shares,
so a binary chosen in one folder was missing in another and the starter
complained although a simulator sat right there. A missing pick now gives
way to the search and is logged once.
2026-10-04 18:58:29 +02:00
maj00r
2e7e967279 Say only what a tail coupler does: end signals or none
The simulator reads the last vehicle's coupler for one thing, whether to put
up the end signals. It does not couple the train to anything behind it, as
the tooltip and a comment claimed.
2026-10-04 18:57:58 +02:00
maj00r
53fe9aa48c Explain the greyed-out ready box and count cars in the depot
A disabled checkbox shows no tooltip, so the settings override that greys
out "ready to go" is now said under it. The consist vehicle count counted
units, so a multi-unit was one vehicle; it now counts cars like the stats bar.
2026-10-04 18:55:21 +02:00
maj00r
00a62ab568 Colour the depot couplers by what they connect
Each coupler on the consist strip now shows its state: free, coupled,
multiple-unit, permanent, and at the back whether the end signals go up.
The tooltip gives the code and the connections instead of the drag hint,
and the flyout edits mark the coupler as set by hand and restyle it in
place. The note about 0 on the last vehicle is gone - the colour says it.
2026-10-04 18:55:16 +02:00
maj00r
36dd4a020f Name the depot categories as the old starter did
Elektrowozy, Spalinowozy, Szynobusy, Wagony A... in Polish, and the old names
in English, Czech and Hungarian; Hungarian keeps the electric locomotives
apart from the trams, which the old list called the same, and Russian, which
it did not have, goes plural like the rest.
2026-10-04 18:49:02 +02:00
maj00r
52ad490fb7 Drop the update check - Steam updates the starter
The daily GitHub check, the top-bar button, the stable/staging channel and
"check now" go, with their settings and texts.
2026-10-04 18:46:38 +02:00
maj00r
6cedf8b6f6 Lay out and drive the depot browser the way the old starter did
Picking a category picks its first class and lists that class, instead of
every vehicle of the category; only categories with something in them are
offered, in the old order. The archival switch has its own line, the class
box shows the thumbnail of the class picked, and the search sits above the
list and looks through every vehicle. The rows are the bare texture names, a
set marked faintly; an empty list says what to do; the thumbnail and Add
share a line so the list keeps its height.
2026-10-04 18:44:17 +02:00
maj00r
9459a21083 Count the depot consist after writing it back, not before
The stats were read off the trainset before the edit was written into it,
so the vehicle count and the rest lagged one change behind.
2026-10-04 18:21:08 +02:00
maj00r
384d8c1bca Let Auto bring the back of the train to 0, hand-set or not
Auto now clears the hand-set marks before picking the couplers and is
available on the last vehicle too.
2026-10-04 18:15:01 +02:00
maj00r
b4ae9164bb Drop the unused paused-volume string
The simulator reads no sound.volume.paused - nor gfx.framebuffer.fidelity,
whose job the buffer scale does through gfx.framebuffer.width and height.
2026-10-04 18:11:59 +02:00
maj00r
0d6af369c4 Open no menu on a scenery group in the tree 2026-10-04 18:11:59 +02:00
maj00r
3dd6aa755e Join the user ini path in the tests the way the starter does
The tests spelled it with slashes and failed on the Windows runner, where
Path.Combine uses backslashes. Also drops a null warning in the drag code.
2026-10-04 18:08:48 +02:00
maj00r
72b4e44c43 Hand the driver over when a locomotive is moved to the head
The locomotive put first takes the driver, in cab A; the one it pushed
behind is left unstaffed. Stepping a card left or right is now an ordinary
move, so it also recouples the train and frees the coupler at its back.
2026-10-04 18:08:14 +02:00
maj00r
bfa899718c Size the class list's thumbnails like the consist cards 2026-10-04 14:43:26 +02:00
maj00r
4650e4899c Bring back the maximum amount button among the consist load tools 2026-10-04 14:40:08 +02:00
maj00r
a54576b975 Find the list item under the pointer with FindAncestorOfType 2026-10-04 14:38:05 +02:00
maj00r
3ac792c0fa Move the log and crash dump lookup out of the start use case 2026-10-04 14:38:05 +02:00
maj00r
00795513b9 Keep a last-vehicle coupler set by hand
The back of the consist goes to 0 after every change unless its coupler was
set in the editor; a car that gets a neighbour loses that mark, so it is
freed again once it is last again.
2026-10-04 14:33:48 +02:00
maj00r
925ac87129 Add several copies from a vehicle's menu in the consist too
The copies go right after the vehicle, a whole unit for one of a set. The
count flyout is shared with the browser.
2026-10-04 14:30:53 +02:00
maj00r
6232930f4c Leave the last vehicle's coupler at 0 so the train gets its end signals
The simulator hangs end signals only on a last vehicle with coupler 0; every
new car came with 3 and the pass that couples neighbours never touched the
last one, nor did taking a car off the end. The coupler editor says so for
the last vehicle.
2026-10-04 14:28:40 +02:00
maj00r
e0901ae0ab Star favourite sceneries in the list
A right click stars or unstars the scenery under it; the starred ones are
gathered in a group on top and marked where they belong. They are kept in
favorites.txt beside the settings, not in eu07.ini.
2026-10-04 14:23:16 +02:00
maj00r
be34d48033 Open the scenery editor from the start button's menu
eu07 -edit with the scenery file itself, not the export, so what is saved
lands in the scenery. Not every simulator build has the editor; that is said
before it starts.
2026-10-04 14:17:02 +02:00
maj00r
e8cec0867e Let the consist panel shrink with the small thumbnails 2026-10-04 14:09:26 +02:00
maj00r
a9f4a09cf5 Add a vehicle several times from the browser's menu
The count is asked for in a flyout; the copies go in one after another, each
staffed as a single add would be, with one redraw.
2026-10-04 14:03:52 +02:00
maj00r
bbc163b204 Warn when the experimental renderer is picked 2026-10-04 14:00:52 +02:00
maj00r
73ff00f22e Join and split vehicles facing different ways without moving a car
Joining took each card's cars in stored order and turned the lot by the
first card's flag, so a turned card came out reversed; splitting gave every
car the unit's flag. Both now go by the running order, and the coupler sides
by each car's own facing, which is what the export writes.
2026-10-04 13:59:01 +02:00
maj00r
d510a55740 Turn multi-car units as a whole when orientation is randomised or a unit swapped
Randomising flipped only the card's flag, so a multi-car unit was exported
with its cars in reverse order but each still facing forward - coupled back
to front. A swap copied the flag without turning the new cars. Both now turn
flag, cars and order together, as Flip does, and a locked unit as one.
2026-10-04 13:54:51 +02:00
maj00r
c1c47f746e Harden the CI workflow
Actions pinned to full commits, no token left in the checkout, no binaries
from a fork's pull request among the artifacts, the version passed through the
environment, an audit of the NuGet packages, time limits, one release at a
time with checksums and never over another commit's release, and Dependabot
for the actions and packages.
2026-10-04 13:50:41 +02:00
maj00r
5e130fcd98 Point the crash report at the minidump the simulator left
The simulator writes crash_*.dmp relative to its working directory, and
crashpad under crashdumps; the newest one from this run is named, with an
offer to open its folder.
2026-10-04 13:44:47 +02:00
maj00r
59798e41fa Report a simulator that crashes or closes right after the start
The exit code, read with its signal or NTSTATUS, and the last lines of the
error stream - or of log.txt when it stayed silent. The stream is captured
only while the starter stays to read it.
2026-10-04 13:38:29 +02:00
maj00r
04f6a4cc8d Keep the crew when a vehicle is swapped in the depot
The crew was copied to the card but not to the new cars, which are what is
shown and exported, so a swapped locomotive came out unstaffed. Swapping now
moves the crew over and otherwise staffs the vehicle as adding would.
2026-10-04 13:31:53 +02:00
maj00r
76f104798c Start a Windows build of the simulator through Wine on Linux
Where wine is on the PATH, an eu07.exe in the installation is found after any
native binary and needs no execute bit.
2026-10-04 13:28:07 +02:00
maj00r
3083523ceb Let the simulator be picked from outside the installation
A build tree kept beside the data can be chosen in the settings, and when no
simulator is found the starter asks for one instead of calling the
installation broken. The simulator now runs in the installation, not in the
folder of its binary, and a binary for the other system is no longer refused.
2026-10-04 13:27:28 +02:00
maj00r
2b3c46938c Check GitHub for a newer starter once a day, with a button to check now
The stable channel compares versions; the testing channel offers the staging
build of another commit. The channel switch is the starter's only, not the
simulator's.
2026-10-04 13:26:49 +02:00
maj00r
a7a1e34239 Version every build of main by its commit count and release it
Major and minor stay in the project file; the patch is counted by CI, so no
bot has to commit a bump. staging keeps its rolling pre-release.
2026-10-04 13:25:46 +02:00
maj00r
cc8496aa85 Run the tests before building the packages 2026-10-04 13:25:23 +02:00
maj00r
9d981cb093 Add a developer tab with the state of the vehicle database 2026-10-04 12:18:36 +02:00
maj00r
93463ae7f2 Load vehicles from a JSON copy of textures.txt, rebuilt when the installation changes 2026-10-04 12:18:36 +02:00
maj00r
d1d458f8ee Add a JSON format for the vehicle database 2026-10-04 12:18:36 +02:00
maj00r
3ddd00895f Tell UTF-8 from 1250 without throwing, and decode timetables the same way
The strict decode threw on every 1250 file, hundreds of them per load.
2026-10-04 12:18:36 +02:00
maj00r
2545daeb82 Close the vehicle catalogue on release, not on the double-click press
Closing from DoubleTapped left the pointer captured by a dead window, and on
Windows the main window stopped taking clicks.
2026-10-04 12:18:36 +02:00
maj00r
c492c26047 fallback decode utf8 strict to win 1250 2026-10-04 12:02:50 +02:00
191 changed files with 25204 additions and 3405 deletions

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

@@ -0,0 +1,14 @@
# Pull requests for new versions of the pinned actions and of the NuGet packages,
# so a fix for a vulnerability reaches the build without anyone watching for it.
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: nuget
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5

View File

@@ -13,6 +13,9 @@ on:
workflow_dispatch: 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')."

View File

@@ -0,0 +1,149 @@
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Domain.Vehicles;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class CargoTests
{
private readonly Consist _consist;
private readonly VehicleCatalog _catalog;
private readonly VehicleInfo _info;
public CargoTests()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "textures.txt"), "!=b\nen57-a.mat=en57,EN57")
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "en57.fiz"),
"Load: LoadAccepted=Passengers,Pantstate MaxLoad=50")
.WithFile(TestInstallation.At("dynamic", "pkp", "tram", "textures.txt"), "!=b\ntram-a.mat=tram,TRAM")
.WithFile(TestInstallation.At("dynamic", "pkp", "tram", "tram.fiz"),
"Load: LoadAccepted=pantstate,Passengers MaxLoad=52")
.WithFile(TestInstallation.At("dynamic", "pkp", "s1", "textures.txt"), "!=b\ns1-a.mat=s1,S1")
.WithFile(TestInstallation.At("dynamic", "pkp", "s1", "s1.fiz"), "Load: LoadAccepted=Coal,Sand"));
installation.Vehicles.Load(installation.Library.Vehicles);
_catalog = installation.Library.Vehicles;
_info = new VehicleInfo(_catalog, installation.Physics);
_consist = new Consist(_catalog, _info);
}
private (Dynamic Car, List<ConsistItem> Consist) Wagon(string skin, string? type = null, int count = 0)
{
var car = _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null);
car.LoadType = type;
car.LoadCount = count;
return (car, new List<ConsistItem> { new() { Cars = new List<Dynamic> { car } } });
}
private Cargo Picking(int index) => new(_info, new Pick(index));
private sealed class Pick : Random
{
private readonly int _index;
public Pick(int index) => _index = index;
public override int Next(int maxValue) => _index;
public override int Next(int minValue, int maxValue) => maxValue - 1;
}
[Fact]
public void A_random_type_takes_no_more_than_it_holds()
{
var (car, consist) = Wagon("en57-a", "Passengers", 50);
Picking(1).RandomTypes(consist);
Assert.Equal("Pantstate", car.LoadType);
Assert.Equal(Dynamic.PantStateMax, car.LoadCount);
Assert.Contains(" 3 Pantstate ", car.ToTrainsetNode());
}
[Fact]
public void A_random_type_leaves_a_count_it_can_hold()
{
var (car, consist) = Wagon("en57-a", "Pantstate", 2);
Picking(0).RandomTypes(consist);
Assert.Equal("Passengers", car.LoadType);
Assert.Equal(2, car.LoadCount);
}
[Fact]
public void A_random_type_with_no_limit_declared_keeps_the_count()
{
// MaxLoad unset: nothing to hold it to.
var (car, consist) = Wagon("s1-a", "Coal", 40);
Picking(1).RandomTypes(consist);
Assert.Equal("Sand", car.LoadType);
Assert.Equal(40, car.LoadCount);
}
[Fact]
public void Filling_an_empty_car_goes_by_the_type_it_is_given()
{
// Its first accepted type is pantographs: the 52 of the .fiz is not theirs.
var (car, _) = Wagon("tram-a");
Picking(0).FillToMax(car);
Assert.Equal("pantstate", car.LoadType);
Assert.Equal(Dynamic.PantStateMax, car.LoadCount);
}
[Fact]
public void A_random_amount_for_an_empty_car_goes_by_the_type_it_is_given()
{
var (car, consist) = Wagon("tram-a");
Picking(0).RandomAmounts(consist);
Assert.Equal("pantstate", car.LoadType);
Assert.InRange(car.LoadCount, 1, Dynamic.PantStateMax);
}
[Fact]
public void A_count_passed_on_keeps_where_the_next_car_declares_no_limit()
{
// MaxLoad unset: nothing to hold it to, as when taken from the car before.
var (_, consist) = Wagon("s1-a", "Coal", 40);
var (next, following) = Wagon("s1-a");
consist.AddRange(following);
Picking(0).CopyToFollowing(consist, consist[0]);
Assert.Equal("Coal", next.LoadType);
Assert.Equal(40, next.LoadCount);
}
[Fact]
public void A_count_passed_on_is_held_to_what_the_next_car_declares()
{
var (_, consist) = Wagon("tram-a", "Passengers", 52);
var (next, following) = Wagon("en57-a");
consist.AddRange(following);
Picking(0).CopyToFollowing(consist, consist[0]);
Assert.Equal("Passengers", next.LoadType);
Assert.Equal(50, next.LoadCount);
}
[Fact]
public void A_car_with_nothing_declared_stays_untouched()
{
var (car, _) = Wagon("en57-a");
car.MaxLoad = 0;
Picking(0).FillToMax(car);
Assert.Null(car.LoadType);
Assert.Equal(0, car.LoadCount);
}
}

View File

@@ -0,0 +1,161 @@
using StarterNG.Classes;
namespace StarterNG.Tests;
public class ConfigFileTests
{
private static string[] Lines(ConfigFile config) =>
config.ToText().Split(Environment.NewLine);
[Fact]
public void A_file_is_written_with_the_hosts_line_endings_whichever_it_came_with()
{
// parser.cpp breaks words on \r as on \n, so the simulator takes either.
string nl = Environment.NewLine;
Assert.Equal($"a 1{nl}b 2{nl}", ConfigFile.Parse("a 1\nb 2\n").ToText());
Assert.Equal($"a 1{nl}b 2{nl}", ConfigFile.Parse("a 1\r\nb 2\r\n").ToText());
}
[Fact]
public void Removing_a_key_comments_every_line_that_sets_it()
{
var config = ConfigFile.Parse("width 800\nheight 600\nwidth 1024\nWIDTH 1920\n");
config.Remove("width");
Assert.False(config.Has("width"));
Assert.Equal(new[] { "// width 800", "height 600", "// width 1024", "// WIDTH 1920", "" }, Lines(config));
// Parsed again, nothing of it comes back.
Assert.False(ConfigFile.Parse(config.ToText()).Has("width"));
}
[Fact]
public void A_removed_key_keeps_its_comment_and_indent()
{
var config = ConfigFile.Parse(" width 800\t// mine\n");
config.Remove("width");
Assert.Equal(" // width 800\t// mine", Lines(config)[0]);
}
[Fact]
public void Removing_a_key_leaves_what_only_mentions_it()
{
var config = ConfigFile.Parse("// width 800\nwidth.max 900\nwidth 1024\n");
config.Remove("width");
Assert.Equal(new[] { "// width 800", "width.max 900", "// width 1024", "" }, Lines(config));
}
[Fact]
public void A_new_key_goes_before_endconfig()
{
// Globals.cpp stops reading at endconfig.
var config = ConfigFile.Parse("width 800\nendconfig\n// notes\n");
config.Set("height", "600");
config.Set("fullscreen", "no");
Assert.Equal(new[] { "width 800", "height 600", "fullscreen no", "endconfig", "// notes", "" }, Lines(config));
}
[Theory]
[InlineData("endconfig")]
[InlineData("ENDCONFIG")]
[InlineData(" EndConfig // the end")]
public void Endconfig_is_known_as_the_simulator_knows_it(string line)
{
// Its parser lower-cases every word.
var config = ConfigFile.Parse(line + "\n");
config.Set("width", "800");
Assert.Equal("width 800", Lines(config)[0]);
}
[Fact]
public void Endconfig_in_a_comment_ends_nothing()
{
var config = ConfigFile.Parse("// endconfig\n");
config.Set("width", "800");
Assert.Equal(new[] { "// endconfig", "", "width 800" }, Lines(config));
}
[Fact]
public void Keys_found_after_an_insertion_are_still_the_right_lines()
{
var config = ConfigFile.Parse("width 800\nendconfig\nlate 1\n");
config.Set("height", "600");
config.Set("width", "1024");
config.Set("height", "720");
Assert.Equal(new[] { "width 1024", "height 720", "endconfig", "late 1", "" }, Lines(config));
Assert.Equal("1024", config.GetRaw("width"));
Assert.Equal("720", config.GetRaw("height"));
Assert.Equal("1", config.GetRaw("late"));
}
[Fact]
public void A_key_past_endconfig_is_read_but_known_to_be_past_it()
{
var config = ConfigFile.Parse("width 800\nendconfig\nheight 600\n");
Assert.Equal("600", config.GetRaw("height"));
Assert.True(config.IsPastEnd("height"));
Assert.False(config.IsPastEnd("width"));
Assert.False(config.IsPastEnd("endconfig"));
Assert.False(config.IsPastEnd("absent"));
}
[Fact]
public void A_key_set_past_endconfig_moves_before_it_with_its_comment()
{
var config = ConfigFile.Parse("endconfig\n height 600\t// mine\nlate 1\n");
config.Set("height", "720");
Assert.Equal(new[] { " height 720\t// mine", "endconfig", "late 1", "" }, Lines(config));
Assert.False(config.IsPastEnd("height"));
Assert.True(config.IsPastEnd("late"));
}
[Fact]
public void Of_a_key_on_both_sides_of_endconfig_the_line_the_simulator_reads_counts()
{
var config = ConfigFile.Parse("width 800\nendconfig\nwidth 1920\n");
Assert.Equal("800", config.GetRaw("width"));
Assert.False(config.IsPastEnd("width"));
config.Set("width", "1024");
Assert.Equal(new[] { "width 1024", "endconfig", "width 1920", "" }, Lines(config));
}
[Fact]
public void A_key_past_endconfig_is_removed_like_any_other()
{
var config = ConfigFile.Parse("width 800\nendconfig\nwidth 1920\nheight 600\n");
config.Remove("width");
config.Remove("height");
Assert.Equal(new[] { "// width 800", "endconfig", "// width 1920", "// height 600", "" }, Lines(config));
}
[Fact]
public void Without_endconfig_a_new_key_goes_at_the_end_as_it_did()
{
var config = ConfigFile.Parse("width 800");
config.Set("height", "600");
Assert.Equal(new[] { "width 800", "height 600" }, Lines(config));
}
}

View File

@@ -0,0 +1,299 @@
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;
private readonly VehicleInfo _info;
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;
_info = new VehicleInfo(_catalog, installation.Physics);
_consist = new Consist(_catalog, _info);
}
private List<Dynamic> Cars(params string[] skins) =>
skins.Select(skin => _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null)).ToList();
[Theory]
[InlineData(eDriverType.Reardriver)]
[InlineData(eDriverType.Passenger)]
public void A_loaded_consist_keeps_the_kind_of_crew(eDriverType crew)
{
var before = Cars("ep07-001", "b111-01");
before[0].DriverType = crew;
var loaded = Cars("ep07-002", "b111-01");
loaded[0].DriverType = eDriverType.Headdriver;
var cars = Consist.LoadedOver(before, loaded, _catalog, _info);
Assert.Equal(crew, cars[0].DriverType);
Assert.Equal(eDriverType.Nobody, cars[1].DriverType);
}
[Fact]
public void A_consist_with_nobody_in_it_stays_so_after_loading()
{
var loaded = Cars("ep07-002");
loaded[0].DriverType = eDriverType.Headdriver;
var cars = Consist.LoadedOver(Cars("ep07-001"), loaded, _catalog, _info);
Assert.Equal(eDriverType.Nobody, cars[0].DriverType);
}
[Fact]
public void A_driver_does_not_go_into_loaded_wagons()
{
var before = Cars("ep07-001");
before[0].DriverType = eDriverType.Headdriver;
var cars = Consist.LoadedOver(before, Cars("b111-01"), _catalog, _info);
Assert.Equal(eDriverType.Nobody, cars[0].DriverType);
}
[Fact]
public void Loaded_over_an_empty_consist_the_vehicles_keep_their_own_crew()
{
var loaded = Cars("ep07-002");
loaded[0].DriverType = eDriverType.Reardriver;
Assert.Equal(eDriverType.Reardriver,
Consist.LoadedOver(new List<Dynamic>(), loaded, _catalog, _info)[0].DriverType);
}
[Fact]
public void Vehicles_joining_a_consist_come_without_their_own_crew()
{
var before = Cars("ep07-001");
before[0].DriverType = eDriverType.Headdriver;
var added = Cars("ep07-002");
added[0].DriverType = eDriverType.Headdriver;
var cars = Consist.Joined(before, added);
Assert.Equal(eDriverType.Headdriver, cars[0].DriverType);
Assert.Equal(eDriverType.Nobody, cars[1].DriverType);
}
[Fact]
public void A_locomotive_moved_to_the_head_takes_the_driver_in_the_same_cab()
{
var first = Add("ep07-001", 0);
var second = Add("ep07-002", 1);
first.Driver = eDriverType.Reardriver;
first.Cars[0].DriverType = eDriverType.Reardriver;
_consist.Move(1, 0);
Assert.Equal(eDriverType.Reardriver, second.Cars[0].DriverType);
Assert.Equal(eDriverType.Nobody, first.Cars[0].DriverType);
}
[Fact]
public void Vehicles_joining_an_empty_consist_keep_their_crew()
{
var added = Cars("ep07-002");
added[0].DriverType = eDriverType.Headdriver;
Assert.Equal(eDriverType.Headdriver, Consist.Joined(new List<Dynamic>(), added)[0].DriverType);
}
[Fact]
public void Selecting_a_wagon_keeps_the_locomotive_as_the_starting_vehicle()
{
var loco = Add("ep07-001", 0);
var wagon = Add("b111-01", 1);
loco.Cars[0].Name = "loco";
wagon.Cars[0].Name = "wagon";
Assert.Equal("loco", Consist.StartingVehicle(_consist, wagon, "loco"));
}
[Fact]
public void A_wagon_selected_after_a_stale_pick_falls_back_to_the_driver()
{
var loco = Add("ep07-001", 0);
var wagon = Add("b111-01", 1);
loco.Cars[0].Name = "loco";
Assert.Equal("loco", Consist.StartingVehicle(_consist, wagon, "gone"));
}
[Fact]
public void Selecting_a_crewed_unit_makes_it_the_starting_vehicle()
{
var first = Add("ep07-001", 0);
var wagon = Add("b111-01", 1);
first.Cars[0].Name = "loco";
wagon.Cars[0].Name = "wagon";
wagon.Cars[0].DriverType = eDriverType.Passenger;
Assert.Equal("wagon", Consist.StartingVehicle(_consist, wagon, "loco"));
}
[Fact]
public void A_locomotive_added_at_the_head_gets_a_driver_in_cab_a()
{
var loco = Add("ep07-001", 0);
Assert.Equal(eDriverType.Headdriver, loco.Cars[0].DriverType);
}
[Fact]
public void Replacing_the_locomotive_keeps_the_driver_on_the_new_one()
{
Add("ep07-001", 0);
Add("b111-01", 1);
var replacement = Replace(0, "ep07-002");
Assert.Equal(eDriverType.Headdriver, replacement.Cars[0].DriverType);
Assert.Equal(eDriverType.Headdriver, replacement.Driver);
}
[Fact]
public void An_unstaffed_locomotive_replaced_at_the_head_is_staffed_in_cab_a()
{
var loco = Add("ep07-001", 0);
loco.Cars[0].DriverType = eDriverType.Nobody;
loco.Driver = eDriverType.Nobody;
var replacement = Replace(0, "ep07-002");
Assert.Equal(eDriverType.Headdriver, replacement.Cars[0].DriverType);
}
[Fact]
public void A_driver_in_cab_b_stays_in_cab_b()
{
var loco = Add("ep07-001", 0);
loco.Cars[0].DriverType = eDriverType.Reardriver;
loco.Driver = eDriverType.Reardriver;
var replacement = Replace(0, "ep07-002");
Assert.Equal(eDriverType.Reardriver, replacement.Cars[0].DriverType);
}
[Fact]
public void A_wagon_put_in_place_of_the_locomotive_gets_no_driver()
{
Add("ep07-001", 0);
var wagon = Replace(0, "b111-01");
Assert.Equal(eDriverType.Nobody, wagon.Cars[0].DriverType);
}
[Fact]
public void A_second_locomotive_behind_a_staffed_one_stays_unstaffed()
{
Add("ep07-001", 0);
var second = Add("ep07-002", 1);
Assert.Equal(eDriverType.Nobody, second.Cars[0].DriverType);
}
[Fact]
public void A_locomotive_moved_to_the_head_takes_the_driver_from_the_one_it_put_behind()
{
var first = Add("ep07-001", 0);
var second = Add("ep07-002", 1);
_consist.Move(1, 0);
Assert.Equal(eDriverType.Headdriver, second.Cars[0].DriverType);
Assert.Equal(eDriverType.Nobody, first.Cars[0].DriverType);
Assert.Equal(eDriverType.Nobody, first.Driver);
}
[Fact]
public void Stepping_a_locomotive_left_onto_the_head_hands_over_the_same_way()
{
var first = Add("ep07-001", 0);
var second = Add("ep07-002", 1);
_consist.MoveLeft(second);
Assert.Equal(eDriverType.Headdriver, second.Cars[0].DriverType);
Assert.Equal(eDriverType.Nobody, first.Cars[0].DriverType);
}
[Fact]
public void A_wagon_moved_to_the_head_leaves_the_driver_where_he_is()
{
var loco = Add("ep07-001", 0);
Add("b111-01", 1);
_consist.Move(1, 0);
Assert.Equal(eDriverType.Headdriver, loco.Cars[0].DriverType);
}
[Fact]
public void A_unit_staffed_at_the_head_has_its_driver_in_cab_1_of_the_first_car()
{
var unit = Unit("ep07-001", "ep07-002");
_consist.Staff(unit, 0);
Assert.Equal(eDriverType.Headdriver, unit.Cars[0].DriverType);
Assert.Equal(eDriverType.Nobody, unit.Cars[1].DriverType);
}
[Fact]
public void A_driver_in_cab_2_moves_into_the_last_car_of_a_unit()
{
var loco = Add("ep07-001", 0);
loco.Cars[0].DriverType = eDriverType.Reardriver;
loco.Driver = eDriverType.Reardriver;
var unit = Unit("ep07-001", "ep07-002");
_consist.Replace(0, unit);
Assert.Equal(eDriverType.Nobody, unit.Cars[0].DriverType);
Assert.Equal(eDriverType.Reardriver, unit.Cars[1].DriverType);
Assert.Equal(eDriverType.Reardriver, unit.Driver);
}
private ConsistItem Unit(params string[] skins) =>
new()
{
Cars = skins.Select(skin => _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null)).ToList(),
Grouped = true
};
private ConsistItem Add(string skin, int at)
{
var item = Item(skin);
_consist.Staff(item, at);
_consist.Insert(at, item);
return item;
}
private ConsistItem Replace(int at, string skin)
{
var item = Item(skin);
_consist.Replace(at, item);
return item;
}
private ConsistItem Item(string skin) =>
new() { Cars = new List<Dynamic> { _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null) } };
}

View File

@@ -0,0 +1,98 @@
using StarterNG.Classes;
using StarterNG.Domain;
namespace StarterNG.Tests;
public class ConsistHistoryTests
{
private static Dynamic Car(string name) => new()
{
Name = name, DataFolder = "pkp", SkinFile = name, MmdFile = name, PathName = ""
};
private static Trainset With(params string[] names) =>
new("trainset none none 0 0\nendtrainset") { Vehicles = names.Select(Car).ToList() };
private static string[] Names(IEnumerable<Dynamic> vehicles) => vehicles.Select(v => v.Name).ToArray();
[Fact]
public void A_redraw_without_a_change_leaves_nothing_to_undo()
{
var history = new ConsistHistory();
var trainset = With("a", "b");
history.Record(trainset, 0);
history.Record(trainset, 1);
Assert.False(history.CanUndo(trainset));
}
[Fact]
public void Undo_goes_back_one_change_and_redo_forward_again()
{
var history = new ConsistHistory();
var trainset = With("a");
history.Record(trainset, 0);
trainset.Vehicles.Add(Car("b"));
history.Record(trainset, 1);
var back = history.Undo(trainset, 1);
Assert.Equal(new[] { "a" }, Names(back!.Vehicles));
Assert.True(history.CanRedo(trainset));
trainset.Vehicles = back.Vehicles.ToList();
history.Record(trainset, 0, rebase: true);
var forward = history.Redo(trainset, 0);
Assert.Equal(new[] { "a", "b" }, Names(forward!.Vehicles));
Assert.Equal(1, forward.Selected);
}
[Fact]
public void An_edit_made_in_place_is_still_undone()
{
// The vehicles are shared with the editor, so a change to one of them must
// not reach the state kept for undo.
var history = new ConsistHistory();
var trainset = With("a");
history.Record(trainset, 0);
trainset.Vehicles[0].DriverType = eDriverType.Passenger;
var back = history.Undo(trainset, 0);
Assert.NotEqual(eDriverType.Passenger, back!.Vehicles[0].DriverType);
}
[Fact]
public void A_new_change_drops_what_could_be_redone()
{
var history = new ConsistHistory();
var trainset = With("a");
history.Record(trainset, 0);
trainset.Vehicles.Add(Car("b"));
history.Record(trainset, 1);
trainset.Vehicles = history.Undo(trainset, 1)!.Vehicles.ToList();
history.Record(trainset, 0, rebase: true);
trainset.Vehicles.Add(Car("c"));
history.Record(trainset, 1);
Assert.False(history.CanRedo(trainset));
}
[Fact]
public void Each_trainset_has_its_own_history()
{
var history = new ConsistHistory();
var first = With("a");
var second = With("x");
history.Record(first, 0);
first.Vehicles.Add(Car("b"));
history.Record(first, 1);
history.Record(second, 0);
Assert.True(history.CanUndo(first));
Assert.False(history.CanUndo(second));
}
}

View File

@@ -0,0 +1,134 @@
using StarterNG.Application;
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));
}
[Fact]
public void A_turned_unit_comes_back_as_it_was_from_every_load()
{
AppServices.Initialize(_installation.Paths, _installation.Files, _installation.Clock, _installation.Environment);
var consist = NewConsist(out var unit);
consist.Flip(unit);
string[] turned = Describe(consist);
var trainset = new Trainset("trainset a tor 0 0\nendtrainset");
for (int load = 0; load < 3; load++)
{
trainset.Vehicles = consist.Flatten();
consist.LoadFrom(trainset);
Assert.True(Assert.Single(consist).Flipped);
Assert.Equal(turned, Describe(consist));
}
}
private Consist NewConsist(out ConsistItem unit)
{
var consist = new Consist(_catalog, new VehicleInfo(_catalog, _installation.Physics));
unit = Unit(consist, "et40-10-a");
consist.Insert(0, unit);
return consist;
}
private ConsistItem Unit(Consist consist, string lead) => new()
{
Cars = _catalog.ResolveSet(_catalog.TextureForSkin(lead)!)!
.Select(texture => consist.MakeDynamic(texture, null))
.ToList(),
Grouped = true
};
// The export order, each car with the way it faces.
private static string[] Describe(Consist consist) =>
consist.Flatten().Select(car => $"{car.SkinFile} {(car.Offset < 0 ? -1 : 1)}").ToArray();
private sealed class AlwaysTurn : Random
{
public override int Next(int maxValue) => 0;
}
}

View File

@@ -0,0 +1,134 @@
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 A_unit_can_join_the_one_it_is_held_to_from_either_side()
{
var first = Append("b111-01");
var second = Append("b111-02");
Assert.False(_consist.CanJoin(first, 0));
Assert.False(_consist.CanJoin(second, 1));
first.Cars[0].Coupling.Set(Coupling.WorkshopLock, true);
Assert.True(_consist.CanJoin(first, 0));
Assert.True(_consist.CanJoin(second, 1));
}
[Fact]
public void The_vehicle_count_counts_cars_not_units()
{
Append("b111-01");
var pair = Append("b111-02");
pair.Cars.Add(pair.Cars[0].Clone());
Assert.Equal(2, _consist.Count);
Assert.Equal(3, _consist.VehicleCount);
}
[Fact]
public void A_new_last_car_is_left_uncoupled_at_the_back()
{
var first = Append("b111-01");
var last = Append("b111-02");
_consist.AutoConnectAll();
Assert.NotEqual(0, first.Cars[0].Coupling.Flags);
Assert.Equal(0, last.Cars[0].Coupling.Flags);
}
[Fact]
public void Taking_off_the_last_car_frees_the_one_now_at_the_back()
{
var first = Append("b111-01");
var last = Append("b111-02");
_consist.AutoConnectAll();
_consist.Remove(last);
Assert.Equal(0, first.Cars[0].Coupling.Flags);
}
[Fact]
public void Freeing_the_back_keeps_the_brake_setting()
{
var car = Append("b111-01");
car.Cars[0].Coupling = Coupling.Parse("3.BP");
_consist.AutoConnectAll();
Assert.Equal("0.BP", car.Cars[0].Coupling.ToString());
}
[Fact]
public void A_coupler_set_by_hand_on_the_last_car_stays()
{
Append("b111-01");
var last = Append("b111-02");
last.Cars[0].Coupling.Flags = 3;
last.Cars[0].CouplerSetByHand = true;
_consist.AutoConnectAll();
Assert.Equal(3, last.Cars[0].Coupling.Flags);
}
[Fact]
public void A_car_set_by_hand_that_gets_a_neighbour_is_freed_again_once_it_is_last_again()
{
var first = Append("b111-01");
first.Cars[0].CouplerSetByHand = true;
var second = Append("b111-02");
_consist.AutoConnectAll();
_consist.Remove(second);
Assert.Equal(0, first.Cars[0].Coupling.Flags);
}
[Fact]
public void The_auto_button_frees_the_back_even_when_it_was_set_by_hand()
{
Append("b111-01");
var last = Append("b111-02");
last.Cars[0].Coupling.Flags = 3;
last.Cars[0].CouplerSetByHand = true;
_consist.AutoCouple();
Assert.Equal(0, last.Cars[0].Coupling.Flags);
Assert.False(last.Cars[0].CouplerSetByHand);
}
private ConsistItem Append(string skin)
{
var item = new ConsistItem
{
Cars = new List<Dynamic> { _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null) }
};
_consist.Insert(_consist.Count, item);
return item;
}
}

View File

@@ -0,0 +1,25 @@
using StarterNG.Classes;
using StarterNG.Views;
namespace StarterNG.Tests;
public class CouplerLookTests
{
[Theory]
[InlineData("0", CouplerState.Free)]
[InlineData("3", CouplerState.Coupled)]
[InlineData("7", CouplerState.MultipleUnit)]
[InlineData("131", CouplerState.Permanent)]
[InlineData("-3", CouplerState.Permanent)]
[InlineData("0.BP", CouplerState.Free)]
public void A_coupler_between_vehicles_reads_by_what_it_connects(string code, CouplerState expected) =>
Assert.Equal(expected, CouplerLook.StateOf(Coupling.Parse(code), trailing: false));
[Theory]
[InlineData("0", CouplerState.EndSignals)]
[InlineData("0.BP", CouplerState.EndSignals)]
[InlineData("3", CouplerState.NoEndSignals)]
[InlineData("-7", CouplerState.NoEndSignals)]
public void The_back_of_the_train_gets_end_signals_only_at_0(string code, CouplerState expected) =>
Assert.Equal(expected, CouplerLook.StateOf(Coupling.Parse(code), trailing: true));
}

View File

@@ -0,0 +1,100 @@
using StarterNG.Infrastructure;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class DisplaysTests
{
/// <summary>A 128-byte block with an 18-byte timing at 54, as a panel's base EDID has it.</summary>
private static byte[] BaseBlock(int pixelClock10KHz, int hActive, int hBlank, int vActive, int vBlank,
int extensions = 0)
{
var edid = new byte[128];
new byte[] { 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0 }.CopyTo(edid, 0);
edid[126] = (byte)extensions;
int at = 54;
edid[at] = (byte)pixelClock10KHz;
edid[at + 1] = (byte)(pixelClock10KHz >> 8);
edid[at + 2] = (byte)hActive;
edid[at + 3] = (byte)hBlank;
edid[at + 4] = (byte)((hActive >> 8) << 4 | hBlank >> 8);
edid[at + 5] = (byte)vActive;
edid[at + 6] = (byte)vBlank;
edid[at + 7] = (byte)((vActive >> 8) << 4 | vBlank >> 8);
return edid;
}
/// <summary>A DisplayID 2.0 block with one type VII timing (kHz, sizes less one).</summary>
private static byte[] DisplayIdBlock(int pixelClockKHz, int hActive, int hBlank, int vActive, int vBlank)
{
var block = new byte[128];
block[0] = 0x70;
block[1] = 0x20;
block[2] = 23; // one data block: 3 bytes of header, 20 of timing
block[3] = 2;
int at = 5;
block[at] = 0x22;
block[at + 2] = 20;
int t = at + 3, clock = pixelClockKHz - 1;
block[t] = (byte)clock;
block[t + 1] = (byte)(clock >> 8);
block[t + 2] = (byte)(clock >> 16);
void Put(int offset, int value)
{
block[t + offset] = (byte)(value - 1);
block[t + offset + 1] = (byte)((value - 1) >> 8);
}
Put(4, hActive);
Put(6, hBlank);
Put(12, vActive);
Put(14, vBlank);
return block;
}
[Fact]
public void A_plain_monitor_gives_its_preferred_timing()
{
// 1920x1080 at 148.5 MHz: the usual 60 Hz.
Assert.Equal(60, Edid.HighestRefreshRate(BaseBlock(14850, 1920, 280, 1080, 45)));
}
[Fact]
public void A_fast_panel_found_only_in_displayid_is_read_there()
{
// As the eDP panel of a gaming laptop: 60 Hz in the base block, 165 Hz in DisplayID.
var edid = BaseBlock(15394, 1920, 160, 1200, 35, extensions: 1)
.Concat(DisplayIdBlock(423250, 1920, 160, 1200, 33)).ToArray();
Assert.Equal(165, Edid.HighestRefreshRate(edid));
}
[Fact]
public void Something_too_short_for_an_edid_has_no_rate()
{
Assert.Equal(0, Edid.HighestRefreshRate(new byte[] { 0, 0xFF }));
Assert.Equal(0, Edid.HighestRefreshRate(new byte[128]));
}
[Fact]
public void Only_connected_screens_are_listed_by_their_connector()
{
var files = new InMemoryFileSystem(caseSensitive: true)
.WithFile("/sys/class/drm/card1-eDP-1/status", "connected\n")
.WithFile("/sys/class/drm/card1-HDMI-A-1/status", "disconnected\n")
.WithFile("/sys/class/drm/card1/device/uevent", "DRIVER=amdgpu\n");
files.WriteAllBytes("/sys/class/drm/card1-eDP-1/edid", BaseBlock(14850, 1920, 280, 1080, 45));
files.WriteAllBytes("/sys/class/drm/card1-HDMI-A-1/edid", BaseBlock(14850, 1920, 280, 1080, 45));
var displays = new Displays(files, new FakeEnvironment()).List();
Assert.Equal(new[] { new Display("eDP-1", 60) }, displays);
}
[Fact]
public void Off_linux_without_windows_nothing_is_read()
{
var environment = new FakeEnvironment { IsLinux = false, IsMacOS = true };
Assert.Empty(new Displays(new InMemoryFileSystem(), environment).List());
}
}

View File

@@ -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,138 @@ 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 What_is_inside_says_what_the_file_is_not_its_name()
{
string windowsBuild = TestInstallation.At("eu07-win");
string nativeBuild = TestInstallation.At("eu07.exe");
var installation = WithWine(new InMemoryFileSystem()
.WithFile(windowsBuild, PortableExecutable)
.WithExecutable(nativeBuild, Elf));
var executables = installation.Executables;
Assert.True(executables.IsWindowsBuild("eu07-win"));
Assert.Equal(Wine, executables.LaunchCommand(windowsBuild, Array.Empty<string>()).FileName);
Assert.False(executables.IsWindowsBuild("eu07.exe"));
Assert.Equal(nativeBuild, executables.LaunchCommand(nativeBuild, Array.Empty<string>()).FileName);
}
[Fact]
public void A_script_on_linux_starts_itself()
{
string script = TestInstallation.At("eu07-start");
var installation = WithWine(new InMemoryFileSystem().WithExecutable(script, "#!/bin/sh\nwine eu07.exe\n"));
string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem);
Assert.EndsWith("eu07-start", resolved);
Assert.Equal(ExeProblem.None, problem);
Assert.Equal(script, installation.Executables.LaunchCommand(script, Array.Empty<string>()).FileName);
}
[Theory]
[InlineData("notatki do eu07\n")]
[InlineData("")]
public void On_linux_the_execute_bit_alone_does_not_make_a_program(string inside)
{
// On a Windows partition every file has the bit.
string note = TestInstallation.At("eu07_opis");
var installation = WithWine(new InMemoryFileSystem()
.WithExecutable(note, inside)
.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());
Assert.Equal(ExeProblem.NotAProgram, installation.Executables.Validate(note));
}
[Fact]
public void A_mac_binary_is_not_taken_for_a_note()
{
string build = TestInstallation.At("eu07");
var installation = new TestInstallation(new InMemoryFileSystem().WithExecutable(build, "Ïúíþ"));
installation.Environment.IsLinux = false;
installation.Environment.IsMacOS = true;
Assert.Equal(ExeProblem.None, installation.Executables.Validate(build));
}
[Fact]
public void Wine_is_never_looked_for_on_windows()
{
var installation = WithWine(new InMemoryFileSystem());
installation.Environment.AsWindows();
Assert.Null(installation.Executables.Wine());
}
} }

View File

@@ -9,16 +9,61 @@ namespace StarterNG.Tests.Fakes;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Paths are normalised to forward slashes and compared case-insensitively, /// Paths are normalised to forward slashes and compared case-insensitively,
/// matching how the parsers treat installation paths on both platforms. /// matching how the parsers treat installation paths on both platforms - or,
/// asked for, case-sensitively, as a Linux disk holds them.
/// </remarks> /// </remarks>
public sealed class InMemoryFileSystem : IFileSystem public sealed class InMemoryFileSystem : IFileSystem
{ {
private readonly Dictionary<string, byte[]> _files = new(StringComparer.OrdinalIgnoreCase); private readonly StringComparison _comparison;
private readonly HashSet<string> _directories = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, byte[]> _files;
private readonly HashSet<string> _executable = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _directories;
private readonly HashSet<string> _executable;
private readonly Dictionary<string, string> _links;
private readonly HashSet<string> _unreadable;
private readonly HashSet<string> _unlistable;
private readonly Dictionary<string, DateTime> _written;
public InMemoryFileSystem(bool caseSensitive = false)
{
_comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
var comparer = StringComparer.FromComparison(_comparison);
_files = new Dictionary<string, byte[]>(comparer);
_directories = new HashSet<string>(comparer);
_executable = new HashSet<string>(comparer);
_links = new Dictionary<string, string>(comparer);
_unreadable = new HashSet<string>(comparer);
_unlistable = new HashSet<string>(comparer);
_written = new Dictionary<string, DateTime>(comparer);
}
public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>Making a link throws, as on a disk that cannot hold one.</summary>
public bool LinksFail { get; set; }
/// <summary>A folder moved to a destination this says yes to, its path with forward slashes, throws.</summary>
public Func<string, bool>? MoveFails { get; set; }
/// <summary>The file stays, but reading or copying it throws, as a locked one does; writing it still goes through.</summary>
public InMemoryFileSystem FailReading(string path)
{
_unreadable.Add(Normalize(path));
return this;
}
public InMemoryFileSystem AllowReading(string path)
{
_unreadable.Remove(Normalize(path));
return this;
}
/// <summary>The folder is there, but listing it throws, as one without the permission does.</summary>
public InMemoryFileSystem DenyListing(string path)
{
_unlistable.Add(Normalize(path));
return this;
}
static InMemoryFileSystem() static InMemoryFileSystem()
{ {
// The game's own files are code page 1250; a test that writes Polish text // The game's own files are code page 1250; a test that writes Polish text
@@ -54,7 +99,10 @@ public sealed class InMemoryFileSystem : IFileSystem
public bool FileExists(string path) => _files.ContainsKey(Normalize(path)); public bool FileExists(string path) => _files.ContainsKey(Normalize(path));
public bool DirectoryExists(string path) => _directories.Contains(Normalize(path)); /// <summary>A link counts as the directory it points at, as the disk has it.</summary>
public bool DirectoryExists(string path) =>
_directories.Contains(Normalize(path)) ||
(_links.TryGetValue(Normalize(path), out string? target) && _directories.Contains(Normalize(target)));
public string ReadAllText(string path) => Encoding.UTF8.GetString(Bytes(path)); public string ReadAllText(string path) => Encoding.UTF8.GetString(Bytes(path));
@@ -63,6 +111,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);
@@ -74,6 +124,10 @@ public sealed class InMemoryFileSystem : IFileSystem
AddParents(key); AddParents(key);
} }
public void WriteAllTextAtomic(string path, string contents) => WithFile(path, contents);
public void WriteAllBytesAtomic(string path, byte[] bytes) => WriteAllBytes(path, bytes);
public void WriteAllLines(string path, IEnumerable<string> lines) => public void WriteAllLines(string path, IEnumerable<string> lines) =>
WithFile(path, string.Join("\n", lines)); WithFile(path, string.Join("\n", lines));
@@ -101,7 +155,7 @@ public sealed class InMemoryFileSystem : IFileSystem
string prefix = Normalize(path) + "/"; string prefix = Normalize(path) + "/";
var pattern = ToRegex(searchPattern); var pattern = ToRegex(searchPattern);
return _files.Keys return _files.Keys
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .Where(key => key.StartsWith(prefix, _comparison))
.Where(key => !key[prefix.Length..].Contains('/')) .Where(key => !key[prefix.Length..].Contains('/'))
.Where(key => pattern.IsMatch(key[prefix.Length..])) .Where(key => pattern.IsMatch(key[prefix.Length..]))
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase) .OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
@@ -113,7 +167,8 @@ public sealed class InMemoryFileSystem : IFileSystem
string prefix = Normalize(path) + "/"; string prefix = Normalize(path) + "/";
var pattern = ToRegex(searchPattern); var pattern = ToRegex(searchPattern);
return _files.Keys return _files.Keys
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .Where(key => key.StartsWith(prefix, _comparison))
.Where(key => !_unlistable.Any(folder => key.StartsWith(folder + "/", _comparison)))
.Where(key => pattern.IsMatch(key[(key.LastIndexOf('/') + 1)..])) .Where(key => pattern.IsMatch(key[(key.LastIndexOf('/') + 1)..]))
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase) .OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
@@ -121,17 +176,30 @@ public sealed class InMemoryFileSystem : IFileSystem
public IReadOnlyList<string> GetDirectories(string path) public IReadOnlyList<string> GetDirectories(string path)
{ {
if (_unlistable.Contains(Normalize(path)))
throw new UnauthorizedAccessException($"Access to the path '{path}' is denied.");
string prefix = Normalize(path) + "/"; string prefix = Normalize(path) + "/";
return _directories return _directories
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .Where(key => key.StartsWith(prefix, _comparison))
.Where(key => !key[prefix.Length..].Contains('/')) .Where(key => !key[prefix.Length..].Contains('/'))
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase) .OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
} }
public DateTime GetLastWriteTimeUtc(string path) => LastWriteTimeUtc; public DateTime GetLastWriteTimeUtc(string path) =>
_written.TryGetValue(Normalize(path), out var written) ? written : LastWriteTimeUtc;
/// <summary>One file's own time, in place of <see cref="LastWriteTimeUtc"/>.</summary>
public InMemoryFileSystem WrittenAt(string path, DateTime utc)
{
_written[Normalize(path)] = utc;
return this;
}
/// <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")
@@ -141,10 +209,56 @@ public sealed class InMemoryFileSystem : IFileSystem
return this; return this;
} }
private byte[] Bytes(string path) => public string? LinkTarget(string path) => _links.GetValueOrDefault(Normalize(path));
_files.TryGetValue(Normalize(path), out var bytes)
public void CreateDirectoryLink(string path, string target)
{
if (LinksFail)
throw new IOException($"cannot link {path}");
string key = Normalize(path);
if (_links.ContainsKey(key) || _directories.Contains(key) || _files.ContainsKey(key))
throw new IOException($"{path} already exists");
_links[key] = target;
AddParents(key);
}
public void MoveDirectory(string sourcePath, string destinationPath)
{
if (MoveFails?.Invoke(Normalize(destinationPath)) == true)
throw new IOException($"cannot move to {destinationPath}");
string from = Normalize(sourcePath), to = Normalize(destinationPath);
if (!_directories.Contains(from) && !_links.ContainsKey(from))
throw new DirectoryNotFoundException(sourcePath);
if (_directories.Contains(to) || _links.ContainsKey(to) || _files.ContainsKey(to))
throw new IOException($"{destinationPath} already exists");
string? Moved(string key) =>
string.Equals(key, from, _comparison) ? to
: key.StartsWith(from + "/", _comparison) ? to + key[from.Length..]
: null;
foreach (var (key, bytes) in _files.ToList())
if (Moved(key) is { } moved) { _files.Remove(key); _files[moved] = bytes; }
foreach (string key in _executable.ToList())
if (Moved(key) is { } moved) { _executable.Remove(key); _executable.Add(moved); }
foreach (var (key, target) in _links.ToList())
if (Moved(key) is { } moved) { _links.Remove(key); _links[moved] = target; }
foreach (string key in _directories.ToList())
if (Moved(key) is { } moved) { _directories.Remove(key); _directories.Add(moved); }
AddParents(to);
}
private byte[] Bytes(string path)
{
if (_unreadable.Contains(Normalize(path)))
throw new IOException($"The process cannot access the file '{path}'.");
return _files.TryGetValue(Normalize(path), out var bytes)
? bytes ? bytes
: throw new FileNotFoundException(path); : throw new FileNotFoundException(path);
}
private void AddParents(string key) private void AddParents(string key)
{ {

View File

@@ -26,14 +26,18 @@ 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);
Tapes = new HaslerTapes(Files, Paths, Log, Clock, new TimetableLocator(Files), () => SimulatorRunning);
} }
public InMemoryFileSystem Files { get; } public InMemoryFileSystem Files { get; }
@@ -45,6 +49,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; }
@@ -52,6 +58,8 @@ public sealed class TestInstallation
public SettingsPaths SettingsPaths { get; } public SettingsPaths SettingsPaths { get; }
public ExecutableLocator Executables { get; } public ExecutableLocator Executables { get; }
public SettingsStore SettingsStore { get; } public SettingsStore SettingsStore { get; }
public HaslerTapes Tapes { get; }
public bool SimulatorRunning { get; set; }
/// <summary>Path of a file inside the fake installation.</summary> /// <summary>Path of a file inside the fake installation.</summary>
public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray()); public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray());

View File

@@ -0,0 +1,12 @@
using StarterNG.Application.Abstractions;
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Tests.Fakes;
/// <summary>The diagnostics log for a test that only wants one to write to or read back.</summary>
public static class TestLog
{
/// <param name="files">Where the log file goes; a disk of its own when left out.</param>
public static FileDiagnosticsLog Over(IFileSystem? files = null) =>
new(files ?? new InMemoryFileSystem(), new FixedClock(new DateTime(2026, 10, 13)), new GamePaths("game"));
}

View File

@@ -0,0 +1,16 @@
using StarterNG.Application.Abstractions;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Infrastructure.Sceneries;
namespace StarterNG.Tests.Fakes;
/// <summary>The scenery reader over a fake clock and dice, for tests of what it makes of a text.</summary>
public static class TestScenery
{
/// <param name="now">The hour a scenery that names none starts at.</param>
public static SceneryParser Parser(DateTime? now = null) =>
new(new FixedClock(now ?? new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
public static SceneryRepository Repository(IFileSystem files) =>
new(files, new GamePaths("game"), TestLog.Over(files), Parser());
}

View File

@@ -0,0 +1,76 @@
using StarterNG.Application;
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));
}
[Fact]
public void A_file_that_could_not_be_read_goes_aside_before_a_star_replaces_it()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa")
.WithFile(FavoritesPath, "one.scn\ntwo.scn")
.FailReading(FavoritesPath));
var scenery = Assert.Single(installation.Sceneries.LoadAll());
var favorites = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath);
installation.Files.AllowReading(FavoritesPath);
favorites.Toggle(scenery);
Assert.Equal("one.scn\ntwo.scn", installation.Files.ReadAllText(FavoritesPath + ".unreadable"));
Assert.Equal("td.scn", installation.Files.ReadAllText(FavoritesPath));
Assert.Contains(FavoritesPath + ".unreadable", installation.Log.Text);
// Set aside once: the file is the starter's own again.
favorites.Toggle(scenery);
Assert.False(installation.Files.FileExists(FavoritesPath + ".unreadable-2"));
}
[Fact]
public void A_locked_file_is_left_alone_when_no_copy_of_it_can_be_made()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa")
.WithFile(FavoritesPath, "one.scn\ntwo.scn")
.FailReading(FavoritesPath));
var scenery = Assert.Single(installation.Sceneries.LoadAll());
var favorites = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath);
favorites.Toggle(scenery);
installation.Files.AllowReading(FavoritesPath);
Assert.Equal("one.scn\ntwo.scn", installation.Files.ReadAllText(FavoritesPath));
Assert.Contains("writing", installation.Log.Text);
}
}

View File

@@ -42,4 +42,128 @@ public class FileDiagnosticsLogTests
Assert.Equal(string.Empty, log.Text); Assert.Equal(string.Empty, log.Text);
} }
private static string Written(string path)
{
// The writer holds it open for as long as the session lasts.
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
[Fact]
public void The_first_entry_of_a_session_keeps_the_log_of_the_one_before()
{
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
try
{
var paths = new GamePaths(root);
Directory.CreateDirectory(paths.StarterConfig);
File.WriteAllText(paths.DiagnosticsLog, "the crash\n");
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
log.Log("after the restart");
log.Log("and on");
Assert.Equal("the crash\n", Written(paths.DiagnosticsLog + ".1"));
string now = Written(paths.DiagnosticsLog);
Assert.DoesNotContain("the crash", now);
Assert.Contains("after the restart", now);
Assert.Contains("and on", now);
log.Clear();
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void Only_the_session_before_is_kept()
{
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
try
{
var paths = new GamePaths(root);
Directory.CreateDirectory(paths.StarterConfig);
File.WriteAllText(paths.DiagnosticsLog, "the last one\n");
File.WriteAllText(paths.DiagnosticsLog + ".1", "the one before it\n");
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
log.Log("now");
Assert.Equal("the last one\n", Written(paths.DiagnosticsLog + ".1"));
log.Clear();
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void Clearing_the_log_does_not_touch_the_session_kept()
{
var log = Build(out var files);
string path = new GamePaths("game").DiagnosticsLog;
files.WithFile(path, "the crash\n");
log.Log("after the restart");
log.Clear();
log.Log("cleared");
Assert.Equal("the crash\n", files.ReadAllText(path + ".1"));
}
[Fact]
public void A_log_that_cannot_be_copied_is_added_to_and_not_wiped()
{
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
try
{
var paths = new GamePaths(root);
Directory.CreateDirectory(paths.StarterConfig);
File.WriteAllText(paths.DiagnosticsLog, "the crash\n");
// A folder where the copy would go: nothing can be written in its place.
Directory.CreateDirectory(paths.DiagnosticsLog + ".1");
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
log.Log("after the restart");
string now = Written(paths.DiagnosticsLog);
Assert.Contains("the crash", now);
Assert.Contains("after the restart", now);
log.Clear();
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void A_folder_it_cannot_write_in_does_not_stop_the_program()
{
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
try
{
var paths = new GamePaths(root);
// A file where the log's folder should be, as unwritable as read-only gets
// on every platform and for every user.
File.WriteAllText(paths.StarterConfig, "");
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
log.Log("first");
log.Log("second");
log.Flush();
log.Clear();
log.Log("third");
Assert.Contains("third", log.Text);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
} }

View File

@@ -72,4 +72,45 @@ public class GameLibraryTests
Assert.Contains("textures.txt", installation.Log.Text); Assert.Contains("textures.txt", installation.Log.Text);
} }
[Fact]
public void A_folder_of_the_rolling_stock_that_cannot_be_listed_costs_only_itself()
{
var files = new InMemoryFileSystem()
.WithFile(TestInstallation.At("dynamic", "aaa", "ep09", "textures.txt"), "ep09-1.mat=ep09.e3d,ep09")
.WithFile(TestInstallation.At("dynamic", "aaa", "ep09", "ep09.fiz"), "Param: M=2")
.WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"), "ep07-135.mat=ep07.e3d,ep07")
.WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "ep07.fiz"), "Param: M=1")
.WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice")
.DenyListing(TestInstallation.At("dynamic", "aaa"));
var installation = new TestInstallation(files);
installation.Library.Load();
Assert.True(installation.Library.Loaded);
Assert.Equal("ep07-135", Assert.Single(installation.Library.Vehicles.Textures).Skinfile);
Assert.Equal("Katowice", Assert.Single(installation.Library.Sceneries).Name);
Assert.Contains("aaa", installation.Log.Text);
Assert.Equal(1, installation.Physics.For("pkp/ep07/", "ep07")!.Mass);
}
[Fact]
public void The_whole_rolling_stock_failing_still_leaves_the_sceneries()
{
var installation = Complete();
var library = new GameLibrary(new FailingVehicles(), installation.Sceneries, installation.MiniTextures,
installation.Physics, installation.Log);
library.Load();
Assert.True(library.Loaded);
Assert.Equal("Katowice", Assert.Single(library.Sceneries).Name);
Assert.Contains("no disk today", installation.Log.Text);
}
private sealed class FailingVehicles : StarterNG.Application.Abstractions.IVehicleRepository
{
public int Load(StarterNG.Domain.Vehicles.VehicleCatalog catalog) =>
throw new IOException("no disk today");
}
} }

View File

@@ -0,0 +1,190 @@
using StarterNG.Domain.Settings;
using StarterNG.Infrastructure;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class GpuOffloadTests
{
// The laptop this was written on: an RTX 5060 beside a Radeon 780M that drives the screen.
private static readonly GpuDevice Nvidia = new("0000:01:00.0", "10DE:2D19", "nvidia", BootVga: false,
InternalPanel: true);
private static readonly GpuDevice Radeon780M = new("0000:65:00.0", "1002:1900", "amdgpu", BootVga: true,
InternalPanel: true);
private static readonly GpuDevice RadeonDiscrete = new("0000:03:00.0", "1002:73DF", "amdgpu", BootVga: false);
private static readonly GpuDevice IntelIntegrated = new("0000:00:02.0", "8086:A7A0", "i915", BootVga: true,
InternalPanel: true);
private static string? Unset(string name) => null;
private static IReadOnlyDictionary<string, string> For(GpuPreference preference, params GpuDevice[] gpus) =>
Inheriting(Unset, preference, gpus);
private static IReadOnlyDictionary<string, string> Inheriting(Func<string, string?> inherited,
GpuPreference preference,
params GpuDevice[] gpus) =>
GpuOffload.Target(gpus, preference, inherited) is { } target
? GpuOffload.EnvironmentFor(target)
: new Dictionary<string, string>();
[Fact]
public void A_proprietary_nvidia_card_gets_prime_render_offload()
{
var environment = For(GpuPreference.Discrete, Nvidia, Radeon780M);
Assert.Equal("1", environment["__NV_PRIME_RENDER_OFFLOAD"]);
Assert.Equal("nvidia", environment["__GLX_VENDOR_LIBRARY_NAME"]);
Assert.Equal("NVIDIA_only", environment["__VK_LAYER_NV_optimus"]);
// DRI_PRIME on a card Mesa does not drive leaves Mesa on llvmpipe.
Assert.False(environment.ContainsKey("DRI_PRIME"));
}
[Fact]
public void A_card_mesa_drives_is_picked_by_its_pci_address()
{
var environment = For(GpuPreference.Discrete, IntelIntegrated, RadeonDiscrete);
Assert.Equal("pci-0000_03_00_0", Assert.Single(environment).Value);
Assert.Equal("DRI_PRIME", environment.Keys.Single());
}
[Fact]
public void A_card_on_a_driver_of_unknown_ways_is_left_alone()
{
var odd = Nvidia with { Driver = "vfio-pci" };
Assert.Empty(For(GpuPreference.Discrete, odd, Radeon780M));
}
[Fact]
public void One_card_leaves_nothing_to_choose()
{
Assert.Empty(For(GpuPreference.Discrete, Radeon780M));
}
[Fact]
public void The_system_choice_changes_nothing()
{
Assert.Empty(For(GpuPreference.System, Nvidia, Radeon780M));
}
[Theory]
[InlineData("DRI_PRIME")]
[InlineData("__NV_PRIME_RENDER_OFFLOAD")]
[InlineData("__GLX_VENDOR_LIBRARY_NAME")]
public void A_choice_the_player_made_stands(string variable)
{
// prime-run %command% in Steam's launch options, say.
var environment = Inheriting(name => name == variable ? "1" : null, GpuPreference.Discrete, Nvidia, Radeon780M);
Assert.Empty(environment);
}
[Fact]
public void A_discrete_card_already_driving_the_screen_needs_no_offload()
{
// A laptop with its MUX switched to the NVIDIA card.
Assert.Empty(For(GpuPreference.Discrete, Nvidia with { BootVga = true },
Radeon780M with { BootVga = false }));
}
[Fact]
public void A_desktop_stays_on_the_card_its_monitor_is_plugged_into()
{
// The monitor on a Radeon, the processor's own graphics left on: moving
// off the boot card would land on the weaker one.
var radeon = RadeonDiscrete with { BootVga = true };
var intel = IntelIntegrated with { BootVga = false, InternalPanel = false };
Assert.Empty(For(GpuPreference.Discrete, radeon, intel));
}
// ── What the settings say about it ───────────────────────────────────────
[Fact]
public void No_card_read_is_said_whatever_was_chosen()
{
Assert.Equal(GpuNote.Unread, GpuOffload.NoteFor(Array.Empty<GpuDevice>(), GpuPreference.Discrete, Unset));
Assert.Equal(GpuNote.Unread, GpuOffload.NoteFor(Array.Empty<GpuDevice>(), GpuPreference.System, name => "1"));
}
[Fact]
public void One_card_is_said_whatever_was_chosen()
{
Assert.Equal(GpuNote.OneCard, GpuOffload.NoteFor(new[] { Radeon780M }, GpuPreference.Discrete, Unset));
Assert.Equal(GpuNote.OneCard, GpuOffload.NoteFor(new[] { Radeon780M }, GpuPreference.System, name => "1"));
}
[Fact]
public void The_system_choice_comes_before_what_the_player_set()
{
Assert.Equal(GpuNote.LeftToSystem,
GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.System, name => "1"));
}
[Fact]
public void What_the_player_set_comes_before_the_card_it_would_be_moved_to()
{
static string? Inherited(string name) => name is "DRI_PRIME" or "__NV_PRIME_RENDER_OFFLOAD" ? "1" : null;
Assert.Equal(GpuNote.PlayerChose,
GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.Discrete, Inherited));
Assert.Equal(new[] { "__NV_PRIME_RENDER_OFFLOAD", "DRI_PRIME" }, GpuOffload.PlayerChoices(Inherited));
}
[Fact]
public void A_variable_set_empty_is_no_choice_of_the_player()
{
Assert.Empty(GpuOffload.PlayerChoices(name => string.Empty));
Assert.Equal(GpuNote.Moved,
GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.Discrete, name => string.Empty));
}
[Fact]
public void The_note_says_a_move_only_with_a_card_to_move_to()
{
Assert.Equal(GpuNote.Moved, GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.Discrete, Unset));
Assert.Equal(GpuNote.NoTarget,
GpuOffload.NoteFor(new[] { Nvidia with { Driver = "vfio-pci" }, Radeon780M },
GpuPreference.Discrete, Unset));
}
// ── Reading the cards ────────────────────────────────────────────────────
private static InMemoryFileSystem Card(InMemoryFileSystem files, string card, string driver, string id,
string slot, bool bootVga) =>
files.WithFile($"/sys/class/drm/{card}/device/uevent",
$"DRIVER={driver}\nPCI_CLASS=30000\nPCI_ID={id}\nPCI_SLOT_NAME={slot}\n")
.WithFile($"/sys/class/drm/{card}/device/boot_vga", bootVga ? "1\n" : "0\n");
[Fact]
public void The_cards_are_read_from_sysfs()
{
var files = new InMemoryFileSystem(caseSensitive: true);
Card(files, "card0", "nvidia", "10DE:2D19", "0000:01:00.0", bootVga: false);
Card(files, "card1", "amdgpu", "1002:1900", "0000:65:00.0", bootVga: true);
// A connector, not a card.
files.WithFile("/sys/class/drm/card0-eDP-2/device/uevent", "DRIVER=nvidia\nPCI_ID=10DE:2D19\nPCI_SLOT_NAME=x\n");
files.WithFile("/sys/class/drm/card1-eDP-1/status", "connected\n");
var gpus = new LinuxGpus(files, new FakeEnvironment()).List();
Assert.Equal(new[] { Nvidia, Radeon780M }, gpus);
}
[Fact]
public void Off_linux_there_are_no_cards_to_move_between()
{
var files = new InMemoryFileSystem(caseSensitive: true);
Card(files, "card0", "nvidia", "10DE:2D19", "0000:01:00.0", bootVga: false);
Assert.Empty(new LinuxGpus(files, new FakeEnvironment().AsWindows()).List());
}
[Fact]
public void Without_sysfs_there_are_no_cards()
{
Assert.Empty(new LinuxGpus(new InMemoryFileSystem(), new FakeEnvironment()).List());
}
}

View File

@@ -0,0 +1,628 @@
using StarterNG.Application;
using StarterNG.Domain.Settings;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class GraphicsPresetTests
{
private static SimulatorSettings Defaults()
{
var installation = new TestInstallation();
installation.SettingsStore.Load();
return installation.SettingsStore.Settings;
}
private static SimulatorSettings AtHigh()
{
var settings = Defaults().Copy();
GraphicsPreset.High.ApplyTo(settings);
return settings;
}
[Fact]
public void The_keys_named_are_the_keys_a_level_sets()
{
var store = new TestInstallation().SettingsStore;
var low = Defaults().Copy();
var top = Defaults().Copy();
(GraphicsPreset.Minimal with { Extras = null }).ApplyTo(low);
(GraphicsPreset.Ultra with { Extras = null, Top = null }).ApplyTo(top);
var lowKeys = store.Render(low);
var topKeys = store.Render(top);
var differing = lowKeys.Keys.Union(topKeys.Keys)
.Where(key => lowKeys.GetValueOrDefault(key) != topKeys.GetValueOrDefault(key))
.Where(key => !key.StartsWith("gfx.framebuffer", StringComparison.Ordinal));
Assert.Equal(GraphicsPreset.Keys.OrderBy(k => k), differing.OrderBy(k => k));
}
// ── The scale ────────────────────────────────────────────────────────────
private static void Climbs(Func<GraphicsPreset, double> value, int repeatsAllowed)
{
var values = GraphicsPreset.All.Select(value).ToList();
Assert.Equal(values.OrderBy(v => v), values);
Assert.True(values.Count - values.Distinct().Count() <= repeatsAllowed, string.Join(", ", values));
}
[Fact]
public void Every_slider_moves_from_level_to_level_as_far_as_it_has_steps_for_six()
{
// Sizes and filtering run out of steps above High.
Climbs(p => p.TextureSize, 1);
Climbs(p => p.CabTextureSize, 1);
Climbs(p => p.Anisotropy, 1);
// Four steps or three for six levels.
Climbs(p => p.Multisampling, 2);
Climbs(p => p.SmokeFidelity, 2);
Climbs(p => p.SplineFidelity, 2);
Climbs(p => p.ReflectionsFidelity, 3);
Climbs(p => p.ShadowRank, 3);
// The simulator takes no more than its own 7 lights, and draws no less than 2 km.
Climbs(p => p.DynamicLights, 2);
Climbs(p => p.DrawRange, 1);
Climbs(p => p.DrawRangeFactor, 2);
Climbs(p => p.CabShadowsRange, 1);
Climbs(p => p.ShadowMap, 1);
Climbs(p => p.ShadowRange, 0);
Climbs(p => p.ReflectionsRate, 0);
Climbs(p => p.DistanceFactor, 0);
Climbs(p => p.BufferScale, 0);
Climbs(p => p.SleeperDistance, 0);
Climbs(p => p.ReflectedInstances, 0);
Climbs(p => p.ReflectedVehicles, 0);
}
[Fact]
public void No_step_up_a_scale_is_more_than_a_few_steps_of_its_slider()
{
// In steps of what the screen offers, so Ultra does not jump to the top from far below.
var rates = GraphicsPreset.All.Select(p => Steps.IndexOf(Steps.ReflectionUpdates, p.ReflectionsRate)).ToList();
Assert.All(rates.Zip(rates.Skip(1)), step => Assert.InRange(step.Second - step.First, 1, 4));
foreach (Func<GraphicsPreset, int> fidelity in new Func<GraphicsPreset, int>[] { p => p.SmokeFidelity, p => p.SplineFidelity })
{
var levels = GraphicsPreset.All.Select(fidelity).ToList();
Assert.All(levels.Zip(levels.Skip(1)), step => Assert.InRange(step.Second - step.First, 0, 1));
}
}
[Fact]
public void High_is_the_simulators_own_but_where_that_is_the_bottom_of_a_scale()
{
var store = new TestInstallation().SettingsStore;
var own = store.Render(Defaults());
var high = store.Render(AtHigh());
var differing = own.Keys.Union(high.Keys)
.Where(key => own.GetValueOrDefault(key) != high.GetValueOrDefault(key))
.Where(key => !GraphicsPreset.RowKeys.Contains(key))
.Where(key => !key.StartsWith("gfx.framebuffer", StringComparison.Ordinal));
Assert.Equal(new[] { "gfx.reflections.fidelity", "gfx.reflections.framerate", "gfx.shadows.cab.range",
"gfx.smoke.fidelity", "splinefidelity" }, differing.OrderBy(k => k));
Assert.Equal(1, Defaults().SmokeFidelity);
Assert.Equal(1, Defaults().SplineFidelity);
}
[Fact]
public void A_player_with_no_settings_of_their_own_starts_at_high()
{
var installation = new TestInstallation();
var store = installation.SettingsStore;
store.Load();
store.StartAtHighWithoutOwnSettings();
Assert.Same(GraphicsPreset.High, GraphicsPreset.Matching(store.Settings));
Assert.False(store.LastSaveFailed);
var again = new TestInstallation(installation.Files).SettingsStore;
again.Load();
Assert.Same(GraphicsPreset.High, GraphicsPreset.Matching(again.Settings));
}
[Fact]
public void A_player_with_settings_of_their_own_stays_as_they_are()
{
var installation = new TestInstallation();
var store = installation.SettingsStore;
store.Load();
GraphicsPreset.Low.ApplyTo(store.Settings);
store.Save();
var again = new TestInstallation(installation.Files).SettingsStore;
again.Load();
again.StartAtHighWithoutOwnSettings();
Assert.Same(GraphicsPreset.Low, GraphicsPreset.Matching(again.Settings));
}
[Fact]
public void Ultra_is_the_top_of_everything_the_screen_offers()
{
var ultra = GraphicsPreset.Ultra;
double Top(string key) => SimulatorKeys.Named(key).Maximum;
Assert.Equal(8192, ultra.TextureSize);
Assert.Equal(8192, ultra.CabTextureSize);
Assert.Equal(8192, ultra.ShadowMap);
Assert.Equal(600, ultra.ShadowRange);
Assert.Equal(Steps.Anisotropy.Max(), ultra.Anisotropy);
Assert.Equal(Steps.MultisamplingLevels.Max(), ultra.Multisampling);
Assert.Equal(Steps.ReflectionUpdates.Max(), ultra.ReflectionsRate);
Assert.Equal(4, ultra.SmokeFidelity);
Assert.Equal(4, ultra.SplineFidelity);
Assert.Equal(200, ultra.BufferScale);
Assert.Equal(Top(GraphicsPreset.DrawRangeKey), ultra.DrawRange);
Assert.Equal(Top(GraphicsPreset.DistanceFactorKey), ultra.DistanceFactor);
Assert.Equal(Top(GraphicsPreset.SleeperKey), ultra.SleeperDistance);
Assert.Equal(Steps.DrawRangeFactors.Max(), ultra.DrawRangeFactor);
Assert.Equal(Top(GraphicsPreset.ReflectedInstancesKey), ultra.ReflectedInstances);
Assert.Equal(Top(GraphicsPreset.ReflectedVehiclesKey), ultra.ReflectedVehicles);
Assert.Equal(100, ultra.CabShadowsRange);
var extras = ultra.Extras!;
Assert.Equal(200, extras.PythonUpdateMs);
Assert.True(extras.MotionBlur && extras.ChromaticAberration && extras.Smoke);
var top = ultra.Top!;
Assert.Equal(Top(GraphicsTop.ScreenWindowFpsKey), top.ScreenWindowFps);
Assert.True(top.Screens && top.ScreenMipmaps && top.Sky && top.Cab && top.AirCouplers && top.SmoothTraction);
}
[Fact]
public void At_ultra_what_would_only_take_away_is_off()
{
var top = GraphicsPreset.Ultra.Top!;
// The longest shadows, the bottom of that scale.
Assert.Equal(0.2, top.ShadowAngleLimit);
Assert.Equal(0, top.TargetFps);
Assert.Equal(0, top.TextureRelease);
Assert.False(top.CompressTextures);
Assert.False(top.SkipDecor);
}
[Fact]
public void Ultra_undoes_what_was_moved_earlier()
{
var settings = Defaults().Copy();
settings.ShadowAngleLimit = 0.8;
settings.IgnoreIrrelevantTrains = true;
settings.Extra[GraphicsTop.TargetFpsKey] = "60";
settings.Extra[GraphicsTop.TextureReleaseKey] = "2";
GraphicsPreset.Ultra.ApplyTo(settings);
Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(settings));
Assert.Equal(0.2, settings.ShadowAngleLimit);
Assert.False(settings.IgnoreIrrelevantTrains);
Assert.Equal("0", settings.Extra[GraphicsTop.TargetFpsKey]);
Assert.Equal("0", settings.Extra[GraphicsTop.TextureReleaseKey]);
}
[Fact]
public void Ultra_leaves_the_screens_in_their_own_windows_to_the_player()
{
var settings = Defaults().Copy();
settings.Extra["python.displaywindows"] = "yes";
GraphicsPreset.Ultra.ApplyTo(settings);
Assert.Equal("yes", settings.Extra["python.displaywindows"]);
Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(settings));
}
[Fact]
public void The_other_levels_leave_what_only_ultra_sets()
{
foreach (var preset in GraphicsPreset.All.Where(preset => preset != GraphicsPreset.Ultra))
{
var settings = Defaults().Copy();
settings.IgnoreIrrelevantTrains = true;
settings.Extra[GraphicsTop.TargetFpsKey] = "60";
preset.ApplyTo(settings);
Assert.True(settings.IgnoreIrrelevantTrains, preset.NameKey);
Assert.Equal("60", settings.Extra[GraphicsTop.TargetFpsKey]);
Assert.Same(preset, GraphicsPreset.Matching(settings));
}
}
[Fact]
public void Ultra_is_not_recognised_with_one_of_its_values_moved()
{
var settings = Defaults().Copy();
GraphicsPreset.Ultra.ApplyTo(settings);
Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(settings));
settings.Extra[GraphicsPreset.ReflectedVehiclesKey] = "250";
Assert.Null(GraphicsPreset.Matching(settings));
}
[Fact]
public void No_level_asks_for_a_range_the_renderer_would_raise_anyway()
{
Assert.All(GraphicsPreset.All, preset => Assert.True(preset.DrawRange >= 2000, preset.NameKey));
}
[Fact]
public void The_view_only_shortens_going_down_the_levels()
{
var factors = GraphicsPreset.All.Select(preset => preset.DistanceFactor).ToList();
Assert.Equal(factors.OrderBy(factor => factor), factors);
Assert.Equal(1, GraphicsPreset.High.DistanceFactor);
}
[Fact]
public void Shadows_reach_further_with_every_level_that_has_them()
{
static double Reach(GraphicsPreset preset) =>
SimulatorSettings.ShadowReach(preset.ShadowRange, preset.ShadowMap, legacyRenderer: false);
var reaches = GraphicsPreset.All.Where(preset => preset.Shadows).Select(Reach).ToList();
Assert.Equal(reaches.Distinct().OrderBy(reach => reach), reaches);
Assert.Equal(250, Reach(GraphicsPreset.High));
Assert.Equal(800, Reach(GraphicsPreset.VeryHigh));
}
[Fact]
public void Without_extras_the_settings_are_what_the_simulator_goes_by()
{
Assert.Equal(GraphicsExtras.None, GraphicsExtras.Of(Defaults()));
Assert.Equal(GraphicsTop.None, GraphicsTop.Of(Defaults()));
}
[Fact]
public void A_level_leaves_what_only_the_lowest_one_sets()
{
var settings = Defaults().Copy();
settings.MotionBlur = false;
GraphicsPreset.Low.ApplyTo(settings);
Assert.False(settings.MotionBlur);
Assert.Same(GraphicsPreset.Low, GraphicsPreset.Matching(settings));
}
[Fact]
public void Every_level_sets_the_buffer_and_is_not_recognised_with_another()
{
foreach (var preset in GraphicsPreset.All)
{
var settings = Defaults().Copy();
settings.BufferScalePercent = 120;
preset.ApplyTo(settings);
Assert.Equal(preset.BufferScale, settings.BufferScalePercent);
settings.BufferScalePercent = 120;
Assert.Null(GraphicsPreset.Matching(settings));
}
}
[Fact]
public void The_buffer_is_the_screen_at_high_and_climbs_through_it()
{
Assert.Equal(100, GraphicsPreset.High.BufferScale);
Assert.Equal(200, GraphicsPreset.Ultra.BufferScale);
Assert.InRange(GraphicsPreset.Minimal.BufferScale, 50, 100);
}
[Fact]
public void The_lowest_level_is_not_recognised_with_one_of_its_extras_undone()
{
var settings = Defaults().Copy();
GraphicsPreset.Minimal.ApplyTo(settings);
settings.Smoke = true;
Assert.Null(GraphicsPreset.Matching(settings));
}
[Fact]
public void The_extras_sit_on_the_steps_the_screen_offers()
{
foreach (var extras in new[] { GraphicsPreset.Minimal.Extras!, GraphicsPreset.Ultra.Extras! })
Assert.Contains(extras.PythonUpdateMs, new[] { 200, 400, 800, 1600 });
}
// ── Leaving an end of the scale ──────────────────────────────────────────
[Fact]
public void A_level_between_the_ends_chooses_only_its_own_keys()
{
var settings = Defaults().Copy();
settings.MotionBlur = false;
settings.Extra[GraphicsTop.TargetFpsKey] = "60";
var applied = new GraphicsPresetMemory().Apply(GraphicsPreset.Medium, settings);
Assert.Same(GraphicsPreset.Medium, GraphicsPreset.Matching(settings));
Assert.False(settings.MotionBlur);
Assert.Equal("60", settings.Extra[GraphicsTop.TargetFpsKey]);
Assert.Equal(GraphicsPreset.RowKeys, applied.RowKeys);
Assert.Equal(GraphicsPreset.Keys, applied.ChosenKeys);
}
[Fact]
public void Leaving_the_lowest_level_brings_back_what_was_there_before_it()
{
var memory = new GraphicsPresetMemory();
var settings = Defaults().Copy();
settings.MotionBlur = false;
var before = GraphicsExtras.Of(settings);
var entering = memory.Apply(GraphicsPreset.Minimal, settings);
Assert.Equal(GraphicsPreset.Minimal.Extras, GraphicsExtras.Of(settings));
Assert.Equal(GraphicsPreset.RowKeys, entering.RowKeys);
Assert.Equal(GraphicsPreset.Keys.Concat(GraphicsExtras.Keys), entering.ChosenKeys);
var leaving = memory.Apply(GraphicsPreset.Medium, settings);
Assert.Equal(before, GraphicsExtras.Of(settings));
Assert.Same(GraphicsPreset.Medium, GraphicsPreset.Matching(settings));
Assert.Equal(GraphicsPreset.RowKeys, leaving.RowKeys);
Assert.Equal(GraphicsPreset.Keys.Concat(GraphicsExtras.Keys), leaving.ChosenKeys);
}
[Fact]
public void Leaving_ultra_brings_back_what_was_there_before_it()
{
var memory = new GraphicsPresetMemory();
var settings = Defaults().Copy();
settings.MotionBlur = false;
settings.ShadowAngleLimit = 0.8;
settings.Extra[GraphicsTop.TargetFpsKey] = "60";
var extras = GraphicsExtras.Of(settings);
var top = GraphicsTop.Of(settings);
memory.Apply(GraphicsPreset.Ultra, settings);
var applied = memory.Apply(GraphicsPreset.VeryHigh, settings);
Assert.Equal(extras, GraphicsExtras.Of(settings));
Assert.Equal(top, GraphicsTop.Of(settings));
Assert.Same(GraphicsPreset.VeryHigh, GraphicsPreset.Matching(settings));
Assert.Equal(GraphicsPreset.RowKeys.Concat(GraphicsTop.RowKeys), applied.RowKeys);
Assert.Equal(GraphicsPreset.Keys.Concat(GraphicsExtras.Keys).Concat(GraphicsTop.Keys), applied.ChosenKeys);
}
[Fact]
public void Leaving_an_end_chosen_before_this_run_gives_the_simulators_own_values()
{
var settings = Defaults().Copy();
GraphicsPreset.Ultra.ApplyTo(settings);
var applied = new GraphicsPresetMemory().Apply(GraphicsPreset.Low, settings);
Assert.Equal(GraphicsExtras.None, GraphicsExtras.Of(settings));
Assert.Equal(GraphicsTop.None, GraphicsTop.Of(settings));
Assert.Same(GraphicsPreset.Low, GraphicsPreset.Matching(settings));
Assert.Equal(GraphicsPreset.RowKeys.Concat(GraphicsTop.RowKeys), applied.RowKeys);
}
[Fact]
public void One_end_to_the_other_keeps_what_was_there_before_either()
{
var memory = new GraphicsPresetMemory();
var settings = Defaults().Copy();
settings.MotionBlur = false;
var extras = GraphicsExtras.Of(settings);
var top = GraphicsTop.Of(settings);
memory.Apply(GraphicsPreset.Minimal, settings);
var toUltra = memory.Apply(GraphicsPreset.Ultra, settings);
Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(settings));
Assert.Equal(GraphicsPreset.RowKeys.Concat(GraphicsTop.RowKeys), toUltra.RowKeys);
var toMinimal = memory.Apply(GraphicsPreset.Minimal, settings);
Assert.Same(GraphicsPreset.Minimal, GraphicsPreset.Matching(settings));
// Ultra's own values go, Minimal has no say over them.
Assert.Equal(top, GraphicsTop.Of(settings));
Assert.Equal(GraphicsPreset.RowKeys.Concat(GraphicsTop.RowKeys), toMinimal.RowKeys);
memory.Apply(GraphicsPreset.Medium, settings);
Assert.Equal(extras, GraphicsExtras.Of(settings));
}
[Fact]
public void What_was_remembered_comes_back_only_once()
{
var memory = new GraphicsPresetMemory();
var settings = Defaults().Copy();
settings.MotionBlur = false;
memory.Apply(GraphicsPreset.Minimal, settings);
memory.Apply(GraphicsPreset.Medium, settings);
Assert.False(settings.MotionBlur);
// Moved since, and no end left behind this time: it stays as moved.
settings.MotionBlur = true;
var applied = memory.Apply(GraphicsPreset.High, settings);
Assert.True(settings.MotionBlur);
Assert.Equal(GraphicsPreset.RowKeys, applied.RowKeys);
Assert.Equal(GraphicsPreset.Keys, applied.ChosenKeys);
}
[Fact]
public void A_level_applied_twice_at_an_end_remembers_what_came_before_the_first()
{
var memory = new GraphicsPresetMemory();
var settings = Defaults().Copy();
settings.MotionBlur = false;
var before = GraphicsExtras.Of(settings);
memory.Apply(GraphicsPreset.Minimal, settings);
memory.Apply(GraphicsPreset.Minimal, settings);
memory.Apply(GraphicsPreset.Low, settings);
Assert.Equal(before, GraphicsExtras.Of(settings));
}
[Fact]
public void A_level_applied_is_recognised_until_one_value_moves()
{
foreach (var preset in GraphicsPreset.All)
{
var settings = Defaults().Copy();
preset.ApplyTo(settings);
Assert.Same(preset, GraphicsPreset.Matching(settings));
settings.DynamicLights = 6;
Assert.Null(GraphicsPreset.Matching(settings));
}
}
[Fact]
public void Every_level_sits_on_the_steps_the_screen_offers()
{
void OnSteps(double value, string name)
{
var key = SimulatorKeys.Named(name);
Assert.InRange(value, key.Minimum, key.Maximum);
double steps = value / key.Step;
Assert.Equal(Math.Round(steps), steps, 6);
}
foreach (var preset in GraphicsPreset.All)
{
OnSteps(preset.DistanceFactor, GraphicsPreset.DistanceFactorKey);
OnSteps(preset.DrawRange, GraphicsPreset.DrawRangeKey);
OnSteps(preset.SleeperDistance, GraphicsPreset.SleeperKey);
OnSteps(preset.ReflectedInstances, GraphicsPreset.ReflectedInstancesKey);
OnSteps(preset.ReflectedVehicles, GraphicsPreset.ReflectedVehiclesKey);
Assert.True(Steps.IndexOf(Steps.Anisotropy, preset.Anisotropy) >= 0, preset.NameKey);
Assert.True(Steps.IndexOf(Steps.MultisamplingLevels, preset.Multisampling) >= 0, preset.NameKey);
Assert.True(Steps.IndexOf(Steps.ReflectionUpdates, preset.ReflectionsRate) >= 0, preset.NameKey);
Assert.True(Steps.IndexOf(Steps.DrawRangeFactors, preset.DrawRangeFactor) >= 0, preset.NameKey);
foreach (int size in new[] { preset.TextureSize, preset.CabTextureSize, preset.ShadowMap })
Assert.True(size is >= 512 and <= 8192 && (size & (size - 1)) == 0, preset.NameKey);
Assert.True(preset.ShadowRange >= SimulatorSettings.ShadowRangeFloor(preset.ShadowMap), preset.NameKey);
Assert.Equal(0, preset.CabShadowsRange % 10);
}
}
// ── A level for one part ─────────────────────────────────────────────────
private static GraphicsGroupLevels Groups() => new(new TestInstallation().SettingsStore);
[Fact]
public void Every_key_a_level_sets_but_the_buffer_belongs_to_one_part()
{
var inGroups = GraphicsGroup.All.SelectMany(group => group.Keys).ToList();
Assert.Equal(inGroups.Distinct(), inGroups);
Assert.Empty(inGroups.Except(GraphicsPreset.Keys).Except(GraphicsExtras.Keys).Except(GraphicsTop.Keys));
Assert.Equal(new[] { "starter.bufferscale" }, GraphicsPreset.Keys.Except(inGroups));
}
[Fact]
public void One_part_moves_alone()
{
var groups = Groups();
var settings = AtHigh();
var moved = groups.Apply(GraphicsGroup.Shadows, GraphicsPreset.Low, settings);
Assert.Equal(GraphicsPreset.Low.ShadowMap, moved.ShadowMapResolution);
Assert.Equal(GraphicsPreset.Low.ShadowRange, moved.ShadowProjectionRange);
Assert.Equal(GraphicsPreset.Low.ShadowRank, moved.ShadowRankCutoff);
Assert.Equal(settings.MaxTextureSize, moved.MaxTextureSize);
Assert.Equal(settings.DynamicLights, moved.DynamicLights);
Assert.Equal(settings.BufferScalePercent, moved.BufferScalePercent);
var levels = groups.LevelsOf(moved);
Assert.Same(GraphicsPreset.Low, levels[GraphicsGroup.Shadows]);
Assert.Same(GraphicsPreset.High, levels[GraphicsGroup.Textures]);
Assert.Null(GraphicsPreset.Matching(moved));
}
[Fact]
public void Every_part_shows_ultra_when_the_whole_is_at_ultra()
{
var settings = Defaults().Copy();
GraphicsPreset.Ultra.ApplyTo(settings);
Assert.All(Groups().LevelsOf(settings).Values, level => Assert.Same(GraphicsPreset.Ultra, level));
// The row of a key at the simulator's own value leaves it out of the file.
settings.Extra.Remove(GraphicsTop.TextureReleaseKey);
Assert.Same(GraphicsPreset.Ultra, Groups().LevelsOf(settings)[GraphicsGroup.Textures]);
}
[Fact]
public void A_part_moved_by_hand_is_at_no_level_and_the_others_keep_theirs()
{
var settings = AtHigh();
settings.DynamicLights = 6;
var levels = Groups().LevelsOf(settings);
Assert.Null(levels[GraphicsGroup.Effects]);
Assert.Same(GraphicsPreset.High, levels[GraphicsGroup.Shadows]);
}
[Fact]
public void Every_part_at_a_level_and_the_buffer_with_it_is_that_level()
{
var groups = Groups();
foreach (var level in GraphicsPreset.All.Where(level => level.Extras is null))
{
var settings = Defaults().Copy();
foreach (var group in GraphicsGroup.All)
settings = groups.Apply(group, level, settings);
settings.BufferScalePercent = level.BufferScale;
Assert.Same(level, GraphicsPreset.Matching(settings));
}
}
[Fact]
public void Every_part_tells_every_level_from_the_others()
{
var groups = Groups();
foreach (var group in GraphicsGroup.All)
foreach (var level in GraphicsPreset.All)
Assert.Same(level, groups.LevelsOf(groups.Apply(group, level, Defaults()))[group]);
}
[Fact]
public void Leaving_an_end_in_a_part_gives_the_simulators_own_for_what_only_the_end_sets()
{
var groups = Groups();
var settings = AtHigh();
var ultra = groups.Apply(GraphicsGroup.Textures, GraphicsPreset.Ultra, settings);
Assert.False(ultra.CompressTextures);
Assert.Same(GraphicsPreset.Ultra, groups.LevelsOf(ultra)[GraphicsGroup.Textures]);
var back = groups.Apply(GraphicsGroup.Textures, GraphicsPreset.High, ultra);
Assert.Equal(GraphicsTop.None.CompressTextures, back.CompressTextures);
Assert.Same(GraphicsPreset.High, groups.LevelsOf(back)[GraphicsGroup.Textures]);
}
[Fact]
public void A_level_in_a_part_between_the_ends_leaves_what_only_an_end_sets()
{
var settings = AtHigh();
settings.MotionBlur = false;
var moved = Groups().Apply(GraphicsGroup.Effects, GraphicsPreset.VeryHigh, settings);
Assert.False(moved.MotionBlur);
Assert.Equal(GraphicsPreset.VeryHigh.SmokeFidelity, moved.SmokeFidelity);
}
}

View File

@@ -0,0 +1,168 @@
using System.Globalization;
using StarterNG.Domain.Tapes;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class HaslerTapeTests
{
private const string Header =
"Time[s] Velocity[m/s] Acceleration[m/ss] Coupler.Dist[m] Coupler.Force[N] TractionForce[kN] FrictionForce[kN] " +
"BrakeForce[kN] BrakePress[MPa] PipePress[MPa] MotorCurrent[A] MCP SCP BCP LBP Direction Command CVal1 CVal2 " +
"Security Wheelslip EngineTemp[Deg] OilTemp[Deg] WaterTemp[Deg] WaterAuxTemp[Deg]\r\n";
private static string Line(double t, double v, double brake, double current, string command = "none",
int security = 0) =>
string.Create(CultureInfo.InvariantCulture,
$"{t:0.0000} {v:0.0000} 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 {brake:0.0000} 0.5000 {current:0.0000} ") +
$"3 0 0.0000 0.0000 1 {command} 0.0000 0.0000 {security} 0 0.0000 0.0000 0.0000 0.0000\r\n";
[Fact]
public void A_physics_log_reads_as_speed_distance_and_the_two_hasler_marks()
{
var tape = HaslerTape.Parse(Header
+ Line(10, 0, 0.45, 0)
+ Line(20, 36, 0, 400)
+ Line(30, 72, 0, 0, security: 1));
Assert.Equal(3, tape.Samples.Count);
Assert.Equal(72, tape.MaxSpeedKmh, 6);
Assert.Equal(20, tape.Duration, 6);
Assert.Equal(50 + 150, tape.DistanceM, 6); // trapezoids: 0->36 km/h and 36->72 km/h over 10 s each
Assert.True(tape.Samples[0].Braking);
Assert.True(tape.Samples[1].Current);
Assert.True(tape.Samples[2].SecurityAlarm);
}
[Fact]
public void Standing_and_running_time_split_the_recording_like_the_old_analyser()
{
var tape = HaslerTape.Parse(Header + Line(0, 0, 0.45, 0) + Line(60, 0, 0.45, 0) + Line(120, 36, 0, 400)
+ Line(180, 36, 0, 0));
Assert.Equal(60, tape.StandingTime, 6);
Assert.Equal(120, tape.RunningTime, 6);
Assert.Equal(900 / 180.0 * 3.6, tape.AverageWithStopsKmh, 6); // 300 m ramping up to 36 km/h, 600 m at it
Assert.Equal(900 / 120.0 * 3.6, tape.AverageSpeedKmh, 6);
}
[Fact]
public void A_line_from_the_simulator_reads_in_kmh_and_bar_whatever_its_header_says()
{
var tape = HaslerTape.Parse(Header +
"107.5512 38.6099 -0.0201 -0.0002 917.2355 -0.0000 436.4420 0.0000 0.0021 4.9981 0.0000 0 0 0.0000 " +
"0.0000 1 none 0.0000 0.0000 0 0 50.0000 45.0000 50.0000 40.0000\r\n");
var s = Assert.Single(tape.Samples);
Assert.Equal(38.6099, s.SpeedKmh, 6);
Assert.Equal(4.9981, s.PipePressBar, 6);
Assert.False(s.Braking);
}
[Fact]
public void A_line_still_being_written_or_a_command_with_spaces_does_not_break_the_tape()
{
var tape = HaslerTape.Parse(Header + Line(0, 18, 0, 0, command: "Set Velocity") + Line(1, 18, 0, 0) + "2.0500 5.0");
Assert.Equal(2, tape.Samples.Count);
Assert.Equal(5, tape.DistanceM, 6);
}
[Fact]
public void The_sample_under_a_point_of_the_tape_is_found_by_time_and_by_distance()
{
var tape = HaslerTape.Parse(Header + Line(0, 36, 0, 0) + Line(1, 36, 0, 0) + Line(2, 36, 0, 0));
Assert.Equal(1, tape.IndexAt(1.5, byDistance: false));
Assert.Equal(2, tape.IndexAt(25, byDistance: true));
Assert.Equal(0, tape.IndexAt(-3, byDistance: false));
}
[Theory]
[InlineData(0, 90)]
[InlineData(90, 90)]
[InlineData(90.5, 100)]
[InlineData(120, 120)]
[InlineData(151, 180)]
[InlineData(180, 180)]
[InlineData(181, 200)]
[InlineData(205, 220)]
public void The_scale_tops_out_at_the_speedometers_range_then_at_the_next_twenty(double max, double top) =>
Assert.Equal(top, TapeFigures.TopSpeed(max));
[Theory]
[InlineData(1, 10)]
[InlineData(0.8, 10)]
[InlineData(0.79, 20)]
[InlineData(0.4, 20)]
[InlineData(0.16, 50)]
[InlineData(0.08, 100)]
[InlineData(0.04, 200)]
[InlineData(0.039, 500)]
public void Speed_lines_take_the_first_step_at_least_8_pixels_apart(double pxPerKmh, int step) =>
Assert.Equal(step, TapeFigures.SpeedStep(pxPerKmh));
[Theory]
[InlineData(60, 1)]
[InlineData(59, 2)]
[InlineData(30, 2)]
[InlineData(12, 5)]
[InlineData(6, 10)]
[InlineData(4, 15)]
[InlineData(2, 30)]
[InlineData(1, 60)]
[InlineData(0.99, 120)]
public void Clock_lines_take_the_first_step_at_least_60_pixels_apart(double pxPerMinute, double step) =>
Assert.Equal(step, TapeFigures.MinuteStep(pxPerMinute));
[Theory]
[InlineData(600, 0.1)]
[InlineData(500, 0.1)]
[InlineData(499, 0.2)]
[InlineData(100, 0.5)]
[InlineData(50, 1)]
[InlineData(25, 2)]
[InlineData(10, 5)]
[InlineData(5, 10)]
[InlineData(2.5, 20)]
[InlineData(2, 50)]
public void Distance_marks_take_the_first_step_at_least_50_pixels_apart(double pxPerKm, double step) =>
Assert.Equal(step, TapeFigures.KmStep(pxPerKm));
[Theory]
[InlineData(-5, "0:00")]
[InlineData(0, "0:00")]
[InlineData(65, "1:05")]
[InlineData(3599, "59:59")]
[InlineData(3600, "1:00:00")]
[InlineData(3723, "1:02:03")]
[InlineData(90000, "25:00:00")]
public void A_length_of_time_prints_hours_only_when_it_has_them(double seconds, string text) =>
Assert.Equal(text, TapeFigures.Clock(seconds));
[Theory]
[InlineData("7:52", 7, 52, 0)]
[InlineData("07:52", 7, 52, 0)]
[InlineData(" 23:05 ", 23, 5, 0)]
[InlineData("7:52:30", 7, 52, 30)]
[InlineData("24:00", 0, 0, 0)]
[InlineData("noon", 0, 0, 0)]
[InlineData("", 0, 0, 0)]
[InlineData(null, 0, 0, 0)]
public void A_clock_typed_in_is_a_time_of_the_day_or_nothing(string? text, int hours, int minutes, int seconds) =>
Assert.Equal(new TimeSpan(hours, minutes, seconds), TapeFigures.ParseClock(text));
[Fact]
public void Tapes_are_listed_from_physicslog_newest_first()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("physicslog", "EU07-424.dat"), Header + Line(0, 1, 0, 0)));
var tapes = installation.Tapes;
var list = tapes.List();
Assert.Single(list);
Assert.Equal("EU07-424", list[0].Vehicle);
Assert.Single(tapes.Load(list[0])!.Samples);
}
}

View File

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

View File

@@ -0,0 +1,119 @@
using StarterNG.Classes;
namespace StarterNG.Tests;
public class KeyMapTests
{
/// <summary>The names of the simulator's key table, keytonamemap in input/keyboardinput.cpp.</summary>
private static readonly HashSet<string> SimulatorKeyNames = new(StringComparer.Ordinal)
{
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", "=",
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
"backspace", "[", "]", "\\", ";", "'", "enter", ",", ".", "/", "space", "pause",
"insert", "delete", "home", "end",
"num_/", "num_*", "num_-", "num_+", "num_enter", "num_.",
"num_0", "num_1", "num_2", "num_3", "num_4", "num_5", "num_6", "num_7", "num_8", "num_9",
"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12",
"tab", "esc", "left", "right", "up", "down", "page_up", "page_down"
};
[Theory]
[InlineData("tab", "KbUseMap")]
[InlineData("f1", "KbUseConsist")]
[InlineData("f2", "KbUseTimetable")]
[InlineData("f3", "KbUseScenario")]
[InlineData("f9", "KbUseLog")]
[InlineData("f10", "KbUseQuit")]
public void What_the_simulator_takes_first_blocks_only_without_a_modifier(string token, string text)
{
Assert.Equal(new SimulatorKeyUse(text, true), KeyMap.SimulatorUse(token, false, false));
Assert.Null(KeyMap.SimulatorUse(token, true, false));
Assert.Null(KeyMap.SimulatorUse(token, false, true));
}
[Fact]
public void Shift_F12_blocks_and_F12_alone_is_free()
{
Assert.Equal(new SimulatorKeyUse("KbUseDebugPanel", true), KeyMap.SimulatorUse("f12", true, false));
Assert.Null(KeyMap.SimulatorUse("f12", false, false));
}
[Theory]
[InlineData("f4", false, false, "KbUseCab")]
[InlineData("f4", true, false, "KbUseCab")]
[InlineData("f5", false, true, "KbUseVehicle")]
[InlineData("f6", false, false, "KbUseDebug")]
[InlineData("f7", false, true, "KbUseDebug")]
[InlineData("f11", false, false, "KbUseEditor")]
[InlineData("3", true, false, "KbUseSceneryEvent")]
[InlineData("3", false, true, "KbUseCamera")]
public void What_is_left_to_a_key_a_binding_may_replace(string token, bool shift, bool ctrl, string text)
{
Assert.Equal(new SimulatorKeyUse(text, false), KeyMap.SimulatorUse(token, shift, ctrl));
}
[Theory]
[InlineData("3", false, false)]
[InlineData("a", false, false)]
[InlineData("a", true, true)]
[InlineData("f7", true, false)]
[InlineData("f8", false, false)]
[InlineData("f11", true, false)]
[InlineData("num_5", true, false)]
[InlineData("enter", false, false)]
public void The_rest_the_simulator_leaves_alone(string token, bool shift, bool ctrl)
{
Assert.Null(KeyMap.SimulatorUse(token, shift, ctrl));
}
[Fact]
public void The_bundled_keys_use_nothing_the_simulator_takes_first()
{
var config = new KeyboardConfig();
string path = Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini");
config.Parse(File.ReadAllText(path));
var taken = config.Bindings
.Where(b => b.IsAssigned && KeyMap.SimulatorUse(b.Key, b.Shift, b.Ctrl) is { Blocks: true })
.Select(b => b.Command)
.ToList();
Assert.Empty(taken);
Assert.Contains(config.Bindings, b => b.IsAssigned);
}
[Fact]
public void Every_key_drawn_is_one_the_simulator_knows()
{
var drawn = KeyMap.MainBlock.Concat(KeyMap.NumpadBlock).Concat(KeyMap.NavBlock)
.SelectMany(row => row)
.Select(cap => cap.Token)
.OfType<string>();
Assert.Empty(drawn.Where(token => !SimulatorKeyNames.Contains(token)));
}
[Theory]
[InlineData("page_up", "Page Up")]
[InlineData("page_down", "Page Down")]
[InlineData("esc", "Esc")]
[InlineData("num_enter", "Num Enter")]
public void Key_names_read_as_on_a_keyboard(string token, string expected)
{
Assert.Equal(expected, KeyMap.DisplayName(token));
}
[Theory]
[InlineData("a", false, false, "A")]
[InlineData("a", true, false, "Shift + A")]
[InlineData("a", false, true, "Ctrl + A")]
[InlineData("num_enter", true, true, "Ctrl + Shift + Num Enter")]
[InlineData("none", true, true, "—")]
[InlineData("NONE", false, true, "—")]
[InlineData("", true, false, "—")]
public void A_combination_reads_with_its_modifiers_unless_there_is_no_key(string token, bool shift, bool ctrl, string expected)
{
Assert.Equal(expected, KeyMap.ComboText(token, shift, ctrl));
}
}

View File

@@ -0,0 +1,157 @@
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"));
private static KeyBinding Parse(string line)
{
var config = new KeyboardConfig();
config.Parse(line);
return Assert.Single(config.Bindings);
}
[Fact]
public void A_binding_without_a_description_shows_the_translated_one()
{
var binding = Parse("hornhighactivate a");
string text = new KeyboardConfig().DescriptionOf(binding, _ => "high tone horn");
Assert.Equal("high tone horn", text);
}
[Fact]
public void A_description_someone_wrote_wins_over_the_translation()
{
var binding = Parse("hornhighactivate a // my own horn");
string text = new KeyboardConfig().DescriptionOf(binding, _ => "high tone horn");
Assert.Equal("my own horn", text);
}
[Fact]
public void Without_a_translation_the_file_description_stays()
{
var binding = Parse("hornhighactivate a // syrena wysoka");
Assert.Equal("syrena wysoka", new KeyboardConfig().DescriptionOf(binding, _ => string.Empty));
}
// ── Which binding sits where ─────────────────────────────────────────────
private static KeyboardConfig Config(params string[] lines)
{
var config = new KeyboardConfig();
config.Parse(string.Join("\n", lines));
return config;
}
[Fact]
public void A_cleared_binding_is_on_no_key_and_no_modifier()
{
var binding = Parse("hornhighactivate shift ctrl a");
binding.Clear();
Assert.False(binding.IsAssigned);
Assert.False(binding.Shift);
Assert.False(binding.Ctrl);
Assert.Equal("none", binding.Key);
}
[Theory]
[InlineData("a", true, false, true)]
[InlineData("A", true, false, true)]
[InlineData("a", false, false, false)]
[InlineData("a", true, true, false)]
[InlineData("b", true, false, false)]
public void A_binding_is_on_its_key_with_exactly_its_modifiers(string token, bool shift, bool ctrl, bool on) =>
Assert.Equal(on, Parse("hornhighactivate shift a").IsOn(token, shift, ctrl));
[Fact]
public void An_unassigned_binding_is_on_nothing_not_even_the_word_none() =>
Assert.False(Parse("hornhighactivate none").IsOn("none", false, false));
[Fact]
public void The_binding_at_a_combination_is_found_by_key_and_modifiers()
{
var config = Config("plain a", "shifted shift a");
Assert.Equal("plain", config.BindingAt("A", false, false)?.Command);
Assert.Equal("shifted", config.BindingAt("a", true, false)?.Command);
Assert.Null(config.BindingAt("a", false, true));
}
[Fact]
public void A_combination_put_on_a_binding_moves_there_from_the_one_that_had_it()
{
var config = Config("first ctrl a", "second b", "third a");
config.Put(config.Bindings[1], "a", shift: false, ctrl: true);
Assert.False(config.Bindings[0].IsAssigned);
Assert.False(config.Bindings[0].Ctrl);
Assert.True(config.Bindings[1].IsOn("a", false, true));
// The same key with other modifiers is another combination.
Assert.True(config.Bindings[2].IsOn("a", false, false));
}
[Fact]
public void Every_binding_doubled_up_on_a_combination_leaves_it_but_the_one_put_there()
{
var config = Config("first a", "second a", "third a");
config.Put(config.Bindings[1], "a", shift: false, ctrl: false);
Assert.False(config.Bindings[0].IsAssigned);
Assert.True(config.Bindings[1].IsOn("a", false, false));
Assert.False(config.Bindings[2].IsAssigned);
}
[Fact]
public void Nothing_put_on_a_combination_frees_it()
{
var config = Config("first shift a", "second b");
config.Put(null, "a", shift: true, ctrl: false);
Assert.False(config.Bindings[0].IsAssigned);
Assert.True(config.Bindings[1].IsOn("b", false, false));
}
[Fact]
public void A_combination_put_back_on_the_binding_that_has_it_stays()
{
var config = Config("first a");
config.Put(config.Bindings[0], "a", shift: false, ctrl: false);
Assert.True(config.Bindings[0].IsOn("a", false, false));
}
[Fact]
public void Two_bindings_on_a_combination_conflict_however_the_key_is_spelled()
{
var config = Config("first shift a", "second shift A", "third a", "fourth ctrl a", "fifth none", "sixth none");
var conflicts = config.ConflictingCombos();
Assert.Equal(config.Bindings[0].ComboKey, Assert.Single(conflicts));
Assert.Contains(config.Bindings[1].ComboKey, conflicts);
Assert.DoesNotContain(config.Bindings[2].ComboKey, conflicts);
}
[Fact]
public void Only_bindings_on_a_key_of_the_numpad_count_as_on_it()
{
var config = Config("first num_1", "second shift NUM_enter", "third a", "fourth none");
Assert.Equal(2, config.BindingsOnNumpad());
}
}

View File

@@ -0,0 +1,90 @@
using System.Runtime.ExceptionServices;
using System.Text;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class LegacyDecodingTests
{
private const string Polish = "Zakład_Świętokrzyski_w_Kielcach";
[Fact]
public void A_code_page_1250_file_decodes_to_its_letters()
{
Assert.Equal(Polish, LegacyText.Decode(LegacyText.CodePage1250.GetBytes(Polish)));
}
[Fact]
public void A_file_re_saved_as_utf8_decodes_too()
{
// A handful of textures.txt in the wild are UTF-8; read as 1250 they turn
// "Zakład" into "ZakĹ‚ad".
Assert.Equal(Polish, LegacyText.Decode(Encoding.UTF8.GetBytes(Polish)));
}
[Fact]
public void A_byte_order_mark_does_not_stick_to_the_text()
{
byte[] bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes("$a\n" + Polish)).ToArray();
Assert.Equal("$a\n" + Polish, LegacyText.Decode(bytes));
}
[Fact]
public void Telling_the_two_apart_does_not_throw()
{
// Decoding used to try strict UTF-8 and catch the failure: a first-chance
// exception for every 1250 file, hundreds of them on a load.
int thread = Environment.CurrentManagedThreadId;
int thrown = 0;
EventHandler<FirstChanceExceptionEventArgs> count = (_, e) =>
{
if (e.Exception is DecoderFallbackException && Environment.CurrentManagedThreadId == thread)
thrown++;
};
AppDomain.CurrentDomain.FirstChanceException += count;
try
{
LegacyText.Decode(LegacyText.CodePage1250.GetBytes(Polish));
}
finally
{
AppDomain.CurrentDomain.FirstChanceException -= count;
}
Assert.Equal(0, thrown);
}
[Fact]
public void Plain_ascii_reads_the_same_either_way()
{
Assert.Equal("PESA_Bydgoszcz", LegacyText.Decode(Encoding.ASCII.GetBytes("PESA_Bydgoszcz")));
}
[Fact]
public void A_utf8_textures_file_reaches_the_catalogue_intact()
{
var files = new InMemoryFileSystem().WithFile(
TestInstallation.At("dynamic", "pkp", "elf", "textures.txt"),
"en96-001.mat=34we,EN96,EN96-001 // v2,EN96-001,PREG,Zakład_Świętokrzyski,29.09.2011");
var installation = new TestInstallation(files);
installation.Vehicles.Load(installation.Library.Vehicles);
var meta = Assert.Single(installation.Library.Vehicles.Textures).Meta;
Assert.Equal("Zakład_Świętokrzyski".Replace('_', ' '), meta!.Depot);
}
[Fact]
public void A_code_page_1250_scenery_reaches_the_list_intact()
{
var files = new InMemoryFileSystem().WithLegacyFile(
TestInstallation.At("scenery", "td.scn"), "//$n Strzebiń - Woźniki Śl.");
var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll());
Assert.Equal("Strzebiń - Woźniki Śl.", scenery.Name);
}
}

View File

@@ -0,0 +1,237 @@
using System.Xml.Linq;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Services;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class LocalizationTests
{
private static LocalizationService Build() =>
new(TestLog.Over());
private static Dictionary<string, string> Strings(string path) =>
XDocument.Load(path).Descendants("String")
.ToDictionary(s => (string)s.Attribute("key")!, s => s.Value);
public static TheoryData<string> Languages()
{
var data = new TheoryData<string>();
foreach (var lang in LocalizationService.AvailableLanguages())
if (lang.Code != "en")
data.Add(lang.Code);
return data;
}
[Theory]
[MemberData(nameof(Languages))]
public void Every_language_has_every_key_but_the_optional_ones(string code)
{
var all = LocalizationService.AvailableLanguages();
var english = Strings(all.Single(l => l.Code == "en").Path);
var other = Strings(all.Single(l => l.Code == code).Path);
var missing = english.Keys
.Where(key => !key.EndsWith("Desc", StringComparison.Ordinal) &&
!key.StartsWith("Cmd.", StringComparison.Ordinal))
.Where(key => !other.ContainsKey(key));
Assert.Empty(missing);
}
[Fact]
public void A_key_description_only_english_has_falls_back_to_english()
{
var loc = Build();
loc.Load("cz");
Assert.Equal(string.Empty, loc.Opt("Cmd.mastercontrollerincrease"));
Assert.Equal("master controller up", loc.OptOrEnglish("Cmd.mastercontrollerincrease"));
}
[Fact]
public void A_string_the_language_has_is_not_replaced_with_english()
{
var loc = Build();
loc.Load("pl");
Assert.Equal(loc["Language"], loc.OptOrEnglish("Language"));
Assert.NotEqual("Language", loc.OptOrEnglish("Language"));
}
private const string English =
"<Language code=\"en\" name=\"English\"><String key=\"Hello\">Hello</String></Language>";
/// <summary>A file cut off right after its header: it still looks like a language.</summary>
private const string CutShort = "<Language code=\"pl\" name=\"Polski\">";
private static (LocalizationService Loc, FileDiagnosticsLog Log, string Directory) InFolderOf(
params (string File, string Text)[] languages)
{
string directory = Directory.CreateTempSubdirectory("starterng-lang-").FullName;
foreach (var (file, text) in languages)
File.WriteAllText(Path.Combine(directory, file), text);
var log = TestLog.Over();
return (new LocalizationService(log, directory), log, directory);
}
[Fact]
public void A_translation_cut_short_counts_as_missing_and_is_logged()
{
var (loc, log, directory) = InFolderOf(("en.xml", English), ("pl.xml", CutShort));
try
{
Assert.Equal("English", loc.Load("pl"));
Assert.Equal("en", loc.CurrentLangCode);
Assert.Equal("Hello", loc["Hello"]);
Assert.Contains("lang/pl.xml", log.Text);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void With_every_translation_cut_short_the_starter_still_starts()
{
var (loc, log, directory) = InFolderOf(("pl.xml", CutShort));
try
{
Assert.Equal("English", loc.Load("pl"));
Assert.Equal("en", loc.CurrentLangCode);
Assert.Equal("Hello", loc["Hello"]);
Assert.Contains("lang/pl.xml", log.Text);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void A_language_cut_short_leaves_english_rather_than_the_one_in_use_before()
{
var (loc, _, directory) = InFolderOf(
("cz.xml", "<Language code=\"cz\" name=\"Cestina\"><String key=\"Hello\">Ahoj</String></Language>"),
("en.xml", English),
("pl.xml", CutShort));
try
{
loc.Load("cz");
Assert.Equal("Ahoj", loc["Hello"]);
loc.Load("pl");
Assert.Equal("en", loc.CurrentLangCode);
Assert.Equal("Hello", loc["Hello"]);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
/// <summary>A player's folder, the bundled one with English in it, the old starter's and the simulator's.</summary>
private sealed class Folders : IDisposable
{
public readonly string Root = Directory.CreateTempSubdirectory("starterng-langs-").FullName;
public string Own => Sub("own");
public string Bundled => Sub("bundled");
public string OldStarter => Sub("starter");
public string Simulator => Sub("lang");
private string Sub(string name) => Directory.CreateDirectory(Path.Combine(Root, name)).FullName;
public LocalizationService Service() =>
new(TestLog.Over(),
Bundled, new LanguageFolders(new[] { Own, Bundled }, OldStarter, Simulator));
public void Dispose() => Directory.Delete(Root, recursive: true);
}
[Fact]
public void A_players_own_translation_wins_over_the_bundled_one()
{
using var folders = new Folders();
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
File.WriteAllText(Path.Combine(folders.Bundled, "pl.xml"),
"<Language code=\"pl\" name=\"Polski\"><String key=\"Hello\">Czesc</String></Language>");
File.WriteAllText(Path.Combine(folders.Own, "moj.xml"),
"<Language code=\"pl\" name=\"Moj polski\"><String key=\"Hello\">Witaj</String></Language>");
var loc = folders.Service();
Assert.Equal("Moj polski", loc.Load("pl"));
Assert.Equal("Witaj", loc["Hello"]);
Assert.True(loc.HasOwnTexts);
Assert.Single(loc.Languages(), lang => lang.Code == "pl");
}
[Fact]
public void An_old_starters_language_is_offered_in_english_with_its_key_descriptions()
{
using var folders = new Folders();
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
File.WriteAllText(Path.Combine(folders.OldStarter, "lang-ZH.txt"),
"[Main]\ncbLang.Items.Text=PL|EN\n[labels]\nx=y\n<eu07_input-keyboard.ini\nhornhighactivate=gwizd\n\n[frmOther]\nz=q\n");
var loc = folders.Service();
Assert.Contains(loc.Languages(), lang => lang.Code == "zh" && lang.Name == "ZH" && lang.Path.Length == 0);
loc.Load("zh");
Assert.Equal("zh", loc.CurrentLangCode);
Assert.False(loc.HasOwnTexts);
Assert.Equal("Hello", loc["Hello"]);
Assert.Equal("gwizd", loc.Opt("Cmd.hornhighactivate"));
Assert.Equal(string.Empty, loc.Opt("Cmd.z"));
}
[Fact]
public void Of_the_simulators_files_only_a_language_code_counts()
{
using var folders = new Folders();
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
foreach (string file in new[] { "hu.po", "editor_pl.po", "template.po" })
File.WriteAllText(Path.Combine(folders.Simulator, file), string.Empty);
var codes = folders.Service().Languages().Select(lang => lang.Code).ToList();
Assert.Contains("hu", codes);
Assert.DoesNotContain("editor_pl", codes);
Assert.DoesNotContain("template", codes);
}
[Fact]
public void A_code_eu07_ini_names_stays_on_the_list_once()
{
using var folders = new Folders();
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
var loc = folders.Service();
loc.Keep("sk");
loc.Keep("SK");
loc.Keep("en");
Assert.Single(loc.Languages(), lang => lang.Code == "sk");
Assert.Equal(2, loc.Languages().Count);
}
[Fact]
public void A_key_a_translation_lacks_comes_in_english()
{
using var folders = new Folders();
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
File.WriteAllText(Path.Combine(folders.Own, "sk.xml"),
"<Language code=\"sk\" name=\"Slovensky\"><String key=\"Other\">Ine</String></Language>");
var loc = folders.Service();
loc.Load("sk");
Assert.Equal("Hello", loc["Hello"]);
Assert.Equal("Ine", loc["Other"]);
}
}

View File

@@ -0,0 +1,36 @@
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Tests;
public sealed class PhysicalFileSystemTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("starterng-disk-").FullName;
private readonly PhysicalFileSystem _files = new();
public void Dispose() => Directory.Delete(_root, recursive: true);
[Fact]
public void An_atomic_write_makes_or_replaces_the_file_and_leaves_nothing_beside_it()
{
string path = Path.Combine(_root, "file.json");
_files.WriteAllTextAtomic(path, "first");
_files.WriteAllTextAtomic(path, "zażółć");
Assert.Equal("zażółć", File.ReadAllText(path));
Assert.Equal(new[] { path }, Directory.GetFiles(_root));
}
[Fact]
public void An_atomic_write_that_cannot_finish_leaves_what_stood_there()
{
string path = Path.Combine(_root, "file.json");
File.WriteAllText(path, "what stood there");
// Nowhere to put the new text on its way in.
Directory.CreateDirectory(path + PhysicalFileSystem.TempSuffix);
Assert.ThrowsAny<Exception>(() => _files.WriteAllTextAtomic(path, "half"));
Assert.Equal("what stood there", File.ReadAllText(path));
}
}

View File

@@ -74,6 +74,70 @@ public class PhysicsAndAssetsTests
Assert.NotNull(installation.Physics.For("pkp/ep07/", "ep07")); Assert.NotNull(installation.Physics.For("pkp/ep07/", "ep07"));
} }
[Theory]
[InlineData("pkp/21xm_v1/")]
[InlineData("pkp/21xm_v1")]
[InlineData("PKP\\21XM_V1")]
[InlineData("dynamic/pkp/21xm_v1/")]
public void Two_fiz_files_of_one_name_are_told_apart_by_their_folder(string folder)
{
// As in the game: dynamic/**/21xm_v1/214m.fiz and sa134_v1/214m.fiz.
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Fiz("pkp", "21xm_v1", "214m"), "Param: M=51000\nDimensions: L=24.38")
.WithFile(Fiz("pkp", "sa134_v1", "214m"), "Param: M=48000\nDimensions: L=24.5"));
var physics = installation.Physics.For(folder, "214m.e3d")!;
Assert.Equal(51000, physics.Mass);
Assert.Equal(24.38, physics.Length);
Assert.Equal(48000, installation.Physics.For("pkp/sa134_v1/", "214m")!.Mass);
// Asked again, the first answer has not stuck to the name.
Assert.Equal(51000, installation.Physics.For(folder, "214m")!.Mass);
}
[Fact]
public void A_folder_without_the_fiz_falls_back_to_the_name_alone()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Fiz("pkp", "ep07", "ep07"), "Param: M=80000"));
Assert.Equal(80000, installation.Physics.For("pkp/other/", "ep07")!.Mass);
Assert.Equal(80000, installation.Physics.For(null, "ep07")!.Mass);
}
[Fact]
public void The_fallback_to_a_name_two_folders_share_is_the_same_on_every_machine()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Fiz("zz", "b", "214m"), "Param: M=2")
.WithFile(Fiz("aa", "a", "214m"), "Param: M=1"));
Assert.Equal(1, installation.Physics.For("elsewhere/", "214m")!.Mass);
}
[Fact]
public void A_dumb_variant_in_the_vehicles_own_folder_goes_before_a_namesake_elsewhere()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Fiz("pkp", "ep07", "ep07dumb"), "Param: M=1000")
.WithFile(Fiz("pkp", "other", "ep07"), "Param: M=2000"));
Assert.Equal(1000, installation.Physics.For("pkp/ep07/", "ep07")!.Mass);
}
[Fact]
public void Two_folders_sharing_a_name_count_once_among_what_is_indexed()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Fiz("pkp", "a", "214m"), "Param: M=1")
.WithFile(Fiz("pkp", "b", "214m"), "Param: M=2")
.WithFile(Fiz("pkp", "c", "ep07"), "Param: M=3"));
installation.Physics.Preload();
Assert.Equal(2, installation.Physics.IndexedCount);
}
[Fact] [Fact]
public void An_unknown_model_has_no_physics_and_an_empty_installation_indexes_nothing() public void An_unknown_model_has_no_physics_and_an_empty_installation_indexes_nothing()
{ {

View File

@@ -0,0 +1,160 @@
using StarterNG.Classes;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
// Over the fake disk throughout: PresetStore itself is the player's real file.
public class PresetFileTests
{
private const string PresetsPath = "/home/user/.config/MaSzyna/starter/userpresets.json";
private const string ThreePresets = """
{ "Presets": [
{ "Name": "a", "Entry": "trainset a" },
{ "Name": "b", "Entry": "trainset b" },
{ "Name": "c", "Entry": "trainset c" }
] }
""";
private static (PresetFile Presets, InMemoryFileSystem Files, FileDiagnosticsLog Log) Rig(string? held = null)
{
var files = new InMemoryFileSystem();
if (held is not null)
files.WithFile(PresetsPath, held);
var log = TestLog.Over(files);
return (new PresetFile(files, log, PresetsPath), files, log);
}
[Fact]
public void A_saved_preset_joins_the_ones_in_the_file()
{
var (presets, files, _) = Rig(ThreePresets);
presets.Save("d", "trainset d");
Assert.Equal(new[] { "a", "b", "c", "d" }, presets.Presets().Select(p => p.Name));
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
}
[Fact]
public void Saving_under_a_name_there_replaces_that_one()
{
var (presets, _, _) = Rig(ThreePresets);
presets.Save("B", "trainset other");
Assert.Equal(new[] { "a", "B", "c" }, presets.Presets().Select(p => p.Name));
}
[Fact]
public void A_first_preset_needs_no_file_to_be_there()
{
var (presets, files, log) = Rig();
presets.Save("a", "trainset a");
Assert.Equal("a", Assert.Single(presets.Presets()).Name);
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
Assert.Equal(string.Empty, log.Text);
}
[Theory]
[InlineData("""{ "Presets": [ { "Name": "a", "Entry": "trainset a" }, { "Na""")]
[InlineData("")]
[InlineData("null")]
[InlineData("""{ "Presets": null }""")]
public void A_file_cut_short_goes_aside_before_a_save_replaces_it(string held)
{
var (presets, files, log) = Rig(held);
presets.Save("new", "trainset new");
Assert.Equal(held, files.ReadAllText(PresetsPath + ".unreadable"));
Assert.Equal("new", Assert.Single(presets.Presets()).Name);
Assert.Contains(PresetsPath + ".unreadable", log.Text);
}
[Fact]
public void An_earlier_copy_set_aside_is_not_overwritten()
{
var (presets, files, _) = Rig("{ cut");
files.WithFile(PresetsPath + ".unreadable", "older");
presets.Save("new", "trainset new");
Assert.Equal("older", files.ReadAllText(PresetsPath + ".unreadable"));
Assert.Equal("{ cut", files.ReadAllText(PresetsPath + ".unreadable-2"));
}
[Fact]
public void A_locked_file_is_left_alone_when_no_copy_of_it_can_be_made()
{
var (presets, files, log) = Rig(ThreePresets);
files.FailReading(PresetsPath);
presets.Save("new", "trainset new");
files.AllowReading(PresetsPath);
Assert.Equal(ThreePresets, files.ReadAllText(PresetsPath));
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
Assert.Contains("saving", log.Text);
}
[Fact]
public void A_file_unlocked_between_the_read_and_the_write_is_still_kept()
{
// The list in hand is the empty one from the read that failed: the file has
// to be kept whatever it looks like by the time it would be replaced.
var (presets, files, _) = Rig(ThreePresets);
var unlocking = new UnlockingLog(files, PresetsPath);
var racing = new PresetFile(files, unlocking, PresetsPath);
files.FailReading(PresetsPath);
racing.Save("new", "trainset new");
Assert.Equal(ThreePresets, files.ReadAllText(PresetsPath + ".unreadable"));
Assert.Equal("new", Assert.Single(presets.Presets()).Name);
}
[Fact]
public void Deleting_from_an_unreadable_file_leaves_it_as_it_is()
{
var (presets, files, _) = Rig("{ cut");
presets.Delete("a");
Assert.Equal("{ cut", files.ReadAllText(PresetsPath));
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
}
[Fact]
public void An_unreadable_file_shows_as_no_presets_and_is_logged()
{
var (presets, _, log) = Rig("{ cut");
Assert.Empty(presets.Presets());
Assert.Contains("reading", log.Text);
}
/// <summary>Lets the file go the moment its read has failed and been logged.</summary>
private sealed class UnlockingLog : StarterNG.Application.Abstractions.IDiagnosticsLog
{
private readonly InMemoryFileSystem _files;
private readonly string _path;
public UnlockingLog(InMemoryFileSystem files, string path)
{
_files = files;
_path = path;
}
public string LogPath => "";
public string Text => "";
public void Log(string message) => _files.AllowReading(_path);
public void Log(string context, Exception exception) => _files.AllowReading(_path);
public void Log(IEnumerable<string> messages) => _files.AllowReading(_path);
public void Clear() { }
public void Flush() { }
}
}

View File

@@ -0,0 +1,103 @@
using StarterNG.Domain.Settings;
namespace StarterNG.Tests;
public class ResolutionChoicesTests
{
private static List<string> Listed(ScreenSize[] monitors, ScreenSize current) =>
ResolutionChoices.For(monitors, current).Select(c => c.Size.ToString()).ToList();
[Fact]
public void The_monitors_come_first_and_are_marked()
{
var choices = ResolutionChoices.For(new[] { new ScreenSize(1920, 1200), new ScreenSize(2560, 1440) },
new ScreenSize(1280, 720));
Assert.Equal(new ScreenSize(1920, 1200), choices[0].Size);
Assert.Equal(new ScreenSize(2560, 1440), choices[1].Size);
Assert.True(choices[0].IsMonitor);
Assert.True(choices[1].IsMonitor);
Assert.All(choices.Skip(2), c => Assert.False(c.IsMonitor));
}
[Fact]
public void A_size_no_monitor_can_show_is_not_offered()
{
var listed = Listed(new[] { new ScreenSize(1920, 1080) }, new ScreenSize(1280, 720));
Assert.Contains("1920x1080", listed);
Assert.Contains("1600x900", listed);
Assert.DoesNotContain("3840x2160", listed);
Assert.DoesNotContain("2560x1440", listed);
// Taller than the screen, though no wider.
Assert.DoesNotContain("1920x1200", listed);
}
[Fact]
public void A_size_fitting_any_of_the_monitors_is_offered()
{
var listed = Listed(new[] { new ScreenSize(1920, 1080), new ScreenSize(3440, 1440) }, new ScreenSize(1280, 720));
Assert.Contains("2560x1080", listed);
Assert.DoesNotContain("3840x2160", listed);
}
[Theory]
[InlineData(1920, 1200)]
[InlineData(1280, 800)]
[InlineData(2560, 1080)]
[InlineData(3440, 1440)]
public void The_monitors_size_is_there_even_where_it_is_not_a_usual_one(int width, int height)
{
var listed = Listed(new[] { new ScreenSize(width, height) }, new ScreenSize(1024, 768));
Assert.Equal($"{width}x{height}", listed[0]);
}
[Fact]
public void The_size_asked_for_stays_on_the_list_whatever_it_is()
{
var listed = Listed(new[] { new ScreenSize(1920, 1080) }, new ScreenSize(5120, 1440));
Assert.Contains("5120x1440", listed);
Assert.Single(listed, "5120x1440");
}
[Fact]
public void The_rest_goes_from_the_largest_down_without_repeats()
{
var listed = Listed(new[] { new ScreenSize(2560, 1440) }, new ScreenSize(1920, 1080));
Assert.Equal(listed.Count, listed.Distinct().Count());
var sizes = ResolutionChoices.For(new[] { new ScreenSize(2560, 1440) }, new ScreenSize(1920, 1080))
.Skip(1).Select(c => c.Size).ToList();
Assert.Equal(sizes.OrderByDescending(s => s.Width).ThenByDescending(s => s.Height), sizes);
}
[Fact]
public void Two_monitors_of_a_size_are_one_entry()
{
var choices = ResolutionChoices.For(new[] { new ScreenSize(1920, 1080), new ScreenSize(1920, 1080) },
new ScreenSize(1920, 1080));
Assert.Single(choices, c => c.Size == new ScreenSize(1920, 1080));
}
[Fact]
public void With_no_monitors_known_everything_usual_is_offered()
{
var listed = Listed(Array.Empty<ScreenSize>(), new ScreenSize(1280, 720));
Assert.Contains("3840x2160", listed);
Assert.Contains("1280x800", listed);
}
[Fact]
public void A_size_of_nothing_is_not_listed()
{
var listed = Listed(new[] { new ScreenSize(0, 0) }, new ScreenSize(0, 0));
Assert.DoesNotContain("0x0", listed);
Assert.NotEmpty(listed);
}
}

View File

@@ -0,0 +1,47 @@
using StarterNG.Domain.Vehicles;
namespace StarterNG.Tests;
public class RevisionSpanTests
{
[Theory]
[InlineData("14.01.2005", "2005-01-14", "2005-01-14")]
[InlineData("06.12.2016", "2016-12-06", "2016-12-06")]
[InlineData("xx.06.2008", "2008-06-01", "2008-06-30")]
[InlineData("?.06.2008", "2008-06-01", "2008-06-30")]
[InlineData("xx.xx.2011", "2011-01-01", "2011-12-31")]
[InlineData("?.?.2011", "2011-01-01", "2011-12-31")]
[InlineData("02.2016", "2016-02-01", "2016-02-29")]
public void A_credit_line_date_is_as_exact_as_it_says(string text, string from, string to)
{
var span = RevisionSpan.Parse(text);
Assert.Equal(DateTime.Parse(from), span?.From);
Assert.Equal(DateTime.Parse(to), span?.To);
}
[Theory]
[InlineData("2016", "2016-01-01", "2016-12-31")]
[InlineData("2016-12", "2016-12-01", "2016-12-31")]
[InlineData("2016-12-06", "2016-12-06", "2016-12-06")]
[InlineData(" 2016-12- ", "2016-12-01", "2016-12-31")]
public void What_is_typed_to_filter_by_reads_the_same_way(string text, string from, string to)
{
var span = RevisionSpan.Parse(text);
Assert.Equal(DateTime.Parse(from), span?.From);
Assert.Equal(DateTime.Parse(to), span?.To);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("?")]
[InlineData("xx.xx.xxxx")]
[InlineData("31.02.2015")]
[InlineData("01.13.2015")]
[InlineData("VRZ-99_Kolowag_Septemwri")]
[InlineData("201")]
public void Anything_else_is_no_date(string? text) =>
Assert.Null(RevisionSpan.Parse(text));
}

View File

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

View File

@@ -1,13 +1,9 @@
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Tests.Fakes; using StarterNG.Tests.Fakes;
namespace StarterNG.Tests; namespace StarterNG.Tests;
public class SceneryExportTests public class SceneryExportTests
{ {
private static SceneryParser Parser() =>
new(new FixedClock(new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
[Fact] [Fact]
public void An_untouched_scenery_exports_its_trainsets_back_verbatim() public void An_untouched_scenery_exports_its_trainsets_back_verbatim()
{ {
@@ -18,7 +14,7 @@ public class SceneryExportTests
endtrainset endtrainset
FirstInit FirstInit
"""; """;
var scenery = Parser().Parse("scenery/x.scn", scn); var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
@@ -27,6 +23,45 @@ public class SceneryExportTests
Assert.Contains("endtrainset", exported); Assert.Contains("endtrainset", exported);
} }
[Fact]
public void A_consist_emptied_stays_out_of_the_export()
{
const string 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 scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.Trainsets[0].RemoveVehicles();
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
Assert.DoesNotContain("trainset pociag", exported);
Assert.DoesNotContain("ep07-001", exported);
Assert.Contains("FirstInit", exported);
}
[Fact]
public void An_entry_that_could_not_be_read_goes_when_emptied_too()
{
const string 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 scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.Trainsets[0].Parsed = false;
scenery.Trainsets[0].RemoveVehicles();
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
Assert.DoesNotContain("ep07-001", exported);
}
[Fact] [Fact]
public void Selected_optional_includes_are_written_back_before_FirstInit() public void Selected_optional_includes_are_written_back_before_FirstInit()
{ {
@@ -36,7 +71,7 @@ public class SceneryExportTests
endoptional endoptional
FirstInit FirstInit
"""; """;
var scenery = Parser().Parse("scenery/x.scn", scn); var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
@@ -54,7 +89,7 @@ public class SceneryExportTests
endoptional endoptional
FirstInit FirstInit
"""; """;
var scenery = Parser().Parse("scenery/x.scn", scn); var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.Includes[0].Selected = false; scenery.Includes[0].Selected = false;
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
@@ -71,7 +106,7 @@ public class SceneryExportTests
endoptional endoptional
FirstInit FirstInit
"""; """;
var scenery = Parser().Parse("scenery/x.scn", scn); var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.HasCompanionTimetable = false; scenery.HasCompanionTimetable = false;
Assert.Contains("include rozklad.inc", Assert.Contains("include rozklad.inc",
@@ -95,7 +130,7 @@ public class SceneryExportTests
time 8:30 0 0 endtime time 8:30 0 0 endtime
FirstInit FirstInit
"""; """;
var scenery = Parser().Parse("scenery/x.scn", scn); var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.Weather.Day = 200; scenery.Weather.Day = 200;
scenery.Weather.Temperature = 25; scenery.Weather.Temperature = 25;
@@ -120,7 +155,7 @@ public class SceneryExportTests
endtrainset endtrainset
FirstInit FirstInit
"""; """;
var scenery = Parser().Parse("scenery/x.scn", scn); var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
Assert.True(scenery.Trainsets[0].Decor); Assert.True(scenery.Trainsets[0].Decor);
Assert.DoesNotContain("trainset ai_1", Assert.DoesNotContain("trainset ai_1",
@@ -136,11 +171,28 @@ public class SceneryExportTests
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
FirstInit FirstInit
"""; """;
var scenery = Parser().Parse("scenery/x.scn", scn); var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
Assert.Contains("wagon_1", exported); Assert.Contains("wagon_1", exported);
Assert.Contains("enddynamic", exported); Assert.Contains("enddynamic", exported);
} }
[Theory]
[InlineData(0, "3 0 enddynamic")]
[InlineData(3, "3 3 pantstate enddynamic")]
public void A_load_type_is_written_only_after_a_count_above_zero(int count, string tail)
{
// After a 0 the simulator would read the type as a destination and take
// the next vehicle with it.
var car = new StarterNG.Classes.Dynamic
{
Name = "en57", DataFolder = "pkp/en57", SkinFile = "s", MmdFile = "m",
LoadType = StarterNG.Classes.Dynamic.PantState, LoadCount = count
};
car.Coupling.Flags = 3;
Assert.EndsWith(tail + "\n", car.ToTrainsetNode());
}
} }

View File

@@ -0,0 +1,77 @@
using StarterNG.Domain.Sceneries;
namespace StarterNG.Tests;
public class SceneryFaultTextTests
{
private static string Wording(string key) => key switch
{
"SyntaxLost" => "lost: {0}",
"SyntaxUnmade" => "unmade: {0}",
"SyntaxMore" => "and {0} more",
_ => key + " {0} {1}"
};
private static string Format(int limit, params SceneryFault[] faults) =>
SceneryFaultText.Format(faults, limit, Wording);
[Fact]
public void No_faults_read_as_nothing() =>
Assert.Equal("", Format(10));
[Fact]
public void The_place_and_the_fault_stand_in_two_columns_as_wide_as_the_longest_place()
{
string text = Format(10,
new SceneryFault(SceneryFaultKind.VehicleIncomplete, "a.scn", 3, "v1", "x"),
new SceneryFault(SceneryFaultKind.NotANumber, "long.inc", 120, "v2", "y"));
Assert.Equal(
"a.scn:3 SyntaxVehicleIncomplete v1 x\n" +
"long.inc:120 SyntaxNotANumber v2 y",
text);
}
[Fact]
public void What_a_fault_has_no_subject_or_token_for_is_filled_in()
{
string text = Format(10, new SceneryFault(SceneryFaultKind.StrayEndTrainset, "a.scn", 3));
Assert.Equal("a.scn:3 SyntaxStrayEndTrainset ?", text);
}
[Fact]
public void What_a_fault_costs_stands_under_it_in_the_second_column()
{
string text = Format(10,
new SceneryFault(SceneryFaultKind.VehicleNotClosed, "a.scn", 3, "v1", "x") { Lost = "v2, v3", Unmade = "b" });
Assert.Equal(
"a.scn:3 SyntaxVehicleNotClosed v1 x\n" +
" lost: v2, v3\n" +
" unmade: b",
text);
}
[Fact]
public void Past_the_limit_the_faults_are_only_counted_and_do_not_widen_the_column()
{
string text = Format(1,
new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 3, "v1", "x"),
new SceneryFault(SceneryFaultKind.NotANumber, "a-longer-name.scn", 4, "v2", "y"),
new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 5, "v3", "z"));
Assert.Equal(
"a.scn:3 SyntaxNotANumber v1 x\n" +
"and 2 more",
text);
}
[Fact]
public void Exactly_as_many_as_the_limit_are_not_counted_as_more()
{
string text = Format(1, new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 3, "v1", "x"));
Assert.DoesNotContain("more", text);
}
}

View File

@@ -0,0 +1,376 @@
using StarterNG.Application;
using StarterNG.Domain.Sceneries;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryFileWatchTests
{
private static readonly string Scn = TestInstallation.At("scenery", "kato.scn");
private static readonly string Other = TestInstallation.At("scenery", "lk4.scn");
private static readonly string Inc = TestInstallation.At("scenery", "auta.inc");
private const string Text =
"//$n Katowice\n" +
"FirstInit\n" +
"trainset a tor 0 0\n" +
"node -1 0 v1 dynamic d s m 0 headdriver 3 0 enddynamic\n" +
"endtrainset\n" +
"trainset b tor 0 0\n" +
"node -1 0 v2 dynamic d s m 0 headdriver 3 0 enddynamic\n" +
"endtrainset\n";
private static (TestInstallation Installation, SceneryFileWatch Watch, AppState State) Rig(InMemoryFileSystem files)
{
var installation = new TestInstallation(files);
installation.Library.ReloadSceneries();
var state = new AppState();
return (installation, new SceneryFileWatch(files, installation.Sceneries, installation.Library, state), state);
}
private static InMemoryFileSystem Folder() => new InMemoryFileSystem().WithFile(Scn, Text);
[Fact]
public void Files_as_they_were_read_are_no_change()
{
var (installation, watch, _) = Rig(Folder());
Assert.Empty(watch.Look(installation.Library.Sceneries));
}
[Fact]
public void A_file_changed_outside_is_read_anew()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
var current = installation.Library.Sceneries.Single();
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
var change = Assert.Single(watch.Look(installation.Library.Sceneries));
Assert.Same(current, change.Current);
Assert.Equal("Katowice Główne", change.Fresh!.Name);
}
[Fact]
public void An_include_that_changed_or_came_makes_the_scenery_read_anew()
{
var files = new InMemoryFileSystem().WithFile(Scn, Text + "include auta.inc end\n");
var (installation, watch, _) = Rig(files);
Assert.Empty(watch.Look(installation.Library.Sceneries));
files.WriteAllText(Inc, "// auta\n");
Assert.Single(watch.Look(installation.Library.Sceneries));
installation.Library.ReloadSceneries();
Assert.Empty(watch.Look(installation.Library.Sceneries));
files.WriteAllText(Inc, "// inne auta\n");
Assert.Single(watch.Look(installation.Library.Sceneries));
}
[Fact]
public void A_new_file_in_the_folder_comes_as_an_added_scenery()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
files.WriteAllText(Other, "//$n Lublin\n");
var change = Assert.Single(watch.Look(installation.Library.Sceneries));
Assert.Null(change.Current);
Assert.Equal("Lublin", change.Fresh!.Name);
}
[Fact]
public void A_file_is_gone_only_if_still_away_on_the_next_look()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
var current = installation.Library.Sceneries.Single();
files.DeleteFile(Scn);
Assert.Empty(watch.Look(installation.Library.Sceneries));
var change = Assert.Single(watch.Look(installation.Library.Sceneries));
Assert.Same(current, change.Current);
Assert.Null(change.Fresh);
}
[Fact]
public void A_file_replaced_between_two_looks_is_not_gone()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
files.DeleteFile(Scn);
Assert.Empty(watch.Look(installation.Library.Sceneries));
files.WriteAllText(Scn, Text);
Assert.Empty(watch.Look(installation.Library.Sceneries));
}
[Fact]
public void A_file_that_will_not_load_is_tried_and_logged_once_until_it_can_be()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
files.WriteAllText(Other, "//$n Lublin\n");
files.FailReading(Other);
Assert.Empty(watch.Look(installation.Library.Sceneries));
string once = installation.Log.Text;
Assert.Contains("lk4.scn", once);
Assert.Empty(watch.Look(installation.Library.Sceneries));
Assert.Equal(once, installation.Log.Text);
files.AllowReading(Other);
Assert.Equal("Lublin", Assert.Single(watch.Look(installation.Library.Sceneries)).Fresh!.Name);
}
[Fact]
public void Its_own_save_is_no_change_from_outside()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
var scenery = installation.Library.Sceneries.Single();
var trainset = scenery.Trainsets[0];
trainset.ReadyToGo = true;
var log = new FileDiagnosticsLog(files, installation.Clock, installation.Paths);
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, new SaveSceneryTrainset(files, log).Execute(scenery, trainset).Outcome);
Assert.False(watch.IsStale(scenery));
Assert.Empty(watch.Look(installation.Library.Sceneries));
}
[Fact]
public void A_consist_whose_text_stands_goes_over_with_its_edits()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
var current = installation.Library.Sceneries.Single();
var edited = current.Trainsets[1];
edited.ReadyToGo = true;
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
Assert.Empty(SceneryFileWatch.CarryOver(current, fresh));
Assert.Same(edited, fresh.Trainsets[1]);
Assert.True(SceneryTrainsetText.IsModified(fresh.Trainsets[1]));
}
[Fact]
public void An_edited_consist_whose_text_changed_too_is_the_conflict()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
var current = installation.Library.Sceneries.Single();
var edited = current.Trainsets[1];
edited.ReadyToGo = true;
files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
Assert.Same(edited, Assert.Single(SceneryFileWatch.CarryOver(current, fresh)));
Assert.NotSame(edited, fresh.Trainsets[1]);
}
[Fact]
public void A_consist_changed_but_not_edited_just_gives_way()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
var current = installation.Library.Sceneries.Single();
files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
Assert.Empty(SceneryFileWatch.CarryOver(current, fresh));
Assert.Equal("v22", fresh.Trainsets[1].Vehicles.Single().Name);
}
[Fact]
public void The_weather_chosen_by_hand_stays_and_the_authored_one_follows_the_file()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
var current = installation.Library.Sceneries.Single();
current.Weather.Day = 123;
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
SceneryFileWatch.CarryOver(current, fresh);
Assert.Equal(123, fresh.Weather.Day);
}
[Fact]
public void What_is_picked_follows_into_the_scenery_read_anew()
{
var files = Folder();
var (installation, watch, state) = Rig(files);
var current = installation.Library.Sceneries.Single();
var picked = current.Trainsets[1];
state.CurrentScenery = current;
state.CurrentTrainset = picked;
state.StartingVehicleName = "v2";
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
var change = watch.Look(installation.Library.Sceneries).Single();
SceneryFileWatch.CarryOver(change.Current!, change.Fresh!);
watch.Apply(change);
Assert.Same(change.Fresh, installation.Library.Sceneries.Single());
Assert.Same(change.Fresh, state.CurrentScenery);
Assert.Same(picked, state.CurrentTrainset);
Assert.Equal("v2", state.StartingVehicleName);
}
[Fact]
public void A_picked_consist_changed_in_the_file_is_followed_by_its_name()
{
var files = Folder();
var (installation, watch, state) = Rig(files);
var current = installation.Library.Sceneries.Single();
var picked = current.Trainsets[1];
state.CurrentScenery = current;
state.CurrentTrainset = picked;
state.StartingVehicleName = "v2";
files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
var change = watch.Look(installation.Library.Sceneries).Single();
SceneryFileWatch.CarryOver(change.Current!, change.Fresh!);
watch.Apply(change);
Assert.Same(change.Fresh!.Trainsets[1], state.CurrentTrainset);
Assert.Null(state.StartingVehicleName);
}
[Fact]
public void A_change_to_a_scenery_reloaded_since_the_look_is_left()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
var change = watch.Look(installation.Library.Sceneries).Single();
installation.Library.ReloadSceneries();
Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict);
}
[Fact]
public void A_change_to_a_scenery_saved_from_here_since_the_look_is_left_and_nothing_carried_over()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
var current = installation.Library.Sceneries.Single();
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
var change = watch.Look(installation.Library.Sceneries).Single();
current.Sources = change.Fresh!.Sources;
Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict);
Assert.NotSame(current.Trainsets[0], change.Fresh.Trainsets[0]);
}
[Fact]
public void A_scenery_new_in_the_folder_is_taken()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
files.WriteAllText(Other, "//$n Lublin\n");
var change = watch.Look(installation.Library.Sceneries).Single();
Assert.Equal(SceneryVerdict.Take, watch.Judge(change).Verdict);
}
[Fact]
public void A_scenery_new_in_the_folder_that_got_in_since_the_look_is_left()
{
var (installation, watch, _) = Rig(Folder());
var change = new SceneryChange(null, installation.Library.Sceneries.Single());
Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict);
}
[Fact]
public void A_change_that_loses_no_edits_is_taken_with_the_edits_carried_over()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
var edited = installation.Library.Sceneries.Single().Trainsets[1];
edited.ReadyToGo = true;
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
var change = watch.Look(installation.Library.Sceneries).Single();
var judgement = watch.Judge(change);
Assert.Equal(SceneryVerdict.Take, judgement.Verdict);
Assert.Empty(judgement.Lost);
Assert.Same(edited, change.Fresh!.Trainsets[1]);
}
[Fact]
public void A_change_that_loses_edits_is_asked_about_naming_them()
{
var files = Folder();
var (installation, watch, _) = Rig(files);
var current = installation.Library.Sceneries.Single();
var edited = current.Trainsets[1];
edited.ReadyToGo = true;
files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
var change = watch.Look(installation.Library.Sceneries).Single();
var judgement = watch.Judge(change);
Assert.Equal(SceneryVerdict.AskFirst, judgement.Verdict);
Assert.Same(edited, Assert.Single(judgement.Lost));
Assert.Same(current.Trainsets[0], change.Fresh!.Trainsets[0]);
}
[Fact]
public void A_scenery_gone_with_edits_in_it_stays_for_them_to_be_saved_elsewhere()
{
var (installation, watch, _) = Rig(Folder());
var current = installation.Library.Sceneries.Single();
current.Trainsets[1].ReadyToGo = true;
Assert.Equal(SceneryVerdict.Leave, watch.Judge(new SceneryChange(current, null)).Verdict);
}
[Fact]
public void A_scenery_gone_with_no_edits_in_it_is_taken_off()
{
var (installation, watch, _) = Rig(Folder());
var current = installation.Library.Sceneries.Single();
Assert.Equal(SceneryVerdict.Take, watch.Judge(new SceneryChange(current, null)).Verdict);
}
[Fact]
public void A_scenery_gone_takes_what_was_picked_with_it()
{
var files = Folder();
var (installation, watch, state) = Rig(files);
var current = installation.Library.Sceneries.Single();
state.CurrentScenery = current;
state.CurrentTrainset = current.Trainsets[0];
state.StartingVehicleName = "v1";
watch.Apply(new SceneryChange(current, null));
Assert.Empty(installation.Library.Sceneries);
Assert.Null(state.CurrentScenery);
Assert.Null(state.CurrentTrainset);
Assert.Null(state.StartingVehicleName);
}
}

View File

@@ -1,13 +1,10 @@
using StarterNG.Infrastructure.Sceneries; using StarterNG.Domain.Sceneries;
using StarterNG.Tests.Fakes; using StarterNG.Tests.Fakes;
namespace StarterNG.Tests; namespace StarterNG.Tests;
public class SceneryParserTests public class SceneryParserTests
{ {
private static SceneryParser Parser(DateTime? now = null) =>
new(new FixedClock(now ?? new DateTime(2026, 5, 4, 9, 15, 0)), new StubRandom());
[Fact] [Fact]
public void Directives_become_the_scenery_headline() public void Directives_become_the_scenery_headline()
{ {
@@ -19,7 +16,7 @@ public class SceneryParserTests
//$i images/kato.jpg //$i images/kato.jpg
"""; """;
var scenery = Parser().Parse("scenery/kato.scn", scn); var scenery = TestScenery.Parser().Parse("scenery/kato.scn", scn);
Assert.Equal("Śląsk", scenery.Group); Assert.Equal("Śląsk", scenery.Group);
Assert.Equal("Osobowy do Katowic", scenery.Name); Assert.Equal("Osobowy do Katowic", scenery.Name);
@@ -32,7 +29,7 @@ public class SceneryParserTests
[Fact] [Fact]
public void The_a_directive_marks_a_scenery_archival() public void The_a_directive_marks_a_scenery_archival()
{ {
var scenery = Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n"); var scenery = TestScenery.Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n");
Assert.True(scenery.Archival); Assert.True(scenery.Archival);
} }
@@ -46,7 +43,7 @@ public class SceneryParserTests
//$f doc/notes.txt //$f doc/notes.txt
"""; """;
var scenery = Parser().Parse("scenery/x.scn", scn); var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
Assert.Collection(scenery.Attachments, Assert.Collection(scenery.Attachments,
first => first =>
@@ -76,7 +73,7 @@ public class SceneryParserTests
FirstInit FirstInit
"""; """;
var scenery = Parser().Parse("scenery/x.scn", scn); var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var include = Assert.Single(scenery.Includes); var include = Assert.Single(scenery.Includes);
Assert.Equal("zima.inc", include.FilePath); Assert.Equal("zima.inc", include.FilePath);
@@ -95,7 +92,7 @@ public class SceneryParserTests
endoptional endoptional
"""; """;
var include = Assert.Single(Parser().Parse("scenery/x.scn", scn).Includes); var include = Assert.Single(TestScenery.Parser().Parse("scenery/x.scn", scn).Includes);
Assert.Equal(2, include.Kind); Assert.Equal(2, include.Kind);
Assert.False(include.Selected); Assert.False(include.Selected);
@@ -112,7 +109,7 @@ public class SceneryParserTests
FirstInit FirstInit
"""; """;
var scenery = Parser().Parse("scenery/x.scn", scn); var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var trainset = Assert.Single(scenery.Trainsets); var trainset = Assert.Single(scenery.Trainsets);
Assert.True(trainset.Parsed); Assert.True(trainset.Parsed);
@@ -126,11 +123,11 @@ public class SceneryParserTests
public void Vehicles_outside_a_trainset_are_collected_as_loose() public void Vehicles_outside_a_trainset_are_collected_as_loose()
{ {
const string scn = """ const string scn = """
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 0 enddynamic
FirstInit FirstInit
"""; """;
var scenery = Parser().Parse("scenery/x.scn", scn); var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var vehicle = Assert.Single(scenery.LooseVehicles); var vehicle = Assert.Single(scenery.LooseVehicles);
Assert.Equal("wagon_1", vehicle.Name); Assert.Equal("wagon_1", vehicle.Name);
@@ -142,13 +139,43 @@ public class SceneryParserTests
[Fact] [Fact]
public void A_malformed_loose_vehicle_is_reported_and_left_in_place() public void A_malformed_loose_vehicle_is_reported_and_left_in_place()
{ {
const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic\n"; const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 0 enddynamic\n";
var scenery = Parser().Parse("scenery/x.scn", scn); var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
Assert.Empty(scenery.LooseVehicles); Assert.Empty(scenery.LooseVehicles);
Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames); Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames);
Assert.Contains(scenery.Faults, fault => fault.Contains("wagon_1")); var fault = Assert.Single(scenery.Faults);
Assert.Equal((SceneryFaultKind.NotANumber, "wagon_1", "x", 1), (fault.Kind, fault.Subject, fault.Token, fault.Line));
Assert.Contains("enddynamic", scenery.Template); Assert.Contains("enddynamic", scenery.Template);
} }
[Fact]
public void Directives_in_a_file_put_in_place_are_not_the_scenery_s_own()
{
const string included = """
//$n Auta
//$l Inne
//$d Opis aut
//$f doc/auta.pdf Auta
//$i auta.jpg
//$a
trainset auto road1 0 50
endtrainset
""";
string text = SceneryIncludes.Inline("//$d Opis scenerii\n//$f doc/mapa.png Mapa\nFirstInit\ninclude auta.scm end\n",
name => (name, included));
var scenery = TestScenery.Parser().Parse("scenery/x.scn", text);
Assert.Equal("Opis scenerii", scenery.Description);
Assert.Equal("doc/mapa.png", Assert.Single(scenery.Attachments).FilePath);
Assert.Null(scenery.Name);
Assert.Null(scenery.Group);
Assert.Null(scenery.ImageName);
Assert.False(scenery.Archival);
// The file's own trainset is still listed, and its text still written for a start.
Assert.Equal("auta.scm", Assert.Single(scenery.Trainsets).SourceFile);
Assert.Contains("//$d Opis aut", scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
}
} }

View File

@@ -0,0 +1,382 @@
using StarterNG.Domain.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class ScenerySyntaxTests
{
private static (SceneryFaultKind, string, int, string?, string?) Seen(SceneryFault fault) =>
(fault.Kind, fault.File, fault.Line, fault.Subject, fault.Token);
[Fact]
public void A_trainset_commented_out_is_no_trainset()
{
const string scn = """
/* trainset stary tor_1 0 0
node -1 0 st dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
endtrainset */
// trainset dla gracza:
trainset pociag tor_1 10 0
node -1 0 ep07-001 dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic//koniec
endtrainset
""";
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var trainset = Assert.Single(scenery.Trainsets);
Assert.Equal("pociag", trainset.Name);
Assert.Single(trainset.Vehicles);
Assert.Empty(scenery.Faults);
Assert.Contains("trainset stary", scenery.Template);
}
[Fact]
public void A_trainset_without_its_end_is_reported_and_the_next_one_read_on_its_own()
{
const string scn = """
FirstInit
trainset pierwszy tor_1 10 0
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
trainset drugi tor_2 10 0
node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
endtrainset
""";
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
Assert.Equal((SceneryFaultKind.TrainsetNotClosed, "x.scn", 2, "pierwszy", null),
Seen(Assert.Single(scenery.Faults)));
var trainset = Assert.Single(scenery.Trainsets);
Assert.Equal("drugi", trainset.Name);
Assert.Equal("b", Assert.Single(trainset.Vehicles).Name);
// The unended one is the simulator's to read as written.
Assert.Contains("trainset pierwszy", scenery.Template);
}
[Fact]
public void A_vehicle_without_its_end_is_reported_yet_its_trainset_listed()
{
const string scn = """
trainset pociag tor_1 10 0
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3
node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
endtrainset
""";
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var fault = Assert.Single(scenery.Faults);
Assert.Equal((SceneryFaultKind.VehicleNotClosed, "x.scn", 2, "a", null), Seen(fault));
// The simulator goes on to the next enddynamic, taking b for a's.
Assert.Equal("b", fault.Lost);
Assert.Equal(new[] { "a", "b" }, Assert.Single(scenery.Trainsets).Vehicles.Select(v => v.Name));
}
[Fact]
public void A_vehicle_with_no_load_count_takes_the_next_one_with_it()
{
const string scn = """
trainset pociag tor_1 10 0
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 enddynamic
node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
endtrainset
""";
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
// Its enddynamic read for the count, "node" for a destination, b's for its end.
var fault = Assert.Single(scenery.Faults);
Assert.Equal((SceneryFaultKind.LoadCountMissing, 2, "a", "b"), (fault.Kind, fault.Line, fault.Subject, fault.Lost));
Assert.Equal(new[] { "a", "b" }, Assert.Single(scenery.Trainsets).Vehicles.Select(v => v.Name));
}
[Fact]
public void A_load_with_no_type_runs_on_into_the_next_trainset()
{
const string scn = """
trainset pierwszy tor_1 10 0
node -1 0 a dynamic PKP/412Z 412z 412z 0 nobody 3 40 enddynamic
endtrainset
trainset drugi tor_2 10 0
node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
node -1 0 c dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
endtrainset
""";
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
// The second endtrainset is what ends the first trainset then.
var fault = Assert.Single(scenery.Faults);
Assert.Equal((SceneryFaultKind.LoadTypeMissing, 2, "a", "40", "endtrainset, trainset drugi, b"),
(fault.Kind, fault.Line, fault.Subject, fault.Token, fault.Lost));
Assert.Equal(new[] { "pierwszy", "drugi" }, scenery.Trainsets.Select(t => t.Name));
}
[Theory]
[InlineData("0 none", "none")]
[InlineData("0 pantstate", "pantstate")]
[InlineData("0 cel Katowice", null)]
[InlineData("3 pantstate", null)]
[InlineData("12 wegiel", null)]
public void One_word_after_the_load_is_taken_for_a_destination_named_enddynamic(string load, string? word)
{
string scn = $"""
trainset pociag tor_1 10 0
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 {load} enddynamic
node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
endtrainset
""";
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults;
if (word is null)
Assert.Empty(faults);
else
Assert.Equal((SceneryFaultKind.WordBeforeEnd, word, "b"), (faults.Single().Kind, faults.Single().Token, faults.Single().Lost));
}
[Fact]
public void A_header_written_twice_is_told_as_such()
{
const string scn = """
trainset baltyk\mod\exe45151 chojnice301 40 0.1
//$o -Skład ozdobny
trainset baltyk\mod\exe45151 chojnice301 40 0.1
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
endtrainset
""";
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
Assert.Equal((SceneryFaultKind.TrainsetHeaderRepeated, "x.scn", 1, "baltyk\\mod\\exe45151", null),
Seen(Assert.Single(scenery.Faults)));
Assert.Single(scenery.Trainsets);
}
[Fact]
public void A_trainset_whose_header_is_swallowed_is_told_at_the_fault_that_began_it()
{
const string scn = """
trainset pierwszy tor_1 10 0
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
endtrainset
//trainset bez_naglowka tor_2 0 0
node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
endtrainset
trainset trzeci tor_3 0 0
node -1 0 c1 dynamic PKP/EU07 eu07 eu07 0 headdriver 3 0 enddynamic
node -1 0 c2 dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
node -1 0 c3 dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
endtrainset
trainset czwarty tor_4 0 0
node -1 0 d dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
endtrainset
""";
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
// c2 with no trainset takes c3, and the endtrainset is left alone - all of
// it from b, where the fault is.
var fault = Assert.Single(scenery.Faults);
Assert.Equal((SceneryFaultKind.VehicleOutsideTrainset, 5, "endtrainset, trainset trzeci, c1", "trzeci"),
(fault.Kind, fault.Line, fault.Lost, fault.Unmade));
Assert.Equal(new[] { "pierwszy", "trzeci", "czwarty" }, scenery.Trainsets.Select(t => t.Name));
}
[Fact]
public void Text_where_a_number_belongs_is_named()
{
const string scn = """
trainset pociag tor_1 1O 0
node -1 0 a dynamic PKP/EP07 ep07 ep07 0,5 headdriver 3 0 enddynamic
endtrainset
""";
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults;
Assert.Equal(new[]
{
(SceneryFaultKind.NotANumber, "x.scn", 1, (string?)"pociag", (string?)"1O"),
(SceneryFaultKind.NotANumber, "x.scn", 2, "a", "0,5")
}, faults.Select(Seen));
}
[Fact]
public void A_track_name_split_by_a_space_is_named_whole()
{
// "Pawonków#L" with its "ó" lost to a space.
const string scn = """
trainset none Pawonk w#L 200.0 0.0
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
endtrainset
""";
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults;
Assert.Equal((SceneryFaultKind.TrackNameSplit, "x.scn", 1, "none", "Pawonk w#L"), Seen(Assert.Single(faults)));
}
[Fact]
public void Vehicles_left_without_their_trainset_header_are_told_so()
{
const string scn = """
//trainset 2003r\64040 Staniszcze_Male_po#2 -50 0.1
node -1 0 EN57-1542ra dynamic pkp\en57_v2 EN57-1542ra EN57RA 0 headdriver 255 0 enddynamic
endtrainset
node -1 0 auto dynamic road\ford transit transit droga-b 0 headdriver 25.0 0 enddynamic
""";
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
// On no track the simulator has, it drops the vehicle and goes on to the
// next enddynamic - past the endtrainset, to the car's.
var fault = Assert.Single(scenery.Faults);
Assert.Equal((SceneryFaultKind.VehicleOutsideTrainset, 2, "endtrainset, auto"), (fault.Kind, fault.Line, fault.Lost));
Assert.Equal("auto", Assert.Single(scenery.LooseVehicles).Name);
}
[Fact]
public void Structure_faults_are_found_where_the_simulator_reports_them()
{
const string scn = """
endtrainset
trainset pusty tor_1 10 0
endtrainset trainset pociag tor_1 10 0
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver enddynamic
node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
reversed
endtrainset
include dekoracje.scm // bez end
include auta.scm end
/* bez końca
""";
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults.Select(f => (f.Kind, f.Line)).ToList();
Assert.Equal(new[]
{
(SceneryFaultKind.StrayEndTrainset, 1),
(SceneryFaultKind.VehicleIncomplete, 4),
(SceneryFaultKind.ReversedMisplaced, 6),
(SceneryFaultKind.IncludeNotClosed, 8),
(SceneryFaultKind.UnclosedComment, 10)
}, faults);
}
[Fact]
public void A_trainset_with_no_vehicles_is_listed_to_be_filled_in_the_depot()
{
const string scn = """
trainset td/rozklad tdo_n1734 0.0 0.1
//$o Skład gotowy na żeberku.
endtrainset
include koniec.scm
""";
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn, _ => new IncludeLookup(true));
Assert.Empty(scenery.Faults);
Assert.Equal("td/rozklad", Assert.Single(scenery.Trainsets).Name);
}
[Fact]
public void Reversed_may_follow_the_assignments_ahead_of_the_vehicles()
{
const string scn = """
Trainset pociag tor_1 10 0
assignment pl "Pociąg" endassignment
reversed
Node -1 0 a dynamic PKP/EP07 ep07 ep07 0 HeadDriver 3 0 EndDynamic
EndTrainset
""";
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
Assert.Empty(scenery.Faults);
var trainset = Assert.Single(scenery.Trainsets);
Assert.True(trainset.Reversed);
Assert.Equal(StarterNG.Classes.eDriverType.Headdriver, Assert.Single(trainset.Vehicles).DriverType);
}
[Fact]
public void A_fault_in_an_included_file_is_placed_in_that_file()
{
string text = SceneryIncludes.Inline(
"FirstInit\ninclude tomaszewo/auta.scm end\ntrainset zly tor 0\nendtrainset\n",
name => (name, "// auta\ntrainset auto road1 0 50\nnode -1 0 a1 dynamic car car car x headdriver 3 0 enddynamic\nendtrainset"));
var faults = TestScenery.Parser().Parse("scenery/x.scn", text).Faults.Select(Seen);
Assert.Equal(new[]
{
(SceneryFaultKind.NotANumber, "tomaszewo/auta.scm", 3, (string?)"a1", (string?)"x"),
(SceneryFaultKind.TrainsetIncomplete, "x.scn", 3, "zly", null)
}, faults);
}
[Fact]
public void Missing_includes_are_reported_but_optional_ones_and_templates_are_not()
{
const string scn = """
include tory.scm end
include brak.scm end // komentarz
include budynek.inc (p1) 0 0 end
include $(p1)_dom.inc end
include opcja.scm end //$optional 2,Opcja
endoptional
""";
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn, name => new IncludeLookup(name is "tory.scm" or "budynek.inc")).Faults;
Assert.Equal((SceneryFaultKind.MissingInclude, "x.scn", 2, null, "brak.scm"), Seen(Assert.Single(faults)));
}
[Fact]
public void An_include_named_in_another_case_is_found_as_the_simulator_finds_it()
{
var files = new InMemoryFileSystem(caseSensitive: true)
.WithLegacyFile("game/scenery/x.scn", "FirstInit\ninclude Tomaszewo/Auta.SCM end\n")
.WithLegacyFile("game/scenery/tomaszewo/auta.scm",
"trainset auto road1 0 50\nnode -1 0 a1 dynamic car car car 0 headdriver 3 0 enddynamic\nendtrainset\n");
var repository = TestScenery.Repository(files);
var scenery = repository.Load("game/scenery/x.scn")!;
Assert.Empty(scenery.Faults);
Assert.Equal("tomaszewo/auta.scm", Assert.Single(scenery.Trainsets).SourceFile);
}
[Fact]
public void An_include_on_disk_only_in_other_letter_case_is_what_linux_misses()
{
// The simulator folds the name to lower case and opens it so.
var files = new InMemoryFileSystem(caseSensitive: true)
.WithLegacyFile("game/scenery/braniewo_szeroki.scn",
"include braniewo/base.ctr end\ninclude Braniewo/Tory.SCM end\n")
.WithFile("game/scenery/Braniewo/Base.ctr", "")
.WithFile("game/scenery/braniewo/tory.scm", "");
var repository = TestScenery.Repository(files);
var faults = repository.Load("game/scenery/braniewo_szeroki.scn")!.Faults;
Assert.Equal((SceneryFaultKind.IncludeLetterCase, "braniewo_szeroki.scn", 1, "Braniewo/Base.ctr", "braniewo/base.ctr"),
Seen(Assert.Single(faults)));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void A_missing_terrain_file_is_no_fault_while_the_scenery_has_its_own_sbt(bool sbt)
{
var files = new InMemoryFileSystem(caseSensitive: true)
.WithLegacyFile("game/scenery/Lk2_Sandbox.scn", "include nmt/nmt100_mazowieckie_ter.scm end\nFirstInit\n");
if (sbt)
files.WithFile("game/scenery/lk2_sandbox.sbt", "EU07");
var repository = TestScenery.Repository(files);
var scenery = repository.Load("game/scenery/Lk2_Sandbox.scn")!;
Assert.Equal(sbt, scenery.HasCompanionTimetable);
Assert.Equal(sbt ? 0 : 1, scenery.Faults.Count);
}
}

View File

@@ -0,0 +1,578 @@
using System.Reflection;
using System.Text;
using StarterNG.Application;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Domain.Sceneries;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryTrainsetSaveTests
{
private const string Path = "scenery/test.scn";
private const string Scn =
"//$n Bałtyk\r\n" +
"FirstInit\r\n" +
"trainset pociag tor_1 200.0 0.0\r\n" +
"//$o -Opis składu\r\n" +
"node -1 0 ep07-001 dynamic PKP\\EP07 ep07-001 ep07 0 headdriver 3 0 enddynamic\r\n" +
"node -1 0 wagon-1 dynamic PKP\\111A b10-1 111a 0 nobody 3 0 enddynamic\r\n" +
"endtrainset\r\n" +
"// koniec pliku z polską literą: żółw\r\n";
private static Encoding Cp1250 => Encoding.GetEncoding(1250);
private static (InMemoryFileSystem Files, Scenery Scenery, SaveSceneryTrainset Save) Setup(string scn) =>
Setup(Cp1250.GetBytes(scn));
private static (InMemoryFileSystem Files, Scenery Scenery, SaveSceneryTrainset Save) Setup(byte[] bytes)
{
var files = new InMemoryFileSystem();
files.WriteAllBytes(Path, bytes);
var scenery = TestScenery.Parser().Parse(Path, LegacyText.Decode(bytes));
return (files, scenery, new SaveSceneryTrainset(files, TestLog.Over(files)));
}
[Fact]
public void Reversed_after_the_header_is_read_and_written_back()
{
var trainset = new Trainset(
"trainset a tor 0 0 reversed\n" +
"node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\n" +
"endtrainset");
Assert.True(trainset.Reversed);
Assert.Single(trainset.Vehicles);
Assert.StartsWith("trainset a tor 0 0 reversed\n", trainset.GetTrainsetEntry());
}
[Fact]
public void A_trainset_without_reversed_is_not_turned()
{
var trainset = new Trainset("trainset a tor 0 0\nendtrainset");
Assert.False(trainset.Reversed);
Assert.DoesNotContain("reversed", trainset.GetTrainsetEntry());
}
[Fact]
public void An_untouched_trainset_is_not_modified_however_its_numbers_are_spelled()
{
var (_, scenery, _) = Setup(Scn);
Assert.False(SceneryTrainsetText.IsModified(scenery.Trainsets[0]));
}
[Fact]
public void The_head_stays_as_written_and_the_vehicles_are_rewritten()
{
var (_, scenery, _) = Setup(Scn.Replace("200.0 0.0\r\n", "200.0 0.0\r\nreversed\r\n"));
var trainset = scenery.Trainsets[0];
trainset.Vehicles.RemoveAt(1);
string entry = SceneryTrainsetText.Compose(trainset, "\r\n");
Assert.StartsWith("trainset pociag tor_1 200.0 0.0\r\nreversed\r\n//$o -Opis składu\r\nnode", entry);
Assert.Contains("ep07-001", entry);
Assert.DoesNotContain("wagon-1", entry);
Assert.EndsWith("enddynamic\r\nendtrainset", entry);
Assert.DoesNotContain("\r\r", entry);
Assert.DoesNotMatch("[^\r]\n", entry);
}
[Fact]
public void Only_the_speed_of_the_header_follows_the_ready_switch()
{
var (_, scenery, _) = Setup(Scn);
var trainset = scenery.Trainsets[0];
trainset.ReadyToGo = true;
string entry = SceneryTrainsetText.Compose(trainset, "\n");
Assert.StartsWith("trainset pociag tor_1 200.0 0.1\n", entry);
Assert.True(SceneryTrainsetText.IsModified(trainset));
}
[Fact]
public void Saving_rewrites_the_trainset_alone_and_keeps_the_original_once()
{
var (files, scenery, save) = Setup(Scn);
var trainset = scenery.Trainsets[0];
trainset.Vehicles.RemoveAt(1);
var (outcome, _) = save.Execute(scenery, trainset);
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, outcome);
string written = Cp1250.GetString(files.ReadAllBytes(Path));
Assert.StartsWith("//$n Bałtyk\r\nFirstInit\r\ntrainset pociag tor_1 200.0 0.0\r\n//$o -Opis składu\r\n", written);
Assert.DoesNotContain("wagon-1", written);
Assert.EndsWith("endtrainset\r\n// koniec pliku z polską literą: żółw\r\n", written);
Assert.Equal(Scn, Cp1250.GetString(files.ReadAllBytes(Path + SaveSceneryTrainset.BackupSuffix)));
Assert.False(SceneryTrainsetText.IsModified(trainset));
// A second save finds what the first one wrote; the backup stays the original.
trainset.Vehicles.Clear();
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, trainset).Outcome);
Assert.DoesNotContain("ep07-001", Cp1250.GetString(files.ReadAllBytes(Path)));
Assert.Equal(Scn, Cp1250.GetString(files.ReadAllBytes(Path + SaveSceneryTrainset.BackupSuffix)));
}
[Fact]
public void Of_two_trainsets_written_alike_the_right_one_is_saved()
{
const string block =
"trainset none tor_1 0 0\r\n" +
"node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\r\n" +
"endtrainset\r\n";
var (files, scenery, save) = Setup(block + "// x\r\n" + block);
var second = scenery.Trainsets[1];
second.Vehicles.Clear();
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, second).Outcome);
string written = Cp1250.GetString(files.ReadAllBytes(Path));
Assert.StartsWith(block + "// x\r\ntrainset none tor_1 0 0\r\nendtrainset", written);
}
[Fact]
public void A_file_changed_meanwhile_is_left_alone()
{
var (files, scenery, save) = Setup(Scn);
var trainset = scenery.Trainsets[0];
trainset.Vehicles.Clear();
files.WithLegacyFile(Path, Scn.Replace("wagon-1", "wagon-2"));
Assert.Equal(SceneryTrainsetSaveOutcome.NotFound, save.Execute(scenery, trainset).Outcome);
Assert.Contains("wagon-2", Cp1250.GetString(files.ReadAllBytes(Path)));
Assert.False(files.FileExists(Path + SaveSceneryTrainset.BackupSuffix));
}
private const string Cars =
"trainset none road1 -20 50\r\n" +
"node -1 0 auto_1 dynamic road\\man man nl293 0 headdriver 0 0 enddynamic\r\n" +
"endtrainset\r\n";
private const string Traffic =
"node -1 0 nl293_a dynamic road\\man man nl293 \t\tryp_rd110 \t0 headdriver 50 0 enddynamic\r\n";
private const string WithIncludes =
"include tory.scm end\r\n" +
"FirstInit\r\n" +
"trainset pociag tor_1 0 0\r\n" +
"node -1 0 ep07-001 dynamic PKP\\EP07 ep07-001 ep07 0 headdriver 3 0 enddynamic\r\n" +
"endtrainset\r\n" +
"include tomaszewo/auta.scm end //ruch uliczny\r\n" +
"include tomaszewo/eventy.ctr end\r\n" +
"/* include tomaszewo/auta.scm end */\r\n" +
"include tomaszewo/auta.scm parametr end\r\n";
private static (string File, string Text)? Files(string name) => name switch
{
"tory.scm" => (name, Cars),
"tomaszewo/auta.scm" => (name, Cars + Traffic),
"tomaszewo/eventy.ctr" => (name, "event e1 multiple 0 none endevent\r\n"),
_ => null
};
[Fact]
public void Includes_after_first_init_that_hold_trainsets_are_put_in_place()
{
string text = SceneryIncludes.Inline(WithIncludes, Files);
// Before FirstInit is the scenery itself; without trainsets there is nothing to list.
Assert.StartsWith("include tory.scm end\r\n", text);
Assert.Contains("include tomaszewo/eventy.ctr end", text);
Assert.Contains(SceneryIncludes.BeginMarker + "tomaszewo/auta.scm\r\ntrainset none road1", text);
// Commented out, or with parameters to put in: left to the simulator.
Assert.Contains("/* include tomaszewo/auta.scm end */", text);
Assert.Contains("include tomaszewo/auta.scm parametr end", text);
Assert.Single(SceneryIncludes.Ranges(text));
}
[Fact]
public void A_trainset_from_an_include_is_listed_with_its_file_and_exported_in_place()
{
var scenery = TestScenery.Parser().Parse(Path, SceneryIncludes.Inline(WithIncludes, Files));
Assert.Equal(2, scenery.Trainsets.Count);
Assert.Null(scenery.Trainsets[0].SourceFile);
Assert.Equal("tomaszewo/auta.scm", scenery.Trainsets[1].SourceFile);
// Loose road traffic in the file stays put, as written; only its name is known.
Assert.Empty(scenery.LooseVehicles);
Assert.Contains("nl293_a", scenery.LooseVehicleNames);
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
Assert.Contains("auto_1", exported);
Assert.Contains(Traffic, exported);
Assert.True(exported.IndexOf(Traffic, StringComparison.Ordinal) > exported.IndexOf("FirstInit", StringComparison.Ordinal));
Assert.DoesNotContain("include tomaszewo/auta.scm end //", exported);
}
[Fact]
public void A_trainset_from_an_include_is_saved_into_that_file()
{
var files = new InMemoryFileSystem()
.WithLegacyFile(Path, WithIncludes)
.WithLegacyFile("scenery/tomaszewo/auta.scm", Cars + "// koniec\r\n");
var scenery = TestScenery.Parser().Parse(Path, SceneryIncludes.Inline(WithIncludes, Files));
var log = TestLog.Over(files);
var car = scenery.Trainsets[1];
car.Vehicles.Clear();
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, new SaveSceneryTrainset(files, log).Execute(scenery, car).Outcome);
Assert.Equal("trainset none road1 -20 50\r\nendtrainset\r\n// koniec\r\n",
Cp1250.GetString(files.ReadAllBytes("scenery/tomaszewo/auta.scm")));
Assert.Equal(WithIncludes, Cp1250.GetString(files.ReadAllBytes(Path)));
Assert.True(files.FileExists("scenery/tomaszewo/auta.scm" + SaveSceneryTrainset.BackupSuffix));
}
[Fact]
public void A_utf8_file_stays_utf8_with_its_mark()
{
var (files, scenery, save) = Setup(new byte[] { 0xEF, 0xBB, 0xBF }.Concat(Encoding.UTF8.GetBytes(Scn)).ToArray());
scenery.Trainsets[0].Vehicles.RemoveAt(1);
save.Execute(scenery, scenery.Trainsets[0]);
byte[] bytes = files.ReadAllBytes(Path);
Assert.Equal(new byte[] { 0xEF, 0xBB, 0xBF }, bytes[..3]);
Assert.Contains("żółw", Encoding.UTF8.GetString(bytes[3..]));
}
private const string V1 = "node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic";
private const string V2 = "node -1 0 v2 dynamic d s m 0 nobody 3 0 enddynamic";
private const string V3 = "node -1 0 v3 dynamic d s m 0 nobody 3 0 enddynamic";
private static string SavedText(string scn, Action<Trainset>? change = null)
{
var (files, scenery, save) = Setup(scn);
change?.Invoke(scenery.Trainsets[0]);
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
return Cp1250.GetString(files.ReadAllBytes(Path));
}
private static IEnumerable<string> VehicleNames(string scn) =>
TestScenery.Parser().Parse(Path, scn).Trainsets.Single().Vehicles.Select(v => v.Name);
private static void Edit(Trainset trainset) => trainset.Vehicles[0].Coupling.Flags = 5;
[Fact]
public void A_vehicle_commented_out_stays_in_its_comment()
{
string scn = $"FirstInit\ntrainset a t 0 0\n/*\n{V1}\n*/\n{V2}\nendtrainset\n" +
"node -1 0 poza dynamic d s m none 0 nobody 0 0 enddynamic\n";
Assert.Equal(scn, SavedText(scn));
string edited = SavedText(scn, Edit);
Assert.Contains($"/*\n{V1}\n*/\n", edited);
var scenery = TestScenery.Parser().Parse(Path, edited);
Assert.Equal(new[] { "v2" }, scenery.Trainsets.Single().Vehicles.Select(v => v.Name));
Assert.Equal(new[] { "poza" }, scenery.LooseVehicleNames);
}
[Theory]
[InlineData("trainset a t 0 0\nNode -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
[InlineData("trainset a t 0 0\nNODE -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
[InlineData("trainset a t 0 0 node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
[InlineData("trainset a t 0 0 /*x*/node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
public void A_vehicle_is_found_however_it_is_written_and_not_written_twice(string scn)
{
Assert.Equal(scn, SavedText(scn));
string edited = SavedText(scn, Edit);
var vehicle = Assert.Single(TestScenery.Parser().Parse(Path, edited).Trainsets.Single().Vehicles);
Assert.Equal(5, vehicle.Coupling.Flags);
}
[Fact]
public void A_vehicle_named_as_a_keyword_is_no_keyword()
{
string scn = "trainset node node 0 0\nnode -1 0 node dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n";
Assert.Equal(scn, SavedText(scn));
Assert.Contains("trainset node node 0 0.1\n", SavedText(scn, t => t.ReadyToGo = true));
}
private const string Commented =
"trainset a t 0 0\n" +
V1 + "\n" +
"// o v2\n" +
V2 + " // przy v2\n" +
"/* o v3 */\n" +
V3 + "\n" +
"// koniec\n" +
"EndTrainset\n";
[Fact]
public void A_save_with_no_change_leaves_the_vehicles_and_comments_as_they_were()
{
Assert.Equal(Commented, SavedText(Commented));
// Nor is anything but the speed touched when only the speed changes.
Assert.Equal(Commented.Replace("t 0 0\n", "t 0 0.1\n"), SavedText(Commented, t => t.ReadyToGo = true));
}
[Fact]
public void Comments_stay_where_they_are_when_a_vehicle_is_edited()
{
string edited = SavedText(Commented, t => t.Vehicles[1].Coupling.Flags = 5);
Assert.Equal(Commented.Replace(V2, V2.Replace("nobody 3", "nobody 5")), edited);
}
[Fact]
public void Comments_stay_when_a_vehicle_is_removed()
{
Assert.Equal(
"trainset a t 0 0\n" + V1 + "\n// o v2\n// przy v2\n/* o v3 */\n" + V3 + "\n// koniec\nEndTrainset\n",
SavedText(Commented, t => t.Vehicles.RemoveAt(1)));
Assert.Equal(
"trainset a t 0 0\n// o v2\n// przy v2\n/* o v3 */\n// koniec\nEndTrainset\n",
SavedText(Commented, t => t.Vehicles.Clear()));
}
[Fact]
public void Comments_stay_when_a_vehicle_is_added()
{
var added = new Dynamic { Name = "nowy", DataFolder = "d", SkinFile = "s", MmdFile = "m" };
Assert.Equal(
Commented.Replace(V3, added.ToTrainsetNode() + V3),
SavedText(Commented, t => t.Vehicles.Insert(2, added)));
Assert.Equal(
Commented.Replace("EndTrainset", added.ToTrainsetNode() + "EndTrainset"),
SavedText(Commented, t => t.Vehicles.Add(added)));
}
[Fact]
public void Comments_stay_when_vehicles_change_places_and_a_moved_one_stays_as_written()
{
string moved = "node 500 0 v3 dynamic d s m /*w srodku*/ 0 nobody 3 0 enddynamic";
string scn = Commented.Replace(V3, moved);
string saved = SavedText(scn, t => t.Vehicles.Reverse());
Assert.Equal(new[] { "v3", "v2", "v1" }, VehicleNames(saved));
foreach (string comment in new[] { "// o v2\n", " // przy v2\n", "/* o v3 */\n", "// koniec\n" })
Assert.Contains(comment, saved);
Assert.Contains(moved, saved);
}
[Theory]
[InlineData("trainset a /*tor*/ t 100 0\nendtrainset\n", "trainset a /*tor*/ t 100 0.1\nendtrainset\n")]
[InlineData("trainset a t /*x*/ 100/*y*/ 0.0 reversed\nendtrainset\n", "trainset a t /*x*/ 100/*y*/ 0.1 reversed\nendtrainset\n")]
[InlineData("Trainset a // tor\n t\n 100\n 0.0\nendtrainset\n", "Trainset a // tor\n t\n 100\n 0.1\nendtrainset\n")]
public void The_speed_is_found_past_a_comment_in_the_header(string scn, string expected)
{
Assert.Equal(expected, SavedText(scn, t => t.ReadyToGo = true));
}
[Fact]
public void An_optional_include_after_first_init_stays_an_option()
{
const string scn = "FirstInit\ninclude zima.inc end //$optional 1, Zima\nx\nendoptional\n";
string content = $"trainset a t 0 0\n{V1}\nendtrainset\n";
string text = SceneryIncludes.Inline(scn, name => (name, content));
Assert.Equal(scn, text);
var scenery = TestScenery.Parser().Parse(Path, text);
Assert.Equal("Zima", Assert.Single(scenery.Includes).Desc);
Assert.Empty(scenery.Trainsets);
}
/// <summary>A disk that gives out on whatever is being written; what it does to the file is <see cref="PhysicalFileSystemTests"/>' business.</summary>
public class FailingDisk : DispatchProxy
{
public InMemoryFileSystem Files = null!;
protected override object? Invoke(MethodInfo? method, object?[]? args) =>
method!.Name == nameof(IFileSystem.WriteAllBytesAtomic)
? throw new IOException("the disk gave out")
: method.Invoke(Files, args);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void A_write_that_fails_leaves_the_file_and_its_backup_whole(bool backedUp)
{
var (files, scenery, _) = Setup(Scn);
string backup = Path + SaveSceneryTrainset.BackupSuffix;
if (backedUp)
files.WithLegacyFile(backup, Scn);
var disk = DispatchProxy.Create<IFileSystem, FailingDisk>();
((FailingDisk)(object)disk).Files = files;
var log = TestLog.Over();
var trainset = scenery.Trainsets[0];
string before = trainset.RawEntry;
trainset.Vehicles.RemoveAt(1);
var (outcome, detail) = new SaveSceneryTrainset(disk, log).Execute(scenery, trainset);
Assert.Equal((SceneryTrainsetSaveOutcome.Failed, "the disk gave out"), (outcome, detail));
Assert.Equal(Scn, Cp1250.GetString(files.ReadAllBytes(Path)));
Assert.Equal(backedUp, files.FileExists(backup));
Assert.Equal(new[] { Path }.Concat(backedUp ? new[] { backup } : Array.Empty<string>()),
files.GetFiles("scenery"));
Assert.Equal(before, trainset.RawEntry);
}
private static (string Scn, SaveSceneryTrainset Save, Scenery Scenery, string Folder) OnDisk(string text)
{
string folder = Directory.CreateTempSubdirectory("starter-save-").FullName;
string scn = System.IO.Path.Combine(folder, "test.scn");
var files = new PhysicalFileSystem();
files.WriteAllText(scn, text, LegacyText.CodePage1250);
var log = TestLog.Over();
return (scn, new SaveSceneryTrainset(files, log), TestScenery.Parser().Parse(scn, text), folder);
}
[Fact]
public void On_disk_the_new_text_is_written_beside_the_file_and_moved_over_it()
{
var (scn, save, scenery, folder) = OnDisk(Scn);
try
{
var trainset = scenery.Trainsets[0];
trainset.Vehicles.RemoveAt(1);
// Nothing can be written beside it: the file is not touched either.
string temp = scn + PhysicalFileSystem.TempSuffix;
Directory.CreateDirectory(temp);
Assert.Equal(SceneryTrainsetSaveOutcome.Failed, save.Execute(scenery, trainset).Outcome);
Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn)));
Assert.True(File.Exists(scn + SaveSceneryTrainset.BackupSuffix));
Directory.Delete(temp);
Assert.Equal((SceneryTrainsetSaveOutcome.Saved, (string?)null), save.Execute(scenery, trainset));
Assert.DoesNotContain("wagon-1", Cp1250.GetString(File.ReadAllBytes(scn)));
Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn + SaveSceneryTrainset.BackupSuffix)));
Assert.Equal(
new[] { "test.scn", "test.scn" + SaveSceneryTrainset.BackupSuffix },
Directory.GetFiles(folder, "test.scn*").Select(file => System.IO.Path.GetFileName(file)).Order());
}
finally
{
Directory.Delete(folder, recursive: true);
}
}
[Fact]
public void On_disk_a_file_that_cannot_be_replaced_stays_and_nothing_is_left_beside_it()
{
// Only Windows keeps a file in use from being moved over.
if (!OperatingSystem.IsWindows())
return;
var (scn, save, scenery, folder) = OnDisk(Scn);
try
{
scenery.Trainsets[0].Vehicles.RemoveAt(1);
using (new FileStream(scn, FileMode.Open, FileAccess.Read, FileShare.Read))
Assert.Equal(SceneryTrainsetSaveOutcome.Failed, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn)));
Assert.False(File.Exists(scn + PhysicalFileSystem.TempSuffix));
}
finally
{
Directory.Delete(folder, recursive: true);
}
}
[Fact]
public void On_disk_a_link_stays_a_link_and_its_file_gets_the_text()
{
var (scn, save, scenery, folder) = OnDisk(Scn);
try
{
string target = scn + ".wlasciwy";
File.Move(scn, target);
try
{
File.CreateSymbolicLink(scn, target);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Windows lets only some users make links.
return;
}
scenery.Trainsets[0].Vehicles.RemoveAt(1);
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
Assert.NotNull(new FileInfo(scn).LinkTarget);
Assert.DoesNotContain("wagon-1", Cp1250.GetString(File.ReadAllBytes(target)));
}
finally
{
Directory.Delete(folder, recursive: true);
}
}
private static byte[] Bytes(params object[] parts) =>
parts.SelectMany(part => part as byte[] ?? Encoding.ASCII.GetBytes((string)part)).ToArray();
[Fact]
public void Bytes_behind_a_mark_that_are_not_utf8_stay_as_they_were()
{
byte[] mark = { 0xEF, 0xBB, 0xBF };
byte[] polish = { 0xB3, 0xF3, 0xBF }; // "łóż" in code page 1250
string trainset = $"trainset a t 0 0\n{V1}\nendtrainset\n";
var (files, scenery, save) = Setup(Bytes(mark, "// ", polish, "\n", trainset, "// ", polish, "\n"));
scenery.Trainsets[0].Vehicles.Clear();
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
Assert.Equal(Bytes(mark, "// ", polish, "\ntrainset a t 0 0\nendtrainset\n// ", polish, "\n"), files.ReadAllBytes(Path));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Every_byte_outside_the_vehicles_stays_as_it_was(bool marked)
{
byte[] mark = marked ? new byte[] { 0xEF, 0xBB, 0xBF } : Array.Empty<byte>();
byte[] all = Enumerable.Range(0, 256).Select(b => (byte)b).ToArray();
string trainset = $"trainset a t 0 0\n{V1}\nendtrainset\n";
var (files, scenery, save) = Setup(Bytes(mark, trainset, all));
scenery.Trainsets[0].Vehicles.Clear();
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
Assert.Equal(Bytes(mark, "trainset a t 0 0\nendtrainset\n", all), files.ReadAllBytes(Path));
}
[Theory]
// Its own line ends, in a file with the other kind elsewhere.
[InlineData("// x\r\ntrainset a t 0 0\n" + V1 + "\nendtrainset\n// y\r\n")]
// Both kinds in the trainset.
[InlineData("trainset a t 0 0\r\n// x\n" + V1 + "\r\nendtrainset\r\n")]
public void Mixed_line_ends_stay_as_they_were(string scn)
{
Assert.Equal(scn, SavedText(scn));
}
[Fact]
public void What_the_starter_writes_takes_the_line_ends_of_the_trainset()
{
string scn = "// x\r\ntrainset a t 0 0\n" + V1 + "\nendtrainset\n// y\r\n";
Assert.Equal(
"// x\r\ntrainset a t 0 0\n" + V2 + "\nendtrainset\n// y\r\n",
SavedText(scn, t => t.Vehicles[0].Name = "v2"));
}
}

View File

@@ -1,13 +1,10 @@
using StarterNG.Domain.Sceneries; using StarterNG.Domain.Sceneries;
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Tests.Fakes; using StarterNG.Tests.Fakes;
namespace StarterNG.Tests; namespace StarterNG.Tests;
public class SceneryWeatherTests public class SceneryWeatherTests
{ {
private static SceneryParser Parser(DateTime now) => new(new FixedClock(now), new StubRandom());
[Fact] [Fact]
public void Authored_weather_is_read_from_the_config_block() public void Authored_weather_is_read_from_the_config_block()
{ {
@@ -21,7 +18,7 @@ public class SceneryWeatherTests
time 8:30 0 0 endtime time 8:30 0 0 endtime
"""; """;
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather; var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather;
Assert.True(weather.IsAuthored); Assert.True(weather.IsAuthored);
Assert.Equal(172, weather.Day); Assert.Equal(172, weather.Day);
@@ -35,7 +32,7 @@ public class SceneryWeatherTests
[Fact] [Fact]
public void A_scenery_without_a_time_starts_at_the_players_clock() public void A_scenery_without_a_time_starts_at_the_players_clock()
{ {
var weather = Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather; var weather = TestScenery.Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather;
Assert.Equal("17:42", weather.ScenarioTimeOverride); Assert.Equal("17:42", weather.ScenarioTimeOverride);
Assert.False(weather.IsAuthored); Assert.False(weather.IsAuthored);
@@ -44,7 +41,7 @@ public class SceneryWeatherTests
[Fact] [Fact]
public void Freshly_parsed_weather_counts_as_unchanged() public void Freshly_parsed_weather_counts_as_unchanged()
{ {
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather; var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
Assert.False(weather.Changed); Assert.False(weather.Changed);
} }
@@ -52,7 +49,7 @@ public class SceneryWeatherTests
[Fact] [Fact]
public void Editing_then_restoring_returns_to_the_authored_values() public void Editing_then_restoring_returns_to_the_authored_values()
{ {
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather; var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
weather.Day = 5; weather.Day = 5;
weather.Temperature = -12; weather.Temperature = -12;
@@ -84,4 +81,168 @@ public class SceneryWeatherTests
Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom())); Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom()));
Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.EffectiveOvercast(new StubRandom())); Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.EffectiveOvercast(new StubRandom()));
} }
private static string Rewritten(string scn) =>
SceneryWeatherWriter.Rewrite(scn, new SceneryWeather(), new StubRandom());
private static string Code(string text) => SceneryCode.Mask(text, out _);
/// <summary>The block the writer puts at the top of the text, and the rest.</summary>
private static (string Written, string Others) Split(string rewritten)
{
const string end = "endtime\r\n";
int at = rewritten.IndexOf(end, StringComparison.Ordinal) + end.Length;
return (rewritten[..at], rewritten[at..]);
}
[Fact]
public void A_config_commented_out_stays_off_and_the_one_after_it_is_kept()
{
// As linia61_osobowy2.scn has them.
const string commented = "//config pause yes endconfig //yes: zatrzymanie symulacji zaraz po wczytaniu, jeśli start jako jadący";
string scn = $"""
sky skydome_clouds.t3d endsky
atmo 0 0 0 1000 2000 0.341 0.408 0.471 -1.2 endatmo
time 6:43 06:07 21:02 endtime //w Lublińcu 7:30
{commented}
config hiddenevents 1 endconfig
include linia61/czestochowa.scm end
""";
var (written, rest) = Split(Rewritten(scn));
Assert.Contains("hiddenevents 1\r\n", written);
Assert.DoesNotContain("pause", written);
Assert.Contains(commented, rest);
Assert.Contains("//w Lublińcu 7:30", rest);
Assert.Contains("sky skydome_clouds.t3d endsky", rest);
Assert.Contains("include linia61/czestochowa.scm end", rest);
Assert.DoesNotContain("hiddenevents", rest);
foreach (string word in new[] { "atmo", "time", "pause" })
Assert.DoesNotContain(word, Code(rest));
}
[Fact]
public void A_keyword_in_a_comment_does_not_cut_up_to_the_next_block()
{
const string scn = """
// atmo: tu byla mgla
include a.scm end
{{0}}
/* config */
include b.scm end
{{1}}
// time 8 tu nie
include c.scm end
atmo 0 0 0 10 2000 0 0 0 0 endatmo
config hiddenevents 1 endconfig
time 8:30 0 0 endtime
""";
var (written, rest) = Split(Rewritten(scn));
Assert.Contains("hiddenevents 1", written);
foreach (string kept in new[]
{
"// atmo: tu byla mgla", "include a.scm end", "{{0}}", "/* config */",
"include b.scm end", "{{1}}", "// time 8 tu nie", "include c.scm end"
})
Assert.Contains(kept, rest);
foreach (string gone in new[] { "endatmo", "endconfig", "endtime" })
Assert.DoesNotContain(gone, rest);
}
[Theory]
[InlineData("/* config a 1 endconfig */")]
[InlineData("// config a 1 endconfig")]
[InlineData("/* atmo 0 0 0 1 2 0 0 0 0 endatmo */")]
[InlineData("// atmo 0 0 0 1 2 0 0 0 0 endatmo")]
[InlineData("/* time 8:30 0 0 endtime */")]
[InlineData("// time 8:30 0 0 endtime")]
[InlineData("// movelight 100")]
[InlineData("/*\nmovelight 100\n*/")]
[InlineData("// scenario.weather.temperature 5")]
[InlineData("/* scenario.time.override 6:00 */")]
public void What_is_commented_out_stays_as_it_is(string comment)
{
var (written, rest) = Split(Rewritten($"x\n{comment}\ny\n"));
Assert.Equal($"x\n{comment}\ny\n", rest);
Assert.DoesNotContain("a 1", written);
}
[Fact]
public void A_comment_in_a_block_stays_when_the_block_goes()
{
const string scn = "atmo 0 0 0 // mgla\n10 2000 0 0 0 /* zachmurzenie */ 0 endatmo\n" +
"config\n// uwaga\nmovelight 5 // lato\nhiddenevents 1\nendconfig\ninclude a.scm end\n";
var (written, rest) = Split(Rewritten(scn));
foreach (string comment in new[] { "// mgla\n", "/* zachmurzenie */", "// uwaga\n", "// lato\n" })
Assert.Contains(comment, rest);
foreach (string word in new[] { "atmo", "2000", "config", "movelight", "hiddenevents" })
Assert.DoesNotContain(word, Code(rest));
Assert.Contains("hiddenevents 1\r\n", written);
Assert.Contains("include a.scm end", rest);
}
[Fact]
public void Settings_of_every_config_are_carried_and_only_the_starters_own_are_replaced()
{
const string scn = "config movelight 5 hiddenevents 1 endconfig\nCONFIG\nscenario.weather.temperature 3 pause no\nENDCONFIG\n";
var (written, rest) = Split(Rewritten(scn));
Assert.Contains("hiddenevents 1\r\n", written);
Assert.Contains("pause no\r\n", written);
Assert.DoesNotContain("movelight 5", written);
Assert.DoesNotContain("temperature 3", written);
Assert.Equal("", rest.Trim());
}
[Fact]
public void A_word_in_an_include_s_path_is_no_keyword()
{
const string scn = """
include atmo/lasy.scm end
include time/8.scm end
include config/a.scm end
atmo 0 0 0 10 2000 0 0 0 0 endatmo
config hiddenevents 1 endconfig
""";
var (_, rest) = Split(Rewritten(scn));
Assert.Contains("include atmo/lasy.scm end", rest);
Assert.Contains("include time/8.scm end", rest);
Assert.Contains("include config/a.scm end", rest);
Assert.DoesNotContain("endatmo", rest);
}
[Fact]
public void Weather_in_comments_is_not_read()
{
const string scn = """
// movelight 7
// scenario.weather.temperature 40
// scenario.time.override 3:33
/* time 4:44 0 0 endtime */
// atmo 5 mgla w l61
include l61/a.scm end
movelight 100
atmo 0 0 0 50 900 0 0 0 0.7 endatmo
""";
var weather = TestScenery.Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", scn).Weather;
Assert.Equal(100, weather.Day);
Assert.NotEqual(40, weather.Temperature);
Assert.Equal("17:42", weather.ScenarioTimeOverride);
Assert.Equal(50, weather.FogStart);
Assert.Equal(0.7, weather.Overcast);
}
} }

View File

@@ -0,0 +1,128 @@
using StarterNG.Domain.Settings;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SettingReachTests
{
private static SimulatorTarget Linux(string renderer = "full", bool server = false) =>
new(WindowsBuild: false, renderer, server);
[Theory]
[InlineData("feedbackmode")]
[InlineData("feedbackport")]
[InlineData("showsystemconsole")]
[InlineData("gfx.angleplatform")]
public void Windows_only_keys_idle_on_a_linux_build(string key)
{
Assert.Equal(IdleReason.WindowsBuildOnly, SettingReach.Why(key, Linux()));
Assert.Equal(IdleReason.None, SettingReach.Why(key, Linux() with { WindowsBuild = true }));
}
[Theory]
[InlineData("full", IdleReason.LegacyRendererOnly)]
[InlineData("experimental", IdleReason.LegacyRendererOnly)]
[InlineData("legacy", IdleReason.None)]
[InlineData("simpleshader", IdleReason.None)]
[InlineData("simple", IdleReason.None)]
public void Legacy_renderer_keys_follow_the_renderer(string renderer, IdleReason expected)
{
Assert.Equal(expected, SettingReach.Why("smoothtraction", Linux(renderer)));
Assert.Equal(expected, SettingReach.Why("usevbo", Linux(renderer)));
Assert.Equal(expected, SettingReach.Why("gfx.shadows.cab.range", Linux(renderer)));
}
[Fact]
public void Pausing_in_the_background_needs_a_network_server()
{
Assert.Equal(IdleReason.NeedsNetworkServer, SettingReach.Why("inactivepause", Linux()));
Assert.Equal(IdleReason.None, SettingReach.Why("inactivepause", Linux(server: true)));
}
[Fact]
public void The_starters_own_keys_never_idle()
{
Assert.Equal(IdleReason.None, SettingReach.Why("starter.autoclose", Linux()));
}
[Theory]
[InlineData("width, height")]
[InlineData("fullscreen")]
public void The_monitors_own_mode_overrides_size_and_full_screen(string key)
{
Assert.Equal(IdleReason.MonitorModeInstead,
SettingReach.Why(key, Linux() with { FullscreenWindowed = true }));
Assert.Equal(IdleReason.None, SettingReach.Why(key, Linux()));
}
[Fact]
public void An_ordinary_key_works_everywhere()
{
Assert.Equal(IdleReason.None, SettingReach.Why("fieldofview", Linux()));
}
[Theory]
[InlineData("full", IdleReason.None)]
[InlineData("legacy", IdleReason.ModernRendererOnly)]
[InlineData("experimental", IdleReason.ModernRendererOnly)]
public void Effects_of_the_full_renderer_follow_the_renderer(string renderer, IdleReason expected)
{
Assert.Equal(expected, SettingReach.Why("gfx.postfx.motionblur.enabled", Linux(renderer)));
Assert.Equal(expected, SettingReach.Why("gfx.envmap.enabled", Linux(renderer)));
Assert.Equal(expected, SettingReach.Why("sleeperdistance", Linux(renderer)));
}
[Theory]
[InlineData("full", IdleReason.None)]
[InlineData("legacy", IdleReason.None)]
[InlineData("experimental", IdleReason.OpenGlRenderersOnly)]
public void The_experimental_renderer_passes_over_what_only_opengl_reads(string renderer, IdleReason expected)
{
Assert.Equal(expected, SettingReach.Why("multisampling", Linux(renderer)));
Assert.Equal(expected, SettingReach.Why("anisotropicfiltering", Linux(renderer)));
Assert.Equal(expected, SettingReach.Why("gfx.smoke", Linux(renderer)));
// ...but reads the rest of the reflections and shadows.
Assert.Equal(IdleReason.None, SettingReach.Why("gfx.reflections.fidelity", Linux(renderer)));
Assert.Equal(IdleReason.None, SettingReach.Why("shadowtune", Linux(renderer)));
}
[Theory]
[InlineData(null, 0, false)]
[InlineData(0.0, 0, false)]
[InlineData(60.0, 0, true)]
[InlineData(null, 50, true)] // fpsaverage, the older regulator
public void The_draw_range_factor_needs_a_regulator(double? targetFps, double fpsAverage, bool regulated)
{
var target = Linux() with { TargetFps = targetFps, FpsAverage = fpsAverage };
Assert.Equal(regulated ? IdleReason.None : IdleReason.NeedsFrameRateTarget,
SettingReach.Why("gfx.drawrange.factor.max", target));
}
// ── The rest of the target ───────────────────────────────────────────────
[Fact]
public void A_windows_build_is_told_from_its_header()
{
var files = new InMemoryFileSystem()
.WithExecutable(TestInstallation.At("eu07"), "\u007fELF binary")
.WithFile(TestInstallation.At("eu07.exe"), "MZ binary");
var installation = new TestInstallation(files);
Assert.False(installation.Executables.IsWindowsBuild("eu07"));
Assert.True(installation.Executables.IsWindowsBuild("eu07.exe"));
}
[Fact]
public void A_network_server_in_the_ini_is_noticed()
{
string ini = Path.Combine("/home/kolejarz", ".config", "MaSzyna", "eu07.ini");
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(ini, "network.server tcp 0.0.0.0:7424\n"));
installation.Environment.With("HOME", "/home/kolejarz");
installation.SettingsStore.Load();
Assert.True(installation.SettingsStore.Settings.NetworkServer);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
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 started = installation.Files.LastWriteTimeUtc.AddMinutes(-1);
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log)
.SimulatorLogTail(2, started);
Assert.Equal(new[] { "second", "third" }, tail);
}
[Fact]
public void A_log_not_written_since_the_start_says_nothing_of_this_run()
{
// Dead before it opened its log: what is there is the session before.
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("log.txt"), "yesterday's error\r\n"));
var started = installation.Files.LastWriteTimeUtc.AddMinutes(10);
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log)
.SimulatorLogTail(2, started);
Assert.Empty(tail);
}
[Fact]
public void A_log_written_a_moment_before_the_start_was_taken_counts()
{
// The start is taken just after the launch, as for the crash dumps.
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("log.txt"), "opening\r\n"));
var started = installation.Files.LastWriteTimeUtc.AddSeconds(3);
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log)
.SimulatorLogTail(2, started);
Assert.Equal(new[] { "opening" }, tail);
}
}

View File

@@ -0,0 +1,82 @@
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, new Dictionary<string, string>(), captureErrors: true,
out string? error);
Assert.Null(error);
await process!.WaitForExitAsync();
Assert.Equal(3, process.ExitCode);
Assert.Equal("boom", Assert.Single(process.ErrorTail).Trim());
}
[Fact]
public async Task A_real_process_gets_the_environment_it_was_given()
{
var launcher = new SystemProcessLauncher(new TestInstallation().Log);
var (shell, script) = OperatingSystem.IsWindows()
? ("cmd", new[] { "/c", "echo %__NV_PRIME_RENDER_OFFLOAD% 1>&2" })
: ("/bin/sh", new[] { "-c", "echo $__NV_PRIME_RENDER_OFFLOAD >&2" });
var process = launcher.Start(shell, script, null,
new Dictionary<string, string> { ["__NV_PRIME_RENDER_OFFLOAD"] = "1" },
captureErrors: true, out _);
await process!.WaitForExitAsync();
Assert.Equal("1", Assert.Single(process.ErrorTail).Trim());
}
}

View File

@@ -0,0 +1,418 @@
using StarterNG.Application;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Infrastructure;
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 With_the_physics_log_on_the_launch_is_noted_for_the_tapes()
{
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build), sceneryClock: "07:52");
installation.SettingsStore.Settings.PhysicsLog = true;
start.Execute(freeFly: false, saveSettings: false);
// Under the name the simulator is given, which is the name it logs under.
string driven = launcher.Arguments![launcher.Arguments.ToList().IndexOf("-v") + 1];
string noted = installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt"));
Assert.Contains("clock\t07:52", noted);
Assert.Contains($"vehicle\t{driven}\tPKP/EP07\tep07.mmd", noted);
}
[Fact]
public void Without_the_physics_log_nothing_is_noted()
{
var (start, _, installation) = Rig(files => files.WithExecutable(Build));
start.Execute(freeFly: false, saveSettings: false);
Assert.False(installation.Files.FileExists(TestInstallation.At("physicslog", "starter-launch.txt")));
}
[Theory]
[InlineData("v2")]
[InlineData("V2")]
[InlineData("gone")]
[InlineData(null)]
[InlineData("")]
public void A_crew_is_there_to_start_with_when_the_pick_has_one_or_names_nothing(string? preferred) =>
Assert.True(StartSimulation.HasCrewToStart(WagonAndLocomotive, preferred));
[Fact]
public void A_pick_with_nobody_in_it_says_no_crew() =>
Assert.False(StartSimulation.HasCrewToStart(WagonAndLocomotive, "v1"));
[Fact]
public void A_consist_nobody_sits_in_has_no_crew_to_start()
{
var empty = new Trainset("trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset");
Assert.False(StartSimulation.HasCrewToStart(empty, null));
}
private static Trainset WagonAndLocomotive => new(
"trainset a tor 0 0\n" +
"node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\n" +
"node -1 0 v2 dynamic d s m 0 headdriver 3 0 enddynamic\n" +
"endtrainset");
[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 Wine_is_pointed_at_the_settings_the_starter_saves()
{
string wine = Path.Combine("/usr/bin", "wine");
string exe = Path.Combine(Path.GetTempPath(), "maszyna", "eu07.exe");
const string roaming = "/home/kolejarz/.wine/drive_c/users/kolejarz/AppData/Roaming";
var (start, _, installation) = Rig(files => files
.WithExecutable(wine)
.WithFile(exe, "MZ\u0090\u0000")
.WithDirectory(roaming));
installation.Environment.With("PATH", "/usr/bin").With("HOME", "/home/kolejarz").With("USER", "kolejarz");
installation.SettingsStore.Settings.ExecutablePath = exe;
start.Execute(freeFly: true, saveSettings: false);
Assert.Equal(Path.GetFullPath(Path.Combine("/home/kolejarz", ".config", "MaSzyna")),
installation.Files.LinkTarget(Path.Combine(roaming, "MaSzyna")));
}
[Fact]
public void A_native_build_leaves_the_wine_prefix_alone()
{
const string roaming = "/home/kolejarz/.wine/drive_c/users/kolejarz/AppData/Roaming";
var (start, _, installation) = Rig(files => files.WithExecutable(Build).WithDirectory(roaming));
installation.Environment.With("HOME", "/home/kolejarz").With("USER", "kolejarz");
start.Execute(freeFly: true, saveSettings: false);
Assert.Null(installation.Files.LinkTarget(roaming + "/MaSzyna"));
}
[Theory]
[InlineData(0)]
[InlineData(3)]
public void Any_renderer_is_left_for_the_simulator_to_take(int renderer)
{
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build, "\u007fELF binary"));
installation.SettingsStore.Settings.RenderEngine = renderer;
Assert.Equal(SimulationStartOutcome.Started, start.Execute(freeFly: true, saveSettings: false).Outcome);
Assert.NotNull(launcher.Executable);
}
[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_new_scenery_opens_at_the_wizard_even_with_a_scenery_selected()
{
var (start, launcher, _) = Rig(files => files.WithExecutable(Build));
var result = start.ExecuteNewSceneryEditor();
Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
Assert.Equal(new[] { "-edit" }, launcher.Arguments);
}
[Fact]
public void With_no_scenery_selected_the_editor_opens_at_its_wizard()
{
var (start, launcher, _) = Rig(files => files.WithExecutable(Build), state => state.CurrentScenery = null);
var result = start.ExecuteEditor();
Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
Assert.Equal(new[] { "-edit" }, launcher.Arguments);
}
[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);
}
[Fact]
public void A_scenery_with_no_consist_picked_starts_with_the_free_camera()
{
var (start, launcher, _) = Rig(files => files.WithExecutable(Build), state => state.CurrentTrainset = null);
var result = start.Execute(freeFly: false, saveSettings: false);
Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
Assert.Equal(new[] { "-s", "$td.scn" }, launcher.Arguments);
}
[Fact]
public void The_copy_for_a_start_is_named_in_lower_case_as_the_simulator_looks_for_it()
{
var (start, launcher, _) = Rig(files => files.WithExecutable(Build), sceneryFile: "Glinojeck.scn");
start.Execute(freeFly: true, saveSettings: false);
Assert.Equal(new[] { "-s", "$glinojeck.scn" }, launcher.Arguments);
}
[Fact]
public void A_free_camera_start_without_a_consist_notes_no_driven_vehicle_for_the_tapes()
{
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build),
state => state.CurrentTrainset = null);
installation.SettingsStore.Settings.PhysicsLog = true;
start.Execute(freeFly: true, saveSettings: false);
Assert.NotNull(launcher.Executable);
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
Assert.Null(note.Driven);
Assert.Empty(note.Consist!);
}
[Fact]
public void A_start_in_a_vehicle_notes_it_and_its_consist_for_the_tapes()
{
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build));
installation.SettingsStore.Settings.PhysicsLog = true;
start.Execute(freeFly: false, saveSettings: false);
// Under the name the simulator is given, which is the name it logs under.
string given = launcher.Arguments![launcher.Arguments.ToList().IndexOf("-v") + 1];
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
Assert.Equal(given, note.Driven);
Assert.Equal(new[] { given }, note.Consist);
}
[Fact]
public void A_start_notes_the_scenario_and_the_train_of_the_timetable_for_the_tapes()
{
var (start, _, installation) = Rig(files =>
{
files.WithExecutable(Build);
files.WithFile(TestInstallation.At("scenery", "pociag.txt"),
"[ Rodzaj i numer pociagu | MPJ15120 ]\r\n");
});
installation.SettingsStore.Settings.PhysicsLog = true;
start.Execute(freeFly: false, saveSettings: false);
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
Assert.Equal("Test", note.SceneryName);
Assert.Equal("MPJ15120", note.Train);
}
[Fact]
public void A_free_camera_start_notes_no_train_for_the_tapes()
{
var (start, _, installation) = Rig(files => files.WithExecutable(Build));
installation.SettingsStore.Settings.PhysicsLog = true;
start.Execute(freeFly: true, saveSettings: false);
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
Assert.Null(note.Train);
}
private static void HybridLaptop(InMemoryFileSystem files)
{
files.WithFile("/sys/class/drm/card0/device/uevent",
"DRIVER=nvidia\nPCI_ID=10DE:2D19\nPCI_SLOT_NAME=0000:01:00.0\n")
.WithFile("/sys/class/drm/card0/device/boot_vga", "0\n")
.WithFile("/sys/class/drm/card1/device/uevent",
"DRIVER=amdgpu\nPCI_ID=1002:1900\nPCI_SLOT_NAME=0000:65:00.0\n")
.WithFile("/sys/class/drm/card1/device/boot_vga", "1\n")
.WithFile("/sys/class/drm/card1-eDP-1/status", "connected\n");
}
[Fact]
public void On_a_laptop_with_two_cards_the_simulator_goes_to_the_discrete_one()
{
var (start, launcher, _) = Rig(files =>
{
files.WithExecutable(Build);
HybridLaptop(files);
});
start.Execute(freeFly: true, saveSettings: false);
Assert.Equal("1", launcher.Environment!["__NV_PRIME_RENDER_OFFLOAD"]);
}
[Fact]
public void Left_to_the_system_the_simulator_gets_no_card_of_the_starters_choosing()
{
var (start, launcher, installation) = Rig(files =>
{
files.WithExecutable(Build);
HybridLaptop(files);
});
installation.SettingsStore.Settings.Gpu = StarterNG.Domain.Settings.GpuPreference.System;
start.Execute(freeFly: true, saveSettings: false);
Assert.Empty(launcher.Environment!);
}
private static (StartSimulation Start, RecordingLauncher Launcher, TestInstallation Installation) Rig(
Action<InMemoryFileSystem> addSimulator, Action<AppState>? arrange = null, string sceneryFile = "td.scn",
string sceneryClock = "10:30")
{
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", sceneryFile),
"""
//$n Test
trainset pociag tor_1 10 0
node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0
endtrainset
FirstInit
""");
scenery.Weather.ScenarioTimeOverride = sceneryClock;
var state = new AppState { CurrentScenery = scenery, CurrentTrainset = scenery.Trainsets[0] };
arrange?.Invoke(state);
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,
new LinuxGpus(installation.Files, installation.Environment),
installation.Environment,
new WineConfigLink(installation.Files, installation.Environment,
installation.SettingsPaths, installation.Log),
installation.Tapes);
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 IReadOnlyDictionary<string, string>? Environment { get; private set; }
public IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
IReadOnlyDictionary<string, string> environment, bool captureErrors,
out string? error)
{
Environment = environment;
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;
public bool IsRunning(string processName) => false;
}
private sealed class RunningProcess : IProcessHandle
{
public bool HasExited => false;
public int? ExitCode => null;
public IReadOnlyList<string> ErrorTail => Array.Empty<string>();
public Task WaitForExitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,42 @@
using StarterNG.Domain.Settings;
namespace StarterNG.Tests;
public class StepsTests
{
[Fact]
public void A_value_among_the_steps_leaves_them_as_they_are()
{
Assert.Same(Steps.FrameRateLimits, Steps.With(Steps.FrameRateLimits, 144));
}
[Fact]
public void A_value_between_the_steps_joins_them_in_order()
{
var steps = Steps.With(Steps.FrameRateLimits, 58);
Assert.Equal(Steps.FrameRateLimits.Length + 1, steps.Count);
Assert.Equal(new double[] { 50, 58, 60 }, steps.Skip(Steps.IndexOf(steps, 50)).Take(3));
}
[Fact]
public void Values_past_either_end_join_at_that_end()
{
Assert.Equal(0.5, Steps.With(Steps.Anisotropy, 0.5)[0]);
Assert.Equal(500, Steps.With(Steps.FrameRateLimits, 500)[^1]);
}
[Fact]
public void The_frame_rate_limits_start_with_none()
{
Assert.Equal(new double[] { 0, 10, 20 }, Steps.With(Steps.FrameRateLimits, 10).Take(3));
}
[Fact]
public void A_step_is_found_from_its_rounding_in_the_file()
{
// 1/300 is written as 0.003333.
Assert.Equal(0, Steps.IndexOf(Steps.ReflectionUpdates, 0.003333));
Assert.Equal(-1, Steps.IndexOf(Steps.ReflectionUpdates, 0.0033));
}
}

View File

@@ -0,0 +1,485 @@
using StarterNG.Application;
using StarterNG.Domain.Tapes;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class TapeLaunchTests
{
private static readonly DateTime Launched = new(2025, 12, 31, 23, 0, 0, DateTimeKind.Utc);
[Fact]
public void A_launch_is_written_and_read_back_with_names_that_hold_spaces()
{
var launch = new TapeLaunch(Launched, "td.scn", new TimeSpan(7, 52, 0), new[]
{
new TapeVehicle("EP07-424-HIST", "pkp/ep07_v1", "ep07-424"),
new TapeVehicle("wagon 1", "pkp/111a_v2", "111a")
});
var back = TapeLaunch.Parse(launch.Format())!;
Assert.Equal(Launched, back.LaunchedUtc);
Assert.Equal("td.scn", back.Scenery);
Assert.Equal(new TimeSpan(7, 52, 0), back.StartClock);
Assert.Equal("pkp/ep07_v1", back.Find("ep07-424-hist")!.DataFolder);
Assert.Equal("111a", back.Find("wagon 1")!.TypeName);
}
[Fact]
public void The_driven_vehicle_and_the_consist_are_written_and_read_back()
{
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
"EP07 424", new[] { "EP07 424", "wagon 1" });
var back = TapeLaunch.Parse(launch.Format())!;
Assert.Equal(TapeRole.Driven, back.RoleOf("ep07 424"));
Assert.Equal(TapeRole.OwnConsist, back.RoleOf("WAGON 1"));
Assert.Equal(TapeRole.Other, back.RoleOf("et22-100"));
}
[Fact]
public void A_note_without_the_driven_vehicle_tells_nothing_of_it()
{
var back = TapeLaunch.Parse("launched\t2025-12-31T23:00:00.0000000Z\nscenery\ttd.scn\nclock\t10:30\n")!;
Assert.Null(back.Driven);
Assert.Equal(TapeRole.Other, back.RoleOf("ep07-424"));
Assert.Null(back.SceneryName);
Assert.Null(back.Train);
}
[Fact]
public void The_scenario_and_the_train_are_written_and_read_back()
{
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
SceneryName: "Tor doświadczalny", Train: "EIE 1531");
var back = TapeLaunch.Parse(launch.Format())!;
Assert.Equal("Tor doświadczalny", back.SceneryName);
Assert.Equal("EIE 1531", back.Train);
}
[Fact]
public void A_tape_of_the_last_launch_gets_its_scenario_and_train()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("physicslog", "EP07-424.dat"), "t"));
installation.Clock.Now = Launched;
var tapes = installation.Tapes;
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "EP07-424", new[] { "EP07-424" },
"Tor doświadczalny", "EIE1531");
var context = tapes.ContextOf(tapes.List().Single());
Assert.Equal("Tor doświadczalny", context.Scenery);
Assert.Equal("EIE1531", context.Train);
}
[Fact]
public void A_tape_of_someone_elses_vehicle_does_not_get_the_train_of_the_player()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("physicslog", "ET22-100.dat"), "t"));
installation.Clock.Now = Launched;
var tapes = installation.Tapes;
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "EP07-424", new[] { "EP07-424" },
"Tor doświadczalny", "EIE1531");
var context = tapes.ContextOf(tapes.List().Single());
Assert.Equal("Tor doświadczalny", context.Scenery);
Assert.Null(context.Train);
}
[Fact]
public void A_scenario_without_a_name_goes_by_its_file()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("physicslog", "EP07-424.dat"), "t"));
installation.Clock.Now = Launched;
var tapes = installation.Tapes;
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>());
Assert.Equal("td.scn", tapes.ContextOf(tapes.List().Single()).Scenery);
}
[Theory]
[InlineData("[ Rodzaj i numer pociagu | EIE1531 \t ] \r\n[ Kategoria | IC ]\r\n", "EIE1531")]
[InlineData(" ____\r\n[ Rodzaj i numer pociągu | LPE 782 ]\r\n", "LPE 782")]
[InlineData("[ Kategoria | IC ]\r\n", null)]
[InlineData("", null)]
public void The_train_number_comes_from_the_heading_of_its_timetable(string timetable, string? number)
{
var files = new InMemoryFileSystem().WithFile(TestInstallation.At("scenery", "baltyk", "eie1531.txt"), timetable);
Assert.Equal(number, new StarterNG.Infrastructure.Sceneries.TimetableLocator(files)
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""),
"baltyk/eie1531"));
}
[Theory]
[InlineData("none")]
[InlineData("NONE")]
[InlineData("")]
[InlineData(null)]
public void A_trainset_without_a_timetable_has_no_train_number(string? name) =>
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""), name));
[Fact]
public void A_timetable_that_is_not_there_gives_no_train_number() =>
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""),
@"baltyk\mod\eie1531"));
[Fact]
public void The_driven_tape_and_the_consist_are_listed_ahead_of_the_rest()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("physicslog", "a-other.dat"), "t")
.WithFile(TestInstallation.At("physicslog", "b-wagon.dat"), "t")
.WithFile(TestInstallation.At("physicslog", "c-lok.dat"), "t"));
installation.Clock.Now = Launched;
var tapes = installation.Tapes;
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "c-lok", new[] { "c-lok", "b-wagon" });
Assert.Equal("c-lok", tapes.DrivenVehicle);
Assert.Equal(new[] { "c-lok", "b-wagon", "a-other" }, tapes.List().Select(tape => tape.Vehicle));
Assert.Equal(new[] { TapeRole.StartedIn, TapeRole.OwnConsist, TapeRole.Other }, tapes.List().Select(tape => tape.Role));
}
[Fact]
public void With_no_log_the_vehicle_of_the_launch_is_only_the_one_it_started_in()
{
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
Assert.Equal(TapeRole.StartedIn, launch.RoleOf("ra", PlayerVehicles.Unknown));
Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("rb", PlayerVehicles.Unknown));
}
[Fact]
public void Where_the_player_has_been_is_written_and_read_back()
{
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
Player: PlayerVehicles.None.After("a received command: [x]\nb received command: [x]\n"));
var back = TapeLaunch.Parse(launch.Format())!;
Assert.Equal("b", back.Player!.Current);
Assert.True(back.Player.Used("a"));
}
[Fact]
public void A_note_of_a_single_launch_still_reads()
{
var all = TapeLaunch.ParseAll("launched\t2025-12-31T23:00:00.0000000Z\nscenery\ttd.scn\nclock\t10:30\n");
Assert.Equal("td.scn", all.Single().Scenery);
Assert.Null(all.Single().Player);
}
[Fact]
public void Tapes_part_between_the_launches_they_were_written_after()
{
var files = new InMemoryFileSystem()
.WithFile(TestInstallation.At("physicslog", "ra.dat"), "t")
.WithFile(TestInstallation.At("physicslog", "rb.dat"), "t");
var installation = new TestInstallation(files);
var tapes = installation.Tapes;
installation.Clock.Now = Launched;
tapes.RecordLaunch("a.scn", new TimeSpan(7, 0, 0), Array.Empty<TapeVehicle>(), "ra", new[] { "ra" }, "A", "T1");
installation.Clock.Now = Launched.AddHours(1);
tapes.RecordLaunch("b.scn", new TimeSpan(9, 0, 0), Array.Empty<TapeVehicle>(), "rb", new[] { "rb" }, "B", "T2");
files.WrittenAt(TestInstallation.At("physicslog", "ra.dat"), Launched.AddMinutes(30))
.WrittenAt(TestInstallation.At("physicslog", "rb.dat"), Launched.AddMinutes(90));
var sessions = tapes.Sessions();
Assert.Equal(new[] { "B", "A" }, sessions.Select(session => session.Launch!.SceneryName));
Assert.Equal("rb", sessions[0].Tapes.Single().Vehicle);
Assert.Equal("ra", sessions[1].Tapes.Single().Vehicle);
Assert.Equal(new TimeSpan(7, 0, 0), tapes.ContextOf(sessions[1].Tapes[0]).StartClock);
Assert.Equal("T1", tapes.ContextOf(sessions[1].Tapes[0]).Train);
}
[Fact]
public void Tapes_with_no_launch_noted_go_together_by_when_they_were_written()
{
var early = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("physicslog", "a.dat"), "t")
.WithFile(TestInstallation.At("physicslog", "b.dat"), "t")
.WithFile(TestInstallation.At("physicslog", "c.dat"), "t")
.WrittenAt(TestInstallation.At("physicslog", "a.dat"), early)
.WrittenAt(TestInstallation.At("physicslog", "b.dat"), early.AddSeconds(30))
.WrittenAt(TestInstallation.At("physicslog", "c.dat"), early.AddHours(1)));
var sessions = installation.Tapes.Sessions();
Assert.Equal(new[] { 1, 2 }, sessions.Select(session => session.Tapes.Count));
Assert.All(sessions, session => Assert.Null(session.Launch));
}
[Fact]
public void A_running_session_is_listed_with_no_tapes_yet_and_goes_with_the_simulator()
{
var installation = new TestInstallation(new InMemoryFileSystem());
installation.Clock.Now = Launched;
var tapes = installation.Tapes;
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra");
installation.SimulatorRunning = true;
var running = tapes.Sessions().Single();
Assert.True(running.Running);
Assert.Empty(running.Tapes);
installation.SimulatorRunning = false;
Assert.Empty(tapes.Sessions());
}
[Fact]
public void A_log_of_its_own_tells_of_the_vehicles_when_the_common_one_is_stale()
{
string kept = TestInstallation.At("logs", "log (td.scn) 2026.txt");
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("log.txt"), "ra received command: [x]\n")
.WithFile(kept, "rb received command: [x]\n")
.WrittenAt(TestInstallation.At("log.txt"), Launched.AddHours(-2))
.WrittenAt(kept, Launched.AddMinutes(10)));
installation.Clock.Now = Launched;
var tapes = installation.Tapes;
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
Assert.Equal("rb", tapes.DrivenVehicle);
}
[Fact]
public void The_vehicles_of_a_run_stay_with_it_when_the_next_begins()
{
var (tapes, installation) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
var written = Launched.AddMinutes(30);
installation.Files.WrittenAt(TestInstallation.At("log.txt"), written)
.WrittenAt(TestInstallation.At("physicslog", "ra.dat"), written)
.WrittenAt(TestInstallation.At("physicslog", "rb.dat"), written)
.WrittenAt(TestInstallation.At("physicslog", "obcy.dat"), written);
installation.Clock.Now = Launched.AddHours(1);
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "inny", new[] { "inny" });
var run = tapes.Sessions().Single();
Assert.Equal("Test", run.Launch!.SceneryName);
Assert.Equal(TapeRole.Driven, run.Tapes.Single(tape => tape.Vehicle == "rb").Role);
Assert.Equal(TapeRole.DrivenEarlier, run.Tapes.Single(tape => tape.Vehicle == "ra").Role);
}
[Fact]
public void Only_the_last_launches_are_remembered()
{
var installation = new TestInstallation(new InMemoryFileSystem());
var tapes = installation.Tapes;
for (int i = 0; i < 35; i++)
{
installation.Clock.Now = Launched.AddMinutes(i);
tapes.RecordLaunch($"s{i}.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>());
}
var noted = TapeLaunch.ParseAll(installation.Files.ReadAllText(
TestInstallation.At("physicslog", "starter-launch.txt"), System.Text.Encoding.UTF8));
Assert.Equal(30, noted.Count);
Assert.Equal("s5.scn", noted[0].Scenery);
Assert.Equal("s34.scn", noted[^1].Scenery);
}
[Fact]
public void The_vehicle_the_commands_go_to_is_the_one_the_player_is_in()
{
var player = PlayerVehicles.None.After(
"[ 21.764 ]\t\tTrying to enter player train, \"lok-a\"\r\n" +
"[ 25.609 ]\t\tlok-a received command: [aidriverdisable]\r\n" +
"[ 255.539 ]\t\tKey pressed: [F5]\r\n" +
"[ 255.569 ]\t\tlok b received command: [aidriverdisable]\r\n" +
"lok-c received command: [cabchangebackward]\n");
Assert.Equal("lok-c", player.Current);
Assert.True(player.Used("LOK-A"));
Assert.True(player.Used("lok b"));
Assert.False(player.Used("lok-d"));
}
[Fact]
public void Going_back_to_a_vehicle_makes_it_the_current_one_again()
{
var player = PlayerVehicles.None
.After("a received command: [x]\nb received command: [x]\n")
.After("A received command: [x]\n");
Assert.Equal("A", player.Current);
Assert.True(player.Used("b"));
}
[Fact]
public void After_a_change_of_vehicle_the_new_one_is_driven_and_the_first_was_driven_earlier()
{
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
"ra", new[] { "ra", "s", "rb" });
var player = PlayerVehicles.None.After("ra received command: [x]\nrb received command: [x]\n");
Assert.Equal(TapeRole.Driven, launch.RoleOf("RB", player));
Assert.Equal(TapeRole.DrivenEarlier, launch.RoleOf("ra", player));
Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("s", player));
Assert.Equal(TapeRole.Other, launch.RoleOf("obcy", player));
}
[Fact]
public void Before_the_first_command_the_vehicle_of_the_launch_is_driven()
{
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
Assert.Equal(TapeRole.Driven, launch.RoleOf("ra", PlayerVehicles.None));
Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("rb", PlayerVehicles.None));
}
private static (HaslerTapes Tapes, TestInstallation Installation) TapesOfTrain(string log)
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("physicslog", "ra.dat"), "t")
.WithFile(TestInstallation.At("physicslog", "rb.dat"), "t")
.WithFile(TestInstallation.At("physicslog", "obcy.dat"), "t")
.WithFile(TestInstallation.At("log.txt"), log));
installation.Clock.Now = Launched;
var tapes = installation.Tapes;
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" }, "Test", "MPJ1");
return (tapes, installation);
}
[Fact]
public void The_tape_of_the_vehicle_changed_to_comes_first_and_keeps_the_train()
{
var (tapes, _) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
Assert.Equal(new[] { "rb", "ra", "obcy" }, tapes.List().Select(tape => tape.Vehicle));
Assert.Equal(new[] { TapeRole.Driven, TapeRole.DrivenEarlier, TapeRole.Other }, tapes.List().Select(tape => tape.Role));
Assert.Equal("rb", tapes.DrivenVehicle);
Assert.Equal("MPJ1", tapes.ContextOf(tapes.List().First()).Train);
}
[Fact]
public void A_vehicle_of_another_train_changed_to_is_driven_without_the_number_of_the_first()
{
var (tapes, _) = TapesOfTrain("obcy received command: [x]\n");
var driven = tapes.List().First();
Assert.Equal("obcy", driven.Vehicle);
Assert.Null(tapes.ContextOf(driven).Train);
}
[Fact]
public void A_change_of_vehicle_during_the_run_is_seen_at_the_next_look()
{
const string start = "ra received command: [x]\n";
var (tapes, installation) = TapesOfTrain(start);
Assert.Equal("ra", tapes.DrivenVehicle);
installation.Files.WithFile(TestInstallation.At("log.txt"), start + "rb received com");
Assert.Equal("ra", tapes.DrivenVehicle);
installation.Files.WithFile(TestInstallation.At("log.txt"), start + "rb received command: [x]\n");
Assert.Equal("rb", tapes.DrivenVehicle);
}
[Fact]
public void A_log_begun_anew_forgets_the_vehicles_of_the_old_one()
{
var (tapes, installation) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
Assert.Equal("rb", tapes.DrivenVehicle);
installation.Files.WithFile(TestInstallation.At("log.txt"), "obcy received command: [x]\n");
Assert.Equal("obcy", tapes.DrivenVehicle);
Assert.Equal(TapeRole.OwnConsist, tapes.List().Single(tape => tape.Vehicle == "rb").Role);
}
[Fact]
public void A_log_older_than_the_launch_says_nothing_of_it()
{
var (tapes, installation) = TapesOfTrain("rb received command: [x]\n");
installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddMinutes(10);
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
Assert.Equal("ra", tapes.DrivenVehicle);
}
[Fact]
public void A_tape_older_than_the_last_launch_has_no_part_in_it()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("physicslog", "c-lok.dat"), "t"));
installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddDays(1);
var tapes = installation.Tapes;
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "c-lok");
Assert.Equal(TapeRole.Other, tapes.List().Single().Role);
}
[Fact]
public void A_log_written_before_the_launch_is_not_taken_for_one_of_it()
{
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>());
Assert.True(launch.Covers(Launched.AddMinutes(3)));
Assert.False(launch.Covers(Launched.AddHours(-1)));
}
[Theory]
[InlineData("pkp/ep07_v1", "ep07-424", RecorderKind.Hasler)]
[InlineData("pkp/en57al_v1", "en57al-1234", RecorderKind.Electronic)]
[InlineData("pkp/x4e_v1", "x4e-659", RecorderKind.Electronic)]
[InlineData("pkp/et22_v3", "et22-1100", RecorderKind.Hasler)]
public void The_recorder_follows_the_vehicle_behind_the_name(string folder, string type, RecorderKind kind) =>
Assert.Equal(kind, Recorders.Guess(new TapeVehicle("poc1_lok", folder, type), "poc1_lok"));
[Fact]
public void Without_a_launch_the_tape_name_is_all_there_is_to_go_by()
{
Assert.Equal(RecorderKind.Electronic, Recorders.Guess(null, "ED250-001"));
Assert.Equal(RecorderKind.Hasler, Recorders.Guess(null, "eu07-193"));
}
[Fact]
public void A_tape_of_the_last_launch_gets_its_clock_and_recorder()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("physicslog", "ED250-001.dat"), "t"));
installation.Clock.Now = Launched;
var tapes = installation.Tapes;
tapes.RecordLaunch("lk4.scn", new TimeSpan(14, 5, 0),
new[] { new TapeVehicle("ED250-001", "pkp/ed250_v1", "ed250-001") });
var context = tapes.ContextOf(tapes.List().Single());
Assert.Equal(new TimeSpan(14, 5, 0), context.StartClock);
Assert.Equal(RecorderKind.Electronic, context.Recorder);
}
[Fact]
public void A_tape_older_than_the_last_launch_keeps_its_hour_unknown()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("physicslog", "EU07-424.dat"), "t"));
installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddDays(1);
var tapes = installation.Tapes;
tapes.RecordLaunch("td.scn", new TimeSpan(8, 0, 0), Array.Empty<TapeVehicle>());
Assert.Null(tapes.ContextOf(tapes.List().Single()).StartClock);
}
}

View File

@@ -0,0 +1,197 @@
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Domain.Vehicles;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class TexRandomizerTests
{
private readonly Consist _consist;
private readonly VehicleCatalog _catalog;
public TexRandomizerTests()
{
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", "en57", "textures.txt"),
"!=z\n^2\nen57-a.mat=en57a,EN57\nen57-b.mat=en57b,EN57\nen57-c.mat=en57a,EN57\nen57-d.mat=en57b,EN57")
.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));
}
private Dynamic Car(string skin) => _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null);
private ConsistItem Put(params Dynamic[] cars)
{
var item = new ConsistItem
{
Cars = cars.ToList(),
Grouped = cars.Length > 1,
Driver = Consist.UnitDriver(cars)
};
_consist.Insert(_consist.Count, item);
return item;
}
/// <summary>The randomizer with the next pick in line always the other livery of two.</summary>
private int Randomize() => new TexRandomizer(_catalog, new SecondPick()).Apply(_consist, t => _consist.MakeDynamic(t, null));
private sealed class SecondPick : Random
{
public override int Next(int maxValue) => 1;
}
[Theory]
[InlineData(eDriverType.Headdriver)]
[InlineData(eDriverType.Reardriver)]
[InlineData(eDriverType.Passenger)]
public void The_crew_stays_on_the_vehicle_that_takes_over(eDriverType crew)
{
var loco = Car("ep07-001");
loco.DriverType = crew;
Put(loco);
Assert.Equal(1, Randomize());
var car = Assert.Single(_consist[0].Cars);
Assert.Equal("ep07-002", car.SkinFile);
Assert.Equal(crew, car.DriverType);
Assert.Equal(crew, _consist[0].Driver);
}
[Fact]
public void A_driver_kept_is_what_the_export_writes()
{
var loco = Car("ep07-001");
loco.DriverType = eDriverType.Headdriver;
Put(loco);
Randomize();
Assert.Contains(" headdriver ", _consist[0].Cars[0].ToTrainsetNode());
}
[Fact]
public void An_empty_locomotive_stays_empty()
{
// Consist.Replace would staff it; a driver of its own is not this one's to give.
Put(Car("ep07-001"));
Randomize();
Assert.Equal(eDriverType.Nobody, _consist[0].Cars[0].DriverType);
Assert.Equal(eDriverType.Nobody, _consist[0].Driver);
}
[Fact]
public void A_vehicle_keeps_facing_the_way_it_did()
{
var loco = Car("ep07-001");
loco.Offset = -1f;
var item = Put(loco);
item.Flipped = true;
Randomize();
Assert.True(_consist[0].Flipped);
Assert.True(Consist.IsTurned(_consist[0].Cars[0]));
}
[Fact]
public void A_vehicle_keeps_its_load()
{
var wagon = Car("b111-01");
wagon.LoadType = "Passengers";
wagon.LoadCount = 40;
wagon.MaxLoad = 60;
Put(wagon);
Assert.Equal(1, Randomize());
var car = _consist[0].Cars[0];
Assert.Equal("b111-02", car.SkinFile);
Assert.Equal("Passengers", car.LoadType);
Assert.Equal(40, car.LoadCount);
Assert.Equal(60, car.MaxLoad);
}
[Fact]
public void A_unit_of_the_same_length_keeps_all_of_it_car_by_car()
{
var first = Car("en57-a");
var second = Car("en57-b");
second.DriverType = eDriverType.Reardriver;
first.LoadType = "Passengers";
first.LoadCount = 10;
second.LoadType = "Passengers";
second.LoadCount = 20;
first.Offset = -1f;
second.Offset = -1f;
var item = Put(first, second);
item.Flipped = true;
Assert.Equal(1, Randomize());
var unit = _consist[0];
Assert.Equal(new[] { "en57-c", "en57-d" }, unit.Cars.Select(c => c.SkinFile));
Assert.True(unit.Grouped);
Assert.True(unit.Flipped);
Assert.Equal(new[] { eDriverType.Nobody, eDriverType.Reardriver }, unit.Cars.Select(c => c.DriverType));
Assert.Equal(eDriverType.Reardriver, unit.Driver);
Assert.Equal(new[] { 10, 20 }, unit.Cars.Select(c => c.LoadCount));
Assert.All(unit.Cars, c => Assert.True(Consist.IsTurned(c)));
}
[Theory]
[InlineData(eDriverType.Headdriver, 0)]
[InlineData(eDriverType.Reardriver, 1)]
public void A_crew_moving_into_a_longer_unit_sits_in_the_car_with_its_cab(eDriverType crew, int car)
{
var loco = Car("en57-c");
loco.DriverType = crew;
Put(loco);
Assert.Equal(1, new TexRandomizer(_catalog, new FirstPick()).Apply(_consist, t => _consist.MakeDynamic(t, null)));
var unit = _consist[0];
Assert.Equal(2, unit.Cars.Count);
Assert.Equal(crew, unit.Cars[car].DriverType);
Assert.Equal(eDriverType.Nobody, unit.Cars[1 - car].DriverType);
Assert.Equal(crew, unit.Driver);
}
private sealed class FirstPick : Random
{
public override int Next(int maxValue) => 0;
}
[Fact]
public void A_selected_vehicle_stays_selected_as_the_one_that_took_over()
{
var item = Put(Car("ep07-001"));
_consist.Selected = item;
Randomize();
Assert.Same(_consist[0], _consist.Selected);
}
[Fact]
public void A_vehicle_not_selected_does_not_take_the_selection()
{
// ep07-001's turn picks ep07-002; ep07-002's turn picks itself and passes.
Put(Car("ep07-001"));
var selected = Put(Car("ep07-002"));
_consist.Selected = selected;
Assert.Equal(1, Randomize());
Assert.Same(selected, _consist.Selected);
}
}

View File

@@ -0,0 +1,77 @@
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Views;
namespace StarterNG.Tests;
public class TrainsetDisplayTests
{
private static Trainset Consist(string driver = "headdriver", string? description = null) =>
new(string.Concat(
description is null ? "" : $"//$o {description}\n",
$"trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 {driver} 3 0 enddynamic\nendtrainset"));
[Theory]
[InlineData("-ai", true)]
[InlineData(" - ai", true)]
[InlineData("a - b", false)]
[InlineData("rozkladowy", false)]
public void A_dash_opening_the_description_says_the_consist_is_ai(string description, bool ai) =>
Assert.Equal(ai, TrainsetDisplay.IsAi(Consist(description: description)));
[Fact]
public void No_description_is_no_ai() =>
Assert.False(TrainsetDisplay.IsAi(Consist()));
[Fact]
public void An_ai_consist_is_listed_only_when_they_are_shown()
{
var ai = Consist(description: "-ai");
Assert.False(TrainsetDisplay.ListedIn(ai, showAi: false, drivableOnly: false, current: null));
Assert.True(TrainsetDisplay.ListedIn(ai, showAi: true, drivableOnly: false, current: null));
}
[Fact]
public void Drivable_only_leaves_out_a_consist_nobody_sits_in()
{
var empty = Consist(driver: "nobody");
Assert.False(TrainsetDisplay.ListedIn(empty, showAi: true, drivableOnly: true, current: null));
Assert.True(TrainsetDisplay.ListedIn(empty, showAi: true, drivableOnly: false, current: null));
Assert.True(TrainsetDisplay.ListedIn(Consist(), showAi: true, drivableOnly: true, current: null));
}
[Fact]
public void Drivable_only_keeps_the_one_picked_whatever_its_crew()
{
var picked = Consist(driver: "nobody");
Assert.True(TrainsetDisplay.ListedIn(picked, showAi: true, drivableOnly: true, current: picked));
}
[Fact]
public void Being_picked_does_not_show_an_ai_consist_the_filter_hides()
{
var picked = Consist(description: "-ai");
Assert.False(TrainsetDisplay.ListedIn(picked, showAi: false, drivableOnly: true, current: picked));
}
[Theory]
[InlineData(eDriverType.Headdriver, 0)]
[InlineData(eDriverType.Reardriver, 1)]
[InlineData(eDriverType.Passenger, 2)]
[InlineData(eDriverType.Nobody, 3)]
public void A_crew_keeps_its_place_in_the_list(eDriverType crew, int place)
{
Assert.Equal(place, CrewChoices.IndexOf(crew));
Assert.Equal(crew, CrewChoices.At(place));
}
[Theory]
[InlineData(-1)]
[InlineData(4)]
public void A_list_with_nothing_selected_means_nobody(int place) =>
Assert.Equal(eDriverType.Nobody, CrewChoices.At(place));
}

View File

@@ -0,0 +1,162 @@
using StarterNG.Domain.Vehicles;
using StarterNG.Tests.Fakes;
using StarterNG.Views;
namespace StarterNG.Tests;
public class VehicleBaseQueryTests
{
private static VehicleBaseRow Row(string skin, string? operatorName = null, string? revision = null,
string? author = null, string? depot = null, string? photo = null,
string directory = "dynamic/pkp/", string? model = "model")
{
return new VehicleBaseRow(new VehicleTexture
{
Directory = directory,
Skinfile = skin,
Model = model,
Meta = new VehicleMeta
{
Operator = operatorName,
RevisionDate = revision,
TextureAuthor = author,
Depot = depot,
PhotoAuthor = photo
}
});
}
private static IReadOnlyList<VehicleTexture> Catalogued(params string[] textures)
{
var files = new InMemoryFileSystem();
foreach (string text in textures)
files.WithFile(TestInstallation.At("dynamic", "pkp", text[..text.IndexOf('=')], "textures.txt"),
text[(text.IndexOf('=') + 1)..]);
var installation = new TestInstallation(files);
installation.Vehicles.Load(installation.Library.Vehicles);
return installation.Library.Vehicles.Textures;
}
private static string[] Skins(IEnumerable<VehicleBaseRow> rows) => rows.Select(r => r.Skin).ToArray();
private static string[] Selected(VehicleBaseFilters filters, params VehicleBaseRow[] rows) =>
Skins(VehicleBaseQuery.Select(rows, filters, r => r.Skin, descending: false));
[Fact]
public void A_question_mark_in_the_credit_line_is_what_its_author_did_not_know()
{
var row = Row("a", operatorName: "?", revision: "?", author: "Jan");
Assert.Equal("", row.Operator);
Assert.Equal("", row.Revision);
Assert.Null(row.Date);
Assert.Equal("", row.DateKey);
Assert.Equal("Jan", row.Author);
}
[Fact]
public void The_date_key_sorts_as_the_dates_do() =>
Assert.Equal("20081201", Row("a", revision: "xx.12.2008").DateKey);
[Fact]
public void Each_filter_looks_in_its_own_field_ignoring_case_and_spaces_around()
{
var a = Row("alfa", operatorName: "PKP", author: "Jan", depot: "Lublin", photo: "Ewa", directory: "dynamic/one/");
var b = Row("beta", operatorName: "CD", author: "Olaf", depot: "Praha", photo: "Karel", directory: "dynamic/two/");
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Name = " ALF " }, a, b));
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Operator = "cd" }, a, b));
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Author = "jan" }, a, b));
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Station = "prah" }, a, b));
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Photo = "EW" }, a, b));
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Model = "TWO/" }, a, b));
Assert.Empty(Selected(new VehicleBaseFilters { Name = "alf", Operator = "cd" }, a, b));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void A_filter_with_nothing_typed_lets_everything_through(string? typed) =>
Assert.Equal(new[] { "a", "b" }, Selected(new VehicleBaseFilters { Name = typed }, Row("a"), Row("b")));
[Theory]
[InlineData("2016", null, true)]
[InlineData("2016-12", null, true)]
[InlineData("2017", null, false)]
[InlineData(null, "2016", true)]
[InlineData(null, "2015", false)]
[InlineData("2010", "2020", true)]
public void A_revision_counts_while_its_span_overlaps_what_is_asked(string? from, string? to, bool shown)
{
var row = Row("a", revision: "xx.xx.2016");
var found = Selected(new VehicleBaseFilters { RevisionFrom = from, RevisionTo = to }, row);
Assert.Equal(shown, found.Length == 1);
}
[Fact]
public void A_month_only_date_overlaps_at_both_of_its_ends()
{
var row = Row("a", revision: "xx.06.2008");
Assert.Single(Selected(new VehicleBaseFilters { RevisionFrom = "30.06.2008" }, row));
Assert.Single(Selected(new VehicleBaseFilters { RevisionTo = "01.06.2008" }, row));
Assert.Empty(Selected(new VehicleBaseFilters { RevisionFrom = "01.07.2008" }, row));
Assert.Empty(Selected(new VehicleBaseFilters { RevisionTo = "31.05.2008" }, row));
}
[Fact]
public void A_revision_asked_for_leaves_out_what_has_no_date_and_what_cannot_be_read_asks_nothing()
{
var dated = Row("dated", revision: "2016");
var undated = Row("undated");
Assert.Equal(new[] { "dated" }, Selected(new VehicleBaseFilters { RevisionFrom = "2000" }, dated, undated));
Assert.Equal(new[] { "dated", "undated" },
Selected(new VehicleBaseFilters { RevisionFrom = "soon" }, dated, undated));
}
[Theory]
[InlineData(false, new[] { "b", "c", "a", "d" })]
[InlineData(true, new[] { "c", "b", "a", "d" })]
public void Blanks_stay_at_the_bottom_whichever_way_a_column_is_sorted_and_the_name_settles_a_tie(
bool descending, string[] order)
{
var rows = new[] { Row("d"), Row("c", operatorName: "Z"), Row("a"), Row("b", operatorName: "M") };
Assert.Equal(order, Skins(VehicleBaseQuery.Select(rows, new VehicleBaseFilters(), r => r.Operator, descending)));
}
[Fact]
public void The_class_list_counts_a_class_under_one_name_whatever_its_case_in_order_of_the_names()
{
var textures = Catalogued("b111=!=b\nb111-01.mat=b111,B111\nb111-02.mat=b222,b111",
"ep07=!=e\nep07-01.mat=ep07,AP07");
var counts = VehicleBaseQuery.ClassCounts(textures.Select(t => new VehicleBaseRow(t)));
Assert.Equal(new[] { ("AP07", 1), ("B111", 2) },
counts.Select(c => (c.Name.ToUpperInvariant(), c.Count)));
}
[Fact]
public void A_row_with_no_class_is_not_counted() =>
Assert.Empty(VehicleBaseQuery.ClassCounts(new[] { Row("a") }));
[Fact]
public void The_class_picked_has_to_match_whole_while_the_typed_one_only_has_to_appear()
{
var textures = Catalogued("b111=!=b\nb111-01.mat=b111,B111");
var row = new VehicleBaseRow(textures[0]);
Assert.NotEqual("", row.Class);
Assert.Single(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { PickedClass = row.Class.ToUpperInvariant() },
r => r.Skin, descending: false));
Assert.Empty(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { PickedClass = row.Class[..1] + "?" },
r => r.Skin, descending: false));
Assert.Single(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { Class = row.Class[..1] },
r => r.Skin, descending: false));
}
}

View File

@@ -0,0 +1,111 @@
using System.Text;
using StarterNG.Domain.Vehicles;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class VehicleCacheTests
{
private const string Ep07 = "ep07-135.mat=ep07.e3d,ep07";
private const string En57 = "en57-1000ra.mat=en57.e3d,en57";
private static string Dynamic(string maker, string vehicle) =>
TestInstallation.At("dynamic", maker, vehicle, "textures.txt");
private static string Cached(TestInstallation installation, string file) =>
Path.Combine(installation.SettingsPaths.VehicleCacheDirectory(), file);
[Fact]
public void The_first_load_migrates_textures_txt_into_the_starters_own_copy()
{
var files = new InMemoryFileSystem().WithLegacyFile(Dynamic("pkp", "ep07"), Ep07);
var installation = new TestInstallation(files);
int liveries = installation.VehicleCache.Load(installation.Library.Vehicles);
Assert.Equal(1, liveries);
Assert.True(files.FileExists(Cached(installation, "vehicles.json")));
Assert.Contains("ep07-135", files.ReadAllText(Cached(installation, "vehicles.json")));
}
[Fact]
public void An_untouched_installation_is_served_from_the_copy_without_reading_textures_txt()
{
var files = new InMemoryFileSystem().WithLegacyFile(Dynamic("pkp", "ep07"), Ep07);
var installation = new TestInstallation(files);
installation.VehicleCache.Load(installation.Library.Vehicles);
// Different liveries, same length and same timestamp: nothing the stamp
// can see has changed, so an answer of ep07-135 can only come from the copy.
files.WithLegacyFile(Dynamic("pkp", "ep07"), "ep07-999.mat=ep07.e3d,ep07");
var catalog = new VehicleCatalog(installation.MiniTextures);
installation.VehicleCache.Load(catalog);
Assert.Equal("legacy:pkp/ep07/ep07-135", Assert.Single(catalog.Textures).Uuid);
}
[Fact]
public void An_edited_textures_txt_makes_the_copy_stale_and_it_is_rebuilt()
{
var files = new InMemoryFileSystem().WithLegacyFile(Dynamic("pkp", "ep07"), Ep07);
var installation = new TestInstallation(files);
installation.VehicleCache.Load(installation.Library.Vehicles);
files.WithLegacyFile(Dynamic("pkp", "ep07"), Ep07 + "\nep07-174.mat=ep07.e3d,ep07");
var catalog = new VehicleCatalog(installation.MiniTextures);
int liveries = installation.VehicleCache.Load(catalog);
Assert.Equal(2, liveries);
Assert.Contains("ep07-174", files.ReadAllText(Cached(installation, "vehicles.json")));
}
[Fact]
public void A_vehicle_folder_that_disappeared_upstream_leaves_the_copy_too()
{
var files = new InMemoryFileSystem()
.WithLegacyFile(Dynamic("pkp", "ep07"), Ep07)
.WithLegacyFile(Dynamic("pkp", "en57"), En57);
var installation = new TestInstallation(files);
installation.VehicleCache.Load(installation.Library.Vehicles);
files.DeleteFile(Dynamic("pkp", "en57"));
var catalog = new VehicleCatalog(installation.MiniTextures);
installation.VehicleCache.Load(catalog);
Assert.Equal("legacy:pkp/ep07/ep07-135", Assert.Single(catalog.Textures).Uuid);
Assert.DoesNotContain("en57", files.ReadAllText(Cached(installation, "vehicles.json")));
}
[Fact]
public void A_damaged_copy_costs_the_parse_it_saved_and_nothing_else()
{
var files = new InMemoryFileSystem().WithLegacyFile(Dynamic("pkp", "ep07"), Ep07);
var installation = new TestInstallation(files);
installation.VehicleCache.Load(installation.Library.Vehicles);
files.WithFile(Cached(installation, "vehicles.json"), "{ obcieta kop");
var catalog = new VehicleCatalog(installation.MiniTextures);
int liveries = installation.VehicleCache.Load(catalog);
Assert.Equal(1, liveries);
Assert.Contains("kopia nie do odczytu", installation.Log.Text);
}
[Fact]
public void The_copy_reads_the_same_catalogue_the_legacy_reader_does()
{
var files = new InMemoryFileSystem()
.WithLegacyFile(Dynamic("pkp", "ep07"), Ep07)
.WithLegacyFile(Dynamic("pkp", "en57"), En57);
var installation = new TestInstallation(files);
var direct = new VehicleCatalog(installation.MiniTextures);
installation.Vehicles.Load(direct);
var cached = new VehicleCatalog(installation.MiniTextures);
installation.VehicleCache.Load(cached);
Assert.Equal(direct.Textures.Select(t => t.Uuid), cached.Textures.Select(t => t.Uuid));
Assert.Equal(direct.GroupsById.Keys.Order(), cached.GroupsById.Keys.Order());
}
}

View File

@@ -117,4 +117,48 @@ public class VehicleCatalogTests
/// <summary>Builds an entry through the real parser, for catalogue-level tests.</summary> /// <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);
}
} }

View File

@@ -0,0 +1,199 @@
using System.Text;
using StarterNG.Domain.Vehicles;
using StarterNG.Infrastructure.Vehicles;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class VehicleJsonTests
{
private static readonly TexturesTxtParser Parser = new();
private static readonly VehicleJsonSerializer Serializer = new();
/// <summary>
/// A vehicle folder exercising every piece of the legacy syntax at once: the
/// headers and comments that carry no data, a category marker, a credit line
/// with Polish text, an alternative model, a wreck and an archival section.
/// </summary>
private const string Ep07 = """
# EP07 - lokomotywy elektryczne
@ep07
!=e
ep07-135.mat=ep07.e3d,ep07 // 1.0,EP07-135,PKP Intercity,Warszawa Grochów,2019-04-01,Wrocław,Zażółć,Gęślą Jaźń
ep07-174.mat=ep07.e3d,ep07=ep07p.e3d,ep07p
ep07wrak.mat=ep07.e3d,ep07
linia bez znaku rownosci
$a
ep07-stary.mat=ep07.e3d,ep07
""";
/// <summary>A unit whose cars are coupled into a fixed set.</summary>
private const string En57 = """
^2
en57-1000ra.mat=en57.e3d,en57
en57-1000rb.mat=en57.e3d,en57
""";
[Fact]
public void An_entry_written_out_and_read_back_is_the_entry_it_started_as()
{
var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
var restored = Serializer.Read(Serializer.Write(entry));
Assert.Equal(Describe(entry), Describe(restored!));
}
[Fact]
public void The_catalogue_built_from_json_matches_the_one_built_from_textures_txt()
{
var files = new InMemoryFileSystem()
.WithLegacyFile(Dynamic("pkp", "ep07"), Ep07)
.WithLegacyFile(Dynamic("pkp", "en57"), En57);
var installation = new TestInstallation(files);
installation.Vehicles.Load(installation.Library.Vehicles);
var fromJson = ReloadThroughJson(installation);
Assert.Equal(5, installation.Library.Vehicles.Textures.Count);
Assert.Equal(Describe(installation.Library.Vehicles), Describe(fromJson));
}
[Fact]
public void Headers_and_unparsable_lines_survive_the_trip_so_nothing_is_dropped()
{
var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
var restored = Serializer.Read(Serializer.Write(entry))!;
Assert.Equal(new[] { "# EP07 - lokomotywy elektryczne", "@ep07", "linia bez znaku rownosci" },
restored.Unknown);
}
[Fact]
public void Polish_text_is_written_as_itself_rather_than_as_escapes()
{
var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
string json = Serializer.Write(entry);
Assert.Contains("Warszawa Grochów", json);
Assert.DoesNotContain("\\u", json);
}
[Fact]
public void The_merged_database_is_read_before_the_packages_layered_on_it()
{
var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
var package = Parser.Parse("pkp/en57/", Lines(En57))!;
var files = new InMemoryFileSystem()
.WithFile(Database("vehicles.json"), Serializer.WriteCollection(new[] { entry }))
.WithFile(Database("aaa-pakiet.json"), Serializer.Write(package));
var installation = new TestInstallation(files);
var catalog = new VehicleCatalog(installation.MiniTextures);
int liveries = Repository(installation).Load(catalog);
Assert.Equal(6, liveries);
Assert.Equal("legacy:pkp/ep07/ep07-135", catalog.Textures[0].Uuid);
}
[Fact]
public void An_entry_from_a_newer_format_is_refused_instead_of_half_read()
{
var files = new InMemoryFileSystem()
.WithFile(Database("z-przyszlosci.json"),
"""{"uuid":"x","schema_version":99,"textures":[{"skinfile":"ep07-135"}]}""");
var installation = new TestInstallation(files);
var catalog = new VehicleCatalog(installation.MiniTextures);
int liveries = Repository(installation).Load(catalog);
Assert.Equal(0, liveries);
Assert.Empty(catalog.Textures);
Assert.Contains("z-przyszlosci.json", installation.Log.Text);
}
[Fact]
public void An_unreadable_package_is_logged_and_leaves_the_rest_of_the_database_alone()
{
var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
var files = new InMemoryFileSystem()
.WithFile(Database("aaa-polamany.json"), "{ to nie jest json")
.WithFile(Database("bbb-dobry.json"), Serializer.Write(entry));
var installation = new TestInstallation(files);
var catalog = new VehicleCatalog(installation.MiniTextures);
int liveries = Repository(installation).Load(catalog);
Assert.Equal(4, liveries);
Assert.Contains("aaa-polamany.json", installation.Log.Text);
}
private static VehicleJsonRepository Repository(TestInstallation installation) =>
new(installation.Files, installation.Paths, installation.Log, Serializer);
/// <summary>
/// Writes every vehicle folder of an installation out as its own package file
/// and loads the catalogue back from those, which is the migration the starter
/// will do silently once this format is the one it reads.
/// </summary>
private static VehicleCatalog ReloadThroughJson(TestInstallation installation)
{
foreach (string maker in installation.Files.GetDirectories(installation.Paths.Dynamic))
foreach (string vehicle in installation.Files.GetDirectories(maker))
{
string path = Path.Combine(vehicle, "textures.txt");
if (!installation.Files.FileExists(path))
continue;
string directory = $"{Path.GetFileName(maker)}/{Path.GetFileName(vehicle)}/";
var entry = Parser.Parse(directory,
installation.Files.ReadAllText(path, Encoding.GetEncoding(1250)).Split('\n'));
if (entry is not null)
installation.Files.WithFile(Database($"{directory.Replace('/', '.')}json"),
Serializer.Write(entry));
}
var catalog = new VehicleCatalog(installation.MiniTextures);
Repository(installation).Load(catalog);
return catalog;
}
private static string[] Lines(string file) => file.Split('\n');
private static string Dynamic(string maker, string vehicle) =>
TestInstallation.At("dynamic", maker, vehicle, "textures.txt");
private static string Database(string file) => TestInstallation.At("databases", "vehicles", file);
/// <summary>Everything an entry carries, flattened so a difference reads as one.</summary>
private static string Describe(VehicleEntry entry) =>
string.Join('\n',
new[] { $"entry {entry.Uuid}" }
.Concat(entry.Groups.Select(Describe))
.Concat(entry.Textures.Select(Describe))
.Concat(entry.Sets.Select(Describe))
.Concat(entry.Unknown.Select(line => $"unknown {line}")));
/// <summary>The same for a whole catalogue, wrecks and resolved fields included.</summary>
private static string Describe(VehicleCatalog catalog) =>
string.Join('\n',
catalog.GroupsById.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => Describe(pair.Value))
.Concat(catalog.TextureByUuid.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => Describe(pair.Value)))
.Concat(catalog.Sets.Select(Describe)));
private static string Describe(VehicleGroup group) =>
$"group {group.Id}|{group.Category}|{group.Mini}|{group.Archived}|{group.Implicit}";
private static string Describe(VehicleSet set) =>
$"set {set.Uuid}|{set.Mode}|{set.Count}|{string.Join(',', set.TextureRefs)}";
private static string Describe(VehicleTexture texture) =>
$"texture {texture.Uuid}|{texture.Directory}|{texture.Skinfile}|{texture.Model}|{texture.Group}|" +
$"{texture.MiniRef}|{texture.TextureMini}|{texture.Wreck}|{texture.ResolvedClass}|" +
$"{texture.ResolvedCategory}|{texture.ResolvedArchived}|{texture.Meta?.Raw}|" +
string.Join(';', texture.Aliases.Select(alias => $"{alias.Model},{alias.MiniRef},{alias.TextureMini}"));
}

View File

@@ -0,0 +1,139 @@
using StarterNG.Application;
using StarterNG.Classes;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
// PresetStore keeps the disk of the first test that reaches it, a fake one as every
// composition root in the tests is built on fakes. So each test has a name to itself.
public class WarehouseEditTests
{
private const string Text =
"trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 headdriver 3 0 enddynamic\nendtrainset\n";
private static readonly string Entry = ConsistText.Serialize(new Trainset(Text));
private readonly WarehouseEdit _edit = new();
public WarehouseEditTests()
{
var installation = new TestInstallation(new InMemoryFileSystem());
AppServices.Initialize(installation.Paths, installation.Files, installation.Clock, installation.Environment);
}
private static TrainsetPreset Preset(string name, string? entry = null) => new() { Name = name, Entry = entry ?? Entry };
private static TrainsetPreset? Stored(string name) => PresetStore.All().FirstOrDefault(p => p.Name == name);
[Fact]
public void Opening_says_what_is_edited_and_closing_that_nothing_is()
{
var trainset = _edit.Open(Preset("edit-open"));
Assert.Same(trainset, _edit.Trainset);
Assert.Equal("edit-open", _edit.Name);
_edit.Close();
Assert.Null(_edit.Trainset);
Assert.Null(_edit.Name);
}
[Fact]
public void A_consist_opened_again_is_the_same_trainset_so_its_undo_history_outlives_a_switch()
{
var first = _edit.Open(Preset("edit-switch"));
_edit.Open(Preset("edit-switch-other"));
Assert.Same(first, _edit.Open(Preset("edit-switch")));
}
[Fact]
public void An_entry_written_over_from_elsewhere_is_read_afresh()
{
var first = _edit.Open(Preset("edit-over"));
var second = _edit.Open(Preset("edit-over", ConsistText.Serialize(new Trainset(Text.Replace(" v1 ", " v9 ")))));
Assert.NotSame(first, second);
Assert.Equal("v9", second.Vehicles.Single().Name);
}
[Fact]
public void The_name_of_a_consist_goes_with_any_letter_case()
{
var first = _edit.Open(Preset("edit-case"));
Assert.Same(first, _edit.Open(Preset("EDIT-CASE")));
}
[Fact]
public void Nothing_is_written_while_the_consist_stands_as_it_was_read()
{
PresetStore.Save("edit-unchanged", Entry);
_edit.Open(Stored("edit-unchanged")!);
PresetStore.Delete("edit-unchanged");
_edit.Save();
Assert.Null(Stored("edit-unchanged"));
}
[Fact]
public void A_change_is_written_once()
{
PresetStore.Save("edit-changed", Entry);
var trainset = _edit.Open(Stored("edit-changed")!);
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
_edit.Save();
Assert.Equal(ConsistText.Serialize(trainset), Stored("edit-changed")!.Entry);
PresetStore.Delete("edit-changed");
_edit.Save();
Assert.Null(Stored("edit-changed"));
}
[Fact]
public void Nothing_is_written_with_nothing_open()
{
var trainset = _edit.Open(Preset("edit-closed"));
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
_edit.Close();
_edit.Save();
Assert.Null(Stored("edit-closed"));
}
[Fact]
public void A_rename_keeps_the_trainset_and_saves_under_the_new_name()
{
var trainset = _edit.Open(Preset("edit-old"));
_edit.Renamed("edit-old", "edit-new");
Assert.Equal("edit-new", _edit.Name);
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
_edit.Save();
Assert.Null(Stored("edit-old"));
Assert.Equal(ConsistText.Serialize(trainset), Stored("edit-new")!.Entry);
Assert.Same(trainset, _edit.Open(Preset("edit-new", Stored("edit-new")!.Entry)));
}
[Fact]
public void Renaming_another_consist_leaves_the_one_open_alone()
{
var other = _edit.Open(Preset("edit-other"));
var open = _edit.Open(Preset("edit-mine"));
_edit.Renamed("edit-other", "edit-other-new");
Assert.Same(open, _edit.Trainset);
Assert.Equal("edit-mine", _edit.Name);
Assert.Same(other, _edit.Open(Preset("edit-other-new")));
}
}

View File

@@ -0,0 +1,209 @@
using StarterNG.Infrastructure;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class WineConfigLinkTests
{
// Rooted the way the host roots a path: the link is made to the folder's full
// path, and on a Windows host "/home" alone is not one. Wine itself only runs off
// Windows, but what is decided here is the same wherever these tests run.
private static readonly string Home = Path.GetFullPath("/home/kolejarz");
private static readonly string Config = Path.GetFullPath(Home + "/.config/MaSzyna");
private static readonly string Roaming = Home + "/.wine/drive_c/users/kolejarz/AppData/Roaming";
private static readonly string PrefixFolder = Roaming + "/MaSzyna";
private static (WineConfigLink Link, TestInstallation Installation) Rig(Action<InMemoryFileSystem>? arrange = null)
{
var files = new InMemoryFileSystem(caseSensitive: true)
.WithFile(Config + "/eu07.ini", "fpslimit 30\n");
arrange?.Invoke(files);
var installation = new TestInstallation(files);
installation.Environment.With("HOME", Home).With("USER", "kolejarz");
return (new WineConfigLink(files, installation.Environment, installation.SettingsPaths, installation.Log),
installation);
}
[Fact]
public void The_prefixs_own_folder_is_kept_aside_and_linked_to_the_starters()
{
var (link, installation) = Rig(files => files
.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n")
.WithFile(PrefixFolder + "/eu07_input-uart.ini", "port COM3\n"));
link.Ensure();
var files = installation.Files;
Assert.Equal(Config, files.LinkTarget(PrefixFolder));
Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini"));
// What only the prefix had comes along; the starter's own eu07.ini stays.
Assert.Equal("port COM3\n", files.ReadAllText(Config + "/eu07_input-uart.ini"));
Assert.Equal("fpslimit 30\n", files.ReadAllText(Config + "/eu07.ini"));
Assert.Contains("now links to", installation.Log.Text);
}
[Fact]
public void A_prefix_already_linked_is_left_alone()
{
var (link, installation) = Rig(files => files.WithDirectory(PrefixFolder));
link.Ensure();
link.Ensure();
Assert.Equal(Config, installation.Files.LinkTarget(PrefixFolder));
Assert.False(installation.Files.DirectoryExists(PrefixFolder + ".before-starter-2"));
}
[Fact]
public void A_link_of_the_players_own_is_not_touched()
{
var (link, installation) = Rig(files =>
{
files.WithDirectory(Roaming);
files.CreateDirectoryLink(PrefixFolder, "/mnt/shared/MaSzyna");
});
link.Ensure();
Assert.Equal("/mnt/shared/MaSzyna", installation.Files.LinkTarget(PrefixFolder));
Assert.Contains("left as it is", installation.Log.Text);
}
[Fact]
public void Without_a_prefix_nothing_is_made()
{
var (link, installation) = Rig();
link.Ensure();
Assert.False(installation.Files.DirectoryExists(Home + "/.wine"));
Assert.Contains("no prefix", installation.Log.Text);
}
[Fact]
public void The_prefix_named_in_wineprefix_is_the_one_linked()
{
string prefix = Path.GetFullPath("/games/wine-maszyna");
var (link, installation) = Rig(files => files
.WithDirectory(prefix + "/drive_c/users/kolejarz/AppData/Roaming")
.WithDirectory(Roaming));
installation.Environment.With("WINEPREFIX", prefix);
link.Ensure();
Assert.Equal(Config, installation.Files.LinkTarget(prefix + "/drive_c/users/kolejarz/AppData/Roaming/MaSzyna"));
Assert.Null(installation.Files.LinkTarget(PrefixFolder));
}
[Fact]
public void An_older_backup_is_not_overwritten()
{
var (link, installation) = Rig(files => files
.WithFile(PrefixFolder + ".before-starter/eu07.ini", "first\n")
.WithFile(PrefixFolder + "/eu07.ini", "second\n"));
link.Ensure();
Assert.Equal("first\n", installation.Files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini"));
Assert.Equal("second\n", installation.Files.ReadAllText(PrefixFolder + ".before-starter-2/eu07.ini"));
}
[Fact]
public void An_older_wine_keeps_application_data_where_xp_did()
{
string applicationData = Home + "/.wine/drive_c/users/kolejarz/Application Data";
var (link, installation) = Rig(files => files.WithDirectory(applicationData));
link.Ensure();
Assert.Equal(Config, installation.Files.LinkTarget(applicationData + "/MaSzyna"));
}
[Fact]
public void The_only_profile_is_used_when_the_user_name_differs()
{
string profile = Home + "/.wine/drive_c/users/steamuser/AppData/Roaming";
var (link, installation) = Rig(files => files
.WithDirectory(Home + "/.wine/drive_c/users/Public")
.WithDirectory(profile));
link.Ensure();
Assert.Equal(Config, installation.Files.LinkTarget(profile + "/MaSzyna"));
}
[Fact]
public void A_link_that_cannot_be_made_leaves_the_prefix_as_it_was()
{
var (link, installation) = Rig(files => files
.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n")
.WithFile(PrefixFolder + "/eu07_input-uart.ini", "port COM3\n"));
var files = installation.Files;
files.LinksFail = true;
link.Ensure();
Assert.Null(files.LinkTarget(PrefixFolder));
Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + "/eu07.ini"));
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter"));
// Nothing carried over stays behind for a move that did not happen.
Assert.False(files.FileExists(Config + "/eu07_input-uart.ini"));
Assert.Equal("fpslimit 30\n", files.ReadAllText(Config + "/eu07.ini"));
Assert.Contains("linking the settings", installation.Log.Text);
}
[Fact]
public void Attempts_that_fail_do_not_pile_up_backups()
{
var (link, installation) = Rig(files => files.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n"));
var files = installation.Files;
files.LinksFail = true;
link.Ensure();
link.Ensure();
link.Ensure();
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter"));
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter-2"));
files.LinksFail = false;
link.Ensure();
Assert.Equal(Config, files.LinkTarget(PrefixFolder));
Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini"));
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter-2"));
}
[Fact]
public void A_copy_that_fails_half_way_takes_back_what_it_had_copied()
{
var (link, installation) = Rig(files => files
.WithFile(PrefixFolder + "/a.ini", "first\n")
.WithFile(PrefixFolder + "/b.ini", "second\n")
// It is read from where the folder has been moved to by then.
.FailReading(PrefixFolder + ".before-starter/b.ini"));
var files = installation.Files;
link.Ensure();
Assert.Null(files.LinkTarget(PrefixFolder));
Assert.Equal("first\n", files.ReadAllText(PrefixFolder + "/a.ini"));
Assert.False(files.FileExists(Config + "/a.ini"));
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter"));
}
[Fact]
public void A_folder_that_cannot_be_put_back_is_said_to_be_where_it_is()
{
var (link, installation) = Rig(files => files.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n"));
var files = installation.Files;
files.LinksFail = true;
// In the way of the move back: made by whatever broke the link, say.
files.MoveFails = path => path.EndsWith("/Roaming/MaSzyna");
link.Ensure();
Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini"));
Assert.Contains("stays at", installation.Log.Text);
}
}

View File

@@ -12,6 +12,17 @@
<StyleInclude Source="avares://Starter/Styles/Carbon.axaml" /> <StyleInclude Source="avares://Starter/Styles/Carbon.axaml" />
<StyleInclude Source="avares://Starter/Styles/Settings.axaml" /> <StyleInclude Source="avares://Starter/Styles/Settings.axaml" />
<!-- A dropdown whose options are bound to the translation table. The closed
box keeps a copy of the text picked, so it would stay in the old
language; this shows the option itself, which follows the change. -->
<Style Selector="ComboBox.Localized">
<Setter Property="SelectionBoxItemTemplate">
<DataTemplate>
<TextBlock Text="{Binding $parent[ComboBox].((ComboBoxItem)SelectedItem).Content}" />
</DataTemplate>
</Setter>
</Style>
<Style Selector="Button[(chrome|WindowDecorationProperties.ElementRole)=FullScreenButton]"> <Style Selector="Button[(chrome|WindowDecorationProperties.ElementRole)=FullScreenButton]">
<Setter Property="IsVisible" Value="False" /> <Setter Property="IsVisible" Value="False" />
</Style> </Style>

View File

@@ -1,10 +1,7 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Threading; using Avalonia.Threading;
@@ -30,6 +27,7 @@ public partial class App : Avalonia.Application
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
AppServices.Current.SettingsStore.Load(); AppServices.Current.SettingsStore.Load();
AppServices.Current.SettingsStore.StartAtHighWithoutOwnSettings();
KeyboardConfig.Instance.Load(); KeyboardConfig.Instance.Load();
@@ -48,6 +46,10 @@ public partial class App : Avalonia.Application
// actually display. // actually display.
string requestedCode = AppServices.Current.Settings.LanguageCode; string requestedCode = AppServices.Current.Settings.LanguageCode;
// Subscribed before any view, so the views rebuild in the new culture.
Loc.LanguageChanged += () => ApplyCulture(Loc.CurrentLangCode);
Loc.Keep(requestedCode);
ApplyLanguage(langName); ApplyLanguage(langName);
// The starter ships no translation for this code, so its own interface // The starter ships no translation for this code, so its own interface
@@ -60,15 +62,26 @@ public partial class App : Avalonia.Application
public static void ApplyLanguage(string langNameOrCode) public static void ApplyLanguage(string langNameOrCode)
{ {
var resolvedName = Loc.Load(langNameOrCode); var resolvedName = Loc.Load(langNameOrCode);
// The service is the authority on which language actually loaded: the code // The service is the authority on which language actually loaded: the code
// goes to eu07.ini for the simulator, the name to the picker. // goes to eu07.ini for the simulator, the name to the picker.
AppServices.Current.Settings.Language = resolvedName; AppServices.Current.Settings.Language = resolvedName;
AppServices.Current.Settings.LanguageCode = Loc.CurrentLangCode; AppServices.Current.Settings.LanguageCode = Loc.CurrentLangCode;
}
ApplyCulture(Loc.CurrentLangCode); /// <summary>
/// A language picked by the user, written to eu07.ini straight away as the old
/// starter did - not left waiting for the Save button of a different tab.
/// Only what is already in the settings goes with it, not other unsaved edits.
/// </summary>
public static void ChangeLanguage(string code)
{
if (string.Equals(code, Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase))
return;
ApplyLanguage(code);
AppServices.Current.SettingsStore.Save();
} }
private static void ApplyCulture(string langCode) private static void ApplyCulture(string langCode)
@@ -88,10 +101,11 @@ public partial class App : Avalonia.Application
culture = CultureInfo.InvariantCulture; culture = CultureInfo.InvariantCulture;
} }
// Only the defaults: a culture set on the thread is an async local, so one set
// from the picker's handler was undone as soon as the handler returned, and
// the UI kept the first language's decimal comma and month names.
CultureInfo.DefaultThreadCurrentCulture = culture; CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture; CultureInfo.DefaultThreadCurrentUICulture = culture;
CultureInfo.CurrentCulture = culture;
CultureInfo.CurrentUICulture = culture;
} }
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
@@ -176,13 +190,20 @@ 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();
if (CpuFeatures.LacksAvx2 && AppServices.Current.Settings.CpuNotice)
await main.ShowCpuNoticeAsync();
}); });
}); });
} }

View File

@@ -17,12 +17,24 @@ 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);
void WriteAllText(string path, string contents, Encoding encoding); void WriteAllText(string path, string contents, Encoding encoding);
/// <summary>
/// Writes the file whole or not at all: a crash or a full disk half way leaves
/// what stood there, not the start of what was on its way. A link stays a link,
/// its file gets the contents; a file keeps its mode.
/// </summary>
void WriteAllBytesAtomic(string path, byte[] bytes);
/// <summary>As UTF-8 without a mark, by <see cref="WriteAllBytesAtomic"/>.</summary>
void WriteAllTextAtomic(string path, string contents);
void WriteAllLines(string path, IEnumerable<string> lines); void WriteAllLines(string path, IEnumerable<string> lines);
void WriteAllBytes(string path, byte[] bytes); void WriteAllBytes(string path, byte[] bytes);
@@ -41,5 +53,35 @@ public interface IFileSystem
DateTime GetLastWriteTimeUtc(string path); DateTime GetLastWriteTimeUtc(string path);
long GetFileSize(string path);
bool IsExecutable(string path); bool IsExecutable(string path);
/// <summary>Where the symbolic link at <paramref name="path"/> points, or null for anything else.</summary>
string? LinkTarget(string path);
/// <summary>Makes <paramref name="path"/> a symbolic link to the directory <paramref name="target"/>.</summary>
void CreateDirectoryLink(string path, string target);
void MoveDirectory(string sourcePath, string destinationPath);
}
public static class FileSystemExtensions
{
/// <summary>
/// Copies a file that could not be read beside itself, before something replaces
/// it: it may hold what was not read. Throws when it cannot; then it must stay.
/// </summary>
public static void KeepAside(this IFileSystem files, string path, IDiagnosticsLog log)
{
if (!files.FileExists(path))
return;
string aside = path + ".unreadable";
for (int n = 2; files.FileExists(aside); n++)
aside = $"{path}.unreadable-{n}";
files.CopyFile(path, aside, overwrite: false);
log.Log($"{path} could not be read; a copy is kept as {aside}");
}
} }

View File

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

View File

@@ -8,15 +8,29 @@ 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 <paramref name="environment"/> added to the starter's
/// own. 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); IReadOnlyDictionary<string, string> environment, bool captureErrors, out string? error);
bool OpenInShell(string pathOrUrl); bool OpenInShell(string pathOrUrl);
IProcessHandle? FindRunning(string processName); IProcessHandle? FindRunning(string processName);
bool IsRunning(string processName);
} }

View File

@@ -11,4 +11,7 @@ public interface ISceneryRepository
IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null); IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null);
Scenery? Load(string path); Scenery? Load(string path);
/// <summary>The scenery files there are now; null while their folder is away.</summary>
IReadOnlyList<string>? ListFiles();
} }

View File

@@ -1,6 +1,8 @@
using System; using System;
using System.IO;
using StarterNG.Application.Abstractions; using StarterNG.Application.Abstractions;
using StarterNG.Domain.Settings; using StarterNG.Domain.Settings;
using StarterNG.Infrastructure;
using StarterNG.Infrastructure.Adapters; using StarterNG.Infrastructure.Adapters;
using StarterNG.Infrastructure.Sceneries; using StarterNG.Infrastructure.Sceneries;
using StarterNG.Infrastructure.Settings; using StarterNG.Infrastructure.Settings;
@@ -25,27 +27,53 @@ public sealed class AppServices
var log = new FileDiagnosticsLog(files, clock, paths); var log = new FileDiagnosticsLog(files, clock, paths);
Log = log; Log = log;
Random = new SystemRandomSource(); var random = new SystemRandomSource();
Processes = new SystemProcessLauncher(log); Processes = new SystemProcessLauncher(log);
Localization = new LocalizationService(log); var settingsPaths = new SettingsPaths(environment, paths);
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random)); SettingsPaths = settingsPaths;
// The player's own first: the game folder may need rights to write to, the user folder never.
Localization = new LocalizationService(log, folders: new LanguageFolders(
new[]
{
Path.Combine(settingsPaths.UserConfigDirectory(), "starter", "lang"),
paths.FromRoot("starter", "lang"),
LocalizationService.LangDirectory,
paths.FromRoot("startercfg", "lang")
},
paths.FromRoot("starter"),
paths.FromRoot("lang")));
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());
// textures.txt stays the truth; the starter reads its own JSON copy of it
// and rebuilds that copy whenever the installation has moved on.
var texturesTxt = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
var vehicleJson = new VehicleJsonSerializer();
Vehicles = new VehicleCacheRepository(files, paths, log, texturesTxt, vehicleJson,
settingsPaths.VehicleCacheDirectory());
Physics = new FizPhysicsRepository(files, paths, log); Physics = new FizPhysicsRepository(files, paths, log);
MissingAssets = new MissingAssetScanner(files, paths); var missingAssets = new MissingAssetScanner(files, paths);
SceneryTexts = new SceneryTranslations(files, log); SceneryTexts = new SceneryTranslations(files, log);
Timetables = new TimetableLocator(files); Timetables = new TimetableLocator(files);
LoadWeights = new LoadWeightsRepository(files, paths, log); LoadWeights = new LoadWeightsRepository(files, paths, log);
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log); Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
State = new AppState(); State = new AppState();
SceneryFiles = new SceneryFileWatch(files, Sceneries, Library, State);
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); Favorites = new FavoriteSceneries(files, log, settingsPaths.FavoritesPath());
StartSimulation = new StartSimulation(State, SettingsStore, files, Processes, Random, log); Tapes = new HaslerTapes(files, paths, log, clock, Timetables,
() => Processes.IsRunning(Path.GetFileNameWithoutExtension(SettingsStore.ResolveExecutable())));
Gpus = new LinuxGpus(files, environment);
Displays = new Displays(files, environment);
StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, Executables,
files, paths, Processes, random, log, Gpus, environment,
new WineConfigLink(files, environment, settingsPaths, log), Tapes);
Aftermath = new SimulatorAftermath(files, paths, log);
SaveTrainset = new SaveSceneryTrainset(files, log);
} }
public static AppServices Current => public static AppServices Current =>
@@ -75,10 +103,10 @@ public sealed class AppServices
public IProcessLauncher Processes { get; } public IProcessLauncher Processes { get; }
public IRandomSource Random { get; }
public ISceneryRepository Sceneries { get; } public ISceneryRepository Sceneries { get; }
public SceneryFileWatch SceneryFiles { get; }
public SceneryImageLocator SceneryImages { get; } public SceneryImageLocator SceneryImages { get; }
public IMiniTextureIndex MiniTextures { get; } public IMiniTextureIndex MiniTextures { get; }
@@ -87,8 +115,6 @@ public sealed class AppServices
public IPhysicsRepository Physics { get; } public IPhysicsRepository Physics { get; }
public MissingAssetScanner MissingAssets { get; }
public ISceneryTranslations SceneryTexts { get; } public ISceneryTranslations SceneryTexts { get; }
public TimetableLocator Timetables { get; } public TimetableLocator Timetables { get; }
@@ -105,11 +131,23 @@ public sealed class AppServices
public ExecutableLocator Executables { get; } public ExecutableLocator Executables { get; }
public LinuxGpus Gpus { get; }
public Displays Displays { get; }
public SettingsPaths SettingsPaths { get; } public SettingsPaths SettingsPaths { get; }
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 HaslerTapes Tapes { get; }
public SaveSceneryTrainset SaveTrainset { get; }
public LocalizationService Localization { get; } public LocalizationService Localization { get; }
} }

View File

@@ -0,0 +1,81 @@
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);
// The file holds more than _names does: it goes aside before anything replaces it.
private bool _unread;
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);
_unread = true;
}
}
private void Save()
{
try
{
if (Path.GetDirectoryName(_path) is { Length: > 0 } directory)
_files.CreateDirectory(directory);
if (_unread)
{
_files.KeepAside(_path, _log);
_unread = false;
}
_files.WriteAllTextAtomic(_path, string.Join('\n', _names.Order(StringComparer.OrdinalIgnoreCase)));
}
catch (Exception ex)
{
_log.Log($"writing {_path}", ex);
}
}
}

View File

@@ -47,10 +47,19 @@ public sealed class GameLibrary
return; return;
progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt")); progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt"));
if (_vehicles.Load(Vehicles) == 0)
_log.Log("dynamic: brak plikow textures.txt");
Parallel.Invoke(_minis.Preload, _physics.Preload); // Whatever goes wrong with the rolling stock, the sceneries still get their turn.
try
{
if (_vehicles.Load(Vehicles) == 0)
_log.Log("dynamic: brak plikow textures.txt");
Parallel.Invoke(_minis.Preload, _physics.Preload);
}
catch (Exception ex)
{
_log.Log("dynamic: loading the rolling stock", ex);
}
ReloadSceneries(progress is null ReloadSceneries(progress is null
? null ? null

View File

@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StarterNG.Domain.Settings;
namespace StarterNG.Application;
/// <summary>Levels of detail part by part: what a level holds for one <see cref="GraphicsGroup"/>, the rest as it is.</summary>
public sealed class GraphicsGroupLevels
{
private readonly SettingsStore _store;
public GraphicsGroupLevels(SettingsStore store) => _store = store;
/// <summary>
/// The level each group is at in <paramref name="settings"/>, null where it is at none. At
/// Ultra the level under it fits too, as it leaves alone what only Ultra sets: the highest counts.
/// </summary>
public IReadOnlyDictionary<GraphicsGroup, GraphicsPreset?> LevelsOf(SimulatorSettings settings)
{
var now = _store.Render(settings);
var rendered = GraphicsPreset.All.Select(level => (Level: level, Keys: RenderAt(level, settings))).ToList();
return GraphicsGroup.All.ToDictionary(group => group, group =>
(GraphicsPreset?)rendered.LastOrDefault(level => Same(group, level.Keys, now)).Level);
}
/// <summary>
/// A copy of <paramref name="settings"/> with the group at <paramref name="level"/>. What only
/// an end of the scale sets goes back to the simulator's own on leaving that end.
/// </summary>
public SimulatorSettings Apply(GraphicsGroup group, GraphicsPreset level, SimulatorSettings settings)
{
var current = LevelsOf(settings)[group];
var source = settings.Copy();
if (level.Extras is null && current?.Extras is not null)
GraphicsExtras.None.ApplyTo(source);
if (level.Top is null && current?.Top is not null)
GraphicsTop.None.ApplyTo(source);
level.ApplyTo(source);
return _store.WithKeysOf(settings, source, group.Keys);
}
private IReadOnlyDictionary<string, string> RenderAt(GraphicsPreset level, SimulatorSettings settings)
{
var copy = settings.Copy();
level.ApplyTo(copy);
return _store.Render(copy);
}
private static bool Same(GraphicsGroup group, IReadOnlyDictionary<string, string> one, IReadOnlyDictionary<string, string> other) =>
group.Keys.All(key => string.Equals(ValueIn(one, key), ValueIn(other, key), StringComparison.Ordinal));
// A key the file does not set is at the simulator's own value.
private static string? ValueIn(IReadOnlyDictionary<string, string> keys, string key) =>
keys.TryGetValue(key, out string? value) ? value : SimulatorKeys.All.FirstOrDefault(known => known.Key == key)?.Default;
}

View File

@@ -0,0 +1,308 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Domain.Sceneries;
using StarterNG.Domain.Tapes;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Infrastructure.Sceneries;
namespace StarterNG.Application;
public sealed record TapeFile(string Path, string Vehicle, DateTime LastWriteUtc, long Size,
TapeRole Role = TapeRole.Other);
/// <summary>One run of the simulator and what is left of its tapes: a later run overwrites those of the vehicles it has too.</summary>
public sealed record TapeSession(TapeLaunch? Launch, DateTime StartedUtc, DateTime LastWrittenUtc, bool Running,
IReadOnlyList<TapeFile> Tapes);
/// <summary>What is known about a tape beyond its samples: the clock it started at, the recorder it is read as, the scenario and the train.</summary>
public sealed record TapeContext(TimeSpan? StartClock, RecorderKind Recorder,
string? Scenery = null, string? Train = null);
/// <summary>The tapes the simulator left in physicslog/ - one per driven vehicle, overwritten every run.</summary>
public sealed class HaslerTapes
{
private const int RememberedLaunches = 30;
private static readonly TimeSpan QuietBetweenRuns = TimeSpan.FromMinutes(2);
private readonly IFileSystem _files;
private readonly IGamePaths _paths;
private readonly IDiagnosticsLog _log;
private readonly IClock _clock;
private readonly TimetableLocator _timetables;
private readonly Func<bool> _simulatorRunning;
private readonly object _playerLock = new();
private IReadOnlyList<TapeLaunch>? _launches;
private (DateTime Written, long Size) _launchesAt;
private PlayerVehicles _player = PlayerVehicles.None;
private DateTime _playerLaunch;
private string? _playerLog;
private long _playerLogRead;
public HaslerTapes(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, IClock clock,
TimetableLocator timetables, Func<bool> simulatorRunning)
{
_files = files;
_paths = paths;
_log = log;
_clock = clock;
_timetables = timetables;
_simulatorRunning = simulatorRunning;
}
public string Folder => _paths.FromRoot("physicslog");
/// <summary>Not a .dat, so the simulator's own logs are never mistaken for it, nor it for one.</summary>
private string LaunchFile => Path.Combine(Folder, "starter-launch.txt");
/// <summary>
/// The simulator logs every driven vehicle under its scenery name, with the
/// time counted from the start; the clock it starts at and what each name is
/// are only known here. So is whose tape is the player's: a scenery with no
/// consist picked starts with the free camera, in no vehicle at all.
/// </summary>
public void RecordLaunch(Scenery scenery, Trainset? trainset, string? driven)
{
var clock = TimeSpan.TryParseExact(scenery.Weather.ScenarioTimeOverride, @"h\:mm",
CultureInfo.InvariantCulture, out var over)
? over
: TimeSpan.TryParseExact(scenery.Weather.Time, @"h\:mm", CultureInfo.InvariantCulture, out var time)
? time
: TimeSpan.Zero;
var own = trainset?.Vehicles ?? new List<Dynamic>();
var vehicles = scenery.Trainsets.SelectMany(t => t.Vehicles).Concat(own)
.Select(v => new TapeVehicle(v.Name, v.DataFolder ?? "", v.MmdFile ?? ""));
RecordLaunch(Path.GetFileName(scenery.Path), clock, vehicles, driven,
driven is null ? null : own.Select(v => v.Name), scenery.Name,
driven is null ? null : _timetables.TrainNumber(scenery, trainset?.Name));
}
/// <summary>Notes down, next to the logs the simulator is about to write, what only the starter knows.</summary>
public void RecordLaunch(string scenery, TimeSpan startClock, IEnumerable<TapeVehicle> vehicles,
string? driven = null, IEnumerable<string>? consist = null,
string? sceneryName = null, string? train = null)
{
var launch = new TapeLaunch(_clock.UtcNow, scenery, startClock,
vehicles.Where(v => !string.IsNullOrEmpty(v.Name))
.DistinctBy(v => v.Name, StringComparer.OrdinalIgnoreCase)
.ToList(),
driven, consist?.ToList(), sceneryName, train);
try
{
var noted = Launches().ToList();
// The log is about to begin anew: what it says of the run before stays with that run.
if (noted.Count > 0)
noted[^1] = noted[^1] with { Player = PlayerIn(noted[^1]) };
noted.Add(launch);
_files.CreateDirectory(Folder);
_files.WriteAllText(LaunchFile, string.Concat(noted.TakeLast(RememberedLaunches).Select(l => l.Format())),
Encoding.UTF8);
lock (_playerLock)
_launches = null;
}
catch (Exception ex)
{
_log.Log($"writing {LaunchFile}", ex);
}
}
/// <summary>
/// The launches noted so far, oldest first. Read again only when the file has
/// changed: this runs every few seconds next to a running simulator.
/// </summary>
private IReadOnlyList<TapeLaunch> Launches()
{
lock (_playerLock)
{
try
{
if (!_files.FileExists(LaunchFile))
return Array.Empty<TapeLaunch>();
var at = (_files.GetLastWriteTimeUtc(LaunchFile), _files.GetFileSize(LaunchFile));
if (_launches is null || at != _launchesAt)
{
_launches = TapeLaunch.ParseAll(_files.ReadAllText(LaunchFile, Encoding.UTF8));
_launchesAt = at;
}
return _launches;
}
catch (Exception ex)
{
_log.Log($"reading {LaunchFile}", ex);
return Array.Empty<TapeLaunch>();
}
}
}
/// <summary>The simulator's log of this launch: log.txt or, with logs kept, one of its own in logs/.</summary>
private string? LogOf(TapeLaunch launch)
{
var logs = new List<string> { _paths.FromRoot("log.txt") };
string kept = _paths.FromRoot("logs");
if (_files.DirectoryExists(kept))
logs.AddRange(_files.GetFiles(kept, "log (*.txt"));
return logs.Where(_files.FileExists)
.Where(log => launch.Covers(_files.GetLastWriteTimeUtc(log)))
.OrderByDescending(_files.GetLastWriteTimeUtc)
.FirstOrDefault();
}
/// <summary>
/// Where the player has been in this launch. Only what the log has gained since
/// the last look is read: this runs every few seconds next to a running simulator.
/// </summary>
private PlayerVehicles PlayerIn(TapeLaunch launch)
{
lock (_playerLock)
{
string? path = null;
try
{
path = LogOf(launch);
if (path is null)
return PlayerVehicles.Unknown;
long size = _files.GetFileSize(path);
if (launch.LaunchedUtc != _playerLaunch || path != _playerLog || size < _playerLogRead)
{
_player = PlayerVehicles.None;
_playerLaunch = launch.LaunchedUtc;
_playerLog = path;
_playerLogRead = 0;
}
if (size == _playerLogRead)
return _player;
using var stream = _files.OpenRead(path);
stream.Seek(_playerLogRead, SeekOrigin.Begin);
using var added = new MemoryStream();
stream.CopyTo(added);
byte[] bytes = added.ToArray();
// A line still being written waits for the next look.
int end = Array.LastIndexOf(bytes, (byte)'\n') + 1;
if (end > 0)
{
_player = _player.After(LegacyText.Decode(bytes[..end]));
_playerLogRead += end;
}
}
catch (Exception ex)
{
_log.Log($"reading {path}", ex);
}
return _player;
}
}
/// <summary>The vehicle the player is in, or went off in while the log has not said otherwise, if the starter launched one.</summary>
public string? DrivenVehicle =>
Launches().LastOrDefault() is { } launch ? PlayerIn(launch).Current ?? launch.Driven : null;
public TapeContext ContextOf(TapeFile file)
{
var launch = Launches().LastOrDefault(l => l.Covers(file.LastWriteUtc));
if (launch is null)
return new TapeContext(null, Recorders.Guess(null, file.Vehicle));
var vehicle = launch.Find(file.Vehicle);
string scenery = string.IsNullOrWhiteSpace(launch.SceneryName) ? launch.Scenery : launch.SceneryName;
return new TapeContext(launch.StartClock, Recorders.Guess(vehicle, file.Vehicle),
string.IsNullOrWhiteSpace(scenery) ? null : scenery,
launch.RoleOf(file.Vehicle) == TapeRole.Other ? null : launch.Train);
}
/// <summary>Every tape, session by session.</summary>
public IReadOnlyList<TapeFile> List() => Sessions().SelectMany(session => session.Tapes).ToList();
/// <summary>
/// The runs the tapes came from, the running or latest one first. A tape belongs
/// to the last launch before it was written; one written before any known launch
/// goes with those written about the same time, as a run writes them all at its end.
/// </summary>
public IReadOnlyList<TapeSession> Sessions()
{
try
{
var launches = Launches();
var tapes = _files.DirectoryExists(Folder)
? _files.GetFiles(Folder, "*.dat").Select(ReadTape).ToList()
: new List<TapeFile>();
var behind = tapes.GroupBy(tape => launches.LastOrDefault(l => l.Covers(tape.LastWriteUtc)))
.Where(group => group.Key is not null)
.ToDictionary(group => group.Key!, group => group.ToList());
var sessions = new List<TapeSession>();
foreach (var launch in launches)
{
bool last = ReferenceEquals(launch, launches[^1]);
bool running = last && _simulatorRunning();
if (!running && !behind.ContainsKey(launch))
continue;
var player = last ? PlayerIn(launch) : launch.Player ?? PlayerVehicles.Unknown;
sessions.Add(Session(launch, running, behind.GetValueOrDefault(launch, new List<TapeFile>())
.Select(t => t with { Role = launch.RoleOf(t.Vehicle, player) })));
}
var run = new List<TapeFile>();
foreach (var tape in tapes.Where(t => !launches.Any(l => l.Covers(t.LastWriteUtc))).OrderBy(t => t.LastWriteUtc))
{
if (run.Count > 0 && tape.LastWriteUtc - run[^1].LastWriteUtc > QuietBetweenRuns)
{
sessions.Add(Session(null, running: false, run));
run = new List<TapeFile>();
}
run.Add(tape);
}
if (run.Count > 0)
sessions.Add(Session(null, running: false, run));
return sessions.OrderByDescending(session => session.Running)
.ThenByDescending(session => session.LastWrittenUtc)
.ToList();
}
catch (Exception ex)
{
_log.Log($"listing {Folder}", ex);
return Array.Empty<TapeSession>();
}
}
private TapeFile ReadTape(string path) =>
new(path, Path.GetFileNameWithoutExtension(path), _files.GetLastWriteTimeUtc(path), _files.GetFileSize(path));
/// <summary>The player's own tapes first: every manned vehicle leaves one.</summary>
private static TapeSession Session(TapeLaunch? launch, bool running, IEnumerable<TapeFile> tapes)
{
var listed = tapes.OrderByDescending(tape => tape.Role).ThenByDescending(tape => tape.LastWriteUtc).ToList();
var started = launch?.LaunchedUtc ?? listed.Min(tape => tape.LastWriteUtc);
return new TapeSession(launch, started, listed.Count > 0 ? listed.Max(tape => tape.LastWriteUtc) : started,
running, listed);
}
public HaslerTape? Load(TapeFile file)
{
try
{
// Through OpenRead, which does not mind the simulator still writing the file.
using var stream = _files.OpenRead(file.Path);
using var reader = new StreamReader(stream, Encoding.ASCII);
return HaslerTape.Parse(reader.ReadToEnd());
}
catch (Exception ex)
{
_log.Log($"reading {file.Path}", ex);
return null;
}
}
}

View File

@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Domain.Sceneries;
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Application;
public enum SceneryTrainsetSaveOutcome
{
Saved,
/// <summary>The file no longer holds the trainset as it was read - edited outside meanwhile.</summary>
NotFound,
Failed
}
/// <summary>
/// Writes a consist edited in the depot into the scenery file itself, so it is
/// there the next time too - not only in the copy written for one start. The
/// file's first version is kept beside it, once - under a name of the starter's
/// own, since the simulator's editor keeps its <c>.scn.bak</c> there and rewrites
/// it on every save.
/// </summary>
public sealed class SaveSceneryTrainset
{
public const string BackupSuffix = ".starter.bak";
private readonly IFileSystem _files;
private readonly IDiagnosticsLog _log;
public SaveSceneryTrainset(IFileSystem files, IDiagnosticsLog log)
{
_files = files;
_log = log;
}
public (SceneryTrainsetSaveOutcome Outcome, string? Detail) Execute(Scenery scenery, Trainset trainset)
{
string path = FileOf(scenery, trainset);
try
{
byte[] bytes = _files.ReadAllBytes(path);
var (encoding, bom) = LegacyText.Detect(bytes);
string text = encoding.GetString(bytes, bom.Length, bytes.Length - bom.Length);
// The trainset's own line ends, if it has any: a file can hold both kinds.
string lines = trainset.RawEntry.Contains('\n') ? trainset.RawEntry : text;
string newline = lines.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
string entry = SceneryTrainsetText.Compose(trainset, newline);
var sameFile = scenery.Trainsets.Where(t => t.SourceFile == trainset.SourceFile).ToList();
if (SceneryTrainsetText.Replace(text, trainset, sameFile, entry) is not { } updated)
return (SceneryTrainsetSaveOutcome.NotFound, null);
string backup = path + BackupSuffix;
if (!_files.FileExists(backup))
_files.WriteAllBytesAtomic(backup, bytes);
_files.WriteAllBytesAtomic(path, bom.Concat(encoding.GetBytes(updated)).ToArray());
// Written here, so no change from outside for the scenery to follow.
scenery.Sources = new Dictionary<string, FileStamp?>(scenery.Sources) { [path] = FileStamp.Of(_files, path) };
// What the file holds now is what the next save looks for.
trainset.RawEntry = entry;
trainset.OriginalVelocity = trainset.Velocity;
return (SceneryTrainsetSaveOutcome.Saved, null);
}
catch (Exception ex)
{
_log.Log($"saving trainset {trainset.Name} to {path}", ex);
return (SceneryTrainsetSaveOutcome.Failed, ex.Message);
}
}
/// <summary>
/// The scenery file, or the included one the trainset came from - named, as
/// includes are, from the scenery folder the scenery itself is in.
/// </summary>
public static string FileOf(Scenery scenery, Trainset trainset) =>
trainset.SourceFile is { } file
? System.IO.Path.Combine(System.IO.Path.GetDirectoryName(scenery.Path) ?? "", file.Replace('\\', '/'))
: scenery.Path;
}

View File

@@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Application;
/// <summary>What became of a scenery's files since they were read.</summary>
/// <param name="Current">The scenery as it is held; null for a file new in the folder.</param>
/// <param name="Fresh">As its files have it now; null for one gone from the folder.</param>
public sealed record SceneryChange(Scenery? Current, Scenery? Fresh);
public enum SceneryVerdict
{
/// <summary>Nothing to do about it, for now or for good.</summary>
Leave,
/// <summary>The library can take it in.</summary>
Take,
/// <summary>Taking it in loses edits, so that is for someone to say.</summary>
AskFirst
}
/// <summary>What is to become of a change.</summary>
/// <param name="Lost">The consists with unsaved edits that <see cref="SceneryVerdict.AskFirst"/> is about.</param>
public sealed record SceneryJudgement(SceneryVerdict Verdict, IReadOnlyList<Trainset> Lost)
{
public static readonly SceneryJudgement Leave = new(SceneryVerdict.Leave, Array.Empty<Trainset>());
public static readonly SceneryJudgement Take = new(SceneryVerdict.Take, Array.Empty<Trainset>());
}
/// <summary>
/// Keeps the sceneries in step with their files when those change outside the
/// starter - in a text editor, in the simulator's editor - without anyone having
/// to ask for a reload.
/// </summary>
public sealed class SceneryFileWatch
{
private readonly IFileSystem _files;
private readonly ISceneryRepository _sceneries;
private readonly GameLibrary _library;
private readonly AppState _state;
// Files that would not load, and how they stood then: not tried, and not logged,
// again before something about them changes.
private readonly Dictionary<string, string> _unreadable = new();
private HashSet<string> _away = new();
public SceneryFileWatch(IFileSystem files, ISceneryRepository sceneries, GameLibrary library, AppState state)
{
_files = files;
_sceneries = sceneries;
_library = library;
_state = state;
}
/// <summary>What has changed among the files of <paramref name="held"/>. Off the UI thread, one look at a time.</summary>
public IReadOnlyList<SceneryChange> Look(IReadOnlyList<Scenery> held)
{
var changes = new List<SceneryChange>();
if (_sceneries.ListFiles() is not { } files)
return changes;
var there = files.ToHashSet();
var away = new HashSet<string>();
foreach (var scenery in held)
{
if (!there.Contains(scenery.Path))
{
// A file being replaced is away for a moment: gone only if still so next time.
away.Add(scenery.Path);
if (_away.Contains(scenery.Path))
changes.Add(new SceneryChange(scenery, null));
}
else if (IsStale(scenery) && LoadOnce(scenery.Path, scenery.Sources.Keys) is { } fresh)
{
changes.Add(new SceneryChange(scenery, fresh));
}
}
_away = away;
var known = held.Select(scenery => scenery.Path).ToHashSet();
foreach (string file in files.Where(file => !known.Contains(file)))
if (LoadOnce(file, new[] { file }) is { } added)
changes.Add(new SceneryChange(null, added));
return changes;
}
/// <summary>Whether any of the files it was read from is no longer as it was.</summary>
public bool IsStale(Scenery scenery) =>
scenery.Sources.Any(source => FileStamp.Of(_files, source.Key) != source.Value);
private Scenery? LoadOnce(string path, IEnumerable<string> sources)
{
string stood = string.Join('|', sources.Select(source => FileStamp.Of(_files, source)));
if (_unreadable.TryGetValue(path, out string? before) && before == stood)
return null;
var loaded = _sceneries.Load(path);
if (loaded is null)
_unreadable[path] = stood;
else
_unreadable.Remove(path);
return loaded;
}
/// <summary>
/// Puts what was held into the scenery read anew. A consist whose text is still
/// what it was goes over as the object it is, with its edits and its undo steps;
/// the choices made by hand go with them.
/// </summary>
/// <returns>The consists with unsaved edits that have no place there: their text changed too.</returns>
public static IReadOnlyList<Trainset> CarryOver(Scenery current, Scenery fresh)
{
var waiting = current.Trainsets.ToList();
for (int i = 0; i < fresh.Trainsets.Count; i++)
{
var read = fresh.Trainsets[i];
int at = waiting.FindIndex(t => t.SourceFile == read.SourceFile && t.RawEntry == read.RawEntry);
if (at < 0)
continue;
fresh.Trainsets[i] = waiting[at];
waiting.RemoveAt(at);
}
fresh.Weather.KeepChoicesOf(current.Weather);
foreach (var include in fresh.Includes)
if (current.Includes.FirstOrDefault(i => i.FilePath == include.FilePath) is { } earlier)
include.Selected = earlier.Selected;
return waiting.Where(SceneryTrainsetText.IsModified).ToList();
}
/// <summary>
/// On the UI thread, once for each change and just before acting on it: what a
/// look found may be out of date by then. A scenery read anew has what was held
/// put into it here.
/// </summary>
public SceneryJudgement Judge(SceneryChange change)
{
var sceneries = _library.Sceneries;
if (change.Current is not { } current)
return sceneries.Any(s => s.Path == change.Fresh!.Path) ? SceneryJudgement.Leave : SceneryJudgement.Take;
// Saved from here since that look, or reloaded by hand.
if (!sceneries.Contains(current) || (change.Fresh is not null && !IsStale(current)))
return SceneryJudgement.Leave;
// One whose file went stays with what was edited in it, for that to be saved elsewhere.
if (change.Fresh is not { } fresh)
return current.Trainsets.Any(SceneryTrainsetText.IsModified) ? SceneryJudgement.Leave : SceneryJudgement.Take;
var lost = CarryOver(current, fresh);
return lost.Count > 0 ? new SceneryJudgement(SceneryVerdict.AskFirst, lost) : SceneryJudgement.Take;
}
/// <summary>On the UI thread: the library, and what is picked in it, take the change in.</summary>
public void Apply(SceneryChange change)
{
var sceneries = _library.Sceneries;
if (change.Current is not { } current)
{
sceneries.Add(change.Fresh!);
return;
}
if (change.Fresh is not { } fresh)
{
sceneries.Remove(current);
if (ReferenceEquals(_state.CurrentScenery, current))
{
_state.CurrentScenery = null;
_state.CurrentTrainset = null;
_state.StartingVehicleName = null;
}
return;
}
sceneries[sceneries.IndexOf(current)] = fresh;
if (ReferenceEquals(_state.CurrentScenery, current))
Follow(current, fresh);
}
private void Follow(Scenery current, Scenery fresh)
{
// Its text changed, the one of its name stands for it, or the one in its place.
var picked = _state.CurrentTrainset;
var kept = picked is null || fresh.Trainsets.Contains(picked)
? picked
: fresh.Trainsets.FirstOrDefault(t => t.Name == picked.Name)
?? fresh.Trainsets.ElementAtOrDefault(current.Trainsets.IndexOf(picked));
_state.CurrentScenery = fresh;
_state.CurrentTrainset = kept;
if (kept is null || kept.Vehicles.All(v => v.Name != _state.StartingVehicleName))
_state.StartingVehicleName = null;
}
}

View File

@@ -1,5 +1,7 @@
using System; using System;
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.Classes; using StarterNG.Classes;
@@ -12,8 +14,20 @@ namespace StarterNG.Application;
public interface ISettingsCapture public interface ISettingsCapture
{ {
void CaptureInto(SimulatorSettings settings); void CaptureInto(SimulatorSettings settings);
/// <summary>
/// Keys the screen shows otherwise than the settings hold them - a value its
/// control cannot show - by key, in the file's terms.
/// </summary>
IReadOnlyDictionary<string, ShownValue> ShownUnlikeSettings => new Dictionary<string, ShownValue>();
} }
/// <summary>A key as the screen shows it, and as the settings held it when it was put there; null for absent.</summary>
public sealed record ShownValue(string? Shown, string? Held);
/// <summary>A key a save changes: its value in the file before and after; null where it has none.</summary>
public sealed record IniChange(string Key, string? Before, string? After);
public sealed class SettingsStore public sealed class SettingsStore
{ {
private readonly IFileSystem _files; private readonly IFileSystem _files;
@@ -29,6 +43,11 @@ public sealed class SettingsStore
private DateTime _configAgeUtc = DateTime.MinValue; private DateTime _configAgeUtc = DateTime.MinValue;
private ISettingsCapture? _capture; private ISettingsCapture? _capture;
// The settings in the file's terms when it was last read or written. A key
// they still put the same way was not changed here, so the file keeps it as
// it has it: a value outside what the starter offers, its spacing, its comment.
private IReadOnlyDictionary<string, string> _synced = new Dictionary<string, string>();
public SettingsStore(IFileSystem files, IClock clock, IDiagnosticsLog log, SettingsPaths paths, public SettingsStore(IFileSystem files, IClock clock, IDiagnosticsLog log, SettingsPaths paths,
SettingsSerializer serializer, ExecutableLocator executables) SettingsSerializer serializer, ExecutableLocator executables)
{ {
@@ -45,6 +64,15 @@ public sealed class SettingsStore
public string LoadedFrom { get; private set; } = string.Empty; public string LoadedFrom { get; private set; } = string.Empty;
/// <summary>
/// The last read failed, so <see cref="Settings"/> holds defaults, not the file's.
/// Nothing is saved until a read works: that would put them in its place.
/// </summary>
public bool LoadFailed { get; private set; }
/// <summary>The last save did not reach the file; the log says why.</summary>
public bool LastSaveFailed { get; private set; }
public string SavePath => _savePath; public string SavePath => _savePath;
public void RegisterCapture(ISettingsCapture capture) => _capture = capture; public void RegisterCapture(ISettingsCapture capture) => _capture = capture;
@@ -57,6 +85,16 @@ public sealed class SettingsStore
LoadFrom(source); LoadFrom(source);
} }
/// <summary>A player with no settings of their own yet starts at High, in the file at once so the simulator goes by it too.</summary>
public void StartAtHighWithoutOwnSettings()
{
if (LoadFailed || _files.FileExists(_savePath))
return;
GraphicsPreset.High.ApplyTo(Settings);
Save();
}
public void LoadFrom(string path) public void LoadFrom(string path)
{ {
try try
@@ -64,17 +102,38 @@ public sealed class SettingsStore
_config = _files.FileExists(path) _config = _files.FileExists(path)
? ConfigFile.Parse(_files.ReadAllText(path, _encoding)) ? ConfigFile.Parse(_files.ReadAllText(path, _encoding))
: new ConfigFile(); : new ConfigFile();
LoadFailed = false;
} }
catch (Exception ex) catch (Exception ex)
{ {
_log.Log($"reading {path}", ex); _log.Log($"reading {path}", ex);
_config = new ConfigFile(); _config = new ConfigFile();
LoadFailed = true;
} }
_serializer.Read(Settings, _config); _serializer.Read(Settings, _config);
_synced = Render(Settings);
TouchConfigAge(); TouchConfigAge();
} }
/// <summary>
/// Reads a profile into the settings; true when it read cleanly. A read that failed
/// would leave defaults to be saved over the real settings, so those are read again.
/// </summary>
public bool TryLoadProfile(string path)
{
if (_files.FileExists(path))
{
LoadFrom(path);
if (!LoadFailed)
return true;
}
if (LoadFailed)
Load();
return false;
}
public void CaptureAndSave() public void CaptureAndSave()
{ {
_capture?.CaptureInto(Settings); _capture?.CaptureInto(Settings);
@@ -91,7 +150,21 @@ public sealed class SettingsStore
public void SaveTo(string path) public void SaveTo(string path)
{ {
_serializer.Write(Settings, _config); LastSaveFailed = true;
if (LoadFailed)
{
_log.Log($"{path} not saved: the settings could not be read, what is held is defaults");
return;
}
foreach (var change in Changes(Settings))
{
if (change.After is null)
_config.Remove(change.Key);
else
_config.Set(change.Key, change.After);
}
_synced = Render(Settings);
try try
{ {
@@ -99,8 +172,9 @@ public sealed class SettingsStore
if (!string.IsNullOrEmpty(directory)) if (!string.IsNullOrEmpty(directory))
_files.CreateDirectory(directory); _files.CreateDirectory(directory);
_files.WriteAllText(path, _config.ToText(), _encoding); _files.WriteAllBytesAtomic(path, _encoding.GetBytes(_config.ToText()));
TouchConfigAge(); TouchConfigAge();
LastSaveFailed = false;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -108,6 +182,116 @@ public sealed class SettingsStore
} }
} }
/// <summary>Every key as <paramref name="settings"/> would put it, in the order they are written.</summary>
public IReadOnlyDictionary<string, string> Render(SimulatorSettings settings)
{
var config = new ConfigFile();
_serializer.Write(settings, config);
var keys = new OrderedDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in config.Entries())
keys[key] = value;
return keys;
}
/// <summary>
/// What saving <paramref name="settings"/> would change in the file: the keys
/// changed since it was read, those it lacks, and the ones no simulator reads.
/// A key the screen shows otherwise than the file has it is left alone until
/// moved. One the file leaves bare or past endconfig counts as lacking.
/// </summary>
public IReadOnlyList<IniChange> Changes(SimulatorSettings settings)
{
var now = Render(settings);
var shown = _capture?.ShownUnlikeSettings ?? new Dictionary<string, ShownValue>();
var changes = new List<IniChange>();
foreach (string key in SettingsSerializer.ObsoleteKeys)
Add(key, null);
foreach (string key in SettingsSerializer.FixedLengthKeys.Where(key => !now.ContainsKey(key)))
Add(key, null);
foreach (string key in SettingsSerializer.OptionalKeys.Concat(SimulatorKeys.All.Select(key => key.Key)))
if (!now.ContainsKey(key) && _config.GetRaw(key) is { Length: 0 })
Add(key, null);
foreach (var (key, value) in now)
if (!Kept(key, value))
Add(key, value);
foreach (string key in _synced.Keys.Where(key => !now.ContainsKey(key)))
if (!Kept(key, null))
Add(key, null);
return changes;
bool Kept(string key, string? value)
{
// Past endconfig the simulator never gets to it; bare, it takes the next
// word of the file for its value.
if (_config.IsPastEnd(key) || _config.GetRaw(key) is { Length: 0 })
return false;
if (string.Equals(key, "uartfeature", StringComparison.OrdinalIgnoreCase))
return Uart.FeaturesStand(_config.GetRaw(key), value);
if (SettingsSerializer.FixedLengthKeys.Contains(key, StringComparer.OrdinalIgnoreCase))
return _config.GetRaw(key) == value;
string? synced = _synced.GetValueOrDefault(key);
if (shown.TryGetValue(key, out var onScreen) && onScreen.Shown == value && onScreen.Held == synced)
return true;
return _config.Has(key) && synced == value;
}
void Add(string key, string? value)
{
// To the simulator a key past endconfig is not set yet, whatever it says.
string? before = value is not null && _config.IsPastEnd(key) ? null : _config.GetRaw(key);
if (before != value)
changes.Add(new IniChange(key, before, value));
}
}
/// <summary>
/// The keys <paramref name="shown"/> puts otherwise than the settings hold them,
/// with both values, in the file's terms.
/// </summary>
public IReadOnlyDictionary<string, ShownValue> ShownUnlike(SimulatorSettings shown)
{
var held = Render(Settings);
var now = Render(shown);
var unlike = new Dictionary<string, ShownValue>(StringComparer.OrdinalIgnoreCase);
foreach (string key in held.Keys.Union(now.Keys, StringComparer.OrdinalIgnoreCase))
{
string? before = held.GetValueOrDefault(key), after = now.GetValueOrDefault(key);
if (before != after)
unlike[key] = new ShownValue(after, before);
}
return unlike;
}
/// <summary>
/// A copy of <paramref name="settings"/> with <paramref name="keys"/> back at
/// what the simulator goes by when eu07.ini does not set them.
/// </summary>
public SimulatorSettings WithDefaults(SimulatorSettings settings, IReadOnlyCollection<string> keys)
{
var defaults = new SimulatorSettings();
_serializer.Read(defaults, new ConfigFile());
return WithKeysOf(settings, defaults, keys);
}
/// <summary>A copy of <paramref name="settings"/> with <paramref name="keys"/> as <paramref name="source"/> has them.</summary>
public SimulatorSettings WithKeysOf(SimulatorSettings settings, SimulatorSettings source, IReadOnlyCollection<string> keys)
{
var taken = Render(source);
var config = new ConfigFile();
foreach (var (key, value) in Render(settings))
if (!keys.Contains(key, StringComparer.OrdinalIgnoreCase))
config.Set(key, value);
foreach (string key in keys)
if (taken.TryGetValue(key, out string? value))
config.Set(key, value);
var copy = settings.Copy();
_serializer.Read(copy, config);
return copy;
}
public void TouchConfigAge() public void TouchConfigAge()
{ {
string path = string.IsNullOrEmpty(_savePath) ? _paths.UserConfigPath() : _savePath; string path = string.IsNullOrEmpty(_savePath) ? _paths.UserConfigPath() : _savePath;

View File

@@ -0,0 +1,87 @@
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
{
// A little slack: the start is taken just after the launch, not before it.
private static readonly TimeSpan StartSlack = TimeSpan.FromSeconds(5);
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"));
var since = startedUtc - StartSlack;
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. A log not written since the start is the session before's, the
/// simulator having died before opening its own: it says nothing about this one.
/// </summary>
public IReadOnlyList<string> SimulatorLogTail(int count, DateTime startedUtc)
{
string path = _paths.FromRoot("log.txt");
try
{
if (!_files.FileExists(path) || _files.GetLastWriteTimeUtc(path) < startedUtc - StartSlack)
return Array.Empty<string>();
var lines = LegacyText.Decode(_files.ReadAllBytes(path))
.Split('\n')
.Select(line => line.TrimEnd('\r'))
.Where(line => line.Trim().Length > 0)
.ToList();
return lines.Skip(Math.Max(0, lines.Count - count)).ToList();
}
catch (Exception ex)
{
_log.Log($"reading {path}", ex);
return Array.Empty<string>();
}
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
namespace StarterNG.Application;
/// <summary>How a simulator the starter launched has ended.</summary>
public sealed record SimulatorExit(int ExitCode, TimeSpan RunTime, IReadOnlyList<string> Errors)
{
/// <summary>Gone before anyone could have played - however clean the exit code.</summary>
public static readonly TimeSpan QuickExit = TimeSpan.FromSeconds(10);
public bool Quick => RunTime < QuickExit;
public bool Failed => ExitCode != 0 || Quick;
/// <summary>The error stream without its colour codes and blank lines.</summary>
public IReadOnlyList<string> CleanErrors =>
Errors.Select(line => AnsiCode.Replace(line, "").TrimEnd())
.Where(line => line.Length > 0)
.ToList();
/// <summary>The exit code, with what it means where it is a known way to die.</summary>
public string DescribeCode()
{
// Linux reports a death by signal as 128 plus the signal.
string? meaning = ExitCode switch
{
132 => "SIGILL",
134 => "SIGABRT",
135 => "SIGBUS",
136 => "SIGFPE",
137 => "SIGKILL",
139 => "SIGSEGV",
143 => "SIGTERM",
_ => null
};
// Windows reports an unhandled exception as its NTSTATUS.
meaning ??= unchecked((uint)ExitCode) switch
{
0xC0000005 => "access violation",
0xC00000FD => "stack overflow",
0xC0000374 => "heap corruption",
0xC0000409 => "stack buffer overrun",
_ => null
};
string code = ExitCode < 0
? "0x" + unchecked((uint)ExitCode).ToString("X8", CultureInfo.InvariantCulture)
: ExitCode.ToString(CultureInfo.InvariantCulture);
return meaning is null ? code : $"{code} ({meaning})";
}
private static readonly Regex AnsiCode = new(@"\x1B\[[0-9;]*m");
}

View File

@@ -1,12 +1,15 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using StarterNG.Application.Abstractions; 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;
using StarterNG.Infrastructure.Adapters; using StarterNG.Infrastructure.Adapters;
using StarterNG.Infrastructure.Settings;
namespace StarterNG.Application; namespace StarterNG.Application;
@@ -37,27 +40,45 @@ 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;
private readonly LinuxGpus _gpus;
private readonly IEnvironment _environment;
private readonly WineConfigLink _wineConfig;
private readonly HaslerTapes _tapes;
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,
LinuxGpus gpus, IEnvironment environment, WineConfigLink wineConfig,
HaslerTapes tapes)
{ {
_gpus = gpus;
_wineConfig = wineConfig;
_environment = environment;
_tapes = tapes;
_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;
} }
public static string? StartableVehicle(Trainset? trainset, string? preferred) /// <summary>
/// What the start button says of the consist. The vehicle picked answers for it
/// even with nobody in it, where the launch itself goes on to the first crew.
/// </summary>
public static bool HasCrewToStart(Trainset trainset, string? preferred)
{ {
if (trainset is null || trainset.Vehicles.Count == 0)
return null;
static bool CanStart(Dynamic vehicle) => static bool CanStart(Dynamic vehicle) =>
vehicle.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger; vehicle.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
@@ -65,39 +86,49 @@ public sealed class StartSimulation
{ {
var picked = trainset.Vehicles.FirstOrDefault(vehicle => var picked = trainset.Vehicles.FirstOrDefault(vehicle =>
string.Equals(vehicle.Name, preferred, StringComparison.OrdinalIgnoreCase)); string.Equals(vehicle.Name, preferred, StringComparison.OrdinalIgnoreCase));
if (picked is not null && CanStart(picked)) if (picked is not null)
return picked.Name; return CanStart(picked);
} }
return trainset.Vehicles.FirstOrDefault(CanStart)?.Name; return trainset.Vehicles.Any(CanStart);
} }
public SimulationStartResult Execute(bool freeFly, bool saveSettings) public SimulationStartResult Execute(bool freeFly, bool saveSettings)
{ {
var scenery = _state.CurrentScenery; var scenery = _state.CurrentScenery;
var trainset = _state.CurrentTrainset; var trainset = _state.CurrentTrainset;
if (scenery is null || trainset is null) if (scenery is null)
return new SimulationStartResult(SimulationStartOutcome.NothingSelected); return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
// The depot checkbox is the consist's own state; this setting is a blanket // A scenery with no consist picked starts with the free camera.
// override for people who always want the same thing, and it wins. string? vehicle = null;
switch (_settings.Settings.ReadyOverride) if (trainset is null)
{ {
case ConsistReadyOverride.AlwaysCold: freeFly = true;
trainset.ReadyToGo = false;
break;
case ConsistReadyOverride.AlwaysReady:
trainset.ReadyToGo = true;
break;
} }
else
{
// The depot checkbox is the consist's own state; this setting is a blanket
// override for people who always want the same thing, and it wins.
switch (_settings.Settings.ReadyOverride)
{
case ConsistReadyOverride.AlwaysCold:
trainset.ReadyToGo = false;
break;
case ConsistReadyOverride.AlwaysReady:
trainset.ReadyToGo = true;
break;
}
string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName) vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles);
?? StartableVehicle(trainset, _state.StartingVehicleName); _state.StartingVehicleName = vehicle;
_state.StartingVehicleName = vehicle; }
scenery.Weather.Dirty = true; scenery.Weather.Dirty = true;
string exportName = ExportPrefix + Path.GetFileName(scenery.Path); // In lower case: that is the name the simulator looks for, and on Linux
// "$Glinojeck.scn" is not "$glinojeck.scn".
string exportName = ExportPrefix + SimulatorNames.Lower(Path.GetFileName(scenery.Path));
string exportPath = Path.Combine(Path.GetDirectoryName(scenery.Path) ?? "scenery", exportName); string exportPath = Path.Combine(Path.GetDirectoryName(scenery.Path) ?? "scenery", exportName);
try try
{ {
@@ -115,16 +146,50 @@ public sealed class StartSimulation
if (saveSettings) if (saveSettings)
_settings.CaptureAndSave(); _settings.CaptureAndSave();
string executable = Path.GetFullPath(_settings.ResolveExecutable(out var problem)); if (_settings.Settings.PhysicsLog)
if (problem != ExeProblem.None) _tapes.RecordLaunch(scenery, trainset, freeFly ? null : vehicle);
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. With none
/// selected the simulator opens its wizard of a new scenery.
/// </summary>
public SimulationStartResult ExecuteEditor() =>
Launch(_state.CurrentScenery is { } scenery
? new[] { "-edit", Path.GetFileName(scenery.Path) }
: new[] { "-edit" });
/// <summary>The simulator's wizard of a new scenery, whichever one is selected.</summary>
public SimulationStartResult ExecuteNewSceneryEditor() => Launch(new[] { "-edit" });
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);
// Under Wine eu07.exe would read the prefix's own eu07.ini, not the one just saved.
if (_executables.RunsThroughWine(executable))
_wineConfig.Ensure();
// 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, GraphicsCard(), 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);
@@ -132,4 +197,30 @@ public sealed class StartSimulation
return new SimulationStartResult(SimulationStartOutcome.Started, process, executable); return new SimulationStartResult(SimulationStartOutcome.Started, process, executable);
} }
/// <summary>
/// What puts the simulator on the card the settings ask for, said in the log
/// either way: a slow simulator on a laptop is the first thing to ask about.
/// </summary>
private IReadOnlyDictionary<string, string> GraphicsCard()
{
var gpus = _gpus.List();
if (gpus.Count < 2)
return new Dictionary<string, string>();
var preference = _settings.Settings.Gpu;
string cards = string.Join(", ", gpus);
if (GpuOffload.Target(gpus, preference, _environment.GetVariable) is not { } target)
{
_log.Log($"GPU: {cards}; left to the system ({(preference == GpuPreference.System
? "chosen in the settings"
: "set by the player, or no card to move to")})");
return new Dictionary<string, string>();
}
var environment = GpuOffload.EnvironmentFor(target);
_log.Log($"GPU: {cards}; simulator on {target} with " +
string.Join(' ', environment.Select(pair => $"{pair.Key}={pair.Value}")));
return environment;
}
} }

View File

@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using StarterNG.Classes;
namespace StarterNG.Application;
/// <summary>
/// The warehouse consist the strip edits while the warehouse tab is open, under
/// its name there, and the entry as last written - every change saves itself.
/// </summary>
public sealed class WarehouseEdit
{
/// <summary>One trainset per warehouse consist, so its undo history outlives a switch.</summary>
private readonly Dictionary<string, Trainset> _trainsets = new(StringComparer.OrdinalIgnoreCase);
private string? _saved;
public Trainset? Trainset { get; private set; }
public string? Name { get; private set; }
public Trainset Open(TrainsetPreset preset)
{
// Written over from elsewhere - "add to warehouse as" the same name - the
// entry is read afresh; the history of what it was no longer applies.
if (!_trainsets.TryGetValue(preset.Name, out var trainset) ||
ConsistText.Serialize(trainset) != preset.Entry)
_trainsets[preset.Name] = trainset = new Trainset(preset.Entry);
Trainset = trainset;
Name = preset.Name;
_saved = preset.Entry;
return trainset;
}
public void Close()
{
Trainset = null;
Name = null;
}
public void Renamed(string old, string fresh)
{
if (_trainsets.Remove(old, out var trainset))
_trainsets[fresh] = trainset;
if (string.Equals(Name, old, StringComparison.OrdinalIgnoreCase))
Name = fresh;
}
public void Save()
{
if (Trainset is null || Name is null)
return;
string entry = ConsistText.Serialize(Trainset);
if (entry == _saved)
return;
PresetStore.Save(Name, entry);
_saved = entry;
}
}

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Text; using System.Text;
namespace StarterNG.Classes; namespace StarterNG.Classes;
@@ -12,6 +13,12 @@ public sealed class ConfigFile
private readonly Dictionary<string, int> _index = private readonly Dictionary<string, int> _index =
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
// The simulator stops reading at this word (Globals.cpp), whatever its case.
private const string EndKey = "endconfig";
// Its first line, or -1 when the file runs to its end.
private int _end = -1;
public static ConfigFile Parse(string text) public static ConfigFile Parse(string text)
{ {
var cfg = new ConfigFile(); var cfg = new ConfigFile();
@@ -24,14 +31,44 @@ public sealed class ConfigFile
private void AddLine(string raw) private void AddLine(string raw)
{ {
int idx = _lines.Count;
_lines.Add(raw); _lines.Add(raw);
if (TryParseKey(raw, out string key)) IndexLine(_lines.Count - 1);
_index[key] = idx; }
private void IndexLine(int line)
{
if (!TryParseKey(_lines[line], out string key))
return;
if (_end < 0 && key.Equals(EndKey, StringComparison.OrdinalIgnoreCase))
_end = line;
// Of a key on both sides of the end, the line the simulator reads counts.
if (_end < 0 || line == _end || !_index.TryGetValue(key, out int seen) || seen > _end)
_index[key] = line;
}
private void Reindex()
{
_index.Clear();
_end = -1;
for (int line = 0; line < _lines.Count; line++)
IndexLine(line);
} }
public bool Has(string key) => _index.ContainsKey(key); public bool Has(string key) => _index.ContainsKey(key);
/// <summary>Whether the line setting the key is one the simulator never gets to.</summary>
public bool IsPastEnd(string key) =>
_end >= 0 && _index.TryGetValue(key, out int line) && line > _end;
/// <summary>Every key the file sets, in the order of its lines, with its value.</summary>
public IEnumerable<KeyValuePair<string, string>> Entries()
{
foreach (var (key, _) in _index.OrderBy(pair => pair.Value))
yield return new KeyValuePair<string, string>(key, GetRaw(key) ?? string.Empty);
}
public string? GetRaw(string key) public string? GetRaw(string key)
{ {
if (!_index.TryGetValue(key, out int i)) if (!_index.TryGetValue(key, out int i))
@@ -83,35 +120,61 @@ public sealed class ConfigFile
if (_index.TryGetValue(key, out int i)) if (_index.TryGetValue(key, out int i))
{ {
SplitLine(_lines[i], out string indent, out _, out _, out string comment); SplitLine(_lines[i], out string indent, out _, out _, out string comment);
var sb = new StringBuilder(); string line = Line(indent, key, value, comment);
sb.Append(indent).Append(key);
if (value.Length > 0) if (i > _end && _end >= 0)
sb.Append(' ').Append(value); {
if (comment.Length > 0) _lines.RemoveAt(i);
sb.Append('\t').Append(comment); InsertBeforeEnd(line);
_lines[i] = sb.ToString(); }
else
{
_lines[i] = line;
}
}
else if (_end >= 0)
{
InsertBeforeEnd(Line("", key, value, ""));
} }
else else
{ {
int idx = _lines.Count; int idx = _lines.Count;
_lines.Add(value.Length > 0 ? $"{key} {value}" : key); _lines.Add(Line("", key, value, ""));
_index[key] = idx; _index[key] = idx;
} }
} }
public void Remove(string key) private static string Line(string indent, string key, string value, string comment)
{ {
if (!_index.TryGetValue(key, out int i))
return;
SplitLine(_lines[i], out string indent, out _, out string value, out string comment);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append(indent).Append("// ").Append(key); sb.Append(indent).Append(key);
if (value.Length > 0) if (value.Length > 0)
sb.Append(' ').Append(value); sb.Append(' ').Append(value);
if (comment.Length > 0) if (comment.Length > 0)
sb.Append('\t').Append(comment); sb.Append('\t').Append(comment);
_lines[i] = sb.ToString(); return sb.ToString();
}
private void InsertBeforeEnd(string line)
{
_lines.Insert(_end, line);
Reindex();
}
/// <summary>Comments out every line setting the key: the last one alone would bring the one before it back.</summary>
public void Remove(string key)
{
if (!_index.ContainsKey(key))
return;
for (int i = 0; i < _lines.Count; i++)
{
SplitLine(_lines[i], out string indent, out string found, out string value, out string comment);
if (!found.Equals(key, StringComparison.OrdinalIgnoreCase))
continue;
_lines[i] = Line(indent + "// ", found, value, comment);
}
_index.Remove(key); _index.Remove(key);
} }
@@ -133,7 +196,7 @@ public sealed class ConfigFile
private static void SplitLine(string raw, out string indent, out string key, private static void SplitLine(string raw, out string indent, out string key,
out string value, out string comment) out string value, out string comment)
{ {
indent = key = value = comment = string.Empty; key = value = comment = string.Empty;
int start = 0; int start = 0;
while (start < raw.Length && char.IsWhiteSpace(raw[start])) while (start < raw.Length && char.IsWhiteSpace(raw[start]))

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Input; using Avalonia.Input;
namespace StarterNG.Classes; namespace StarterNG.Classes;
@@ -18,8 +19,46 @@ public sealed class KeyCap
} }
} }
/// <summary>What the simulator does with a key combination on its own.</summary>
/// <param name="TextKey">What that is, as a translation key.</param>
/// <param name="Blocks">
/// It gets there ahead of the key file, so a binding here would never fire.
/// Otherwise it is what is left to the key when no binding takes it.
/// </param>
public readonly record struct SimulatorKeyUse(string TextKey, bool Blocks);
public static class KeyMap public static class KeyMap
{ {
public const string NumpadPrefix = "num_";
/// <summary>
/// Read from the simulator's code: ui_layer::on_key and driver_ui::on_key are asked first and only
/// without a modifier, driver_mode::on_key takes Shift+F12 itself, then comes the key
/// file, and driver_mode::OnKeyDown gets what that leaves.
/// </summary>
public static SimulatorKeyUse? SimulatorUse(string token, bool shift, bool ctrl)
{
bool plain = !shift && !ctrl;
switch (token.ToLowerInvariant())
{
case "tab" when plain: return new SimulatorKeyUse("KbUseMap", true);
case "f1" when plain: return new SimulatorKeyUse("KbUseConsist", true);
case "f2" when plain: return new SimulatorKeyUse("KbUseTimetable", true);
case "f3" when plain: return new SimulatorKeyUse("KbUseScenario", true);
case "f9" when plain: return new SimulatorKeyUse("KbUseLog", true);
case "f10" when plain: return new SimulatorKeyUse("KbUseQuit", true);
case "f12" when shift: return new SimulatorKeyUse("KbUseDebugPanel", true);
case "f4": return new SimulatorKeyUse("KbUseCab", false);
case "f5": return new SimulatorKeyUse("KbUseVehicle", false);
case "f6": return new SimulatorKeyUse("KbUseDebug", false);
case "f7" when plain || ctrl: return new SimulatorKeyUse("KbUseDebug", false);
case "f11" when plain: return new SimulatorKeyUse("KbUseEditor", false);
case [>= '0' and <= '9'] when shift: return new SimulatorKeyUse("KbUseSceneryEvent", false);
case [>= '0' and <= '9'] when ctrl: return new SimulatorKeyUse("KbUseCamera", false);
default: return null;
}
}
public static string? FromInput(Key key, PhysicalKey physical) public static string? FromInput(Key key, PhysicalKey physical)
{ {
@@ -75,6 +114,16 @@ public static class KeyMap
Key.OemSemicolon => ";", Key.OemSemicolon => ";",
Key.OemQuotes => "'", Key.OemQuotes => "'",
Key.OemBackslash or Key.OemPipe => "\\", Key.OemBackslash or Key.OemPipe => "\\",
Key.OemOpenBrackets => "[",
Key.OemCloseBrackets => "]",
Key.Enter => "enter",
Key.Tab => "tab",
Key.PageUp => "page_up",
Key.PageDown => "page_down",
Key.Left => "left",
Key.Right => "right",
Key.Up => "up",
Key.Down => "down",
Key.F1 => "f1", Key.F2 => "f2", Key.F3 => "f3", Key.F4 => "f4", Key.F1 => "f1", Key.F2 => "f2", Key.F3 => "f3", Key.F4 => "f4",
Key.F5 => "f5", Key.F6 => "f6", Key.F7 => "f7", Key.F8 => "f8", Key.F5 => "f5", Key.F6 => "f6", Key.F7 => "f7", Key.F8 => "f8",
Key.F9 => "f9", Key.F10 => "f10", Key.F11 => "f11", Key.F12 => "f12", Key.F9 => "f9", Key.F10 => "f10", Key.F11 => "f11", Key.F12 => "f12",
@@ -88,15 +137,29 @@ public static class KeyMap
Key.LeftAlt or Key.RightAlt or Key.LeftAlt or Key.RightAlt or
Key.LWin or Key.RWin; Key.LWin or Key.RWin;
/// <summary>A key combination as it reads on screen; a dash for no key, whatever the modifiers.</summary>
public static string ComboText(string? token, bool shift, bool ctrl)
{
if (IsNoKey(token))
return "—";
var parts = new List<string>();
if (ctrl) parts.Add("Ctrl");
if (shift) parts.Add("Shift");
parts.Add(DisplayName(token));
return string.Join(" + ", parts);
}
private static bool IsNoKey([NotNullWhen(false)] string? token) =>
string.IsNullOrEmpty(token) || token.Equals("none", StringComparison.OrdinalIgnoreCase);
public static string DisplayName(string? token) public static string DisplayName(string? token)
{ {
if (string.IsNullOrEmpty(token) || if (IsNoKey(token))
token.Equals("none", StringComparison.OrdinalIgnoreCase))
return "—"; return "—";
if (token.StartsWith("num_", StringComparison.OrdinalIgnoreCase)) if (token.StartsWith(NumpadPrefix, StringComparison.OrdinalIgnoreCase))
{ {
string rest = token.Substring(4); string rest = token.Substring(NumpadPrefix.Length);
return rest switch return rest switch
{ {
"enter" => "Num Enter", "enter" => "Num Enter",
@@ -116,8 +179,9 @@ public static class KeyMap
"end" => "End", "end" => "End",
"insert" => "Insert", "insert" => "Insert",
"delete" => "Delete", "delete" => "Delete",
"pageup" => "Page Up", "page_up" => "Page Up",
"pagedown" => "Page Down", "page_down" => "Page Down",
"esc" => "Esc",
"backspace" => "Backspace", "backspace" => "Backspace",
"pause" => "Pause", "pause" => "Pause",
"\\" => "\\", "\\" => "\\",
@@ -131,10 +195,10 @@ public static class KeyMap
new[] new[]
{ {
new KeyCap("Esc", null, 1.4), new KeyCap("Esc", "esc", 1.4),
new KeyCap("F1"), new KeyCap("F2"), new KeyCap("F3"), new KeyCap("F4"), new KeyCap("F1", "f1"), new KeyCap("F2", "f2"), new KeyCap("F3", "f3"), new KeyCap("F4", "f4"),
new KeyCap("F5"), new KeyCap("F6"), new KeyCap("F7"), new KeyCap("F8"), new KeyCap("F5", "f5"), new KeyCap("F6", "f6"), new KeyCap("F7", "f7"), new KeyCap("F8", "f8"),
new KeyCap("F9"), new KeyCap("F10"), new KeyCap("F11"), new KeyCap("F12"), new KeyCap("F9", "f9"), new KeyCap("F10", "f10"), new KeyCap("F11", "f11"), new KeyCap("F12", "f12"),
}, },
new[] new[]
{ {
@@ -147,11 +211,11 @@ public static class KeyMap
}, },
new[] new[]
{ {
new KeyCap("Tab", null, 1.5), new KeyCap("Tab", "tab", 1.5),
new KeyCap("Q", "q"), new KeyCap("W", "w"), new KeyCap("E", "e"), new KeyCap("R", "r"), new KeyCap("Q", "q"), new KeyCap("W", "w"), new KeyCap("E", "e"), new KeyCap("R", "r"),
new KeyCap("T", "t"), new KeyCap("Y", "y"), new KeyCap("U", "u"), new KeyCap("I", "i"), new KeyCap("T", "t"), new KeyCap("Y", "y"), new KeyCap("U", "u"), new KeyCap("I", "i"),
new KeyCap("O", "o"), new KeyCap("P", "p"), new KeyCap("O", "o"), new KeyCap("P", "p"),
new KeyCap("[", null), new KeyCap("]", null), new KeyCap("[", "["), new KeyCap("]", "]"),
new KeyCap("\\", "\\", 1.5), new KeyCap("\\", "\\", 1.5),
}, },
new[] new[]
@@ -161,7 +225,7 @@ public static class KeyMap
new KeyCap("G", "g"), new KeyCap("H", "h"), new KeyCap("J", "j"), new KeyCap("K", "k"), new KeyCap("G", "g"), new KeyCap("H", "h"), new KeyCap("J", "j"), new KeyCap("K", "k"),
new KeyCap("L", "l"), new KeyCap("L", "l"),
new KeyCap(";", ";"), new KeyCap("'", "'"), new KeyCap(";", ";"), new KeyCap("'", "'"),
new KeyCap("Enter", null, 2.2), new KeyCap("Enter", "enter", 2.2),
}, },
new[] new[]
{ {
@@ -215,11 +279,19 @@ public static class KeyMap
new[] new[]
{ {
new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp"), new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp", "page_up"),
}, },
new[] new[]
{ {
new KeyCap("Del", "delete"), new KeyCap("End", "end"), new KeyCap("PgDn"), new KeyCap("Del", "delete"), new KeyCap("End", "end"), new KeyCap("PgDn", "page_down"),
},
new[]
{
new KeyCap("", null), new KeyCap("↑", "up"), new KeyCap("", null),
},
new[]
{
new KeyCap("←", "left"), new KeyCap("↓", "down"), new KeyCap("→", "right"),
}, },
}; };
} }

View File

@@ -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.Infrastructure.Adapters; using StarterNG.Infrastructure.Adapters;
@@ -23,6 +24,20 @@ public sealed class KeyBinding
public bool IsAssigned => public bool IsAssigned =>
!string.IsNullOrEmpty(Key) && !string.IsNullOrEmpty(Key) &&
!Key.Equals("none", StringComparison.OrdinalIgnoreCase); !Key.Equals("none", StringComparison.OrdinalIgnoreCase);
/// <summary>The combination in a form to compare, however the key is spelled.</summary>
public string ComboKey => $"{(Ctrl ? 1 : 0)}|{(Shift ? 1 : 0)}|{Key.ToLowerInvariant()}";
public void Clear()
{
Shift = Ctrl = false;
Key = "none";
}
public bool IsOn(string token, bool shift, bool ctrl) =>
IsAssigned &&
string.Equals(Key, token, StringComparison.OrdinalIgnoreCase) &&
Shift == shift && Ctrl == ctrl;
} }
public sealed class KeyboardConfig public sealed class KeyboardConfig
@@ -86,9 +101,112 @@ 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 file's own when someone wrote their own, else
/// the <paramref name="translated"/> one for the starter's language, else the
/// bundled one with Polish letters.
/// </summary>
public string DescriptionOf(KeyBinding binding, Func<string, string>? translated = null)
{
bool stock = string.IsNullOrEmpty(binding.Description);
if (_polishDescriptions.TryGetValue(binding.Command, out string? polish))
stock |= string.Equals(StripPolish(polish), binding.Description.Trim(), StringComparison.OrdinalIgnoreCase);
if (!stock)
return binding.Description;
if (translated?.Invoke(binding.Command) is { Length: > 0 } text)
return text;
return polish ?? binding.Description;
}
public KeyBinding? BindingAt(string token, bool shift, bool ctrl) =>
Bindings.FirstOrDefault(b => b.IsOn(token, shift, ctrl));
/// <summary>
/// Puts <paramref name="binding"/> on a key combination, or nothing for null, and
/// frees it from the rest: 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>
public void Put(KeyBinding? binding, string token, bool shift, bool ctrl)
{
foreach (var b in Bindings)
if (!ReferenceEquals(b, binding) && b.IsOn(token, shift, ctrl))
b.Clear();
if (binding is null)
return;
binding.Key = token;
binding.Shift = shift;
binding.Ctrl = ctrl;
}
/// <summary>The <see cref="KeyBinding.ComboKey"/> of each combination more than one binding is on.</summary>
public HashSet<string> ConflictingCombos()
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var dup = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var b in Bindings)
{
if (!b.IsAssigned)
continue;
string k = b.ComboKey;
if (!seen.Add(k))
dup.Add(k);
}
return dup;
}
public int BindingsOnNumpad() =>
Bindings.Count(b => b.IsAssigned && b.Key.StartsWith(KeyMap.NumpadPrefix, StringComparison.OrdinalIgnoreCase));
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))

View File

@@ -1,13 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata; using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using StarterNG.Application;
using StarterNG.Application.Abstractions;
using StarterNG.Infrastructure.Adapters; using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Classes; namespace StarterNG.Classes;
@@ -54,18 +54,14 @@ public static class PresetStore
public static PresetSort SortMode { get; set; } = PresetSort.Name; public static PresetSort SortMode { get; set; } = PresetSort.Name;
private static readonly JsonSerializerOptions Options = new() private static PresetFile? _disk;
{
WriteIndented = true,
TypeInfoResolver = PresetJsonContext.Default,
};
private static JsonTypeInfo<PresetCollection> TypeInfo => private static PresetFile Disk =>
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection)); _disk ??= new PresetFile(AppServices.Current.Files, AppServices.Current.Log, FilePath);
public static IReadOnlyList<TrainsetPreset> All() public static IReadOnlyList<TrainsetPreset> All()
{ {
var list = Load().Presets.ToList(); var list = Disk.Presets();
if (SortMode == PresetSort.Track) if (SortMode == PresetSort.Track)
list.Sort((a, b) => string.Compare(TrackOf(a), TrackOf(b), StringComparison.OrdinalIgnoreCase)); list.Sort((a, b) => string.Compare(TrackOf(a), TrackOf(b), StringComparison.OrdinalIgnoreCase));
else else
@@ -73,25 +69,9 @@ public static class PresetStore
return list; return list;
} }
public static void Save(string? name, string? entry) public static void Save(string? name, string? entry) => Disk.Save(name, entry);
{
name = name?.Trim() ?? "";
if (name.Length == 0 || string.IsNullOrWhiteSpace(entry))
return;
var col = Load(); public static void Delete(string name) => Disk.Delete(name);
col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
col.Presets.Add(new TrainsetPreset { Name = name, Entry = entry! });
col.Presets.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
Persist(col);
}
public static void Delete(string name)
{
var col = Load();
if (col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) > 0)
Persist(col);
}
public static int ImportMagazyn(string? path = null) public static int ImportMagazyn(string? path = null)
{ {
@@ -162,35 +142,95 @@ public static class PresetStore
private static string Sanitize(string name) => private static string Sanitize(string name) =>
string.IsNullOrWhiteSpace(name) ? "preset" : name.Replace(' ', '_'); string.IsNullOrWhiteSpace(name) ? "preset" : name.Replace(' ', '_');
}
private static PresetCollection Load() /// <summary>The presets as one file holds them; <see cref="PresetStore"/> is the player's own.</summary>
public sealed class PresetFile
{
private static readonly JsonSerializerOptions Options = new()
{ {
try WriteIndented = true,
{ TypeInfoResolver = PresetJsonContext.Default,
if (File.Exists(FilePath)) };
{
var col = JsonSerializer.Deserialize(File.ReadAllText(FilePath), TypeInfo); private static JsonTypeInfo<PresetCollection> TypeInfo =>
if (col?.Presets != null) (JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
return col;
} private readonly IFileSystem _files;
} private readonly IDiagnosticsLog _log;
catch (Exception ex) private readonly string _path;
{
StarterNG.Infrastructure.Diagnostics.Log($"reading {FilePath}", ex); public PresetFile(IFileSystem files, IDiagnosticsLog log, string path)
} {
return new PresetCollection(); _files = files;
_log = log;
_path = path;
} }
private static void Persist(PresetCollection col) public List<TrainsetPreset> Presets()
{ {
TryRead(out var col);
return col.Presets;
}
public void Save(string? name, string? entry)
{
name = name?.Trim() ?? "";
if (name.Length == 0 || string.IsNullOrWhiteSpace(entry))
return;
bool read = TryRead(out var col);
col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
col.Presets.Add(new TrainsetPreset { Name = name, Entry = entry! });
col.Presets.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
Persist(col, read);
}
public void Delete(string name)
{
bool read = TryRead(out var col);
if (col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) > 0)
Persist(col, read);
}
/// <summary>The file's presets; false, with none, when there is a file but they cannot be had from it.</summary>
private bool TryRead(out PresetCollection col)
{
col = new PresetCollection();
try try
{ {
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); if (!_files.FileExists(_path))
File.WriteAllText(FilePath, JsonSerializer.Serialize(col, TypeInfo)); return true;
var read = JsonSerializer.Deserialize(_files.ReadAllText(_path), TypeInfo);
if (read?.Presets != null)
{
col = read;
return true;
}
_log.Log($"reading {_path}: no presets in it");
} }
catch (Exception ex) catch (Exception ex)
{ {
StarterNG.Infrastructure.Diagnostics.Log($"saving {FilePath}", ex); _log.Log($"reading {_path}", ex);
}
return false;
}
// What was not read is not in the list: written over, those presets are gone, so
// a copy goes first and without one the file stays - the new preset waits.
private void Persist(PresetCollection col, bool read)
{
try
{
_files.CreateDirectory(Path.GetDirectoryName(_path)!);
if (!read)
_files.KeepAside(_path, _log);
_files.WriteAllTextAtomic(_path, JsonSerializer.Serialize(col, TypeInfo));
}
catch (Exception ex)
{
_log.Log($"saving {_path}", ex);
} }
} }
} }

View File

@@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Classes; namespace StarterNG.Classes;
@@ -58,6 +59,28 @@ public class Trainset
public string? Mini; public string? Mini;
/// <summary>
/// "reversed" ahead of the vehicles: the simulator turns the whole trainset round -
/// the first vehicle stays at the offset and leads towards the track's point 1,
/// every vehicle turned. The author's to set; the starter keeps it as found.
/// </summary>
public bool Reversed { get; private set; }
/// <summary>
/// The file the trainset is written in, as the scenery's include names it, for
/// one from a file put in place by <see cref="Domain.Sceneries.SceneryIncludes"/>;
/// null for the scenery's own.
/// </summary>
public string? SourceFile { get; init; }
/// <summary>
/// Where the speed, each vehicle and the <c>endtrainset</c> stand in the text, read
/// word by word as the fields above are - comments left out, a word taken for a
/// field whatever it says. With no <c>endtrainset</c> the end is the end of the text.
/// Known only of a trainset that was <see cref="Parsed"/>.
/// </summary>
internal (SceneryCode.Token? Speed, List<(int Start, int End)> Vehicles, int End) Layout { get; private set; }
public Trainset(string trainsetEntry) public Trainset(string trainsetEntry)
{ {
RawEntry = trainsetEntry; RawEntry = trainsetEntry;
@@ -77,11 +100,7 @@ public class Trainset
private void ParseEntry(string trainsetEntry) private void ParseEntry(string trainsetEntry)
{ {
List<string> tokens = Regex List<SceneryCode.Token> tokens = SceneryCode.Tokens(SceneryCode.Mask(trainsetEntry, out _));
.Matches(trainsetEntry, @"/\*[\s\S]*?\*/|//[^\r\n]*|[^\s\r\n]+")
.Select(m => m.Value)
.Where(t => !t.StartsWith("//") && !t.StartsWith("/*"))
.ToList();
var match = Regex.Match( var match = Regex.Match(
trainsetEntry, trainsetEntry,
@@ -99,66 +118,92 @@ public class Trainset
if (mini.Success) if (mini.Success)
Mini = mini.Groups[1].Value.Trim(); Mini = mini.Groups[1].Value.Trim();
// The simulator reads its words in lower case: "Trainset" is a trainset too.
bool Is(int at, string word) => tokens[at].Is(word);
SceneryCode.Token? speed = null;
var spans = new List<(int Start, int End)>();
int end = trainsetEntry.Length;
for (int i = 0; i < tokens.Count; i++) for (int i = 0; i < tokens.Count; i++)
{ {
if (tokens[i] == "endtrainset") if (Is(i, "endtrainset"))
break;
if (tokens[i] == "trainset")
{ {
this.Name = tokens[++i]; end = tokens[i].Index;
this.Track = tokens[++i]; break;
this.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture); }
this.Velocity = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
if (Is(i, "trainset"))
{
this.Name = tokens[++i].Text;
this.Track = tokens[++i].Text;
this.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
speed = tokens[++i];
this.Velocity = float.Parse(tokens[i].Text, CultureInfo.InvariantCulture);
this.OriginalVelocity = this.Velocity; this.OriginalVelocity = this.Velocity;
continue; continue;
} }
if (tokens[i] == "assignments") // Ahead of the vehicles; the simulator refuses it after the first one.
if (Is(i, "reversed"))
{ {
while (i < tokens.Count && tokens[i] != "endassignment") Reversed |= Vehicles.Count == 0;
i++;
i++;
continue; continue;
} }
if (tokens[i] == "node") if (Is(i, "assignment") || Is(i, "assignments"))
{ {
while (i < tokens.Count && !Is(i, "endassignment"))
i++;
continue;
}
if (Is(i, "node"))
{
int start = tokens[i].Index;
Dynamic nodeDynamic = new Dynamic(); Dynamic nodeDynamic = new Dynamic();
nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture); nodeDynamic.RangeMax = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture); nodeDynamic.RangeMin = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
nodeDynamic.Name = tokens[++i]; nodeDynamic.Name = tokens[++i].Text;
i++; i++;
nodeDynamic.DataFolder = tokens[++i]; nodeDynamic.DataFolder = tokens[++i].Text;
nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i]); nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i].Text);
nodeDynamic.MmdFile = tokens[++i]; nodeDynamic.MmdFile = tokens[++i].Text;
nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture); nodeDynamic.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
nodeDynamic.DriverType = tokens[++i] switch nodeDynamic.DriverType = Dynamic.ParseDriver(tokens[++i].Text);
{
"headdriver" => eDriverType.Headdriver,
"reardriver" => eDriverType.Reardriver,
"passenger" => eDriverType.Passenger,
_ => eDriverType.Nobody
};
nodeDynamic.Coupling = Coupling.Parse(tokens[++i]); nodeDynamic.Coupling = Coupling.Parse(tokens[++i].Text);
// A missing enddynamic does not take the next vehicle with it.
var trailing = new List<string>(); var trailing = new List<string>();
while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic") while (i + 1 < tokens.Count && !Is(i + 1, "enddynamic") && !Is(i + 1, "node") &&
trailing.Add(tokens[++i]); !Is(i + 1, "endtrainset"))
trailing.Add(tokens[++i].Text);
nodeDynamic.ReadTrailing(trailing); nodeDynamic.ReadTrailing(trailing);
i++; if (i + 1 < tokens.Count && Is(i + 1, "enddynamic"))
i++;
Vehicles.Add(nodeDynamic); Vehicles.Add(nodeDynamic);
spans.Add((start, tokens[i].End));
} }
} }
Layout = (speed, spans, end);
} }
public string ToSceneryEntry() => public string ToSceneryEntry() =>
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry; Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
/// <summary>No vehicles, in the export too: an entry that could not be read goes out as its raw text.</summary>
public void RemoveVehicles()
{
Vehicles = new List<Dynamic>();
if (!Parsed)
RawEntry = string.Empty;
}
public string GetTrainsetEntry() public string GetTrainsetEntry()
{ {
@@ -177,13 +222,11 @@ public class Trainset
sb.Append(Track).Append(' '); sb.Append(Track).Append(' ');
sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' '); sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' ');
sb.Append(Velocity.ToString(CultureInfo.InvariantCulture)); sb.Append(Velocity.ToString(CultureInfo.InvariantCulture));
if (Reversed)
sb.Append(" reversed");
sb.Append('\n'); sb.Append('\n');
string entry = sb.ToString(); return sb.Append(Dynamic.ToTrainsetNodes(Vehicles)).ToString();
foreach (Dynamic vehicle in Vehicles)
entry += vehicle.ToTrainsetNode();
return entry;
} }
} }
@@ -201,12 +244,6 @@ public class Dynamic
public Coupling Coupling = new(); public Coupling Coupling = new();
public byte couplingData
{
get => (byte)(Coupling.AbsFlags & 0xFF);
set => Coupling.Flags = value;
}
public const string PantState = "pantstate"; public const string PantState = "pantstate";
public const int PantStateMax = 3; public const int PantStateMax = 3;
@@ -235,11 +272,15 @@ 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);
public bool IsPantState => IsPantStateType(LoadType);
internal void ReadTrailing(List<string> t) internal void ReadTrailing(List<string> t)
{ {
int p = 0; int p = 0;
@@ -272,14 +313,33 @@ public class Dynamic
return dot > 0 ? token[..dot] : token; return dot > 0 ? token[..dot] : token;
} }
internal static eDriverType ParseDriver(string word) => word.ToLowerInvariant() switch
{
"headdriver" => eDriverType.Headdriver,
"reardriver" => eDriverType.Reardriver,
"passenger" => eDriverType.Passenger,
_ => eDriverType.Nobody
};
private string DriverWord => DriverType switch
{
eDriverType.Headdriver => "headdriver",
eDriverType.Reardriver => "reardriver",
eDriverType.Passenger => "passenger",
_ => "nobody"
};
internal string WriteTrailing() internal string WriteTrailing()
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
bool loaded = !string.IsNullOrEmpty(LoadType); bool loaded = !string.IsNullOrEmpty(LoadType) && LoadCount > 0;
sb.Append(' ').Append((loaded ? LoadCount : 0).ToString(CultureInfo.InvariantCulture)); sb.Append(' ').Append((loaded ? LoadCount : 0).ToString(CultureInfo.InvariantCulture));
if (loaded && (LoadCount > 0 || IsPantState)) // The simulator reads a type only after a count above 0. One after a 0 it
// takes for a destination, and with it the next vehicle, up to its enddynamic.
// Pantographs set to none are the simulator's default anyway.
if (loaded)
sb.Append(' ').Append(LoadType); sb.Append(' ').Append(LoadType);
return sb.ToString(); return sb.ToString();
@@ -302,42 +362,32 @@ 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()
{ {
string driver = DriverType switch
{
eDriverType.Headdriver => "headdriver",
eDriverType.Reardriver => "reardriver",
eDriverType.Passenger => "passenger",
_ => "nobody"
};
return return
$"node {RangeMax} {RangeMin} {Name} dynamic " + $"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
$"{DataFolder} {SkinFile} {MmdFile} " + $"{DataFolder} {SkinFile} {MmdFile} " +
$"{Offset.ToString(CultureInfo.InvariantCulture)} " + $"{Offset.ToString(CultureInfo.InvariantCulture)} " +
$"{driver} {Coupling}{WriteTrailing()} enddynamic\n"; $"{DriverWord} {Coupling}{WriteTrailing()} enddynamic\n";
} }
public static string ToTrainsetNodes(IEnumerable<Dynamic> vehicles) =>
string.Concat(vehicles.Select(v => v.ToTrainsetNode()));
public string ToLooseNode() public string ToLooseNode()
{ {
string driver = DriverType switch
{
eDriverType.Headdriver => "headdriver",
eDriverType.Reardriver => "reardriver",
eDriverType.Passenger => "passenger",
_ => "nobody"
};
string path = string.IsNullOrEmpty(PathName) ? "none" : PathName; string path = string.IsNullOrEmpty(PathName) ? "none" : PathName;
string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture); string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture);
return return
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " + $"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " + $"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
$"{DataFolder} {SkinFile} {MmdFile} {path} " + $"{DataFolder} {SkinFile} {MmdFile} {path} " +
$"{Offset.ToString(CultureInfo.InvariantCulture)} {driver} {vel}" + $"{Offset.ToString(CultureInfo.InvariantCulture)} {DriverWord} {vel}" +
$"{WriteTrailing()} enddynamic\n"; $"{WriteTrailing()} enddynamic\n";
} }
} }

View File

@@ -0,0 +1,21 @@
using Avalonia;
using Avalonia.Controls;
namespace StarterNG.Controls;
/// <summary>
/// Where the settings layout wants the row of one of the SimulatorKeys, so it
/// sits among the rows it belongs with; the row is put in when the page is built.
/// </summary>
public sealed class KeySlot : Decorator
{
public static readonly StyledProperty<string?> KeyProperty =
AvaloniaProperty.Register<KeySlot, string?>(nameof(Key));
/// <summary>The eu07.ini key whose row goes here.</summary>
public string? Key
{
get => GetValue(KeyProperty);
set => SetValue(KeyProperty, value);
}
}

View File

@@ -17,6 +17,13 @@ public class SettingRow : ContentControl
public static readonly StyledProperty<string?> ValueTextProperty = public static readonly StyledProperty<string?> ValueTextProperty =
AvaloniaProperty.Register<SettingRow, string?>(nameof(ValueText)); AvaloniaProperty.Register<SettingRow, string?>(nameof(ValueText));
/// <summary>
/// Why the simulator to be started would pass this setting over; the row dims
/// and says so. The input stays usable - the file may serve another build.
/// </summary>
public static readonly StyledProperty<string?> IdleNoteProperty =
AvaloniaProperty.Register<SettingRow, string?>(nameof(IdleNote));
public string? Label public string? Label
{ {
get => GetValue(LabelProperty); get => GetValue(LabelProperty);
@@ -41,6 +48,12 @@ public class SettingRow : ContentControl
set => SetValue(ValueTextProperty, value); set => SetValue(ValueTextProperty, value);
} }
public string? IdleNote
{
get => GetValue(IdleNoteProperty);
set => SetValue(IdleNoteProperty, value);
}
public SettingRow() public SettingRow()
{ {
UpdatePseudoClasses(); UpdatePseudoClasses();
@@ -54,8 +67,12 @@ public class SettingRow : ContentControl
change.Property == DescriptionProperty || change.Property == DescriptionProperty ||
change.Property == ConfigKeyProperty || change.Property == ConfigKeyProperty ||
change.Property == ValueTextProperty || change.Property == ValueTextProperty ||
change.Property == IdleNoteProperty ||
change.Property == ContentProperty) change.Property == ContentProperty)
UpdatePseudoClasses(); UpdatePseudoClasses();
if (change.Property == IdleNoteProperty)
ToolTip.SetTip(this, string.IsNullOrEmpty(IdleNote) ? null : IdleNote);
} }
private void UpdatePseudoClasses() private void UpdatePseudoClasses()
@@ -65,5 +82,6 @@ public class SettingRow : ContentControl
PseudoClasses.Set(":no-key", string.IsNullOrEmpty(ConfigKey)); PseudoClasses.Set(":no-key", string.IsNullOrEmpty(ConfigKey));
PseudoClasses.Set(":no-value", string.IsNullOrEmpty(ValueText)); PseudoClasses.Set(":no-value", string.IsNullOrEmpty(ValueText));
PseudoClasses.Set(":no-content", Content is null); PseudoClasses.Set(":no-content", Content is null);
PseudoClasses.Set(":idle", !string.IsNullOrEmpty(IdleNote));
} }
} }

View File

@@ -1,4 +1,6 @@
using System;
using Avalonia; using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
namespace StarterNG.Controls; namespace StarterNG.Controls;
@@ -8,9 +10,56 @@ public class SettingsSection : HeaderedContentControl
public static readonly StyledProperty<object?> ToggleProperty = public static readonly StyledProperty<object?> ToggleProperty =
AvaloniaProperty.Register<SettingsSection, object?>(nameof(Toggle)); AvaloniaProperty.Register<SettingsSection, object?>(nameof(Toggle));
public static readonly StyledProperty<string?> ToggleKeyProperty =
AvaloniaProperty.Register<SettingsSection, string?>(nameof(ToggleKey));
public static readonly StyledProperty<string?> ResetLabelProperty =
AvaloniaProperty.Register<SettingsSection, string?>(nameof(ResetLabel));
public static readonly StyledProperty<object?> LevelProperty =
AvaloniaProperty.Register<SettingsSection, object?>(nameof(Level));
private Button? _reset;
/// <summary>The header's choice of a level of detail for this section alone; none where it has no such thing.</summary>
public object? Level
{
get => GetValue(LevelProperty);
set => SetValue(LevelProperty, value);
}
public object? Toggle public object? Toggle
{ {
get => GetValue(ToggleProperty); get => GetValue(ToggleProperty);
set => SetValue(ToggleProperty, value); set => SetValue(ToggleProperty, value);
} }
/// <summary>The eu07.ini key the header's switch sets.</summary>
public string? ToggleKey
{
get => GetValue(ToggleKeyProperty);
set => SetValue(ToggleKeyProperty, value);
}
/// <summary>The words on the header's button that puts the section back as the simulator has it; none, no button.</summary>
public string? ResetLabel
{
get => GetValue(ResetLabelProperty);
set => SetValue(ResetLabelProperty, value);
}
/// <summary>Raised when the header's reset button is pressed.</summary>
public event Action<SettingsSection>? ResetRequested;
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (_reset is not null)
_reset.Click -= OnResetClick;
_reset = e.NameScope.Find<Button>("PART_Reset");
if (_reset is not null)
_reset.Click += OnResetClick;
}
private void OnResetClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => ResetRequested?.Invoke(this);
} }

View File

@@ -61,14 +61,18 @@ public sealed class Cargo
public void FillToMax(Dynamic car) public void FillToMax(Dynamic car)
{ {
int max = MaxFor(car); string? type = TypeToLoad(car);
if (string.IsNullOrEmpty(type)) return;
int max = MaxFor(car, type);
if (max <= 0) return; if (max <= 0) return;
if (string.IsNullOrEmpty(car.LoadType)) car.LoadType = type;
car.LoadType = AcceptedTypes(car).FirstOrDefault();
if (string.IsNullOrEmpty(car.LoadType)) return;
car.LoadCount = max; car.LoadCount = max;
} }
// The type first: what it may hold depends on it, pantographs above all.
private string? TypeToLoad(Dynamic car) =>
string.IsNullOrEmpty(car.LoadType) ? AcceptedTypes(car).FirstOrDefault() : car.LoadType;
public void CopyFromPrevious(IReadOnlyList<ConsistItem> consist, ConsistItem item) public void CopyFromPrevious(IReadOnlyList<ConsistItem> consist, ConsistItem item)
{ {
int idx = IndexOf(consist, item); int idx = IndexOf(consist, item);
@@ -103,7 +107,7 @@ public sealed class Cargo
if (!AcceptedTypes(c).Contains(type!, StringComparer.OrdinalIgnoreCase)) if (!AcceptedTypes(c).Contains(type!, StringComparer.OrdinalIgnoreCase))
continue; continue;
c.LoadType = type; c.LoadType = type;
c.LoadCount = Math.Min(lead.LoadCount, MaxFor(c)); c.LoadCount = Math.Min(lead.LoadCount, LimitFor(c));
} }
} }
@@ -113,19 +117,27 @@ public sealed class Cargo
{ {
var types = AcceptedTypes(c); var types = AcceptedTypes(c);
if (types.Count == 0) continue; if (types.Count == 0) continue;
c.LoadType = types[_rng.Next(types.Count)]; string type = types[_rng.Next(types.Count)];
c.LoadType = type;
c.LoadCount = Math.Min(c.LoadCount, LimitFor(c, type));
} }
} }
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))
{ {
int max = MaxFor(c); string? type = TypeToLoad(c);
if (string.IsNullOrEmpty(type)) continue;
int max = MaxFor(c, type);
if (max <= 0) continue; if (max <= 0) continue;
if (string.IsNullOrEmpty(c.LoadType)) c.LoadType = type;
c.LoadType = AcceptedTypes(c).FirstOrDefault();
if (string.IsNullOrEmpty(c.LoadType)) continue;
int min = Math.Max(1, max / 10); int min = Math.Max(1, max / 10);
c.LoadCount = _rng.Next(min, max + 1); c.LoadCount = _rng.Next(min, max + 1);
} }

View File

@@ -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];
@@ -49,15 +52,24 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
public void Raise() => Changed?.Invoke(); public void Raise() => Changed?.Invoke();
public void LoadFrom(Trainset trainset) /// <param name="select">The unit to select, by position; the first one otherwise.</param>
public void LoadFrom(Trainset trainset, int select = 0)
{ {
EditingTrainset = trainset; EditingTrainset = trainset;
GroupIntoUnits(trainset.Vehicles);
Selected = _items.Count == 0 ? null : _items[Math.Clamp(select, 0, _items.Count - 1)];
SyncStartingVehicle();
Raise();
}
private void GroupIntoUnits(IEnumerable<Dynamic> vehicles)
{
_items.Clear(); _items.Clear();
var cars = trainset.Vehicles.Select(v => var cars = 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();
@@ -71,19 +83,13 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
while (j < cars.Count - 1 && HoldsNext(cars[j])) while (j < cars.Count - 1 && HoldsNext(cars[j]))
locked.Add(cars[++j]); locked.Add(cars[++j]);
_items.Add(new ConsistItem _items.Add(Unit(locked));
{
Cars = locked,
Grouped = true,
Flipped = locked[0].Offset < 0,
Driver = UnitDriver(locked)
});
i = j + 1; i = j + 1;
continue; continue;
} }
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 +100,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
@@ -103,13 +109,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
if (group.Count >= 2) if (group.Count >= 2)
{ {
_items.Add(new ConsistItem _items.Add(Unit(group));
{
Cars = group,
Grouped = true,
Flipped = group[0].Offset < 0,
Driver = UnitDriver(group)
});
i = j; i = j;
continue; continue;
} }
@@ -125,13 +125,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
if (group.Count >= 2) if (group.Count >= 2)
{ {
_items.Add(new ConsistItem _items.Add(Unit(group));
{
Cars = group,
Grouped = true,
Flipped = group[0].Offset < 0,
Driver = UnitDriver(group)
});
i = j; i = j;
continue; continue;
} }
@@ -146,34 +140,86 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
}); });
i++; i++;
} }
}
Selected = _items.FirstOrDefault(); /// <summary>
SyncStartingVehicle(); /// A unit from cars in the order they run. A turned unit keeps its own order
/// reversed, which <see cref="RunningOrder"/> undoes - kept as it runs, its cars
/// would swap ends with every load.
/// </summary>
private static ConsistItem Unit(List<Dynamic> running)
{
bool flipped = running[0].Offset < 0;
if (flipped)
running.Reverse();
return new ConsistItem
{
Cars = running,
Grouped = true,
Flipped = flipped,
Driver = UnitDriver(running)
};
}
/// <summary>Nothing to edit: the strip goes empty until a consist is loaded again.</summary>
public void Unload()
{
EditingTrainset = null;
_items.Clear();
Selected = null;
SelectedCar = null;
Raise(); Raise();
} }
public List<Dynamic> Flatten() public List<Dynamic> Flatten() => _items.SelectMany(RunningOrder).ToList();
{
var flat = new List<Dynamic>();
foreach (var item in _items)
{
var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse().ToList() : item.Cars;
foreach (var car in cars)
flat.Add(car);
}
return flat;
}
/// <summary>A card's cars in the order they run, which is the order the export writes.</summary>
public static IEnumerable<Dynamic> RunningOrder(ConsistItem item) =>
item.Flipped ? Enumerable.Reverse(item.Cars) : item.Cars;
/// <summary>
/// The vehicle the simulator puts the player into follows the selection - but
/// only onto a unit someone sits in. Selecting a wagon to edit it, or adding
/// one, which selects it, keeps the pick made before; otherwise Start would
/// read "no crew" and the simulator would open in an empty wagon.
/// </summary>
public void SyncStartingVehicle() public void SyncStartingVehicle()
{ {
if (Selected is not { Cars.Count: > 0 } item) // A consist from the warehouse, say, is edited here but not the one started.
var state = AppServices.Current.State;
if (EditingTrainset is null || !ReferenceEquals(EditingTrainset, state.CurrentTrainset))
return; return;
var driven = item.Cars.FirstOrDefault(c => if (StartingVehicle(_items, Selected, state.StartingVehicleName) is { } pick)
c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver); state.StartingVehicleName = pick;
AppServices.Current.State.StartingVehicleName = (driven ?? item.Cars[0]).Name;
} }
/// <summary>
/// The selected unit's driver, or its passenger; with no one in it, the pick
/// made before while it still can be started from, else the first crew there is.
/// </summary>
public static string? StartingVehicle(IEnumerable<ConsistItem> items, ConsistItem? selected, string? current)
{
var cars = items.SelectMany(i => i.Cars).ToList();
string? pick =
selected?.Cars.FirstOrDefault(c => c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)?.Name
?? selected?.Cars.FirstOrDefault(c => c.DriverType is eDriverType.Passenger)?.Name;
if (pick != null)
return pick;
if (cars.Any(c => IsNamed(c, current) && Crewed(c)))
return current;
return cars.FirstOrDefault(c => c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)?.Name
?? cars.FirstOrDefault(Crewed)?.Name;
}
private static bool Crewed(Dynamic car) =>
car.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
private static bool IsNamed(Dynamic car, string? name) =>
!string.IsNullOrEmpty(name) && string.Equals(car.Name, name, StringComparison.OrdinalIgnoreCase);
public Dynamic MakeDynamic(VehicleTexture texture, Scenery? scenery) public Dynamic MakeDynamic(VehicleTexture texture, Scenery? scenery)
{ {
string skin = Base(texture.Skinfile); string skin = Base(texture.Skinfile);
@@ -236,45 +282,182 @@ 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, in the same cab, and that one is left unstaffed.
/// </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;
var crew = previousHead.Driver;
previousHead.Driver = eDriverType.Nobody;
foreach (var car in previousHead.Cars)
car.DriverType = eDriverType.Nobody;
Crew(head, crew);
}
/// <summary>
/// Vehicles loaded in place of a consist keep its kind of crew: in the unit the
/// loaded ones have crew in, else in the first powered one. A driver goes only
/// into something powered, and with nobody in the old consist nobody is in the new.
/// </summary>
public static List<Dynamic> LoadedOver(IReadOnlyList<Dynamic> before, List<Dynamic> loaded,
VehicleCatalog db, VehicleInfo info)
{
if (before.Count == 0 || loaded.Count == 0)
return loaded;
var consist = new Consist(db, info);
consist.GroupIntoUnits(loaded);
var crew = UnitDriver(before);
if (crew == eDriverType.Nobody)
{
foreach (var item in consist._items)
Crew(item, eDriverType.Nobody);
return consist.Flatten();
}
var seat = consist._items.FirstOrDefault(item => item.Driver != eDriverType.Nobody)
?? consist._items.FirstOrDefault(consist.IsPowered);
if (seat is not null && (crew == eDriverType.Passenger || consist.IsPowered(seat)))
Crew(seat, crew);
return consist.Flatten();
}
/// <summary>Vehicles joining a consist that has its crew come without their own.</summary>
public static List<Dynamic> Joined(IEnumerable<Dynamic> before, IEnumerable<Dynamic> added)
{
var joined = before.ToList();
bool crewed = joined.Count > 0;
foreach (var car in added)
{
if (crewed)
car.DriverType = eDriverType.Nobody;
joined.Add(car);
}
return joined;
}
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 +471,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 +489,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 +510,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 +535,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 +569,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 +637,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 +688,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 +704,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 +715,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,9 +739,37 @@ 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));
/// <summary>Whether the unit at that place can join a neighbour: one it holds or may form a unit with, or one holding it.</summary>
public bool CanJoin(ConsistItem item, int index) =>
(index + 1 < Count && (HoldsTail(item) || CanFormUnit(item, this[index + 1]))) ||
(index > 0 && HoldsTail(this[index - 1]));
public static bool HoldsNext(Dynamic car) => public static bool HoldsNext(Dynamic car) =>
car.Coupling.Has(Coupling.WorkshopLock) || car.Coupling.Locked; car.Coupling.Has(Coupling.WorkshopLock) || car.Coupling.Locked;

View File

@@ -0,0 +1,92 @@
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using StarterNG.Classes;
namespace StarterNG.Domain;
/// <summary>
/// Undo and redo for the consist editor, one history per trainset. It is fed the
/// vehicles as written back after every redraw; a state equal to the last one -
/// a redraw for a new selection - changes nothing, so feeding it is cheap to do
/// everywhere instead of remembering which of the many edits went through it.
/// </summary>
public sealed class ConsistHistory
{
private const int Depth = 100;
public sealed record State(IReadOnlyList<Dynamic> Vehicles, string Signature, int Selected);
private sealed class Track
{
public State? Last;
public readonly List<State> Undo = new();
public readonly Stack<State> Redo = new();
}
private readonly ConditionalWeakTable<Trainset, Track> _tracks = new();
/// <summary>
/// Notes what the trainset holds now. With <paramref name="rebase"/> it only
/// becomes the state undo returns to - for the redraw after an undo itself.
/// </summary>
public void Record(Trainset trainset, int selected, bool rebase = false)
{
var track = _tracks.GetOrCreateValue(trainset);
string signature = Dynamic.ToTrainsetNodes(trainset.Vehicles);
if (track.Last is { } last && last.Signature == signature)
{
track.Last = last with { Selected = selected };
return;
}
var now = new State(trainset.Vehicles.Select(v => v.Clone()).ToList(), signature, selected);
if (track.Last is { } previous && !rebase)
{
// Undone, the selection goes back to where the change was made.
track.Undo.Add(previous with { Selected = selected });
if (track.Undo.Count > Depth)
track.Undo.RemoveAt(0);
track.Redo.Clear();
}
track.Last = now;
}
public bool CanUndo(Trainset trainset) =>
_tracks.TryGetValue(trainset, out var track) && track.Undo.Count > 0;
public bool CanRedo(Trainset trainset) =>
_tracks.TryGetValue(trainset, out var track) && track.Redo.Count > 0;
/// <summary>The state before the last change, as fresh copies to put into the trainset.</summary>
public State? Undo(Trainset trainset, int selected)
{
// An edit that never asked for a redraw is still in the trainset; noting it
// first makes it the change that gets undone instead of being lost.
Record(trainset, selected);
var track = _tracks.GetOrCreateValue(trainset);
if (track.Undo.Count == 0 || track.Last is not { } last)
return null;
track.Redo.Push(last);
track.Last = track.Undo[^1];
track.Undo.RemoveAt(track.Undo.Count - 1);
return Copy(track.Last);
}
public State? Redo(Trainset trainset, int selected)
{
Record(trainset, selected);
var track = _tracks.GetOrCreateValue(trainset);
if (track.Redo.Count == 0 || track.Last is not { } last)
return null;
track.Undo.Add(last);
track.Last = track.Redo.Pop();
return Copy(track.Last);
}
private static State Copy(State state) =>
state with { Vehicles = state.Vehicles.Select(v => v.Clone()).ToList() };
}

View File

@@ -0,0 +1,23 @@
using System;
using StarterNG.Application.Abstractions;
namespace StarterNG.Domain.Sceneries;
/// <summary>A file as it stood, to tell whether it has been changed since.</summary>
public readonly record struct FileStamp(DateTime LastWriteUtc, long Size)
{
/// <summary>Null for one that is not there, or cannot be looked at just now.</summary>
public static FileStamp? Of(IFileSystem files, string path)
{
try
{
return files.FileExists(path)
? new FileStamp(files.GetLastWriteTimeUtc(path), files.GetFileSize(path))
: null;
}
catch (Exception)
{
return null;
}
}
}

View File

@@ -1,11 +1,9 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions; using StarterNG.Application.Abstractions;
using StarterNG.Classes; using StarterNG.Classes;
using StarterNG.Domain;
namespace StarterNG.Domain.Sceneries; namespace StarterNG.Domain.Sceneries;
@@ -59,10 +57,16 @@ public sealed class Scenery
public List<string> LooseVehicleNames { get; } = new(); public List<string> LooseVehicleNames { get; } = new();
public List<string> Faults { get; } = new(); public List<SceneryFault> Faults { get; } = new();
public SceneryWeather Weather { get; } = new(); public SceneryWeather Weather { get; } = new();
/// <summary>
/// The files it was read from, as each stood then: its own and those put in place
/// of their includes. Replaced whole, as it is looked at off the UI thread too.
/// </summary>
public IReadOnlyDictionary<string, FileStamp?> Sources { get; set; } = new Dictionary<string, FileStamp?>();
public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path); public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path);
public string LocalizedDescription(ISceneryTranslations translations) => public string LocalizedDescription(ISceneryTranslations translations) =>
@@ -81,7 +85,9 @@ public sealed class Scenery
for (int i = 0; i < Trainsets.Count; i++) for (int i = 0; i < Trainsets.Count; i++)
{ {
string entry = skipDecorTrainsets && Trainsets[i].Decor // The simulator logs an empty trainset as a bad one and does nothing else with it.
bool empty = Trainsets[i].Parsed && Trainsets[i].Vehicles.Count == 0;
string entry = empty || (skipDecorTrainsets && Trainsets[i].Decor)
? string.Empty ? string.Empty
: Trainsets[i].ToSceneryEntry(); : Trainsets[i].ToSceneryEntry();
result = result.Replace("{{" + i + "}}", entry); result = result.Replace("{{" + i + "}}", entry);

View File

@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace StarterNG.Domain.Sceneries;
/// <summary>
/// A scenery's text read the way the simulator's parser reads it: comments -
/// <c>//</c> to the end of the line, <c>/* */</c> anywhere, even glued to a word -
/// left out, and the rest split into words at spaces, tabs, line ends and
/// semicolons. A trainset commented out is no trainset.
/// </summary>
public static class SceneryCode
{
public readonly record struct Token(string Text, int Index)
{
public int End => Index + Text.Length;
public bool Is(string word) => string.Equals(Text, word, StringComparison.OrdinalIgnoreCase);
}
private static readonly Regex Word = new(@"[^\s;]+");
/// <summary>
/// The text with every comment blanked out, line ends kept, so a place in one
/// is the same place in the other.
/// </summary>
/// <param name="unclosed">Where a <c>/*</c> that never ends starts, or -1.</param>
public static string Mask(string text, out int unclosed)
{
unclosed = -1;
char[]? masked = null;
for (int i = 0; i < text.Length; i++)
{
char c = text[i];
if (c == '"')
{
// Quotes glue what they enclose into one word, comment marks and all.
int close = text.IndexOf('"', i + 1);
i = close < 0 ? text.Length : close;
continue;
}
if (c != '/' || i + 1 >= text.Length)
continue;
int end;
if (text[i + 1] == '/')
{
end = text.IndexOf('\n', i + 2);
if (end < 0)
end = text.Length;
}
else if (text[i + 1] == '*')
{
int close = text.IndexOf("*/", i + 2, StringComparison.Ordinal);
if (close < 0)
unclosed = i;
end = close < 0 ? text.Length : close + 2;
}
else
{
continue;
}
masked ??= text.ToCharArray();
for (int j = i; j < end; j++)
if (masked[j] != '\n' && masked[j] != '\r')
masked[j] = ' ';
i = end - 1;
}
return masked is null ? text : new string(masked);
}
/// <param name="code">Text with its comments blanked out by <see cref="Mask"/>.</param>
public static List<Token> Tokens(string code)
{
var tokens = new List<Token>();
for (var m = Word.Match(code); m.Success; m = m.NextMatch())
tokens.Add(new Token(m.Value, m.Index));
return tokens;
}
}

View File

@@ -0,0 +1,104 @@
namespace StarterNG.Domain.Sceneries;
public enum SceneryFaultKind
{
/// <summary><c>/*</c> never closed: the simulator skips the rest of the file.</summary>
UnclosedComment,
/// <summary>A trainset with no <c>endtrainset</c>; the simulator ends it at the next one.</summary>
TrainsetNotClosed,
/// <summary>
/// A trainset's header written twice: the simulator ends the first as an empty
/// trainset, noting it in its log, and reads the trainset under the second.
/// </summary>
TrainsetHeaderRepeated,
/// <summary>The header lacks its name, track, offset or speed.</summary>
TrainsetIncomplete,
StrayEndTrainset,
/// <summary><c>reversed</c> outside a trainset, or after its first vehicle.</summary>
ReversedMisplaced,
/// <summary>A vehicle with no <c>enddynamic</c>, which swallows what follows.</summary>
VehicleNotClosed,
VehicleIncomplete,
/// <summary>
/// A vehicle with no load count: the simulator takes its <c>enddynamic</c> for
/// one and goes on to the next.
/// </summary>
LoadCountMissing,
/// <summary>
/// A load count other than 0 with no type after it - the simulator takes the
/// <c>enddynamic</c> for the type; <see cref="SceneryFault.Token"/> holds the count.
/// </summary>
LoadTypeMissing,
/// <summary>
/// One word between the load and <c>enddynamic</c>: the simulator takes it for
/// a destination, the <c>enddynamic</c> for its name, and goes on to the next.
/// <see cref="SceneryFault.Token"/> holds the word.
/// </summary>
WordBeforeEnd,
/// <summary>A vehicle written as a trainset's is, with no trainset around it.</summary>
VehicleOutsideTrainset,
/// <summary>
/// A trainset's track named with a space, which splits it into words; the
/// <see cref="SceneryFault.Token"/> holds the name as written.
/// </summary>
TrackNameSplit,
/// <summary>Text where a number belongs; <see cref="SceneryFault.Token"/> holds it.</summary>
NotANumber,
/// <summary>An include whose file is not there; <see cref="SceneryFault.Token"/> names it.</summary>
MissingInclude,
/// <summary>
/// An include whose file is there only under a name in other letter case: the
/// simulator looks for it in lower case, which on Linux misses it. The
/// <see cref="SceneryFault.Subject"/> holds the name on disk.
/// </summary>
IncludeLetterCase,
/// <summary>An include with no <c>end</c>, which swallows what follows it.</summary>
IncludeNotClosed,
/// <summary>A trainset the starter could not read, for a reason none of the above names.</summary>
TrainsetUnreadable,
/// <summary>A vehicle outside a trainset the starter could not read.</summary>
VehicleUnreadable
}
/// <summary>
/// A mistake in a scenery's text: where it is - the scenery's file or one it
/// includes, and the line, as an editor counts them - and what it concerns.
/// </summary>
/// <param name="Subject">The trainset or vehicle it concerns, when it has a name.</param>
public sealed record SceneryFault(SceneryFaultKind Kind, string File, int Line, string? Subject = null,
string? Token = null)
{
/// <summary>Where in the scenery's text it was found, to tell which trainset it falls in.</summary>
public int Offset { get; init; }
/// <summary>
/// What the simulator does not read because of it - the vehicles, trainsets and
/// includes it takes as part of this entry - or null when it loses none.
/// </summary>
public string? Lost { get; init; }
/// <summary>
/// The trainsets the simulator does not make because of it, their headers
/// swallowed by it or by what it set going - named, or placed when unnamed -
/// or null when there are none.
/// </summary>
public string? Unmade { get; init; }
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace StarterNG.Domain.Sceneries;
/// <summary>A scenery's faults as they are read: where each one is, in a column, and what it is.</summary>
public static class SceneryFaultText
{
/// <param name="limit">Most it lists; the rest are only counted.</param>
/// <param name="text">The wording for a key, in the language shown.</param>
public static string Format(IReadOnlyList<SceneryFault> faults, int limit, Func<string, string> text)
{
if (faults.Count == 0)
return "";
var shown = faults.Take(limit).Select(f => (Where: $"{f.File}:{f.Line}", Fault: f)).ToList();
int width = shown.Max(f => f.Where.Length) + 2;
var lines = new StringBuilder();
foreach (var (where, fault) in shown)
{
lines.Append(where.PadRight(width)).Append(Describe(fault, text)).Append('\n');
// What it costs, under it: too long to follow on the same line.
if (fault.Lost is { } lost)
lines.Append(' ', width).Append(string.Format(text("SyntaxLost"), lost)).Append('\n');
if (fault.Unmade is { } unmade)
lines.Append(' ', width).Append(string.Format(text("SyntaxUnmade"), unmade)).Append('\n');
}
if (faults.Count > limit)
lines.Append(string.Format(text("SyntaxMore"), faults.Count - limit));
return lines.ToString().TrimEnd();
}
private static string Describe(SceneryFault fault, Func<string, string> text) =>
string.Format(text("Syntax" + fault.Kind), fault.Subject ?? "?", fault.Token ?? "");
}

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace StarterNG.Domain.Sceneries;
/// <summary>
/// The scenario's own files - included after FirstInit - put in place of their
/// include line when they hold trainsets, as the old starter did. Decorations
/// and road traffic live there; inlined, their consists are listed, can be
/// started from, and the copy written for a start carries them instead of the
/// include, so they are not loaded twice.
/// </summary>
/// <remarks>
/// Only the plain <c>include path end</c> form: an include with parameters needs
/// them put in, and one whose <c>end</c> is not on its line is read by the
/// simulator in ways not worth guessing. One marked <c>$optional</c> stays too:
/// whether it is loaded is the player's to choose. Each inlined file is wrapped in
/// marker comments, which is how a trainset is told to belong to it.
/// </remarks>
public static class SceneryIncludes
{
public const string BeginMarker = "//$starter-include ";
public const string EndMarker = "//$starter-endinclude";
private static readonly Regex FirstInit = new(@"\bFirstInit\b", RegexOptions.IgnoreCase);
private static readonly Regex Include = new(
@"^[ \t]*include[ \t]+(\S+)[ \t]+end\b[^\r\n]*", RegexOptions.Multiline | RegexOptions.IgnoreCase);
private static readonly Regex HasTrainset = new(@"^[ \t]*trainset\b", RegexOptions.Multiline | RegexOptions.IgnoreCase);
private static readonly Regex BlockComment = new(@"/\*[\s\S]*?\*/");
private static readonly Regex Marked = new(
Regex.Escape(BeginMarker) + @"(\S+)[\s\S]*?" + Regex.Escape(EndMarker));
/// <param name="read">
/// A file named as an include names it: its name as it is on disk, which the
/// marker carries, and its text - or null when there is none.
/// </param>
public static string Inline(string text, Func<string, (string File, string Text)?> read)
{
var firstInit = FirstInit.Match(text);
if (!firstInit.Success)
return text;
string head = text[..firstInit.Index];
string tail = text[firstInit.Index..];
string newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
var comments = BlockComment.Matches(tail).Select(m => (m.Index, End: m.Index + m.Length)).ToList();
return head + Include.Replace(tail, m =>
{
if (comments.Any(c => m.Index >= c.Index && m.Index < c.End))
return m.Value;
if (m.Value.Contains("$optional", StringComparison.OrdinalIgnoreCase))
return m.Value;
if (read(m.Groups[1].Value) is not var (file, content) || !HasTrainset.IsMatch(content))
return m.Value;
return BeginMarker + file + newline + content.TrimEnd() + newline + EndMarker;
});
}
/// <summary>The text without the inlined files: what the scenery itself says.</summary>
public static string WithoutInlined(string text) => Marked.Replace(text, "");
/// <summary>Where each inlined file sits in the text, by the name its include gave.</summary>
public static IReadOnlyList<(int Start, int End, string File)> Ranges(string text) =>
Marked.Matches(text).Select(m => (m.Index, m.Index + m.Length, m.Groups[1].Value)).ToList();
public static string? FileAt(IReadOnlyList<(int Start, int End, string File)> ranges, int index) =>
ranges.FirstOrDefault(r => index >= r.Start && index < r.End).File;
}

View File

@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
namespace StarterNG.Domain.Sceneries;
/// <summary>
/// Which file and line a place in a scenery's text is - its own file, or one put
/// in place of its include by <see cref="SceneryIncludes"/>, counted from that
/// file's own first line.
/// </summary>
public sealed class SceneryLines
{
private readonly List<int> _starts = new();
private readonly List<(string File, int Line)> _lines = new();
public SceneryLines(string text, string file)
{
var outer = new Stack<(string File, int Line)>();
string current = file;
int number = 1;
for (int start = 0; ;)
{
int end = text.IndexOf('\n', start);
if (end < 0)
end = text.Length;
var line = text.AsSpan(start, end - start);
_starts.Add(start);
if (line.StartsWith(SceneryIncludes.BeginMarker, StringComparison.Ordinal))
{
// The marker stands where the include was; the file's lines follow.
_lines.Add((current, number));
outer.Push((current, number));
current = line[SceneryIncludes.BeginMarker.Length..].Trim().ToString();
number = 1;
}
else if (line.StartsWith(SceneryIncludes.EndMarker, StringComparison.Ordinal) && outer.Count > 0)
{
(current, number) = outer.Pop();
_lines.Add((current, number++));
}
else
{
_lines.Add((current, number++));
}
if (end == text.Length)
break;
start = end + 1;
}
}
public (string File, int Line) At(int index)
{
int found = _starts.BinarySearch(index);
return _lines[Math.Max(0, found >= 0 ? found : ~found - 1)];
}
/// <summary>A mistake at a place in the text, told by its file and line.</summary>
public SceneryFault Fault(SceneryFaultKind kind, int at, string? subject = null, string? token = null)
{
var (file, line) = At(at);
return new SceneryFault(kind, file, line, subject, token) { Offset = at };
}
}

View File

@@ -0,0 +1,528 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace StarterNG.Domain.Sceneries;
/// <summary>
/// What the simulator makes of an include: whether it opens the file - and when
/// it does not, the file that is there under a name differing only in letter case.
/// </summary>
public readonly record struct IncludeLookup(bool Opens, string? OnDisk = null);
/// <summary>
/// Goes through a scenery's text for the parts the starter reads - trainsets,
/// vehicles, includes - finding where each trainset and loose vehicle is, and
/// what in them is written wrong. Whatever else the text holds is passed over.
/// </summary>
/// <remarks>
/// The text is read twice. Once word by word as the simulator reads it, which
/// takes every field it expects whatever stands there, and after a vehicle
/// written wrong goes on to the next <c>enddynamic</c> - that finds the faults,
/// and what each makes the simulator lose. Once as the author meant it, each
/// entry ending where it was meant to - that finds the trainsets and vehicles,
/// so one the simulator would lose is still listed, and written right when
/// the starter writes it.
/// </remarks>
public static class ScenerySyntax
{
public sealed record Result(
IReadOnlyList<(int Start, int End)> Trainsets,
IReadOnlyList<(int Start, int End)> LooseVehicles,
IReadOnlyList<SceneryFault> Faults,
SceneryLines Lines);
/// <param name="text">The scenery's text, its includes put in place.</param>
/// <param name="file">The scenery's file name, for faults in its own text.</param>
/// <param name="findInclude">
/// What the simulator makes of an include, named as the include names it; null
/// leaves includes unchecked.
/// </param>
public static Result Scan(string text, string file, Func<string, IncludeLookup>? findInclude = null)
{
string code = SceneryCode.Mask(text, out int unclosed);
var tokens = SceneryCode.Tokens(code);
var lines = new SceneryLines(text, file);
var simulator = new Reading(tokens, lines, findInclude, asSimulator: true);
simulator.Run();
var author = new Reading(tokens, lines, null, asSimulator: false);
author.Run();
var faults = Unmade(simulator, author, lines);
if (unclosed >= 0)
faults.Insert(0, lines.Fault(SceneryFaultKind.UnclosedComment, unclosed));
return new Result(author.Trainsets.Select(t => (t.Start, t.End)).ToList(), author.Loose, faults, lines);
}
/// <summary>
/// The simulator's faults, with those in a trainset whose header it swallowed
/// left out: they come of reading its vehicles with no trainset, and the
/// trainset is told of instead, with the fault it came of - the first of a
/// run of them, where the run started.
/// </summary>
private static List<SceneryFault> Unmade(Reading simulator, Reading author, SceneryLines lines)
{
var unmade = new List<(int Start, int End, int Cause)>();
var lost = new Dictionary<int, List<string>>();
foreach (var (start, end, name) in author.Trainsets)
{
int cause = -1;
foreach (var (from, to, at) in simulator.Swallowed)
if (start >= from && start <= to)
{
cause = at;
break;
}
if (cause < 0)
continue;
foreach (var earlier in unmade)
if (cause >= earlier.Start && cause < earlier.End)
{
cause = earlier.Cause;
break;
}
unmade.Add((start, end, cause));
var (file, line) = lines.At(start);
bool named = name.Length > 0 && !name.Equals("none", StringComparison.OrdinalIgnoreCase);
if (!lost.TryGetValue(cause, out var names))
lost[cause] = names = new List<string>();
names.Add(named ? name : $"{file}:{line}");
}
if (unmade.Count == 0)
return simulator.Faults;
var faults = new List<SceneryFault>();
foreach (var fault in simulator.Faults)
{
if (unmade.Any(u => fault.Offset >= u.Start && fault.Offset < u.End))
continue;
faults.Add(fault.Lost is not null && lost.Remove(fault.Offset, out var names)
? fault with { Unmade = Listed(names) }
: fault);
}
return faults;
}
private static string Listed(List<string> entries)
{
const int Named = 6;
string named = string.Join(", ", entries.Take(Named));
return entries.Count > Named ? $"{named} … +{entries.Count - Named}" : named;
}
private sealed class Reading(
List<SceneryCode.Token> tokens, SceneryLines lines, Func<string, IncludeLookup>? findInclude, bool asSimulator)
{
public readonly List<(int Start, int End, string Name)> Trainsets = new();
public readonly List<(int, int)> Loose = new();
public readonly List<SceneryFault> Faults = new();
/// <summary>The text each fault makes the simulator pass over - from, to - and where the fault is.</summary>
public readonly List<(int From, int To, int At)> Swallowed = new();
// The trainset being read: where it starts, its name, how many vehicles so
// far, and the word its header starts at.
private (int Index, string Name, int Vehicles, int At)? _open;
public void Run()
{
for (int i = 0; i < tokens.Count; i++)
{
var token = tokens[i];
if (token.Is("trainset"))
i = Header(i);
else if (token.Is("endtrainset"))
{
// One with no vehicles is no mistake: a place for the player's own
// consist, filled in the depot.
if (_open is { } ended)
Trainsets.Add((ended.Index, token.End, ended.Name));
else
Fault(SceneryFaultKind.StrayEndTrainset, token.Index);
_open = null;
}
else if (token.Is("reversed"))
{
// Turns the trainset round only while none of its vehicles is placed yet.
if (_open is not { Vehicles: 0 })
Fault(SceneryFaultKind.ReversedMisplaced, token.Index);
}
else if (token.Is("assignment") || token.Is("assignments"))
{
while (i + 1 < tokens.Count && !tokens[i].Is("endassignment"))
i++;
}
else if (token.Is("node"))
{
// Not a vehicle: only its kind is stepped over, what follows is its own.
if (i + 4 >= tokens.Count || !tokens[i + 4].Is("dynamic"))
i += 4;
else
i = Vehicle(i);
}
else if (token.Is("include"))
i = Include(i);
}
if (_open is { } last)
NotClosed(last);
}
// Unless its header is told incomplete already: the endtrainset taken for a field.
private void NotClosed((int Index, string Name, int Vehicles, int At) trainset)
{
if (!Faults.Any(f => f.Offset == trainset.Index))
Fault(SceneryFaultKind.TrainsetNotClosed, trainset.Index, trainset.Name);
}
// Each reader below takes the entry starting at the given word and gives
// back the last word it took.
private int Header(int at)
{
var token = tokens[at];
if (_open is { } unended)
{
// The same header twice over, the first with nothing under it: the
// simulator ends that one as empty - noting it in its log - and reads on.
if (unended.Vehicles == 0 && Same(unended.At, at, 4))
Fault(SceneryFaultKind.TrainsetHeaderRepeated, unended.Index, unended.Name);
else
NotClosed(unended);
}
_open = null;
if (!Fields(at, 4))
{
// Still opened, so its endtrainset ends it rather than standing alone.
int given = 0;
while (given < 4 && at + given + 1 < tokens.Count && !Keyword(tokens[at + given + 1]))
given++;
string? partial = given > 0 ? tokens[at + 1].Text : null;
_open = (token.Index, partial ?? "", 0, at);
if (!asSimulator)
return at + given;
// The simulator takes four words, whatever they are.
int taken = Math.Min(at + 4, tokens.Count - 1);
Fault(SceneryFaultKind.TrainsetIncomplete, token.Index, partial, swallowed: (at + given, taken));
return taken;
}
string name = tokens[at + 1].Text;
if (SplitTrack(tokens, at) is { } words)
{
// Its offset is the rest of the track's name - a lost letter, often.
string track = string.Join(' ', tokens.Skip(at + 2).Take(words).Select(t => t.Text));
Fault(SceneryFaultKind.TrackNameSplit, tokens[at + 2].Index, name, track);
}
else
{
Number(tokens[at + 3], name);
Number(tokens[at + 4], name);
}
_open = (token.Index, name, 0, at);
return at + 4;
}
private int Vehicle(int at)
{
bool inTrainset = _open is not null;
// node range range name dynamic, then folder skin mmd [track] offset driver [coupling | speed]
int fields = inTrainset ? 10 : 11;
string name = tokens[at + 3].Text;
bool complete = Fields(at, fields);
// Where the author meant it to end: its enddynamic, or with that missing
// the word before whatever comes next.
int meant = at + 5;
for (; meant < tokens.Count && !Keyword(tokens[meant]); meant++)
if (tokens[meant].Is("enddynamic"))
break;
bool closed = meant < tokens.Count && tokens[meant].Is("enddynamic");
if (!closed)
meant--;
if (!asSimulator)
return ListVehicle(at, complete, closed, meant);
if (at + fields + 1 >= tokens.Count)
{
Fault(complete ? SceneryFaultKind.VehicleNotClosed : SceneryFaultKind.VehicleIncomplete,
tokens[at].Index, name);
return tokens.Count - 1;
}
var (fault, trackless) = CheckFields(at, complete, inTrainset, name);
if (fault is null && !closed)
fault = SceneryFaultKind.VehicleNotClosed;
int end = LoadEnd(at + fields + 1, trackless, meant, ref fault, out string? faulty);
if (fault is { } kind)
Fault(kind, tokens[at].Index, name, faulty, end > meant ? (meant, end) : null);
if (_open is { } open)
_open = open with { Vehicles = open.Vehicles + 1 };
return end;
}
// As the author meant it: there to be listed, whatever is wrong with it.
private int ListVehicle(int at, bool complete, bool closed, int meant)
{
// Without even the fields every vehicle has it is no vehicle to list.
if (!complete)
return at;
// One with its end missing still counts in its trainset: the starter
// reads it all the same, and writes it whole for a start. One written
// for a trainset with none around it is no loose vehicle either.
if (_open is { } current)
_open = current with { Vehicles = current.Vehicles + 1 };
else if (closed && !TrainsetStyle(at))
Loose.Add((tokens[at].Index, tokens[meant].End));
return meant;
}
// What is written wrong in a vehicle's fields, the numbers told of right away.
private (SceneryFaultKind? Fault, bool Trackless) CheckFields(int at, bool complete, bool inTrainset, string name)
{
SceneryFaultKind? fault = null;
// A track named by a number is none the simulator has: it drops the vehicle
// right after its load, going to the next enddynamic.
bool trackless = false;
if (!complete)
fault = SceneryFaultKind.VehicleIncomplete;
else
{
Number(tokens[at + 1], name);
Number(tokens[at + 2], name);
if (inTrainset)
{
Number(tokens[at + 8], name);
string coupling = tokens[at + 10].Text;
int dot = coupling.IndexOf('.');
string flags = dot < 0 ? coupling : coupling[..dot];
if (flags.Length > 0 && !int.TryParse(flags, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
Fault(SceneryFaultKind.NotANumber, tokens[at + 10].Index, name, coupling);
}
else if (TrainsetStyle(at))
{
// Its header missing or commented out.
fault = SceneryFaultKind.VehicleOutsideTrainset;
trackless = true;
}
else
{
Number(tokens[at + 9], name);
Number(tokens[at + 11], name);
trackless = IsNumber(tokens[at + 8]);
}
}
return (fault, trackless);
}
// The last word of a vehicle, from its load count on, with what is written wrong
// there unless something before it already was.
private int LoadEnd(int next, bool trackless, int meant, ref SceneryFaultKind? fault, out string? faulty)
{
faulty = null;
// Then the load: its count, and only after a count other than 0 its type.
var count = tokens[next++];
if (Counted(count.Text))
{
if (next < tokens.Count && tokens[next].Is("enddynamic") && fault is null)
(fault, faulty) = (SceneryFaultKind.LoadTypeMissing, count.Text);
next++;
}
else if (count.Is("enddynamic"))
fault ??= SceneryFaultKind.LoadCountMissing;
// Then enddynamic - or a destination, one word more, and on to the next enddynamic.
int end;
if (trackless)
end = EndDynamic(next);
else if (next < tokens.Count && tokens[next].Is("enddynamic"))
end = next;
else
{
end = EndDynamic(next + 2);
if (fault is null && end > meant)
(fault, faulty) = (SceneryFaultKind.WordBeforeEnd, tokens[next].Text);
}
return end;
}
private int Include(int at)
{
var token = tokens[at];
string name = at + 1 < tokens.Count ? tokens[at + 1].Text : "";
if (!asSimulator)
{
// Its parameters run to "end". Another include, a trainset or a node on
// the way means the end is missing - read on as what they are.
int stop = at + 2;
while (stop < tokens.Count && !tokens[stop].Is("end") && !Keyword(tokens[stop]) &&
!tokens[stop].Is("include") && !tokens[stop].Is("firstinit"))
stop++;
return stop < tokens.Count && tokens[stop].Is("end") ? stop : stop - 1;
}
if (findInclude is not null && Plain(name) && findInclude(name) is { Opens: false } lookup)
{
if (lookup.OnDisk is { } onDisk)
Fault(SceneryFaultKind.IncludeLetterCase, token.Index, onDisk, name);
else
Fault(SceneryFaultKind.MissingInclude, token.Index, token: name);
}
// The simulator takes its parameters up to the word "end", written just so,
// whatever comes before it.
int end = at + 2;
while (end < tokens.Count && tokens[end].Text != "end")
end++;
int last = Math.Min(end, tokens.Count - 1);
if (Lost(at + 1, last) is not null)
Fault(SceneryFaultKind.IncludeNotClosed, token.Index, token: name, swallowed: (at + 1, last));
return last;
}
// A vehicle with no trainset around it, written as within one - offset, crew,
// coupling - where its track, offset and crew belong.
private bool TrainsetStyle(int at) =>
at + 9 < tokens.Count && IsNumber(tokens[at + 8]) && Driver(tokens[at + 9]);
// The first enddynamic from the given word on, or the last word there is.
private int EndDynamic(int from)
{
int at = from;
while (at < tokens.Count && !tokens[at].Is("enddynamic"))
at++;
return Math.Min(at, tokens.Count - 1);
}
/// <summary>
/// The entries starting past <paramref name="after"/> up to <paramref name="last"/>,
/// as they would be named in the text - vehicles by their names - or null
/// when none start there.
/// </summary>
private string? Lost(int after, int last)
{
var entries = new List<string>();
for (int at = after + 1; at <= last && at < tokens.Count; at++)
{
var token = tokens[at];
if (token.Is("node") && at + 4 < tokens.Count)
entries.Add(tokens[at + 4].Is("dynamic")
? tokens[at + 3].Text
: $"{tokens[at + 4].Text} {tokens[at + 3].Text}");
else if ((token.Is("trainset") || token.Is("include")) && at + 1 < tokens.Count)
entries.Add($"{token.Text} {tokens[at + 1].Text}");
else if (token.Is("endtrainset") || token.Is("firstinit"))
entries.Add(token.Text);
}
return entries.Count == 0 ? null : Listed(entries);
}
// Whether the words after the two given are the same, so many of them.
private bool Same(int first, int second, int count)
{
for (int i = 1; i <= count; i++)
if (second + i >= tokens.Count || !tokens[first + i].Text.Equals(tokens[second + i].Text, StringComparison.OrdinalIgnoreCase))
return false;
return true;
}
/// <summary>Whether the <paramref name="count"/> words after the one at <paramref name="at"/> are all there and none starts something else.</summary>
private bool Fields(int at, int count)
{
if (at + count >= tokens.Count)
return false;
for (int i = at + 1; i <= at + count; i++)
if (Keyword(tokens[i]) || tokens[i].Is("enddynamic"))
return false;
return true;
}
private void Number(SceneryCode.Token field, string subject)
{
if (!IsNumber(field))
Fault(SceneryFaultKind.NotANumber, field.Index, subject, field.Text);
}
/// <param name="swallowed">The words the simulator takes along with it, past the one given up to the last.</param>
private void Fault(SceneryFaultKind kind, int index, string? subject = null, string? token = null,
(int After, int Last)? swallowed = null)
{
// Faults are the simulator's reading's to tell.
if (!asSimulator)
return;
string? lost = null;
if (swallowed is var (after, last) && Lost(after, last) is { } entries)
{
lost = entries;
Swallowed.Add((tokens[after + 1].Index, tokens[last].Index, index));
}
Faults.Add(lines.Fault(kind, index, subject, token) with { Lost = lost });
}
}
/// <summary>
/// How many words a trainset's track name was split into by spaces, judged by
/// the two numbers - offset and speed - that follow them; null when it was not.
/// </summary>
private static int? SplitTrack(List<SceneryCode.Token> tokens, int header)
{
if (IsNumber(tokens[header + 3]))
return null;
for (int words = 2; words <= 4; words++)
{
int offset = header + 2 + words;
if (offset + 1 >= tokens.Count || Keyword(tokens[offset - 1]) || IsNumber(tokens[offset - 1]))
return null;
if (IsNumber(tokens[offset]) && IsNumber(tokens[offset + 1]))
return words;
}
return null;
}
/// <summary>
/// Whether a load count reads as other than 0, the way the simulator reads a
/// whole number: the digits it starts with, and 0 for one that does not.
/// </summary>
private static bool Counted(string text)
{
int at = text.Length > 0 && text[0] is '+' or '-' ? 1 : 0;
bool any = false;
for (; at < text.Length && char.IsAsciiDigit(text[at]); at++)
any |= text[at] != '0';
return any;
}
private static bool Driver(SceneryCode.Token token) =>
token.Is("headdriver") || token.Is("reardriver") || token.Is("passenger") || token.Is("nobody");
private static bool IsNumber(SceneryCode.Token token) =>
double.TryParse(token.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out _);
private static bool Keyword(SceneryCode.Token token) =>
token.Is("node") || token.Is("trainset") || token.Is("endtrainset");
/// <summary>
/// An include named outright - not one built from a template's parameters or
/// picked at random, which only the simulator can tell.
/// </summary>
private static bool Plain(string name) =>
name.Length > 0 && !name.Contains("(p", StringComparison.OrdinalIgnoreCase) &&
name.IndexOfAny(new[] { '*', '?' }) < 0;
}

View File

@@ -0,0 +1,183 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using StarterNG.Classes;
namespace StarterNG.Domain.Sceneries;
/// <summary>
/// A trainset written back into its own scenery file. Only the vehicles that
/// changed are the starter's to rewrite: everything else the author put there -
/// the header as typed, the description, comments, an assignments block, the other
/// vehicles as they are spelled - stays as it was, and the rest of the file is not
/// touched at all.
/// </summary>
public static class SceneryTrainsetText
{
private static readonly Regex LoneLineFeed = new(@"(?<!\r)\n");
private static readonly Regex RestOfLine = new(@"^[ \t]*\r?\n?");
/// <summary>
/// The trainset as the file should now hold it, in the file's own line ends.
/// Ends at <c>endtrainset</c>, as the parser's match of the old one did.
/// </summary>
/// <remarks>
/// What stands between the vehicles stays in its place among those that stay.
/// A comment inside a vehicle's own node goes when that node is rewritten or
/// the vehicle removed: there is no longer a place for it.
/// </remarks>
public static string Compose(Trainset trainset, string newline)
{
string raw = trainset.RawEntry;
var saved = new Trainset(raw);
// Not read to its end: what stands there is not known, so it all stays.
if (!saved.Parsed)
return raw;
var (speed, vehicles, end) = saved.Layout;
// Line ends of both kinds are left as they are: there is no one kind to give them.
bool mixed = raw.Contains("\r\n", StringComparison.Ordinal) && LoneLineFeed.IsMatch(raw);
string Own(string text) => mixed ? text : Normalize(text).Replace("\n", newline);
int first = vehicles.Count > 0 ? vehicles[0].Start : end;
string head = raw[..first];
// The speed is the one header field the depot changes - the ready switch.
// Rewritten only when it differs, so "0.0" stays "0.0".
if (saved.Velocity != trainset.Velocity && speed is { } field)
head = raw[..field.Index] + trainset.Velocity.ToString(CultureInfo.InvariantCulture) + raw[field.End..first];
string[] was = saved.Vehicles.Select(v => v.ToTrainsetNode()).ToArray();
string[] now = trainset.Vehicles.Select(v => v.ToTrainsetNode()).ToArray();
string ending = end < raw.Length ? raw[end..] : "endtrainset";
if (was.SequenceEqual(now))
return Own(head + raw[first..end] + ending);
string Written(int i) => raw[vehicles[i].Start..vehicles[i].End];
string After(int i) => raw[vehicles[i].End..(i + 1 < vehicles.Count ? vehicles[i + 1].Start : end)];
var sb = new StringBuilder(Own(head));
void AtLineStart()
{
if (sb.Length > 0 && sb[^1] != '\n')
sb.Append(newline);
}
// One moved here is still written as it was.
void Rewrite(int j)
{
AtLineStart();
int written = Array.IndexOf(was, now[j]);
sb.Append(written >= 0 ? Own(Written(written)) : now[j].TrimEnd('\n'));
}
int[,] staying = Staying(was, now);
for (int i = 0, j = 0; i < was.Length || j < now.Length;)
{
bool both = i < was.Length && j < now.Length;
if (both && was[i] == now[j])
{
sb.Append(Own(Written(i) + After(i)));
i++;
j++;
}
else if (both && staying[i + 1, j + 1] == staying[i, j])
{
// In the place of the one there before, so what followed that one stays.
Rewrite(j++);
sb.Append(Own(After(i++)));
}
else if (j < now.Length && (i == was.Length || staying[i, j + 1] == staying[i, j]))
{
Rewrite(j++);
sb.Append(newline);
}
else
{
// Gone, the end of its line with it; what followed it stays.
string after = After(i++);
if (!string.IsNullOrWhiteSpace(after))
{
AtLineStart();
sb.Append(Own(RestOfLine.Replace(after, "", 1)));
}
}
}
AtLineStart();
return sb.Append(Own(ending)).ToString();
}
/// <summary>
/// How many vehicles can stay in the order they are in, of those from each place on
/// in the old list and in the new one: the longest common run of the two.
/// </summary>
private static int[,] Staying(string[] was, string[] now)
{
var staying = new int[was.Length + 1, now.Length + 1];
for (int i = was.Length - 1; i >= 0; i--)
for (int j = now.Length - 1; j >= 0; j--)
staying[i, j] = was[i] == now[j]
? staying[i + 1, j + 1] + 1
: Math.Max(staying[i + 1, j], staying[i, j + 1]);
return staying;
}
/// <summary>
/// The file with the trainset's old text swapped for <paramref name="entry"/>,
/// or null when that text cannot be told apart in the file: missing, or found
/// more often than the scenery has trainsets written that way.
/// </summary>
public static string? Replace(string fileText, Trainset trainset, IReadOnlyList<Trainset> sceneryTrainsets,
string entry)
{
var twins = sceneryTrainsets.Where(t => t.RawEntry == trainset.RawEntry).ToList();
int ordinal = twins.FindIndex(t => ReferenceEquals(t, trainset));
if (ordinal < 0 || trainset.RawEntry.Length == 0)
return null;
var found = new List<int>();
for (int at = fileText.IndexOf(trainset.RawEntry, StringComparison.Ordinal);
at >= 0;
at = fileText.IndexOf(trainset.RawEntry, at + trainset.RawEntry.Length, StringComparison.Ordinal))
found.Add(at);
if (found.Count != twins.Count)
return null;
int start = found[ordinal];
return fileText[..start] + entry + fileText[(start + trainset.RawEntry.Length)..];
}
/// <summary>
/// Whether the trainset now differs from what its file holds, compared the way
/// both would be written so a different spelling of the same number is no change.
/// </summary>
public static bool IsModified(Trainset trainset) =>
trainset.Parsed && Signature(trainset) != SavedSignature(trainset.RawEntry);
private static readonly Dictionary<string, string> SavedSignatures = new();
private static string SavedSignature(string raw)
{
lock (SavedSignatures)
{
if (!SavedSignatures.TryGetValue(raw, out string? signature))
SavedSignatures[raw] = signature = Signature(new Trainset(raw));
return signature;
}
}
private static string Signature(Trainset trainset) =>
trainset.Velocity.ToString(CultureInfo.InvariantCulture) + "\n" +
Dynamic.ToTrainsetNodes(trainset.Vehicles);
private static string Normalize(string text) => text.Replace("\r\n", "\n");
}

View File

@@ -69,6 +69,23 @@ public sealed class SceneryWeather
Dirty = true; Dirty = true;
} }
/// <summary>What was set by hand stays over a file read anew; untouched, the file's own stands.</summary>
public void KeepChoicesOf(SceneryWeather earlier)
{
if (!earlier.Changed)
return;
Time = earlier.Time;
ScenarioTimeOverride = earlier.ScenarioTimeOverride;
Day = earlier.Day;
Temperature = earlier.Temperature;
FogStart = earlier.FogStart;
FogEnd = earlier.FogEnd;
Overcast = earlier.Overcast;
OvercastRandom = earlier.OvercastRandom;
Dirty = earlier.Dirty;
}
public void SetFogRange(int start, int end, IRandomSource random) public void SetFogRange(int start, int end, IRandomSource random)
{ {
FogStart = Math.Clamp(start, FogMin, FogMax); FogStart = Math.Clamp(start, FogMin, FogMax);

View File

@@ -1,5 +1,5 @@
using System;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions; using StarterNG.Application.Abstractions;
@@ -8,9 +8,26 @@ namespace StarterNG.Domain.Sceneries;
public static class SceneryWeatherWriter public static class SceneryWeatherWriter
{ {
private static readonly Regex ManagedConfigLine = new( // Whole words, as the simulator splits them: "atmo/lasy.scm" is a path, no atmo.
@"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b", private const string WordStart = @"(?<![^\s;])";
RegexOptions.IgnoreCase | RegexOptions.Compiled); private const string WordEnd = @"(?![^\s;])";
private static readonly Regex ConfigBlock = new(
$@"(?is){WordStart}config{WordEnd}(.*?){WordStart}endconfig{WordEnd}");
private static readonly Regex ManagedSetting = new(
$@"(?i){WordStart}(movelight|scenario\.weather\.temperature|scenario\.time\.override)\s+\S+");
/// <summary>What the written block takes the place of - looked for in the code alone, never in a comment.</summary>
private static readonly Regex[] Replaced =
{
ConfigBlock,
new($@"(?is){WordStart}time\s+\d[^\r\n]*?{WordStart}endtime{WordEnd}"),
new($@"(?is){WordStart}atmo{WordEnd}.*?{WordStart}endatmo{WordEnd}"),
new(@"(?im)^[ \t]*movelight\s+\S+"),
new($@"(?i){WordStart}scenario\.weather\.temperature\s+\S+"),
new($@"(?i){WordStart}scenario\.time\.override\s+\S+")
};
public static string Rewrite(string text, SceneryWeather weather, IRandomSource random) public static string Rewrite(string text, SceneryWeather weather, IRandomSource random)
{ {
@@ -19,12 +36,8 @@ public static class SceneryWeatherWriter
string carried = CarriedConfigLines(text); string carried = CarriedConfigLines(text);
string stripped = text; string stripped = text;
stripped = Regex.Replace(stripped, @"(?is)\bconfig\b.*?\bendconfig\b", " "); foreach (var pattern in Replaced)
stripped = Regex.Replace(stripped, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " "); stripped = Cut(stripped, pattern);
stripped = Regex.Replace(stripped, @"(?is)\batmo\b.*?\bendatmo\b", " ");
stripped = Regex.Replace(stripped, @"(?im)^[ \t]*movelight\s+\S+", " ");
stripped = Regex.Replace(stripped, @"(?i)scenario\.weather\.temperature\s+\S+", " ");
stripped = Regex.Replace(stripped, @"(?i)scenario\.time\.override\s+\S+", " ");
var inv = CultureInfo.InvariantCulture; var inv = CultureInfo.InvariantCulture;
string config = string config =
@@ -46,6 +59,38 @@ public static class SceneryWeatherWriter
} }
} }
/// <summary>
/// The text without what the pattern finds in its code. A comment inside what
/// is taken out stays where it is, line ends and all, for a <c>//</c> to end
/// where it did.
/// </summary>
private static string Cut(string text, Regex pattern)
{
string code = SceneryCode.Mask(text, out _);
var sb = new StringBuilder(text.Length);
int at = 0;
foreach (Match found in pattern.Matches(code))
{
sb.Append(text, at, found.Index - at);
string written = text.Substring(found.Index, found.Length);
if (written == found.Value)
{
sb.Append(' ');
}
else
{
var comments = new StringBuilder(written);
foreach (var word in Enumerable.Reverse(SceneryCode.Tokens(found.Value)))
comments.Remove(word.Index, word.Text.Length);
sb.Append(comments);
}
at = found.Index + found.Length;
}
return sb.Append(text, at, text.Length - at).ToString();
}
private static string DelphiHourMinute(string hhmm) private static string DelphiHourMinute(string hhmm)
{ {
int colon = hhmm.IndexOf(':'); int colon = hhmm.IndexOf(':');
@@ -57,20 +102,17 @@ public static class SceneryWeatherWriter
: "0" + hhmm[colon..]; : "0" + hhmm[colon..];
} }
/// <summary>The settings of every config block but those the starter writes itself.</summary>
private static string CarriedConfigLines(string text) private static string CarriedConfigLines(string text)
{ {
var block = Regex.Match(text, @"(?is)\bconfig\b(.*?)\bendconfig\b");
if (!block.Success)
return "";
var sb = new StringBuilder(); var sb = new StringBuilder();
foreach (string raw in block.Groups[1].Value.Split('\n')) foreach (Match block in ConfigBlock.Matches(SceneryCode.Mask(text, out _)))
{ foreach (string raw in ManagedSetting.Replace(block.Groups[1].Value, " ").Split('\n'))
string line = raw.TrimEnd('\r').Trim(); {
if (line.Length == 0 || ManagedConfigLine.IsMatch(line)) string line = raw.Trim();
continue; if (line.Length > 0)
sb.Append(line).Append("\r\n"); sb.Append(line).Append("\r\n");
} }
return sb.ToString(); return sb.ToString();
} }
} }

View File

@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StarterNG.Domain.Settings;
/// <summary>Which graphics card the simulator is started on, where there are two.</summary>
public enum GpuPreference
{
/// <summary>The card that is not driving the boot display - the discrete one on a laptop.</summary>
Discrete,
/// <summary>Whatever the system gives a program, as a rule the integrated card.</summary>
System
}
/// <summary>What there is to say about the cards found and the choice made.</summary>
public enum GpuNote
{
/// <summary>No card could be read.</summary>
Unread,
/// <summary>One card leaves nothing to choose.</summary>
OneCard,
/// <summary>The system decides, by choice.</summary>
LeftToSystem,
/// <summary>The player chose, by what the starter was started with.</summary>
PlayerChose,
/// <summary>The simulator would be moved to <see cref="GpuOffload.Target"/>.</summary>
Moved,
/// <summary>There is no card to move the simulator to.</summary>
NoTarget
}
/// <summary>A graphics card as Linux lists it under /sys/class/drm.</summary>
/// <param name="Slot">PCI address, as 0000:01:00.0.</param>
/// <param name="PciId">Vendor and device, as 10DE:2D19.</param>
/// <param name="Driver">The kernel driver: nvidia, amdgpu, i915...</param>
/// <param name="BootVga">The card the firmware started the display on.</param>
/// <param name="InternalPanel">The card has a laptop's built-in screen among its outputs (eDP, LVDS, DSI).</param>
public sealed record GpuDevice(string Slot, string PciId, string Driver, bool BootVga, bool InternalPanel = false)
{
public string Vendor => PciId.Split(':')[0].ToUpperInvariant() switch
{
"10DE" => "NVIDIA",
"1002" => "AMD",
"8086" => "Intel",
_ => PciId
};
public override string ToString() => $"{Vendor} {PciId} ({Driver})";
}
/// <summary>
/// The environment that moves a native Linux simulator onto the discrete card,
/// as switcheroo-control would set it. A Windows build asks for it itself
/// (NvOptimusEnablement in windows.cpp); on Linux the choice is made by
/// variables read when libGL loads, so the one starting the program sets them.
/// </summary>
public static class GpuOffload
{
// Any of these already set means the player chose - prime-run %command% in
// Steam's launch options, say - and the choice is theirs.
private static readonly string[] SelectionVariables =
{
"__NV_PRIME_RENDER_OFFLOAD", "__GLX_VENDOR_LIBRARY_NAME", "__VK_LAYER_NV_optimus",
"DRI_PRIME", "MESA_VK_DEVICE_SELECT", "VK_LOADER_DRIVERS_SELECT"
};
// Cards Mesa drives, which DRI_PRIME can pick. On one the proprietary NVIDIA
// driver owns, DRI_PRIME leaves Mesa without a card and on llvmpipe.
private static readonly HashSet<string> MesaDrivers = new(StringComparer.Ordinal)
{
"amdgpu", "radeon", "i915", "xe", "nouveau"
};
/// <summary>The card the simulator would be moved to, or null when it is left alone.</summary>
public static GpuDevice? Target(IReadOnlyList<GpuDevice> gpus, GpuPreference preference,
Func<string, string?> inherited)
{
if (preference == GpuPreference.System || gpus.Count < 2)
return null;
if (PlayerChoices(inherited).Count > 0)
return null;
// Only a laptop's integrated card hands its work to the other one. A desktop
// runs on the card its monitor is plugged into - moving off it could as well
// land on the integrated one - and an NVIDIA card driving the screen, through
// a MUX, is already the discrete one.
var boot = gpus.FirstOrDefault(g => g.BootVga);
if (boot is null || !boot.InternalPanel || boot.Vendor == "NVIDIA")
return null;
return gpus.FirstOrDefault(g => !g.BootVga && Supported(g));
}
/// <summary>The variables that say the player chose a card, of those set.</summary>
public static IReadOnlyList<string> PlayerChoices(Func<string, string?> inherited) =>
SelectionVariables.Where(name => !string.IsNullOrEmpty(inherited(name))).ToArray();
public static GpuNote NoteFor(IReadOnlyList<GpuDevice> gpus, GpuPreference preference,
Func<string, string?> inherited) =>
gpus.Count switch
{
0 => GpuNote.Unread,
1 => GpuNote.OneCard,
_ when preference == GpuPreference.System => GpuNote.LeftToSystem,
_ when PlayerChoices(inherited).Count > 0 => GpuNote.PlayerChose,
_ => Target(gpus, preference, inherited) is not null ? GpuNote.Moved : GpuNote.NoTarget
};
private static bool Supported(GpuDevice gpu) => gpu.Driver == "nvidia" || MesaDrivers.Contains(gpu.Driver);
/// <summary>The variables that put a program on <paramref name="target"/>.</summary>
public static IReadOnlyDictionary<string, string> EnvironmentFor(GpuDevice target) =>
target.Driver == "nvidia"
? new Dictionary<string, string>
{
["__NV_PRIME_RENDER_OFFLOAD"] = "1",
["__GLX_VENDOR_LIBRARY_NAME"] = "nvidia",
["__VK_LAYER_NV_optimus"] = "NVIDIA_only",
["VK_LOADER_DRIVERS_SELECT"] = "*nvidia*"
}
: new Dictionary<string, string>
{
["DRI_PRIME"] = "pci-" + target.Slot.Replace(':', '_').Replace('.', '_')
};
}

View File

@@ -0,0 +1,396 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace StarterNG.Domain.Settings;
/// <summary>
/// What only the two ends of the scale touch: the effects left to taste at every other
/// level and how often the screens refresh. The lowest level gives them up, the highest
/// takes the most there is.
/// </summary>
/// <param name="PythonUpdateMs">python.updatetime, one of the steps of its list.</param>
public sealed record GraphicsExtras(
bool MotionBlur,
bool ChromaticAberration,
bool Smoke,
int PythonUpdateMs)
{
/// <summary>The eu07.ini keys these values live in.</summary>
public static readonly IReadOnlyList<string> Keys = new[]
{
"gfx.postfx.motionblur.enabled", "gfx.postfx.chromaticaberration.enabled",
"gfx.smoke", "python.updatetime"
};
/// <summary>The simulator's own, for leaving a level chosen before this run: what was there earlier is gone.</summary>
public static readonly GraphicsExtras None = new(true, true, true, 200);
public static GraphicsExtras Of(SimulatorSettings s) =>
new(s.MotionBlur, s.ChromaticAberration, s.Smoke, s.PythonScreenUpdateRate);
public void ApplyTo(SimulatorSettings s)
{
s.MotionBlur = MotionBlur;
s.ChromaticAberration = ChromaticAberration;
s.Smoke = Smoke;
s.PythonScreenUpdateRate = PythonUpdateMs;
}
}
/// <summary>
/// What only Ultra sets, switches and limits with no scale to climb. Not every best is the
/// top of a scale: shadows are longest with the limit at its bottom, the regulator and the
/// release of textures would only take away, off.
/// Left to the player, picture unchanged: separate windows for the screens
/// (python.displaywindows, python.vsync), texture streaming, the frame pacing.
/// </summary>
/// <param name="ShadowAngleLimit">gfx.shadow.angle.min: how low the sun counts for shadows, so how long they
/// get: up to 5× the height of what casts them at 0.2, no longer than it at 1 (opengl33renderer.cpp).</param>
/// <param name="TargetFps">targetfps: above 0 the simulator moves the distance factor by itself.</param>
/// <param name="ScreenWindowFps">python.fpslimit: read only by the screens' separate windows
/// (pythonscreenviewer.cpp), it does nothing without them.</param>
/// <param name="TextureRelease">gfx.textures.releasedistance: above 0 textures of far vehicles leave the card.</param>
/// <param name="CompressTextures">compresstex: on saves card memory at a cost to the picture.</param>
/// <param name="SkipDecor">starter.ignoreirrelevant: on leaves decorative consists out of the scenario.</param>
public sealed record GraphicsTop(
double ShadowAngleLimit,
double TargetFps,
double ScreenWindowFps,
double TextureRelease,
bool Screens,
bool ScreenMipmaps,
bool CompressTextures,
bool SkipDecor,
bool Sky,
bool Cab,
bool AirCouplers,
bool SmoothTraction)
{
public const string TargetFpsKey = "targetfps";
public const string ScreenWindowFpsKey = "python.fpslimit";
public const string TextureReleaseKey = "gfx.textures.releasedistance";
public const string SkyKey = "skyenabled";
public const string CabKey = "rendercab";
public const string AirCouplersKey = "newaircouplers";
public const string SmoothTractionKey = "smoothtraction";
/// <summary>Those of its keys shown as rows of <see cref="SimulatorKeys"/>.</summary>
public static readonly IReadOnlyList<string> RowKeys = new[]
{
TargetFpsKey, ScreenWindowFpsKey, TextureReleaseKey, SkyKey, CabKey, AirCouplersKey, SmoothTractionKey
};
/// <summary>The eu07.ini keys these values live in.</summary>
public static readonly IReadOnlyList<string> Keys = RowKeys.Concat(new[]
{
"gfx.shadow.angle.min", "python.enabled", "python.mipmaps", "compresstex", "starter.ignoreirrelevant"
}).ToList();
/// <summary>What they are with nothing set, as <see cref="GraphicsExtras.None"/>.</summary>
public static readonly GraphicsTop None =
new(0.2, 0, 100, 0, true, true, true, false, true, true, true, true);
public static GraphicsTop Of(SimulatorSettings s) =>
new(s.ShadowAngleLimit, GraphicsPreset.NumberOf(s, TargetFpsKey),
GraphicsPreset.NumberOf(s, ScreenWindowFpsKey), GraphicsPreset.NumberOf(s, TextureReleaseKey),
s.PythonScreens, s.ScreenMipmaps, s.CompressTextures, s.IgnoreIrrelevantTrains,
GraphicsPreset.ToggleOf(s, SkyKey), GraphicsPreset.ToggleOf(s, CabKey),
GraphicsPreset.ToggleOf(s, AirCouplersKey), GraphicsPreset.ToggleOf(s, SmoothTractionKey));
public void ApplyTo(SimulatorSettings s)
{
void Number(string key, double value) => s.Extra[key] = value.ToString(CultureInfo.InvariantCulture);
void Toggle(string key, bool value) => s.Extra[key] = SimulatorKey.FormatToggle(value);
s.ShadowAngleLimit = ShadowAngleLimit;
Number(TargetFpsKey, TargetFps);
Number(ScreenWindowFpsKey, ScreenWindowFps);
Number(TextureReleaseKey, TextureRelease);
s.PythonScreens = Screens;
s.ScreenMipmaps = ScreenMipmaps;
s.CompressTextures = CompressTextures;
s.IgnoreIrrelevantTrains = SkipDecor;
Toggle(SkyKey, Sky);
Toggle(CabKey, Cab);
Toggle(AirCouplersKey, AirCouplers);
Toggle(SmoothTractionKey, SmoothTraction);
}
}
/// <summary>
/// A level of detail set in one go: what weighs on the graphics card. Every scale climbs in
/// even steps from <see cref="Minimal"/> to <see cref="Ultra"/>, as far as it has steps for six
/// levels. The screen (resolution, field of view), the frame pacing and the effects that are a
/// matter of taste - motion blur, chromatic aberration - stay as they are, except at the
/// two ends of the scale, which have <see cref="Extras"/>.
/// </summary>
/// <param name="NameKey">Its name's translation key.</param>
/// <param name="ReflectionsRate">gfx.reflections.framerate, in updates a second.</param>
/// <param name="DrawRange">basedrawrange, in metres. Below 2000 it changes nothing on screen: the renderer
/// keeps at least that for signals and lights (opengl33renderer.cpp).</param>
/// <param name="DistanceFactor">heightbase: the only thing that draws less than those 2 km, and draws
/// shapes, wires and sections nearer. A regulating targetfps moves it from there.</param>
/// <param name="BufferScale">starter.bufferscale, in percent. Above the screen over High: on a 4K one
/// Ultra is a picture of 8K, multisampled on top.</param>
/// <param name="DrawRangeFactor">gfx.drawrange.factor.max: how far the regulator may stretch the draw range.</param>
/// <param name="CabShadowsRange">gfx.shadows.cab.range, in metres, read only by the legacy renderer.</param>
public sealed record GraphicsPreset(
string NameKey,
int TextureSize,
int CabTextureSize,
double Anisotropy,
int Multisampling,
int DynamicLights,
int SmokeFidelity,
bool Shadows,
int ShadowMap,
int ShadowRange,
int ShadowRank,
bool EnvMap,
int ReflectionsFidelity,
double ReflectionsRate,
bool ExtraEffects,
int SplineFidelity,
double DrawRange,
double DistanceFactor,
int BufferScale,
double SleeperDistance,
double DrawRangeFactor,
double ReflectedInstances,
double ReflectedVehicles,
int CabShadowsRange,
GraphicsExtras? Extras = null,
GraphicsTop? Top = null)
{
public const string DrawRangeKey = "basedrawrange";
public const string DistanceFactorKey = "heightbase";
public const string SleeperKey = "sleeperdistance";
public const string ReflectedInstancesKey = "gfx.reflections.range_instances";
public const string ReflectedVehiclesKey = "gfx.reflections.range_vehicles";
/// <summary>Those of its keys shown as rows of <see cref="SimulatorKeys"/>.</summary>
public static readonly IReadOnlyList<string> RowKeys = new[]
{
DrawRangeKey, DistanceFactorKey, SleeperKey, ReflectedInstancesKey, ReflectedVehiclesKey
};
/// <summary>The eu07.ini keys a level sets.</summary>
public static readonly IReadOnlyList<string> Keys = new[]
{
"maxtexturesize", "maxcabtexturesize", "anisotropicfiltering", "multisampling", "dynamiclights",
"gfx.smoke.fidelity", "shadows", "gfx.shadowmap.enabled", "shadowtune", "gfx.shadow.rank.cutoff",
"gfx.envmap.enabled", "gfx.reflections.fidelity", "gfx.reflections.framerate", "gfx.extraeffects",
"splinefidelity", DrawRangeKey, DistanceFactorKey, "starter.bufferscale", SleeperKey,
"gfx.drawrange.factor.max", ReflectedInstancesKey, ReflectedVehiclesKey, "gfx.shadows.cab.range"
};
/// <summary>The last resort: no shadows, no reflections, no effects, no smoke, a softer picture.</summary>
public static readonly GraphicsPreset Minimal = new(
"PresetMinimal", TextureSize: 512, CabTextureSize: 512, Anisotropy: 1, Multisampling: 0,
DynamicLights: 1, SmokeFidelity: 1, Shadows: false, ShadowMap: 512, ShadowRange: 75, ShadowRank: 1,
EnvMap: false, ReflectionsFidelity: 0, ReflectionsRate: 1 / 300.0, ExtraEffects: false,
SplineFidelity: 1, DrawRange: 2000, DistanceFactor: 0.6, BufferScale: 70,
SleeperDistance: 100, DrawRangeFactor: 1, ReflectedInstances: 250, ReflectedVehicles: 50, CabShadowsRange: 0,
Extras: new GraphicsExtras(MotionBlur: false, ChromaticAberration: false, Smoke: false, PythonUpdateMs: 800));
public static readonly GraphicsPreset Low = new(
"PresetLow", TextureSize: 1024, CabTextureSize: 1024, Anisotropy: 2, Multisampling: 0,
DynamicLights: 3, SmokeFidelity: 1, Shadows: true, ShadowMap: 1024, ShadowRange: 125, ShadowRank: 2,
EnvMap: true, ReflectionsFidelity: 0, ReflectionsRate: 1 / 60.0, ExtraEffects: true,
SplineFidelity: 1, DrawRange: 2000, DistanceFactor: 0.7, BufferScale: 80,
SleeperDistance: 150, DrawRangeFactor: 1.5, ReflectedInstances: 400, ReflectedVehicles: 100, CabShadowsRange: 0);
public static readonly GraphicsPreset Medium = new(
"PresetMedium", TextureSize: 2048, CabTextureSize: 2048, Anisotropy: 4, Multisampling: 1,
DynamicLights: 5, SmokeFidelity: 2, Shadows: true, ShadowMap: 1024, ShadowRange: 175, ShadowRank: 2,
EnvMap: true, ReflectionsFidelity: 1, ReflectionsRate: 1 / 30.0, ExtraEffects: true,
SplineFidelity: 2, DrawRange: 2250, DistanceFactor: 0.85, BufferScale: 90,
SleeperDistance: 200, DrawRangeFactor: 2, ReflectedInstances: 550, ReflectedVehicles: 150, CabShadowsRange: 20);
/// <summary>
/// Where a player with no settings of their own starts. The simulator's own values (Globals.h),
/// but for smoke, track curves, reflections and cab shadows: theirs are the bottom of a scale
/// with levels to go under this one.
/// </summary>
public static readonly GraphicsPreset High = new(
"PresetHigh", TextureSize: 4096, CabTextureSize: 4096, Anisotropy: 8, Multisampling: 2,
DynamicLights: 7, SmokeFidelity: 2, Shadows: true, ShadowMap: 2048, ShadowRange: 250, ShadowRank: 3,
EnvMap: true, ReflectionsFidelity: 1, ReflectionsRate: 0.1, ExtraEffects: true,
SplineFidelity: 2, DrawRange: 2500, DistanceFactor: 1, BufferScale: 100,
SleeperDistance: 250, DrawRangeFactor: 3, ReflectedInstances: 750, ReflectedVehicles: 250, CabShadowsRange: 40);
/// <summary>For a strong card. A map of 4096 doubles the cascades, so the range here reaches 800 m.</summary>
public static readonly GraphicsPreset VeryHigh = new(
"PresetVeryHigh", TextureSize: 8192, CabTextureSize: 8192, Anisotropy: 16, Multisampling: 3,
DynamicLights: 7, SmokeFidelity: 3, Shadows: true, ShadowMap: 4096, ShadowRange: 400, ShadowRank: 3,
EnvMap: true, ReflectionsFidelity: 2, ReflectionsRate: 2, ExtraEffects: true,
SplineFidelity: 3, DrawRange: 5000, DistanceFactor: 2, BufferScale: 150,
SleeperDistance: 2000, DrawRangeFactor: 3, ReflectedInstances: 1500, ReflectedVehicles: 1000, CabShadowsRange: 70);
/// <summary>
/// Every value at the top of what the screen offers. Not the best picture throughout: a map
/// of 8192 stretches the cascades fourfold, so shadows reach 2400 m but are coarser up close
/// than at <see cref="VeryHigh"/>.
/// </summary>
public static readonly GraphicsPreset Ultra = new(
"PresetUltra", TextureSize: 8192, CabTextureSize: 8192, Anisotropy: 16, Multisampling: 3,
DynamicLights: 7, SmokeFidelity: 4, Shadows: true, ShadowMap: 8192, ShadowRange: 600, ShadowRank: 3,
EnvMap: true, ReflectionsFidelity: 2, ReflectionsRate: 60, ExtraEffects: true,
SplineFidelity: 4, DrawRange: 10000, DistanceFactor: 3, BufferScale: 200,
SleeperDistance: 5000, DrawRangeFactor: 3, ReflectedInstances: 3000, ReflectedVehicles: 2000, CabShadowsRange: 100,
Extras: new GraphicsExtras(MotionBlur: true, ChromaticAberration: true, Smoke: true, PythonUpdateMs: 200),
Top: new GraphicsTop(ShadowAngleLimit: 0.2, TargetFps: 0, ScreenWindowFps: 200, TextureRelease: 0,
Screens: true, ScreenMipmaps: true, CompressTextures: false, SkipDecor: false,
Sky: true, Cab: true, AirCouplers: true, SmoothTraction: true));
public static readonly IReadOnlyList<GraphicsPreset> All = new[] { Minimal, Low, Medium, High, VeryHigh, Ultra };
public void ApplyTo(SimulatorSettings s)
{
s.MaxTextureSize = TextureSize;
s.MaxCabTextureSize = CabTextureSize;
s.TextureFiltering = Anisotropy;
s.Multisampling = Multisampling;
s.DynamicLights = DynamicLights;
s.SmokeFidelity = SmokeFidelity;
s.RenderShadows = Shadows;
s.ShadowMapResolution = ShadowMap;
s.ShadowProjectionRange = ShadowRange;
s.ShadowRankCutoff = ShadowRank;
s.EnvMap = EnvMap;
s.ReflectionsFidelity = ReflectionsFidelity;
s.ReflectionsFramerate = ReflectionsRate;
s.ExtraEffects = ExtraEffects;
s.SplineFidelity = SplineFidelity;
s.Extra[DrawRangeKey] = DrawRange.ToString(CultureInfo.InvariantCulture);
s.Extra[DistanceFactorKey] = DistanceFactor.ToString(CultureInfo.InvariantCulture);
s.BufferScalePercent = BufferScale;
s.Extra[SleeperKey] = SleeperDistance.ToString(CultureInfo.InvariantCulture);
s.DrawRangeFactor = DrawRangeFactor;
s.Extra[ReflectedInstancesKey] = ReflectedInstances.ToString(CultureInfo.InvariantCulture);
s.Extra[ReflectedVehiclesKey] = ReflectedVehicles.ToString(CultureInfo.InvariantCulture);
s.CabShadowsRange = CabShadowsRange;
Extras?.ApplyTo(s);
Top?.ApplyTo(s);
}
/// <summary>Whether <paramref name="s"/> is at this level throughout.</summary>
public bool Matches(SimulatorSettings s) =>
s.MaxTextureSize == TextureSize && s.MaxCabTextureSize == CabTextureSize &&
Near(s.TextureFiltering, Anisotropy) && s.Multisampling == Multisampling &&
s.DynamicLights == DynamicLights && s.SmokeFidelity == SmokeFidelity &&
s.RenderShadows == Shadows && s.ShadowMapResolution == ShadowMap &&
s.ShadowProjectionRange == ShadowRange && s.ShadowRankCutoff == ShadowRank &&
s.EnvMap == EnvMap && s.ReflectionsFidelity == ReflectionsFidelity &&
Near(s.ReflectionsFramerate, ReflectionsRate) && s.ExtraEffects == ExtraEffects &&
s.SplineFidelity == SplineFidelity && Near(NumberOf(s, DrawRangeKey), DrawRange) &&
Near(NumberOf(s, DistanceFactorKey), DistanceFactor) &&
s.BufferScalePercent == BufferScale &&
Near(NumberOf(s, SleeperKey), SleeperDistance) && Near(s.DrawRangeFactor, DrawRangeFactor) &&
Near(NumberOf(s, ReflectedInstancesKey), ReflectedInstances) &&
Near(NumberOf(s, ReflectedVehiclesKey), ReflectedVehicles) &&
s.CabShadowsRange == CabShadowsRange &&
(Extras is null || GraphicsExtras.Of(s) == Extras) &&
(Top is null || GraphicsTop.Of(s) == Top);
/// <summary>The level <paramref name="s"/> is at throughout, if any.</summary>
public static GraphicsPreset? Matching(SimulatorSettings s) => All.FirstOrDefault(preset => preset.Matches(s));
internal static double NumberOf(SimulatorSettings s, string name)
{
var key = SimulatorKeys.Named(name);
return key.ParseNumber(key.RawIn(s));
}
internal static bool ToggleOf(SimulatorSettings s, string name) =>
SimulatorKey.ParseToggle(SimulatorKeys.Named(name).RawIn(s));
// As the file rounds it: 1/300 comes back as 0.003333.
private static bool Near(double value, double target) =>
Math.Abs(value - target) <= Math.Abs(target) * 2e-4 + 1e-9;
}
/// <summary>
/// A part of the picture with a level of its own, as its section on the screen has it. The rest
/// of a level - the buffer, the frame pacing, the sky and the cab - goes only with the whole.
/// </summary>
/// <param name="Keys">The eu07.ini keys it sets.</param>
public sealed record GraphicsGroup(IReadOnlyList<string> Keys)
{
public static readonly GraphicsGroup DrawRange = new(new[]
{
GraphicsPreset.DrawRangeKey, GraphicsPreset.DistanceFactorKey, "splinefidelity",
GraphicsPreset.SleeperKey, "gfx.drawrange.factor.max"
});
public static readonly GraphicsGroup Textures = new(new[]
{
"maxtexturesize", "maxcabtexturesize", "anisotropicfiltering", "multisampling",
"compresstex", GraphicsTop.TextureReleaseKey
});
public static readonly GraphicsGroup Effects = new(new[]
{
"dynamiclights", "gfx.smoke.fidelity", "gfx.extraeffects", "gfx.postfx.motionblur.enabled",
"gfx.postfx.chromaticaberration.enabled", "gfx.smoke"
});
public static readonly GraphicsGroup Shadows = new(new[]
{
"shadows", "gfx.shadowmap.enabled", "shadowtune", "gfx.shadow.rank.cutoff",
"gfx.shadow.angle.min", "gfx.shadows.cab.range"
});
public static readonly GraphicsGroup Reflections = new(new[]
{
"gfx.envmap.enabled", "gfx.reflections.fidelity", "gfx.reflections.framerate",
GraphicsPreset.ReflectedInstancesKey, GraphicsPreset.ReflectedVehiclesKey
});
public static readonly IReadOnlyList<GraphicsGroup> All = new[] { DrawRange, Textures, Effects, Shadows, Reflections };
}
/// <summary>What applying a level touched.</summary>
/// <param name="RowKeys">The keys of <see cref="SimulatorKeys"/> rows to show its values in, in that order.</param>
/// <param name="ChosenKeys">The eu07.ini keys it chose as a whole.</param>
public sealed record PresetApplied(IReadOnlyList<string> RowKeys, IReadOnlyList<string> ChosenKeys);
/// <summary>
/// Remembers what was there before an end of the scale, which comes back with any level
/// between. Leaving one chosen before this run, what was there earlier is gone, so the
/// simulator's own values take its place.
/// </summary>
public sealed class GraphicsPresetMemory
{
private GraphicsExtras? _beforeExtras;
private GraphicsTop? _beforeTop;
/// <summary>Puts <paramref name="preset"/> in <paramref name="settings"/>, with what an end of the scale left behind.</summary>
public PresetApplied Apply(GraphicsPreset preset, SimulatorSettings settings)
{
var current = GraphicsPreset.Matching(settings);
var coming = preset.Extras is not null ? null
: _beforeExtras ?? (current?.Extras is not null ? GraphicsExtras.None : null);
_beforeExtras = preset.Extras is not null ? _beforeExtras ?? GraphicsExtras.Of(settings) : null;
var comingTop = preset.Top is not null ? null
: _beforeTop ?? (current?.Top is not null ? GraphicsTop.None : null);
_beforeTop = preset.Top is not null ? _beforeTop ?? GraphicsTop.Of(settings) : null;
coming?.ApplyTo(settings);
comingTop?.ApplyTo(settings);
preset.ApplyTo(settings);
var rows = new List<string>(GraphicsPreset.RowKeys);
var chosen = new List<string>(GraphicsPreset.Keys);
if (preset.Extras is not null || coming is not null)
chosen.AddRange(GraphicsExtras.Keys);
if (preset.Top is not null || comingTop is not null)
{
chosen.AddRange(GraphicsTop.Keys);
rows.AddRange(GraphicsTop.RowKeys);
}
return new PresetApplied(rows, chosen);
}
}

View File

@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
namespace StarterNG.Domain.Settings;
/// <summary>A size in pixels, as width and height in eu07.ini take it.</summary>
public readonly record struct ScreenSize(int Width, int Height)
{
public override string ToString() => $"{Width}x{Height}";
}
/// <summary>A size to offer for the simulator's window.</summary>
/// <param name="IsMonitor">A monitor in the computer has exactly this size.</param>
public readonly record struct ResolutionChoice(ScreenSize Size, bool IsMonitor);
/// <summary>
/// What the resolution list offers: the monitors in the computer first, then the
/// usual sizes that fit on one of them, largest first. A full-screen simulator gets
/// the closest mode its monitor has, so a size no monitor can show is of no use.
/// </summary>
public static class ResolutionChoices
{
private static readonly ScreenSize[] Usual =
{
// 16:9
new(3840, 2160), new(2560, 1440), new(1920, 1080), new(1600, 900), new(1366, 768), new(1280, 720),
// 16:10
new(2560, 1600), new(1920, 1200), new(1680, 1050), new(1440, 900), new(1280, 800),
// Ultrawide
new(3440, 1440), new(2560, 1080),
// 5:4 and 4:3
new(1280, 1024), new(1024, 768)
};
/// <param name="monitors">The monitors, the primary one first; empty where they cannot be told.</param>
/// <param name="current">The size asked for now, kept on the list whatever it is.</param>
public static IReadOnlyList<ResolutionChoice> For(IReadOnlyCollection<ScreenSize> monitors, ScreenSize current)
{
var known = monitors.Where(IsReal).Distinct().ToList();
var rest = Usual.Where(size => known.Count == 0 || known.Any(m => Fits(size, m)));
if (IsReal(current))
rest = rest.Append(current);
return known.Select(size => new ResolutionChoice(size, true))
.Concat(rest.Except(known).Distinct()
.OrderByDescending(size => size.Width)
.ThenByDescending(size => size.Height)
.Select(size => new ResolutionChoice(size, false)))
.ToList();
}
private static bool IsReal(ScreenSize size) => size.Width > 0 && size.Height > 0;
private static bool Fits(ScreenSize size, ScreenSize monitor) =>
size.Width <= monitor.Width && size.Height <= monitor.Height;
}

View File

@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
namespace StarterNG.Domain.Settings;
/// <summary>Why the simulator about to be started would pass a key over.</summary>
public enum IdleReason
{
None,
WindowsBuildOnly,
LegacyRendererOnly,
/// <summary>Read by the full OpenGL renderer alone.</summary>
ModernRendererOnly,
/// <summary>Read by the OpenGL renderers, which the experimental one is not.</summary>
OpenGlRenderersOnly,
NeedsNetworkServer,
/// <summary>The draw range is only stretched by the frame rate regulator, which is off.</summary>
NeedsFrameRateTarget,
/// <summary>fullscreenwindowed takes the monitor's own mode instead.</summary>
MonitorModeInstead
}
/// <summary>What about the simulator to be started decides which keys it heeds.</summary>
/// <param name="WindowsBuild">The executable is a Windows build, under Wine or not.</param>
/// <param name="Renderer">The gfxrenderer word, as <see cref="SimulatorSettings.RenderEngines"/> has it.</param>
/// <param name="NetworkServer">eu07.ini starts a network.server.</param>
/// <param name="TargetFps">targetfps as eu07.ini sets it, or null where it does not.</param>
/// <param name="FpsAverage">fpsaverage, the older regulator's target; 0 for none.</param>
/// <param name="FullscreenWindowed">fullscreenwindowed is on.</param>
public sealed record SimulatorTarget(bool WindowsBuild, string Renderer, bool NetworkServer,
double? TargetFps = null, double FpsAverage = 0,
bool FullscreenWindowed = false);
/// <summary>The renderers a gfxrenderer word picks, as application.cpp tells them apart.</summary>
public enum RendererKind { Modern, Legacy, Experimental }
/// <summary>
/// Where an eu07.ini key takes effect, from the simulator's sources: a key it
/// reads but leaves alone on the platform, render path or setup at hand.
/// </summary>
public static class SettingReach
{
// The keyboard lights, LPT and PoKeys behind feedback live in Console.cpp,
// which only the Windows build compiles; the system console and ANGLE are
// Windows-only in application.cpp.
private static readonly HashSet<string> WindowsBuildOnly = new(StringComparer.OrdinalIgnoreCase)
{
"feedbackmode", "feedbackport", "showsystemconsole", "gfx.angleplatform"
};
// Read by openglrenderer.cpp alone; the modern path forces VBOs on.
private static readonly HashSet<string> LegacyRendererOnly = new(StringComparer.OrdinalIgnoreCase)
{
"smoothtraction", "gfx.shadows.cab.range", "usevbo"
};
// Read by opengl33renderer.cpp and the shaders it builds (glsl_common.cpp) alone.
private static readonly HashSet<string> ModernRendererOnly = new(StringComparer.OrdinalIgnoreCase)
{
"gfx.postfx.chromaticaberration.enabled", "gfx.postfx.motionblur.enabled",
"gfx.postfx.motionblur.shutter", "gfx.extraeffects", "gfx.envmap.enabled", "gfx.skippipeline",
"rendercab", "sleeperdistance"
};
// Read by both OpenGL renderers but not by the experimental one, which sets its
// own anisotropy, draws no smoke and has no frame rate regulator.
private static readonly HashSet<string> OpenGlRenderersOnly = new(StringComparer.OrdinalIgnoreCase)
{
"anisotropicfiltering", "multisampling", "dynamiclights", "gfx.smoke", "gfx.smoke.fidelity",
"gfx.reflections.framerate", "gfx.reflections.range_vehicles", "gfx.shadow.angle.min",
"scalespeculars", "gfx.drawrange.factor.max"
};
// application.cpp: fullscreenwindowed takes its size from the monitor and always
// goes full screen. "width, height" is how the settings screen names that row.
private static readonly HashSet<string> OverriddenByMonitorMode = new(StringComparer.OrdinalIgnoreCase)
{
"width, height", "fullscreen"
};
public static IdleReason Why(string key, SimulatorTarget target)
{
// The starter's own keys mean nothing to any simulator, and need not.
if (key.StartsWith("starter.", StringComparison.OrdinalIgnoreCase))
return IdleReason.None;
if (WindowsBuildOnly.Contains(key) && !target.WindowsBuild)
return IdleReason.WindowsBuildOnly;
if (OverriddenByMonitorMode.Contains(key) && target.FullscreenWindowed)
return IdleReason.MonitorModeInstead;
var renderer = KindOf(target.Renderer);
if (LegacyRendererOnly.Contains(key) && renderer != RendererKind.Legacy)
return IdleReason.LegacyRendererOnly;
if (ModernRendererOnly.Contains(key) && renderer != RendererKind.Modern)
return IdleReason.ModernRendererOnly;
if (OpenGlRenderersOnly.Contains(key) && renderer == RendererKind.Experimental)
return IdleReason.OpenGlRenderersOnly;
// on_focus_change pauses only a simulator that serves the network.
if (string.Equals(key, "inactivepause", StringComparison.OrdinalIgnoreCase) && !target.NetworkServer)
return IdleReason.NeedsNetworkServer;
if (string.Equals(key, "gfx.drawrange.factor.max", StringComparison.OrdinalIgnoreCase) &&
!DrawRangeRegulated(target))
return IdleReason.NeedsFrameRateTarget;
return IdleReason.None;
}
/// <summary>
/// Whether the renderer moves the draw range with the frame rate - the only use
/// of gfx.drawrange.factor.max: targetfps does it, as the older fpsaverage does.
/// </summary>
private static bool DrawRangeRegulated(SimulatorTarget target) =>
target.FpsAverage != 0 || (target.TargetFps ?? 0) != 0;
/// <summary>"full" and "default" are the modern renderer, "experimental" its own; every other word the legacy one.</summary>
public static RendererKind KindOf(string renderer) => renderer.ToLowerInvariant() switch
{
"full" or "default" => RendererKind.Modern,
"experimental" => RendererKind.Experimental,
_ => RendererKind.Legacy
};
}

View File

@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace StarterNG.Domain.Settings;
public enum SimulatorKeyKind { Toggle, Number, Text }
/// <summary>
/// An eu07.ini key the simulator reads that needs no logic of the starter's own:
/// a switch, a number or a word, shown as one row of the settings. The default
/// is the simulator's own, from Globals.h, used while the file does not carry
/// the key.
/// </summary>
public sealed record SimulatorKey(
string Key,
SimulatorKeyKind Kind,
string Default,
string Category,
string Section,
double Minimum = 0,
double Maximum = 0,
double Step = 1,
string Unit = "",
bool ZeroIsOff = false,
int Repeat = 1)
{
/// <summary>The label's translation key; the explanation's adds "Desc".</summary>
public string LabelKey => "Ini." + Key;
public string DescriptionKey => LabelKey + ".Desc";
public static bool ParseToggle(string raw) =>
FirstToken(raw).ToLowerInvariant() is "yes" or "true" or "1" or "tak";
public static string FormatToggle(bool value) => value ? "yes" : "no";
/// <summary>The key's value in <paramref name="s"/>, in the file's words: what is set, or else the default.</summary>
public string RawIn(SimulatorSettings s) => s.Extra.TryGetValue(Key, out string? raw) ? raw : Default;
public double ParseNumber(string raw) =>
double.TryParse(FirstToken(raw), NumberStyles.Float, CultureInfo.InvariantCulture, out double value)
? value
: double.Parse(Default, CultureInfo.InvariantCulture);
/// <summary>
/// Shake has a factor per axis; the row moves them together, so a number is
/// written as many times as the key takes it.
/// </summary>
public string FormatNumber(double value)
{
string one = value.ToString("0.####", CultureInfo.InvariantCulture);
return Repeat <= 1 ? one : string.Join(' ', Enumerable.Repeat(one, Repeat));
}
private static string FirstToken(string raw)
{
var tokens = raw.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
return tokens.Length > 0 ? tokens[0] : string.Empty;
}
}
public static class SimulatorKeys
{
public const string General = "general";
public const string Graphics = "graphics";
public const string Sound = "sound";
public const string Physics = "physics";
public const string Advanced = "advanced";
private static SimulatorKey Toggle(string key, bool value, string category, string section) =>
new(key, SimulatorKeyKind.Toggle, SimulatorKey.FormatToggle(value), category, section);
private static SimulatorKey Number(string key, double value, string category, string section,
double min, double max, double step, string unit = "",
bool zeroIsOff = false, int repeat = 1) =>
new(key, SimulatorKeyKind.Number, value.ToString(CultureInfo.InvariantCulture), category, section,
min, max, step, unit, zeroIsOff, repeat);
private static SimulatorKey Text(string key, string category, string section) =>
new(key, SimulatorKeyKind.Text, string.Empty, category, section);
public static SimulatorKey Named(string key) => All.First(k => k.Key == key);
public static IReadOnlyList<SimulatorKey> All { get; } = new[]
{
Number("ui.scale", 1, General, "IniSectionInterface", 0.5, 3, 0.05, "×"),
Number("ui.fontsize", 13, General, "IniSectionInterface", 8, 32, 1, " px"),
Number("ui.bg.opacity", 0.94, General, "IniSectionInterface", 0.1, 1, 0.05),
Toggle("gui.defaultwindows", true, General, "IniSectionInterface"),
Toggle("gui.trainingdefault", false, General, "IniSectionInterface"),
Toggle("gui.showtranscripts", true, General, "IniSectionInterface"),
Toggle("loadinglog", false, General, "IniSectionInterface"),
Toggle("captureonstart", true, General, "IniSectionInterface"),
Toggle("mousecontrol", true, General, "IniSectionInterface"),
Text("fullscreenmonitor", General, "IniSectionInterface"),
Text("screenshotsdir", General, "IniSectionInterface"),
Number("basedrawrange", 2500, Graphics, "IniSectionRanges", 500, 10000, 250, " m"),
Number("heightbase", 1, Graphics, "IniSectionRanges", 0.5, 3, 0.05, "×"),
Number("targetfps", 0, Graphics, "IniSectionRanges", 0, 240, 5, " FPS", zeroIsOff: true),
Number("sleeperdistance", 250, Graphics, "IniSectionRanges", 0, 5000, 50, " m", zeroIsOff: true),
Number("gfx.reflections.range_instances", 750, Graphics, "IniSectionRanges", 0, 3000, 50, " m"),
Number("gfx.reflections.range_vehicles", 250, Graphics, "IniSectionRanges", 0, 2000, 50, " m"),
Toggle("gfx.textures.streaming", false, Graphics, "IniSectionRanges"),
Number("gfx.textures.releasedistance", 0, Graphics, "IniSectionRanges", 0, 10, 0.5, "×", zeroIsOff: true),
Toggle("skyenabled", true, Graphics, "IniSectionRanges"),
Toggle("rendercab", true, Graphics, "IniSectionRanges"),
Number("gfx.postfx.motionblur.shutter", 0.01, Graphics, "IniSectionRanges", 0.001, 0.1, 0.001, " s"),
Number("python.fpslimit", 100, Graphics, "IniSectionRanges", 5, 200, 5, " FPS"),
Toggle("python.vsync", true, Graphics, "IniSectionRanges"),
Toggle("python.displaywindows", false, Graphics, "IniSectionRanges"),
Toggle("newaircouplers", true, Graphics, "IniSectionRanges"),
Toggle("smoothtraction", true, Graphics, "IniSectionRanges"),
Number("sound.maxsources", 30, Sound, "IniSectionAudio", 8, 256, 8),
Toggle("sound.asyncload", false, Sound, "IniSectionAudio"),
Toggle("loadtraction", true, Physics, "IniSectionPhysics"),
Toggle("rollfix", true, Physics, "IniSectionPhysics"),
Number("shakefactor", 1, Physics, "IniSectionPhysics", 0, 3, 0.1, "×", repeat: 3),
Toggle("map.manualswitchcontrol", false, Physics, "IniSectionPhysics"),
Number("map.highlightdistance", 3000, Physics, "IniSectionPhysics", 0, 10000, 250, " m", zeroIsOff: true),
Toggle("showsystemconsole", false, Advanced, "IniSectionConsole"),
};
}

View File

@@ -1,16 +1,23 @@
using System; using System;
using System.Collections.Generic;
namespace StarterNG.Domain.Settings; namespace StarterNG.Domain.Settings;
// Values match the key eu07.ini has carried since the old starter. // Values match the key eu07.ini has carried since the old starter.
public enum ConsistReadyOverride { Scenery = 0, AlwaysCold = 1, AlwaysReady = 2 } public enum ConsistReadyOverride { Scenery = 0, AlwaysCold = 1, AlwaysReady = 2 }
public enum ExeProblem { None, NotFound, NotExecutable, WrongPlatform } public enum ExeProblem { None, NotFound, NotExecutable, WrongPlatform, NotAProgram }
public sealed class SimulatorSettings public sealed class SimulatorSettings
{ {
public bool LanguageWasSet { get; internal set; } public bool LanguageWasSet { get; internal set; }
/// <summary>eu07.ini starts a network.server, which the starter leaves to the file.</summary>
public bool NetworkServer { get; internal set; }
/// <summary>fpsaverage: the older regulator's frame rate target, which the starter leaves as it is.</summary>
public double FpsAverage { get; internal set; }
public string Language = "English"; public string Language = "English";
public string LanguageCode = ""; public string LanguageCode = "";
@@ -26,10 +33,15 @@ public sealed class SimulatorSettings
public int FeedbackPort = 888; public int FeedbackPort = 888;
public bool UartEnabled; public bool UartEnabled;
public string UartPort = ""; public string UartPort = "";
public int UartBaud = Uart.DefaultBaud;
public double UartUpdateTime = Uart.DefaultUpdateTime;
public string UartTune = ""; public string UartTune = "";
public bool UartDebug; public bool UartDebug;
public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel; public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel;
/// <summary>The simulator's dynamicbrake feature, which has no box in the starter: kept as the file has it.</summary>
public bool UartDynamicBrake;
public bool SelectExeAutomatically = true; public bool SelectExeAutomatically = true;
public string ExecutablePath = "eu07.exe"; public string ExecutablePath = "eu07.exe";
public bool DebugMode; public bool DebugMode;
@@ -42,7 +54,7 @@ public sealed class SimulatorSettings
public int BufferScalePercent = 100; public int BufferScalePercent = 100;
public int MaxTextureSize = 4096; public int MaxTextureSize = 4096;
public int MaxCabTextureSize = 4096; public int MaxCabTextureSize = 4096;
public int TextureFiltering = 8; public double TextureFiltering = 8;
public int Multisampling = 2; public int Multisampling = 2;
public int DynamicLights = 7; public int DynamicLights = 7;
public double DrawRangeFactor = 3.0; public double DrawRangeFactor = 3.0;
@@ -55,8 +67,10 @@ public sealed class SimulatorSettings
public bool EnvMap = true; public bool EnvMap = true;
public bool UseVbo = true; public bool UseVbo = true;
public bool RenderShadows = true; public bool RenderShadows = true;
public int ReflectionsFramerate = 60; /// <summary>Updates of the reflections a second; the simulator's own is one in five minutes.</summary>
public double ReflectionsFramerate = 1 / 300.0;
public int ShadowMapResolution = 2048; public int ShadowMapResolution = 2048;
/// <summary>The third number of shadowtune; the second and fourth go unread.</summary>
public int ShadowProjectionRange = 250; public int ShadowProjectionRange = 250;
public int CabShadowsRange; public int CabShadowsRange;
public int ShadowRankCutoff = 3; public int ShadowRankCutoff = 3;
@@ -65,7 +79,7 @@ public sealed class SimulatorSettings
public bool PythonScreens = true; public bool PythonScreens = true;
public bool PythonThreadedUpload = true; public bool PythonThreadedUpload = true;
public bool FpsLimitEnabled; public bool FpsLimitEnabled;
public int FpsLimit = 30; public double FpsLimit = 30;
public double ShadowAngleLimit = 0.2; public double ShadowAngleLimit = 0.2;
public bool FullscreenWindowed; public bool FullscreenWindowed;
public bool RenderAngleVulkan; public bool RenderAngleVulkan;
@@ -109,6 +123,12 @@ public sealed class SimulatorSettings
public bool LargeThumbnails; public bool LargeThumbnails;
public bool AutoExpandSceneryTree; public bool AutoExpandSceneryTree;
/// <summary>Warn at start when the processor cannot run the official simulator builds.</summary>
public bool CpuNotice = true;
/// <summary>The card a native Linux simulator is started on, on a machine with two.</summary>
public GpuPreference Gpu = GpuPreference.Discrete;
public ConsistReadyOverride ReadyOverride = ConsistReadyOverride.Scenery; public ConsistReadyOverride ReadyOverride = ConsistReadyOverride.Scenery;
public bool ShowAiVehicles = true; public bool ShowAiVehicles = true;
@@ -119,12 +139,62 @@ public sealed class SimulatorSettings
public bool WindowMaximized; public bool WindowMaximized;
public static readonly string[] RenderEngines = /// <summary>
{ "full", "legacy", "simpleshader", "simple", "off", "experimental" }; /// The <see cref="SimulatorKeys"/> as eu07.ini carries them, by key. One the
/// file does not set is absent, and stays out of the file until changed.
/// </summary>
public Dictionary<string, string> Extra { get; private set; } = new(StringComparer.OrdinalIgnoreCase);
public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 }; /// <summary>A copy to try changes on, apart from these settings.</summary>
public SimulatorSettings Copy()
{
var copy = (SimulatorSettings)MemberwiseClone();
copy.Extra = new Dictionary<string, string>(Extra, StringComparer.OrdinalIgnoreCase);
copy.ShadowTuneUnused = (string[])ShadowTuneUnused.Clone();
return copy;
}
/// <summary>
/// The gfxrenderer words the simulator tells apart (application.cpp): every
/// other word - the "simpleshader" and "off" older starters offered among them -
/// gets the legacy renderer.
/// </summary>
public static readonly string[] RenderEngines = { "full", "legacy", "simple", "experimental" };
/// <summary>Where <paramref name="word"/> stands among <see cref="RenderEngines"/>, as the simulator reads it.</summary>
public static int RenderEngineOf(string word) => word.Trim().ToLowerInvariant() switch
{
"full" or "default" => 0,
"simple" => 2,
"experimental" => 3,
_ => 1
};
/// <summary>
/// The least shadow range the simulator keeps for a map size: a map above
/// 2048 px stretches over proportionally more ground.
/// </summary>
public static int ShadowRangeFloor(int mapSize) => mapSize <= 2048 ? 75 : 75 * mapSize / 2048;
/// <summary>
/// How far shadows actually reach. The full renderer's cascades stretch with a
/// map above 2048 px (opengl33renderer.cpp), on top of the floor; the legacy
/// one draws a single map out to the range itself.
/// </summary>
public static int ShadowReach(int range, int mapSize, bool legacyRenderer)
{
int reach = Math.Max(range, ShadowRangeFloor(mapSize));
return legacyRenderer || mapSize <= 2048 ? reach : reach * (mapSize / 2048);
}
/// <summary>
/// The file's own first mousescale number. The slider takes whole steps: this
/// stands for as long as <see cref="CursorSensitivity"/> is the step nearest it.
/// </summary>
internal double MouseScaleXMagnitude { get; set; } = 2;
internal double MouseScaleYMagnitude { get; set; } = 0.2; internal double MouseScaleYMagnitude { get; set; } = 0.2;
internal string[] ShadowTuneExtra { get; set; } = { "400", "300" }; /// <summary>The numbers of shadowtune the simulator skips, kept as the file has them.</summary>
internal string[] ShadowTuneUnused { get; set; } = { "250", "300" };
} }

Some files were not shown because too many files have changed in this diff Show More