107 Commits

Author SHA1 Message Date
maj00r
9872b316c2 fix(blackbox): the traction's values in two columns, each whole in its cell 2026-11-11 03:19:14 +01:00
maj00r
ed9c1394bd fix(blackbox): the driver's valve as its gauge shows, no clash of the traction's labels, fewer self-made events
- The pressure of the driver's valve is named as what its gauge shows
  (the equalizing reservoir, the FV4a's time reservoir), alike in both
  languages.
- The traction's values are clipped to their columns, and the torque
  converter's label is shorter: it ran into the oil pressure's.
- The doors in two columns, the radio's label shorter: the names were cut.
- The compressor and the motor fans go on and off by themselves: no
  events for them.
- The summary of HeadCodes.Right back above it.
2026-11-11 02:55:42 +01:00
maj00r
0d16524953 feat(blackbox): the cab panel as fits the vehicle, every switch the driver worked in the events
- The rest of the cab record is read when written: what drives the
  vehicle, the doors' permits and departure signal, the brakes of the
  whole train, a diesel's oil and temperatures, the cruise control and
  the rest.
- Cab panel: the traction as fits what drives the vehicle (voltage and
  current of an electric one; revolutions, oil pressure and temperatures
  of a diesel; clutch or torque converter of a mechanical or hydraulic
  one; nothing for a draisine), the equalizing reservoir, the main
  reservoir pipe and the most braked cylinder of the train, the brakes'
  setting and mode, a group of the doors, the pumps of a diesel where an
  electric vehicle has its pantographs, the hand brake, the releaser, the
  horn's tone, the radio's channel, the integrated handle of a unit.
- Events: every switch on and off, doors opened and closed, the departure
  signal, the hand brake, the brakes' setting, the radio channel, the
  cruise control's speed; moving with the doors open and an overheating
  engine as faults.
- A vehicle with no lamps at an end (a draisine) is not judged on its
  lights, and one which never had a voltage shows none.
2026-11-11 02:43:51 +01:00
maj00r
4dcfd1c4e3 fix(blackbox): the timeline's labels on a narrow timeline, marks out of view, the cab on a short window
- The hover label and the clock bubble keep to the left edge on a
  narrow timeline instead of throwing in Render.
- A mark is told under the pointer only when it is drawn: within the
  plot and the time shown.
- The device groups are clipped at their own width, not 4 px short.
- On a short window the cab card scrolls, so the list above it keeps
  room for a few rows.
2026-11-11 01:50:40 +01:00
maj00r
9ee059bc16 feat(blackbox): a readable map legend, a key for the timeline's marks, the cab panel in titled parts
- Map legend: a title for each part, the texts in the text colour, the
  speed bands said in full ("up to 5 km/h faster than the AI").
- Timeline: the key names every mark (station, W6, signal passed,
  coupling, autopilot, command), the row of marks has its name, and the
  mark under the pointer is told in a label. The key no longer sits on
  the station plates.
- Cab panel: titled parts (controls, brakes, traction, lights, devices);
  the AI's speed and the next limit no longer run into the controllers;
  the lights of each end with the signal they show, and the one they are
  to show when it differs; the devices in three groups (power, driving,
  safety) instead of one long list.
2026-11-11 01:45:40 +01:00
maj00r
88adf5ee0f Black box: the test of an older recording's light pattern checks the lights were judged 2026-11-11 01:09:46 +01:00
maj00r
de3f088ba8 Black box: older recordings read as before, single vehicles told apart, each platform end its own tolerance
- A light pattern asked for in a recording made before the lamps were
  written is checked as the signal it shows, as before.
- Whether the couplings were written is told by the format (the
  movement in the cab samples), not by there being any: a recording
  where every train is a single vehicle has none, and the user going
  over to another vehicle left the timetable shown.
- The 2 m a train may stand off the platform is at each end, not for
  both together.
2026-11-11 01:05:59 +01:00
maj00r
532875fc7b Black box: the queries of the user's train worked out once a read, lights as the driver checks them
- The stations, the signals passed, the horn points, the couplings, the
  consists and the timetable progress of the user's train are kept until
  a read adds something; the views asked for them every frame, which on
  a long session with many trains took the window down. A read that adds
  nothing no longer counts as a change.
- The lights are checked against the lamps the vehicles at the ends have
  (written by the simulator from now on), as its driver checks them: Pc 1
  without the upper headlight, with the auxiliary lamps, Pc 5 with the
  rear-end signals. Only while the user drives: the autopilot's lights
  are its own.
- A train off the platform counts at both ends: its head past the stop
  point as well as its tail before the platform's end.
- A recording without the couplings keeps the timetable for any vehicle
  of the user.
- A signal passed at danger is ringed on the map only once it was.
- In the tapes window a black box opens on a click or Enter, not when
  the arrow keys go over it.
- The viewer's screenshot of a window not laid out saves nothing and
  still closes.
2026-11-11 00:57:57 +01:00
maj00r
e530b95352 Play back the black box of the simulator: map, cab, timeline, timetable and a summary
The simulator writes a black box of each run to physicslog/*.blackbox
(blackbox_doc.md in the maszyna repository). Three new projects read and
show it:

- Eu07.BlackBox: the reader of the records, the recording as a whole
  (the user's vehicle and consist at a time, the timetable shown, the
  progress along it with arrival and departure delays, whether the
  train stood at the platform), the light patterns Pc 1, Pc 2, Pc 5 and
  Tb 1 with the ones the run asks for, and a summary of the driving.
- Eu07.BlackBox.Views: the player window, with the scenery map (tracks,
  signals, W4 stop points with their heading and W32, the train, the
  cameras), the cab, the timeline of speed against the AI's reference,
  the events, the timetable and the summary.
- Eu07.BlackBox.Viewer: the window on its own, with options for the
  time, following the train and screenshots.

The tapes window of the starter lists the black boxes of each session
beside the Hasler tapes and opens them in the player.
2026-11-11 00:39:35 +01:00
e7d8596652 Merge pull request #37 from MaSzyna-EU07/feature/hasler-tape
Feature/hasler tape
2026-11-07 14:18:27 +00:00
maj00r
cb092784ae Put the comment of FullPath back on it
It stayed above DlssLibraries, which then had two summaries, when that method
went in between.
2026-11-03 21:51:58 +01:00
maj00r
d68999dfb7 Look at each source file of a scenery once
The watch of scenery files asked three times per file, whether it is there,
its time and its size, every two seconds for every source of every scenery.
One look gives all three.
2026-11-03 21:51:56 +01:00
maj00r
9eb10fc9bd Let the signatures of saved trainsets go with their sceneries
They were kept by the trainset's text for the whole session, behind one lock,
and every reload added more. They are now kept as long as a trainset holds
that text.
2026-11-03 21:51:55 +01:00
maj00r
71c32c5b9a Read the old starter's key descriptions in code page 1250 too
Its language files are code page 1250 as often as UTF-8. Read as UTF-8 only,
Czech and Polish descriptions came out with replacement characters.
2026-11-03 21:51:55 +01:00
maj00r
cf1ba319c1 Leave the settings as they are when they are written
Writing the settings put them right for the simulator, DLSS to TAA on OpenGL
and the like, in the object given. A preview of the changes or a look at the
levels goes through the same write and changed the held settings. A copy is
put right now.
2026-11-03 21:51:54 +01:00
maj00r
02ea1277fc Read the last run's log before a start has to
A start notes down which vehicles the player drove in the run before, as the
log begins anew. With the tape window never opened nothing of a long run's log
had been read yet, and Start waited on the whole of it. It is now read off the
UI thread when the simulator exits and when the starter opens.
2026-11-03 21:51:54 +01:00
maj00r
306bdc44df Write the list of launches beside the tapes in one move
The tape window reads starter-launch.txt every two seconds. Written in place,
the file could be read half written, and a crash during the write lost the
launches noted before.
2026-11-03 21:51:54 +01:00
maj00r
d95182f17c List a key taken out of eu07.ini once
A uart or uarttune the settings no longer write was found missing twice, as a
key of set length and as one the file had, and showed twice among the changes.
2026-11-03 21:51:54 +01:00
maj00r
c10d771f00 Save a profile without taking it for a save of eu07.ini
A profile went through the save of eu07.ini with another path: what the store
holds of the file took the changes the file never got, and the list of changes
was empty after it. A profile is now written as an export, from a copy. Like a
save, an export is not written from defaults when the settings could not be
read.
2026-11-03 21:51:53 +01:00
maj00r
2cfb4e4fec Tell the tapes the simulator runs only while the starter watches it
The tapes looked for any process named as the chosen exe, every two seconds
off the UI thread. Under Wine the process is eu07.exe and never matched, so a
run never showed as running and the tape window did not follow the driven
vehicle, while a simulator started some other way was taken for the last
launch. The starter's own watch of the process it started now says it.
2026-11-03 21:51:53 +01:00
maj00r
a8f8cb0b74 Keep each file's encoding in the copy written for a start
The copy carries the scenario includes put in place of their include lines,
read as UTF-8 where they are, and it was written all in code page 1250. A name
with a Polish letter in a UTF-8 include then no longer matched the same name in
the files the simulator reads apart, and what 1250 lacks became '?'. Each part
now goes out in the encoding of the file it came from, the scenery's own file
too, as a save of a trainset already did.
2026-11-03 21:51:52 +01:00
maj00r
32d7417c22 Read the processor once and take FMA into the AVX2 check
/arch:AVX2 lets the compiler use FMA as well, so a processor that reports AVX2
without it would still stop the simulator. The comment on OSXSAVE no longer
says more than the bit does.
2026-11-03 20:42:28 +01:00
maj00r
16d665156c Take code page 1250 in the trainset save tests from LegacyText
The tests asked Encoding for it before anything had registered the code pages.
That held only when another test had made an InMemoryFileSystem first, so the
class failed when it ran first, as it did on Linux in CI.
2026-11-03 20:42:28 +01:00
maj00r
39d4ef0bc4 Read AVX2 from the processor
Avx2.IsSupported says whether the starter's own code may use AVX2, and the AOT
compiler fixes that at build time to its baseline, which has none. A published
starter told every player that their processor lacked AVX2. The check now reads
CPUID: AVX2 in leaf 7, AVX and OSXSAVE in leaf 1.
2026-11-03 20:29:18 +01:00
maj00r
6d8a6c36e7 Put what only the new simulator reads in one panel
The rows of keys the public simulator does not read sat among the others in
Graphics and Effects, where a player with that exe could not tell them apart. They
are now one section at the end of Advanced, with a note that an older exe skips
them. Graphics and Effects hold what that exe reads.

Buffer scale and anti-aliasing stay in Graphics, as an older exe reads them through
other keys. The level list of the atmosphere rows goes with them; the whole levels
still set that group. With its rows gone, the nature section is called Smoke.
2026-11-01 22:32:11 +01:00
maj00r
96cfe2503e Shorten the experimental renderer's warning
It says what can go wrong and what to do then. That the renderer is still being
developed helps nobody choosing it.
2026-11-01 22:32:11 +01:00
maj00r
b12189b2d5 Center the text of buttons
Buttons have a minimum height but kept their text at the top of it.
2026-11-01 22:32:09 +01:00
maj00r
42b2259b7c Show the graphics card beside the levels
Its name and memory, with the other cards in the tooltip. Windows has them in the
registry. On Linux the name comes from the driver or pci.ids and the memory from
amdgpu or nvidia-smi; without either there is only the name. A card in a processor
says shared memory, as what it reports is a share set in the firmware.

No rating of the card against the settings: what a ride takes depends on the
textures of its scenery and vehicles, which are not known before it starts.
2026-11-01 20:54:01 +01:00
maj00r
a7c9cdb97f Take the rest of feat/opengl-vulkan's settings
What the merge left out, now on this branch's own six levels:
- GraphicsRules, which say what a renderer or an anti-aliasing mode rules out,
  and the notes that show it
- the settings split into Graphics and Effects tabs
- the DLSS library check beside the exe
- eu07.ini import and export
- one list of renderers: the backend follows from the renderer chosen

The post-processing part of the levels is gone, as three switches cannot tell six
levels apart. Bloom goes with the whole level.

Still a transitional variant. The old renderers, their keys and their rows stay
on the page, as the exe a player runs may be an old one or may have no Vulkan.
That branch's five-level list and its removal of the old renderers stay out.
2026-11-01 20:54:01 +01:00
maj00r
0d2e5de110 Keep the gaps of the bottom bar equal
The editor button had a margin of its own on top of the grid's spacing.
2026-11-01 20:54:00 +01:00
maj00r
1f231423f3 Ask once when the settings file changes
The window is activated again as the question closes, before the answer is in,
so a changed eu07.ini was asked about three times in a row.
2026-11-01 20:53:59 +01:00
maj00r
b60c176533 Merge feat/opengl-vulkan: settings for the OpenGL 4.6 and Vulkan 1.4 simulator
A transitional variant, since the exe a player runs may not have the new
renderers. The old renderers stay in the list and the old keys are still written
next to the new ones. A new exe without Vulkan starts OpenGL by itself, an old one
reads the word as its legacy renderer, and both pass over the keys they do not know.

Taken from Hirek193's branch:
- the new keys as rows of a section of their own, with their texts
- their values on the graphics levels, in steps over the six levels here, with
  High as what the simulator does with nothing set
- Vulkan 1.4 in the list of renderers, and its own keys marked as idle without it
- gfx.buffer.percent next to the old buffer keys, read before them
- gfx.antialiasing saved below multisampling, for the later line to count

Keys checked against feat/gl46-migration of the simulator.

Left out, as this branch has its own or does not need them: the rewritten settings
page and its five levels, GraphicsRules, the DLSS library check, eu07.ini import and
export, and the removal of the old renderers.
2026-11-01 19:43:46 +01:00
maj00r
212e11278f Move to Avalonia 12.1.4
12.1.3 stops drawing a stretched PathGeometry, which took the Discord icon, so
this goes one patch past the version Dependabot offered.

Three things follow from the new version. A combo box placeholder keeps its full
opacity. The consist strip's padding, ignored until now, is gone, as it would
take 4 px from the panels above. A scenery picked from code is brought into view.
2026-11-01 18:59:57 +01:00
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
f8617942ea Settings for new vulkan/opengl 2026-10-29 22:15:19 +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
d222f49179 Describe the DLSS presets instead of showing their bare letters 2026-10-27 10:54:50 +01:00
8d6b4805c8 Add graphics presets, eu07.ini import/export and the new simulator settings 2026-10-27 10:44:48 +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
231 changed files with 36463 additions and 3521 deletions

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

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

View File

@@ -13,6 +13,9 @@ on:
workflow_dispatch:
# Read-only unless a job says otherwise; only the release writes. Actions are
# pinned to full commits - a tag can be moved under us, a commit cannot - and
# checkout leaves no token behind for the pull request code that runs next.
permissions:
contents: read
@@ -20,11 +23,98 @@ env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
DOTNET_NOLOGO: true
PROJECT_PATH: StarterNG/StarterNG.csproj
TEST_PROJECT_PATH: StarterNG.Tests/StarterNG.Tests.csproj
jobs:
# On both systems the packages are built for: paths, encodings and the file
# system differ, and that is where these tests earn their keep.
test:
name: Tests (${{ matrix.name }})
strategy:
fail-fast: false
matrix:
include:
- name: Windows x64
os: windows-2022
- name: Linux x64
os: ubuntu-22.04
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup .NET 9
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 9.0.x
- name: Test
run: >
dotnet test
"${{ env.TEST_PROJECT_PATH }}"
-c Release
# A known vulnerability in a NuGet package, direct or pulled in, fails the run.
- name: Audit packages
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
dotnet list StarterNG.sln package --vulnerable --include-transitive 2>&1 | tee audit.txt
if grep -q "has the following vulnerable packages" audit.txt; then
echo "::error::Vulnerable NuGet packages, listed above"
exit 1
fi
# Major and minor are set by hand in the project file; the patch is the number
# of commits, so every build of main is higher than the one before and no bot
# has to commit a bump back to the branch.
version:
name: Version
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
number: ${{ steps.number.outputs.number }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Number this build
id: number
shell: bash
run: |
set -euo pipefail
prefix=$(sed -n 's:.*<VersionPrefix>\([0-9][0-9]*\.[0-9][0-9]*\)</VersionPrefix>.*:\1:p' "$PROJECT_PATH")
if [ -z "$prefix" ]; then
echo "::error file=$PROJECT_PATH::No major.minor <VersionPrefix> in the project file"
exit 1
fi
number="$prefix.$(git rev-list --count HEAD)"
echo "number=$number" >> "$GITHUB_OUTPUT"
echo "Version $number"
publish:
name: ${{ matrix.name }}
# A failing test stops the packages, and with them the release.
needs: [test, version]
strategy:
fail-fast: false
@@ -43,13 +133,16 @@ jobs:
package: artifacts/StarterNG-linux-x64.tar.gz
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup .NET 9
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 9.0.x
@@ -68,6 +161,9 @@ jobs:
-r "${{ matrix.rid }}"
- name: Publish NativeAOT
shell: bash
env:
VERSION: ${{ needs.version.outputs.number }}
run: >
dotnet publish
"${{ env.PROJECT_PATH }}"
@@ -77,6 +173,7 @@ jobs:
--no-restore
-p:PublishAot=true
-p:PublishTrimmed=true
-p:Version="$VERSION"
-o "publish/${{ matrix.rid }}"
- name: Package Windows build
@@ -100,8 +197,11 @@ jobs:
-czf "${{ matrix.package }}" \
.
# A fork's pull request is built, to prove it builds, but its binaries are not
# handed out under the repository's name.
- name: Upload artifact
uses: actions/upload-artifact@v7
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.package }}
@@ -109,51 +209,96 @@ jobs:
archive: false
# Workflow artifacts are only reachable for signed-in users with repository
# access, so every build of a release branch also lands in a rolling
# pre-release, whose assets anyone can download:
# https://github.com/${{ github.repository }}/releases/download/<tag>/<file>
# main -> "latest", staging -> "staging"; the two never overwrite each other.
# access, so every build of a release branch also lands in a release, whose
# assets anyone can download. main gets a release of its own per build, tagged
# with its version - the newest is what the starter checks itself against:
# https://github.com/${{ github.repository }}/releases/latest/download/<file>
# staging keeps one rolling pre-release, offered only to starters set to the
# testing channel, which tell its builds apart by commit.
release:
name: Rolling pre-release
needs: publish
name: Release
needs: [publish, version]
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 10
# Two pushes in a row release one after the other, not over each other.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
env:
RELEASE_TAG: ${{ github.ref_name == 'main' && 'latest' || 'staging' }}
RELEASE_NAME: ${{ github.ref_name == 'main' && 'Latest build' || 'Staging build' }}
steps:
# v8, not v7: the packages are uploaded with archive: false, and only v8
# can pull those non-zipped artifacts back down. skip-decompress keeps the
# Windows .zip a file - unpacked, its contents would end up in the release
# instead of the package.
- name: Collect the packages
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
merge-multiple: true
skip-decompress: true
# Recreated rather than edited, so the tag follows its branch and no asset
# from an older build is left behind.
- name: Replace the rolling pre-release
- name: Checksum the packages
working-directory: dist
run: sha256sum StarterNG-windows-x64.zip StarterNG-linux-x64.tar.gz | tee SHA256SUMS.txt
- name: Publish the release
if: github.ref_name == 'main'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: v${{ needs.version.outputs.number }}
VERSION: ${{ needs.version.outputs.number }}
run: |
set -euo pipefail
ls -l dist
gh release delete "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag || true
gh release create "$RELEASE_TAG" \
# A re-run of the same commit finds its release already there and refreshes
# it; a release of the same number for another commit - main rewritten - is
# left alone rather than given packages that are not its own.
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then
tagged=$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)
if [ "$tagged" != "$GITHUB_SHA" ]; then
echo "::error::$TAG already belongs to $tagged, not to $GITHUB_SHA"
exit 1
fi
gh release upload "$TAG" dist/* --repo "$GITHUB_REPOSITORY" --clobber
exit 0
fi
gh release create "$TAG" \
dist/StarterNG-windows-x64.zip \
dist/StarterNG-linux-x64.tar.gz \
dist/SHA256SUMS.txt \
--repo "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" \
--title "StarterNG $VERSION" \
--generate-notes \
--latest
# Recreated rather than edited, so the tag follows its branch and no asset
# from an older build is left behind.
- name: Replace the staging pre-release
if: github.ref_name == 'staging'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.number }}
run: |
set -euo pipefail
ls -l dist
gh release delete staging --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag || true
gh release create staging \
dist/StarterNG-windows-x64.zip \
dist/StarterNG-linux-x64.tar.gz \
dist/SHA256SUMS.txt \
--repo "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" \
--prerelease \
--title "$RELEASE_NAME" \
--title "Staging build $VERSION" \
--notes "Automatic build of $GITHUB_REF_NAME (${GITHUB_SHA::7}), $(date -u '+%Y-%m-%d %H:%M UTC')."

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- The black box on its own, without the starter: opens the file given on the
command line, or one picked. -->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<IsAotCompatible>true</IsAotCompatible>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>..\StarterNG\Assets\starter.ico</ApplicationIcon>
<AssemblyName>BlackBoxViewer</AssemblyName>
<RootNamespace>Eu07.BlackBox.Viewer</RootNamespace>
<VersionPrefix>1.0</VersionPrefix>
<Company>eu07.pl</Company>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.4" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.4" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Eu07.BlackBox.Views\Eu07.BlackBox.Views.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,109 @@
using System;
using System.IO;
using System.Linq;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Styling;
using Avalonia.Themes.Fluent;
using Eu07.BlackBox.Views;
namespace Eu07.BlackBox.Viewer;
/// <summary>
/// BlackBoxViewer [file.blackbox] [--live] [--time seconds] [--follow] [--timetable | --summary] [--screenshot out.png]
/// --live follows a file the simulator still writes; --screenshot saves the window
/// at the moment given and quits, for documentation and checks.
/// </summary>
internal static class Program
{
[STAThread]
public static int Main(string[] args)
{
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<ViewerApp>()
.UsePlatformDetect()
.WithInterFont();
}
internal sealed class ViewerApp : Application
{
public override void Initialize()
{
Styles.Add(new FluentTheme());
RequestedThemeVariant = ThemeVariant.Dark;
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var options = ViewerOptions.Parse(desktop.Args ?? Array.Empty<string>());
var window = new BlackBoxWindow { WindowStartupLocation = Avalonia.Controls.WindowStartupLocation.CenterScreen };
window.Live = options.Live;
desktop.MainWindow = window;
window.Opened += async (_, _) =>
{
if (options.File is { } file)
await window.OpenAsync(Path.GetFullPath(file));
window.ShowTimetable = options.Timetable;
if (options.Summary)
window.SummaryShown = true;
if (options.Screenshot is { } shot)
{
await Screenshot.SaveAsync(window, shot, options.Time, options.Follow);
return;
}
if (options.Time is { } time)
window.SeekTo(time);
window.FollowTrain = options.Follow;
};
}
base.OnFrameworkInitializationCompleted();
}
}
internal sealed record ViewerOptions(string? File, bool Live, double? Time, string? Screenshot, bool Follow, bool Timetable,
bool Summary = false)
{
public static ViewerOptions Parse(string[] args)
{
string? file = null, screenshot = null;
double? time = null;
bool live = false, follow = false, timetable = false, summary = false;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--live":
live = true;
break;
case "--follow":
follow = true;
break;
case "--timetable":
timetable = true;
break;
case "--summary":
summary = true;
break;
case "--time" when i + 1 < args.Length &&
double.TryParse(args[i + 1], System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var t):
time = t;
++i;
break;
case "--screenshot" when i + 1 < args.Length:
screenshot = args[++i];
break;
default:
if (!args[i].StartsWith("--", StringComparison.Ordinal))
file ??= args[i];
break;
}
}
return new ViewerOptions(file, live, time, screenshot, follow, timetable, summary);
}
}

View File

@@ -0,0 +1,29 @@
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media.Imaging;
using Eu07.BlackBox.Views;
namespace Eu07.BlackBox.Viewer;
/// <summary>The window as it shows a moment, saved to a PNG; then the viewer closes.</summary>
internal static class Screenshot
{
public static async Task SaveAsync(BlackBoxWindow window, string path, double? time, bool follow)
{
if (time is { } t)
window.SeekTo(t);
window.FollowTrain = follow;
// A few frames for the layout and the first render.
await Task.Delay(1000);
// Not laid out (a minimised window): nothing to save, and the viewer still closes.
if (window.Content is Control content && content.Bounds.Width >= 1 && content.Bounds.Height >= 1)
{
var size = new PixelSize((int)content.Bounds.Width, (int)content.Bounds.Height);
using var bitmap = new RenderTargetBitmap(size);
bitmap.Render(content);
bitmap.Save(path, new PngBitmapEncoderOptions());
}
window.Close();
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="Eu07.BlackBoxViewer"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -0,0 +1,579 @@
using System.Collections.Generic;
using System.Globalization;
namespace Eu07.BlackBox.Views;
/// <summary>
/// The texts of the window, by key. The starter gives its own translations; on its
/// own the window speaks Polish or English, as the system does.
/// </summary>
public interface IBlackBoxStrings
{
string this[string key] { get; }
}
public sealed class DefaultBlackBoxStrings : IBlackBoxStrings
{
private static readonly Dictionary<string, string> English = new()
{
["Title"] = "Black box",
["TitleOf"] = "Black box – {0}",
["Open"] = "Open…",
["Reload"] = "Reload",
["Live"] = "Follow the file",
["LiveTip"] = "Reads what the simulator adds to the file every 2 seconds",
["Play"] = "Play",
["Pause"] = "Pause",
["Follow"] = "Follow the train",
["Fit"] = "Whole map",
["NoFile"] = "Open a black box: physicslog/*.blackbox, written with the physics logs on.",
["Unreadable"] = "Can't read {0}: {1}",
["Reading"] = "Reading {0}…",
["Summary"] = "{0} · {1:0.0} km · max {2:0} km/h · driven {3} · autopilot {4} · over the AI {5} ({6} by more than {7:0} km/h, at most +{8:0} km/h) · signals {9} · at danger {10} · commands {11}",
["NotComplete"] = "still being written, or cut short",
["Cursor"] = "{0} · {1:0} km/h · AI {2} · limit {3}",
["CursorNoAi"] = "{0} · {1:0} km/h · no AI data",
["CursorOut"] = "{0} · not in a vehicle",
["None"] = "–",
["LimitAt"] = "{0:0} km/h in {1:0} m",
["EventCommand"] = "{0}",
["EventAutopilotOn"] = "Autopilot on",
["EventAutopilotOff"] = "Autopilot off",
["EventPassed"] = "Signal {0}: {1}, {2:0} km/h",
["EventDanger"] = "PASSED AT DANGER {0}, {1:0} km/h, {2:0} m beyond",
["EventInVehicle"] = "In {0}",
["EventOutside"] = "Out of the cab of {0}",
["EventNoVehicle"] = "Not in any vehicle",
["EventEnd"] = "End of the recording",
["AspectStop"] = "stop",
["AspectFree"] = "go",
["AspectSpeed"] = "{0:0}/{1}",
["LegendTitle"] = "The train's way: its speed against the AI's",
["LegendWithin"] = "no faster than the AI",
["LegendSlightly"] = "up to {0:0} km/h faster than the AI",
["LegendOver"] = "over {0:0} km/h faster than the AI",
["LegendAutopilot"] = "the autopilot drove",
["LegendNoData"] = "no AI to compare with",
["LegendMarks"] = "Signs on the map",
["TimelineSpeed"] = "speed",
["TimelineTarget"] = "AI",
["TimelineLimit"] = "next limit",
["BandEvents"] = "events",
["KeyStation"] = "station",
["KeyHorn"] = "W6 horn",
["KeySignal"] = "signal passed",
["KeyCoupling"] = "coupling",
["KeyDriver"] = "autopilot on/off",
["KeyCommand"] = "command",
["TlSignal"] = "signal {0} passed: {1}",
["TlSignalDanger"] = "signal {0} passed at stop",
["TlCoupled"] = "coupled",
["TlUncoupled"] = "uncoupled",
["TlCouplingChanged"] = "a coupling changed",
["TlAutopilotOn"] = "the autopilot switched on",
["TlAutopilotOff"] = "the user takes over",
["TlStationStopped"] = "{0}: stopped",
["TlStationPassed"] = "{0}: passed",
["TlStationMissed"] = "{0}: missed",
["TlHornSounded"] = "W6: the horn sounded",
["TlHornSilent"] = "W6: no horn",
["TlCommand"] = "command: {0}",
["BandDriving"] = "driving",
["BandBrakes"] = "brakes",
["BandAlerter"] = "alerter",
["BandController"] = "controller",
["Cab"] = "Cab",
["CabNoData"] = "The user was in no vehicle then.",
["CabController"] = "Master controller",
["CabShunt"] = "Field shunting",
["CabReverser"] = "Reverser",
["CabForward"] = "forward",
["CabBackward"] = "backward",
["CabNeutral"] = "0",
["CabTrainBrake"] = "Driver's brake valve",
["CabLocalBrake"] = "Local brake",
["CabReservoir"] = "Main reservoir",
["CabCylinder"] = "Brake cylinders",
["CabPipe"] = "Brake pipe",
["CabCurrent"] = "Current",
["CabVoltage"] = "Voltage",
["CabRpm"] = "Revolutions",
["CabLights"] = "Lights",
["CabLightsFront"] = "front",
["CabLightsRear"] = "rear",
["DevBattery"] = "Battery",
["DevMainBreaker"] = "Main breaker",
["DevPantographFront"] = "Front pantograph",
["DevPantographRear"] = "Rear pantograph",
["DevConverter"] = "Converter",
["DevCompressor"] = "Compressor",
["DevLineContactors"] = "Line contactors",
["DevHeating"] = "Train heating",
["DevRadio"] = "Radio",
["DevShuntMode"] = "Shunting mode",
["DevDoorsLeft"] = "Left doors open",
["DevDoorsRight"] = "Right doors open",
["DevSanding"] = "Sanding",
["DevHorn"] = "Horn",
["DevSpringBrake"] = "Spring brake",
["DevAlerter"] = "Alerter",
["DevWheelSlip"] = "Wheel slip",
["DevOverloadRelay"] = "Overload relay",
["DevRadioStop"] = "Radio-stop",
["DevAlarmChain"] = "Emergency brake",
["TabEvents"] = "Events",
["TabTimetable"] = "Timetable",
["TabSummary"] = "Summary",
["PanelMap"] = "Map",
["PanelTimeline"] = "Run",
["Relation"] = "{0} → {1}",
["StatDistance"] = "Distance",
["StatMaxSpeed"] = "Top speed",
["StatDriven"] = "Driven by the user",
["StatAutopilotSub"] = "autopilot {0}",
["StatOverAi"] = "Over the AI",
["StatOverAiSub"] = "{0} by >{1:0} km/h · up to +{2:0}",
["StatSignals"] = "Signals",
["StatSignalsSub"] = "{0} at danger",
["StatStops"] = "Stops",
["StatStopsSub"] = "{0} missed",
["StatDelay"] = "Largest delay",
["StatDelaySub"] = "leaving a station: {0} at most",
["StatPlatform"] = "Stops off the platform",
["StatLights"] = "Head and tail signals",
["StatLightsRight"] = "right",
["StatLightsSub"] = "as required all the {0} on the move",
["StatLightsHead"] = "head {0} instead of {1}",
["StatLightsTail"] = "tail {0} instead of {1}",
["StatPlatformAt"] = "{0} {1:0.0} m",
["StatPlatformNone"] = "the whole train along the platform at every stop",
["StatHorn"] = "Horn at W6",
["StatHornSub"] = "{0} without the horn",
["StatAcceleration"] = "Acceleration",
["StatAccelerationSub"] = "lateral up to {0:0.00} m/s²",
["StatSecurity"] = "CA/SHP braking",
["StatRecording"] = "Recording",
["StatComplete"] = "complete",
["StatIncomplete"] = "being written or cut short",
["Minutes"] = "{0:+0.0;−0.0;0} min",
["OnTime"] = "on time",
["DelayNow"] = "delay {0}",
["DelayStanding"] = "{0} standing at {1}",
["DelayAfter"] = "{0} after {1}",
["TimetableNone"] = "The user's train had no timetable.",
["TtArrived"] = "arr. {0}",
["TtDeparted"] = "dep. {0}",
["TtPassed"] = "passed {0}",
["TtMissed"] = "stop missed",
["TtLeftEarly"] = "left early",
["TtStopShort"] = "{0:0.0} m short",
["TtStopPast"] = "{0:0.0} m past",
["TtStopExact"] = "on the spot",
["TtOnPlatform"] = "along the platform",
["TtOffPlatform"] = "{0:0.0} m of the train off the platform",
["TtTechnical"] = "technical stop",
["TtStanding"] = "standing",
["EventStopped"] = "Stop at {0}, {1}",
["EventStationPassed"] = "Passed {0}",
["EventStationMissed"] = "STOP MISSED: {0}",
["EventReady"] = "{0}: time to leave",
["EventHornSounded"] = "W6: horn sounded",
["EventHornMissed"] = "W6: NO HORN",
["EventCab"] = "Cab {0} of {1}",
["EventSecurityBraking"] = "BRAKING BY {0}",
["EventTimetable"] = "Timetable {0}, {1}",
["CabSpeed"] = "Speed",
["CabAiTarget"] = "AI {0:0} km/h",
["CabAcceleration"] = "Along",
["CabLateral"] = "Lateral",
["CabEndFront"] = "A",
["CabEndRear"] = "B",
["CabDriverUser"] = "user drives",
["CabDriverAi"] = "autopilot",
["CabOutside"] = "outside the cab",
["CabLightsHead"] = "head",
["CabLightsTail"] = "tail",
["CabSectionBrakes"] = "Brakes",
["CabSectionDrive"] = "Traction",
["CabSectionControls"] = "Controls",
["CabSectionDevices"] = "Devices",
["CabGroupPower"] = "Power",
["CabGroupDriving"] = "Driving",
["CabGroupSafety"] = "Safety",
["CabLightsWant"] = "{0}, should be {1}",
["CabLightsNone"] = "no lamps",
["CabBrakeSetting"] = "setting {0}",
["CabDriverValve"] = "Driver's valve",
["CabFeedPipe"] = "Main reservoir pipe",
["CabConsistCylinder"] = "Cylinders, train max",
["CabPower"] = "Power",
["CabSpeedControl"] = "Cruise control",
["CabOilPressure"] = "Oil pressure",
["CabWater"] = "Water",
["CabOil"] = "Oil",
["CabEngine"] = "Engine",
["CabNoDrive"] = "no drive of its own",
["CabClutch"] = "Clutch",
["CabConverterFill"] = "Converter",
["CabIntegrated"] = "Driving handle",
["CabGroupDoors"] = "Doors",
["DevDoorPermitLeft"] = "Left doors permitted",
["DevDoorPermitRight"] = "Right doors permitted",
["DevDepartureSignal"] = "Departure signal",
["DevDoorStep"] = "Steps",
["DevDoorLock"] = "Door lock",
["DevDoorsLocked"] = "Doors locked",
["DevBrakeReleaser"] = "Brake releaser",
["DevFuelPump"] = "Fuel pump",
["DevOilPump"] = "Oil pump",
["DevMotorBlowers"] = "Motor fans",
["DevEngineHot"] = "Engine overheating",
["DevSpeedControl"] = "Cruise control",
["DevRadioChannel"] = "Radio, channel {0}",
["DevHandBrake"] = "Hand brake",
["DevHornTone"] = "Horn ({0})",
["HornLow"] = "low",
["HornHigh"] = "high",
["HornBoth"] = "both",
["EventDeviceOn"] = "{0}: on",
["EventDeviceOff"] = "{0}: off",
["EventDoorsLeftClosed"] = "Left doors closed",
["EventDoorsRightClosed"] = "Right doors closed",
["EventMovingDoorsOpen"] = "Moving with the doors open ({0:0} km/h)",
["EventHandBrakeOn"] = "Hand brake applied",
["EventHandBrakeOff"] = "Hand brake released",
["EventBrakeSetting"] = "Brake setting: {0}",
["EventRadioChannel"] = "Radio: channel {0}",
["EventSpeedControlSet"] = "Cruise control: {0:0} km/h",
["DevVigilance"] = "CA",
["DevCabSignal"] = "SHP",
["DevSecurityBraking"] = "CA/SHP braking",
["LegendStopPoint"] = "W4 stop point",
["LegendShortStop"] = "W32 stop for shorter trains",
["ShortStopHover"] = "W32 {0}",
["LegendHorn"] = "W6 horn point",
["BandVigilance"] = "CA",
["BandCabSignal"] = "SHP",
["StopPointHover"] = "W4 {0}",
["PlatformHover"] = "platform {0:0} m",
["ShortTrainsHover"] = "for trains up to {0:0} m",
["StopHeadHover"] = "the head to stop {0:0} m before it",
["StopMiddleHover"] = "the middle of the train to stop {0:0} m before it",
["HornHover"] = "W6 {0} · {1:0} s",
["ZoomIn"] = "Zoom in",
["ZoomOut"] = "Zoom out",
["EventCamera"] = "Camera: {0}",
["CameraCab"] = "in the cab",
["CameraExternal"] = "outside view",
["CameraFree"] = "free camera",
["CameraDebug"] = "debug camera",
["EventCoupled"] = "Coupled {0} to {1}",
["EventUncoupled"] = "Uncoupled {0} from {1}",
["EventCouplingChanged"] = "{0} – {1}: {2}",
["CouplingCoupler"] = "coupler",
["CouplingBrakePipe"] = "brake pipe",
["CouplingControl"] = "control",
["CouplingPower"] = "high voltage",
["CouplingGangway"] = "gangway",
["CouplingMainPipe"] = "main pipe",
["CouplingHeating"] = "heating",
["CouplingNothing"] = "nothing",
["BandCamera"] = "camera",
["LegendCamera"] = "camera",
["Events"] = "Events",
["OnlyUser"] = "only the user's",
["FileFilter"] = "Black box"
};
private static readonly Dictionary<string, string> Polish = new()
{
["Title"] = "Czarna skrzynka",
["TitleOf"] = "Czarna skrzynka – {0}",
["Open"] = "Otwórz…",
["Reload"] = "Odśwież",
["Live"] = "Śledź plik",
["LiveTip"] = "Co 2 sekundy doczytuje to, co symulator dopisał do pliku",
["Play"] = "Odtwarzaj",
["Pause"] = "Pauza",
["Follow"] = "Śledź pociąg",
["Fit"] = "Cała mapa",
["NoFile"] = "Otwórz czarną skrzynkę: physicslog/*.blackbox, zapisywaną razem z logami fizyki.",
["Unreadable"] = "Nie można odczytać {0}: {1}",
["Reading"] = "Czytanie {0}…",
["Summary"] = "{0} · {1:0.0} km · maks. {2:0} km/h · jazda {3} · autopilot {4} · ponad AI {5} (w tym {6} o więcej niż {7:0} km/h, najwyżej +{8:0} km/h) · sygnalizatory {9} · minięte na „Stój” {10} · komendy {11}",
["NotComplete"] = "wciąż zapisywana albo ucięta",
["Cursor"] = "{0} · {1:0} km/h · AI {2} · ograniczenie {3}",
["CursorNoAi"] = "{0} · {1:0} km/h · brak danych AI",
["CursorOut"] = "{0} · poza pojazdem",
["None"] = "–",
["LimitAt"] = "{0:0} km/h za {1:0} m",
["EventCommand"] = "{0}",
["EventAutopilotOn"] = "Autopilot włączony",
["EventAutopilotOff"] = "Autopilot wyłączony",
["EventPassed"] = "Sygnalizator {0}: {1}, {2:0} km/h",
["EventDanger"] = "MINIĘTY NA „STÓJ” {0}, {1:0} km/h, {2:0} m za nim",
["EventInVehicle"] = "W {0}",
["EventOutside"] = "Poza kabiną {0}",
["EventNoVehicle"] = "Poza pojazdami",
["EventEnd"] = "Koniec nagrania",
["AspectStop"] = "stój",
["AspectFree"] = "wolna droga",
["AspectSpeed"] = "{0:0}/{1}",
["LegendTitle"] = "Ślad pociągu: prędkość wobec AI",
["LegendWithin"] = "nie szybciej niż AI",
["LegendSlightly"] = "do {0:0} km/h szybciej niż AI",
["LegendOver"] = "ponad {0:0} km/h szybciej niż AI",
["LegendAutopilot"] = "prowadził autopilot",
["LegendNoData"] = "brak AI do porównania",
["LegendMarks"] = "Znaki na mapie",
["TimelineSpeed"] = "prędkość",
["TimelineTarget"] = "AI",
["TimelineLimit"] = "następne ograniczenie",
["BandEvents"] = "zdarzenia",
["KeyStation"] = "stacja",
["KeyHorn"] = "W6 trąbienie",
["KeySignal"] = "minięty semafor",
["KeyCoupling"] = "sprzęg",
["KeyDriver"] = "autopilot wł./wył.",
["KeyCommand"] = "polecenie",
["TlSignal"] = "semafor {0} minięty: {1}",
["TlSignalDanger"] = "semafor {0} minięty na „Stój”",
["TlCoupled"] = "sprzęg połączony",
["TlUncoupled"] = "sprzęg rozłączony",
["TlCouplingChanged"] = "zmiana na sprzęgu",
["TlAutopilotOn"] = "autopilot włączony",
["TlAutopilotOff"] = "prowadzenie przejmuje użytkownik",
["TlStationStopped"] = "{0}: postój",
["TlStationPassed"] = "{0}: przejazd",
["TlStationMissed"] = "{0}: pominięta",
["TlHornSounded"] = "W6: zatrąbiono",
["TlHornSilent"] = "W6: bez trąbienia",
["TlCommand"] = "polecenie: {0}",
["BandDriving"] = "jazda",
["BandBrakes"] = "hamulce",
["BandAlerter"] = "czuwak",
["BandController"] = "nastawnik",
["Cab"] = "Kabina",
["CabNoData"] = "Użytkownik nie był wtedy w żadnym pojeździe.",
["CabController"] = "Nastawnik",
["CabShunt"] = "Bocznik",
["CabReverser"] = "Kierunek",
["CabForward"] = "naprzód",
["CabBackward"] = "wstecz",
["CabNeutral"] = "0",
["CabTrainBrake"] = "Hamulec zespolony",
["CabLocalBrake"] = "Hamulec pomocniczy",
["CabReservoir"] = "Zbiornik główny",
["CabCylinder"] = "Cylindry",
["CabPipe"] = "Przewód główny",
["CabCurrent"] = "Prąd",
["CabVoltage"] = "Napięcie",
["CabRpm"] = "Obroty",
["CabLights"] = "Światła",
["CabLightsFront"] = "przód",
["CabLightsRear"] = "tył",
["DevBattery"] = "Baterie",
["DevMainBreaker"] = "WS",
["DevPantographFront"] = "Pantograf przedni",
["DevPantographRear"] = "Pantograf tylny",
["DevConverter"] = "Przetwornica",
["DevCompressor"] = "Sprężarka",
["DevLineContactors"] = "Styczniki liniowe",
["DevHeating"] = "Ogrzewanie",
["DevRadio"] = "Radiotelefon",
["DevShuntMode"] = "Jazda manewrowa",
["DevDoorsLeft"] = "Drzwi lewe otwarte",
["DevDoorsRight"] = "Drzwi prawe otwarte",
["DevSanding"] = "Piasecznica",
["DevHorn"] = "Syrena",
["DevSpringBrake"] = "Hamulec sprężynowy",
["DevAlerter"] = "Czuwak",
["DevWheelSlip"] = "Poślizg",
["DevOverloadRelay"] = "Nadmiarowy",
["DevRadioStop"] = "Radiostop",
["DevAlarmChain"] = "Hamulec bezpieczeństwa",
["TabEvents"] = "Zdarzenia",
["TabTimetable"] = "Rozkład",
["TabSummary"] = "Podsumowanie",
["PanelMap"] = "Mapa",
["PanelTimeline"] = "Przebieg jazdy",
["Relation"] = "{0} → {1}",
["StatDistance"] = "Dystans",
["StatMaxSpeed"] = "Prędkość maks.",
["StatDriven"] = "Jazda użytkownika",
["StatAutopilotSub"] = "autopilot {0}",
["StatOverAi"] = "Ponad AI",
["StatOverAiSub"] = "{0} o >{1:0} km/h · do +{2:0}",
["StatSignals"] = "Sygnalizatory",
["StatSignalsSub"] = "{0} na „Stój”",
["StatStops"] = "Postoje",
["StatStopsSub"] = "{0} pominięte",
["StatDelay"] = "Największe opóźnienie",
["StatDelaySub"] = "przy odjeździe: najwyżej {0}",
["StatPlatform"] = "Postoje poza peronem",
["StatLights"] = "Sygnały czoła i końca",
["StatLightsRight"] = "zgodne",
["StatLightsSub"] = "jak trzeba przez całe {0} jazdy",
["StatLightsHead"] = "czoło {0} zamiast {1}",
["StatLightsTail"] = "koniec {0} zamiast {1}",
["StatPlatformAt"] = "{0} {1:0.0} m",
["StatPlatformNone"] = "na każdym postoju cały skład przy peronie",
["StatHorn"] = "Trąbienie na W6",
["StatHornSub"] = "{0} bez trąbienia",
["StatAcceleration"] = "Przyspieszenie",
["StatAccelerationSub"] = "boczne do {0:0.00} m/s²",
["StatSecurity"] = "Hamowania CA/SHP",
["StatRecording"] = "Nagranie",
["StatComplete"] = "pełne",
["StatIncomplete"] = "w trakcie zapisu lub ucięte",
["Minutes"] = "{0:+0.0;−0.0;0} min",
["OnTime"] = "planowo",
["DelayNow"] = "opóźnienie {0}",
["DelayStanding"] = "{0} stoi w: {1}",
["DelayAfter"] = "{0} za: {1}",
["TimetableNone"] = "Pociąg użytkownika nie miał rozkładu.",
["TtArrived"] = "przyj. {0}",
["TtDeparted"] = "odj. {0}",
["TtPassed"] = "przejazd {0}",
["TtMissed"] = "pominięty postój",
["TtLeftEarly"] = "odjazd przed czasem",
["TtStopShort"] = "{0:0.0} m przed miejscem",
["TtStopPast"] = "{0:0.0} m za miejscem",
["TtStopExact"] = "w punkt",
["TtOnPlatform"] = "cały przy peronie",
["TtOffPlatform"] = "{0:0.0} m składu poza peronem",
["TtTechnical"] = "postój techniczny",
["TtStanding"] = "stoi",
["EventStopped"] = "Postój: {0}, {1}",
["EventStationPassed"] = "Przejazd: {0}",
["EventStationMissed"] = "POMINIĘTY POSTÓJ: {0}",
["EventReady"] = "{0}: czas odjazdu",
["EventHornSounded"] = "W6: zatrąbił",
["EventHornMissed"] = "W6: BEZ TRĄBIENIA",
["EventCab"] = "Kabina {0} w {1}",
["EventSecurityBraking"] = "HAMOWANIE PRZEZ {0}",
["EventTimetable"] = "Rozkład {0}, {1}",
["CabSpeed"] = "Prędkość",
["CabAiTarget"] = "AI {0:0} km/h",
["CabAcceleration"] = "Wzdłużne",
["CabLateral"] = "Boczne",
["CabEndFront"] = "A",
["CabEndRear"] = "B",
["CabDriverUser"] = "prowadzi użytkownik",
["CabDriverAi"] = "autopilot",
["CabOutside"] = "poza kabiną",
["CabLightsHead"] = "czoło",
["CabLightsTail"] = "koniec",
["CabSectionBrakes"] = "Hamulce",
["CabSectionDrive"] = "Napęd",
["CabSectionControls"] = "Nastawniki",
["CabSectionDevices"] = "Urządzenia",
["CabGroupPower"] = "Zasilanie",
["CabGroupDriving"] = "Jazda",
["CabGroupSafety"] = "Bezpieczeństwo",
["CabLightsWant"] = "{0}, a ma być {1}",
["CabLightsNone"] = "brak lamp",
["CabBrakeSetting"] = "nastawa {0}",
["CabDriverValve"] = "Zawór maszynisty",
["CabFeedPipe"] = "Przewód zasilający",
["CabConsistCylinder"] = "Cylindry, skład max",
["CabPower"] = "Moc",
["CabSpeedControl"] = "Tempomat",
["CabOilPressure"] = "Ciśn. oleju",
["CabWater"] = "Woda",
["CabOil"] = "Olej",
["CabEngine"] = "Silnik",
["CabNoDrive"] = "brak własnego napędu",
["CabClutch"] = "Sprzęgło",
["CabConverterFill"] = "Przekładnia",
["CabIntegrated"] = "Zadajnik",
["CabGroupDoors"] = "Drzwi",
["DevDoorPermitLeft"] = "Zezwolenie lewe",
["DevDoorPermitRight"] = "Zezwolenie prawe",
["DevDepartureSignal"] = "Sygnał odjazdu",
["DevDoorStep"] = "Stopień",
["DevDoorLock"] = "Blokada drzwi",
["DevDoorsLocked"] = "Drzwi zablokowane",
["DevBrakeReleaser"] = "Odluźniacz",
["DevFuelPump"] = "Pompa paliwa",
["DevOilPump"] = "Pompa oleju",
["DevMotorBlowers"] = "Wentylatory silników",
["DevEngineHot"] = "Przegrzanie silnika",
["DevSpeedControl"] = "Tempomat",
["DevRadioChannel"] = "Radio, kanał {0}",
["DevHandBrake"] = "Hamulec ręczny",
["DevHornTone"] = "Syrena ({0})",
["HornLow"] = "niski",
["HornHigh"] = "wysoki",
["HornBoth"] = "oba",
["EventDeviceOn"] = "{0}: wł.",
["EventDeviceOff"] = "{0}: wył.",
["EventDoorsLeftClosed"] = "Drzwi lewe zamknięte",
["EventDoorsRightClosed"] = "Drzwi prawe zamknięte",
["EventMovingDoorsOpen"] = "Jazda z otwartymi drzwiami ({0:0} km/h)",
["EventHandBrakeOn"] = "Hamulec ręczny zaciągnięty",
["EventHandBrakeOff"] = "Hamulec ręczny zwolniony",
["EventBrakeSetting"] = "Nastawa hamulca: {0}",
["EventRadioChannel"] = "Radiotelefon: kanał {0}",
["EventSpeedControlSet"] = "Tempomat: {0:0} km/h",
["DevVigilance"] = "CA",
["DevCabSignal"] = "SHP",
["DevSecurityBraking"] = "Hamowanie CA/SHP",
["LegendStopPoint"] = "W4 – wskaźnik zatrzymania",
["LegendShortStop"] = "W32 – zatrzymanie krótszych pociągów",
["ShortStopHover"] = "W32 {0}",
["LegendHorn"] = "W6 – „trąbić”",
["BandVigilance"] = "CA",
["BandCabSignal"] = "SHP",
["StopPointHover"] = "W4 {0}",
["PlatformHover"] = "peron {0:0} m",
["ShortTrainsHover"] = "dla pociągów do {0:0} m",
["StopHeadHover"] = "czoło ma stanąć {0:0} m przed W4",
["StopMiddleHover"] = "środek składu ma stanąć {0:0} m przed W4",
["HornHover"] = "W6 {0} · {1:0} s",
["ZoomIn"] = "Przybliż",
["ZoomOut"] = "Oddal",
["EventCamera"] = "Kamera: {0}",
["CameraCab"] = "w kabinie",
["CameraExternal"] = "widok zewnętrzny",
["CameraFree"] = "wolna kamera",
["CameraDebug"] = "kamera debugowania",
["EventCoupled"] = "Sprzęgnięto {0} z {1}",
["EventUncoupled"] = "Rozprzęgnięto {0} od {1}",
["EventCouplingChanged"] = "{0} – {1}: {2}",
["CouplingCoupler"] = "sprzęg",
["CouplingBrakePipe"] = "przewód hamulcowy",
["CouplingControl"] = "sterowanie",
["CouplingPower"] = "WN",
["CouplingGangway"] = "mostek",
["CouplingMainPipe"] = "przewód zasilający",
["CouplingHeating"] = "ogrzewanie",
["CouplingNothing"] = "nic",
["BandCamera"] = "kamera",
["LegendCamera"] = "kamera",
["Events"] = "Zdarzenia",
["OnlyUser"] = "tylko użytkownika",
["FileFilter"] = "Czarna skrzynka"
};
private readonly Dictionary<string, string> _texts;
public DefaultBlackBoxStrings() : this(CultureInfo.CurrentUICulture)
{
}
public DefaultBlackBoxStrings(CultureInfo culture) : this(culture.TwoLetterISOLanguageName)
{
}
/// <summary>Polish for "pl", English for any other language.</summary>
public DefaultBlackBoxStrings(string languageCode)
{
_texts = languageCode == "pl" ? Polish : English;
}
/// <summary>The keys there are texts for, for a translation to give them all.</summary>
public static IEnumerable<string> Keys => English.Keys;
public string this[string key] => _texts.TryGetValue(key, out var text) ? text : English.GetValueOrDefault(key, key);
}

View File

@@ -0,0 +1,571 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
namespace Eu07.BlackBox.Views;
/// <summary>
/// Replays a black box. Above: the train and the scenery, playing and how late the train
/// was. In the middle the map at the moment played, the timeline of the user's train under
/// it; beside them the events, the timetable or the figures of the driving, and the cab. A file
/// the simulator still writes can be followed: what it adds is read every two seconds, and
/// playing at its end keeps up with it.
/// </summary>
public sealed class BlackBoxWindow : Window
{
private static readonly double[] Speeds = { 1, 2, 5, 10, 30, 60 };
private readonly IBlackBoxStrings _strings;
private readonly MapView _map;
private readonly TimelineView _timeline;
private readonly EventList _events;
private readonly TimetablePanel _timetable;
private readonly ScrollViewer _timetableScroll;
private readonly ScrollViewer _summaryScroll;
private readonly CabPanel _cab;
private readonly UiKit.Segmented _tabs;
private readonly TextBlock _title = new() { FontSize = 14, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, TextTrimming = TextTrimming.CharacterEllipsis };
private readonly TextBlock _subtitle = new() { FontSize = 11, Foreground = Palette.DimText, TextTrimming = TextTrimming.CharacterEllipsis };
private readonly TextBlock _clock = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 18, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, MinWidth = 86, TextAlignment = TextAlignment.Center };
private readonly TextBlock _delayText = new() { FontSize = 12, FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center, MinWidth = 120 };
private readonly TextBlock _readout = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 11, Foreground = Palette.DimText, TextTrimming = TextTrimming.CharacterEllipsis };
private readonly TextBlock _fileState = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 11, Foreground = Palette.FaintText };
private readonly TextBlock _message = new() { FontSize = 14, Foreground = Palette.DimText, TextWrapping = TextWrapping.Wrap, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, TextAlignment = TextAlignment.Center, Margin = new Thickness(40) };
private readonly StackPanel _tiles = new();
private readonly UiKit.Tile _distance, _maxSpeed, _driven, _overAi, _signals, _stops, _delay, _platform, _lights, _horn, _acceleration, _security, _state;
private readonly Button _open;
private readonly Button _play;
private readonly ComboBox _speed = new() { VerticalAlignment = VerticalAlignment.Center, MinWidth = 64, FontSize = 12, Padding = new Thickness(8, 3) };
private readonly Avalonia.Controls.Primitives.ToggleButton _follow;
private readonly Avalonia.Controls.Primitives.ToggleButton _live;
private readonly DispatcherTimer _tick = new() { Interval = TimeSpan.FromMilliseconds(40) };
private readonly DispatcherTimer _reload = new() { Interval = TimeSpan.FromSeconds(2) };
private readonly Stopwatch _played = new();
private BlackBoxReader? _reader;
private string? _path;
private double _time;
private bool _reading;
public BlackBoxWindow() : this(null)
{
}
public BlackBoxWindow(IBlackBoxStrings? strings)
{
_strings = strings ?? new DefaultBlackBoxStrings();
Title = _strings["Title"];
Width = 1440;
Height = 900;
MinWidth = 960;
MinHeight = 600;
Background = Palette.Background;
UiKit.Accent(this);
WindowStartupLocation = WindowStartupLocation.CenterOwner;
_map = new MapView { Strings = _strings };
_timeline = new TimelineView { Strings = _strings };
_events = new EventList { Strings = _strings, Background = Brushes.Transparent };
_timetable = new TimetablePanel { Strings = _strings };
_timetableScroll = new ScrollViewer { Content = _timetable, IsVisible = false };
_summaryScroll = new ScrollViewer { Content = _tiles, IsVisible = false };
_cab = new CabPanel { Strings = _strings };
// ----- the bar on top: the train, playing, the switches -----
_open = UiKit.Button(_strings["Open"]);
_open.Click += async (_, _) => await PickAsync();
var reload = UiKit.Button(_strings["Reload"]);
reload.Click += (_, _) => ReadMore();
var fit = UiKit.Button(_strings["Fit"]);
fit.Click += (_, _) => ShowWhole();
_follow = UiKit.Toggle(_strings["Follow"]);
_follow.IsCheckedChanged += (_, _) => _map.Follow = _follow.IsChecked == true;
_map.FollowStopped += () => _follow.IsChecked = false;
_live = UiKit.Toggle(_strings["Live"], _strings["LiveTip"]);
_live.IsCheckedChanged += (_, _) =>
{
if (_live.IsChecked == true)
_reload.Start();
else
_reload.Stop();
};
_play = UiKit.PlayButton();
_play.Content = UiKit.PlayGlyph(false);
ToolTip.SetTip(_play, _strings["Play"]);
_play.Click += (_, _) => Playing = !Playing;
_speed.ItemsSource = Speeds.Select(s => $"×{s.ToString(CultureInfo.InvariantCulture)}").ToArray();
_speed.SelectedIndex = 3;
var titles = new StackPanel { VerticalAlignment = VerticalAlignment.Center, Children = { _title, _subtitle } };
var transport = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10, VerticalAlignment = VerticalAlignment.Center, Children = { _play, _clock, _speed, _delayText } };
var switches = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2, VerticalAlignment = VerticalAlignment.Center, Children = { _follow, _live, fit, reload, _open } };
var header = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto,*") };
Place(header, titles, 0, 0);
Place(header, transport, 0, 1);
switches.HorizontalAlignment = HorizontalAlignment.Right;
Place(header, switches, 0, 2);
titles.Margin = new Thickness(0, 0, 16, 0);
var headerBar = new Border
{
Background = Palette.Panel,
BorderBrush = Palette.CardBorder,
BorderThickness = new Thickness(0, 0, 0, 1),
Padding = new Thickness(8, 0),
Height = 44,
Child = header
};
// ----- the figures, a table in a tab of their own -----
_distance = Tile("StatDistance");
_maxSpeed = Tile("StatMaxSpeed");
_driven = Tile("StatDriven");
_overAi = Tile("StatOverAi");
_signals = Tile("StatSignals");
_stops = Tile("StatStops");
_delay = Tile("StatDelay");
_platform = Tile("StatPlatform");
_lights = Tile("StatLights");
_horn = Tile("StatHorn");
_acceleration = Tile("StatAcceleration");
_security = Tile("StatSecurity");
_state = Tile("StatRecording");
_timeline.Seek += t => SetTime(t);
_timeline.Hovered += ShowReadout;
_events.Seek += t => SetTime(t);
_timetable.Seek += t => SetTime(t);
_tick.Tick += (_, _) => Advance();
_reload.Tick += (_, _) => ReadMore();
// ----- the map with its zoom, the timeline, the side -----
var zoomIn = MapButton("+", _strings["ZoomIn"], () => _map.ZoomBy(1.6));
var zoomOut = MapButton("−", _strings["ZoomOut"], () => _map.ZoomBy(1 / 1.6));
var zoomFit = MapButton("⤢", _strings["Fit"], ShowWhole);
var zoom = new StackPanel
{
Spacing = 1,
Margin = new Thickness(8),
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Top,
Children = { zoomIn, zoomOut, zoomFit }
};
var mapArea = new Panel { Children = { _map, zoom, _message } };
_map.ClipToBounds = true;
_tabs = new UiKit.Segmented(_strings["TabEvents"], _strings["TabTimetable"], _strings["TabSummary"]);
_tabs.SelectedChanged += index =>
{
_events.IsVisible = index == 0;
_timetableScroll.IsVisible = index == 1;
_summaryScroll.IsVisible = index == 2;
};
var side = new DockPanel();
DockPanel.SetDock(_tabs, Dock.Top);
side.Children.Add(_tabs);
side.Children.Add(new Panel { Children = { _events, _timetableScroll, _summaryScroll } });
var cabCard = UiKit.Group(_strings["Cab"], new ScrollViewer { Content = _cab, HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled });
cabCard.Margin = new Thickness(0, UiKit.Gap, 0, 0);
var sideColumn = new DockPanel();
// On a short window the cab scrolls, so the list above keeps room for a few rows.
sideColumn.SizeChanged += (_, e) => cabCard.MaxHeight = Math.Max(160, e.NewSize.Height - 160);
DockPanel.SetDock(cabCard, Dock.Bottom);
sideColumn.Children.Add(cabCard);
sideColumn.Children.Add(UiKit.Frame(side));
var body = new Grid
{
ColumnDefinitions = new ColumnDefinitions($"*,{UiKit.Gap.ToString(CultureInfo.InvariantCulture)},400"),
RowDefinitions = new RowDefinitions($"*,{UiKit.Gap.ToString(CultureInfo.InvariantCulture)},230")
};
Place(body, UiKit.Group(_strings["PanelMap"], mapArea), 0, 0);
Place(body, new GridSplitter { Background = Brushes.Transparent, ResizeDirection = GridResizeDirection.Rows, MinHeight = UiKit.Gap }, 1, 0);
Place(body, UiKit.Group(_strings["PanelTimeline"], _timeline), 2, 0);
var columns = new GridSplitter { Background = Brushes.Transparent, ResizeDirection = GridResizeDirection.Columns, MinWidth = UiKit.Gap };
Place(body, columns, 0, 1);
Grid.SetRowSpan(columns, 3);
Place(body, sideColumn, 0, 2);
Grid.SetRowSpan(sideColumn, 3);
// ----- the line at the bottom: the moment under the pointer, the state of the file -----
var status = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto") };
Place(status, _readout, 0, 0);
Place(status, _fileState, 0, 1);
var statusBar = new Border
{
Background = Palette.Panel,
BorderBrush = Palette.CardBorder,
BorderThickness = new Thickness(0, 1, 0, 0),
Padding = new Thickness(12, 3),
Child = status
};
body.Margin = new Thickness(UiKit.Gap);
var root = new DockPanel { Background = Palette.Background };
DockPanel.SetDock(headerBar, Dock.Top);
DockPanel.SetDock(statusBar, Dock.Bottom);
root.Children.Add(headerBar);
root.Children.Add(statusBar);
root.Children.Add(body);
Content = root;
_title.Text = _strings["Title"];
_subtitle.Text = _strings["NoFile"];
_message.Text = _strings["NoFile"];
Closed += (_, _) =>
{
_tick.Stop();
_reload.Stop();
};
}
// When and at which end the signals were wrong: "10:55:12 head Tb 1 instead of Pc 1".
private string LightsFaultText(Recording recording, LightsFault fault, CultureInfo culture)
{
var ends = new List<string>();
if (fault.HeadWrong)
ends.Add(string.Format(culture, _strings["StatLightsHead"], HeadCodes.Name(fault.Head), HeadCodes.Name(fault.HeadRequired)));
if (fault.TailWrong)
ends.Add(string.Format(culture, _strings["StatLightsTail"], HeadCodes.Name(fault.Tail), HeadCodes.Name(fault.TailRequired)));
return $"{Clock(recording.ClockAt(fault.Start).TotalSeconds)} {string.Join(", ", ends)}";
}
private UiKit.Tile Tile(string key)
{
var tile = new UiKit.Tile(_strings[key]);
_tiles.Children.Add(tile);
return tile;
}
private static Button MapButton(string text, string tip, Action action)
{
var button = UiKit.Button(text, tip);
button.Width = 28;
button.Height = 28;
button.Padding = new Thickness(0);
button.FontSize = 15;
button.HorizontalContentAlignment = HorizontalAlignment.Center;
button.VerticalContentAlignment = VerticalAlignment.Center;
button.Background = Palette.Panel;
button.Click += (_, _) => action();
return button;
}
private static void Place(Grid grid, Control control, int row, int column)
{
Grid.SetRow(control, row);
Grid.SetColumn(control, column);
grid.Children.Add(control);
}
/// <summary>The button for opening another file: the starter opens the one picked in its list.</summary>
public bool CanOpenFiles
{
get => _open.IsVisible;
set => _open.IsVisible = value;
}
/// <summary>Reads what the simulator adds to the file every two seconds.</summary>
public bool Live
{
get => _live.IsChecked == true;
set => _live.IsChecked = value;
}
public string? FilePath => _path;
public Recording? Recording => _reader?.Recording;
public double CurrentTime => _time;
/// <summary>Goes to a moment of the recording, in seconds from its start.</summary>
public void SeekTo(double t) => SetTime(t);
/// <summary>Keeps the user's train in the middle of the map.</summary>
public bool FollowTrain
{
get => _follow.IsChecked == true;
set => _follow.IsChecked = value;
}
/// <summary>The timetable beside the map instead of the events.</summary>
public bool ShowTimetable
{
get => _tabs.Selected == 1;
set => _tabs.Selected = value ? 1 : 0;
}
/// <summary>The summary of the driving beside the map instead of the events.</summary>
public bool SummaryShown
{
get => _tabs.Selected == 2;
set => _tabs.Selected = value ? 2 : 0;
}
private bool Playing
{
get => _tick.IsEnabled;
set
{
if (value == _tick.IsEnabled)
return;
if (value)
{
if (Recording is { } r && _time >= r.Duration - 0.05 && !Live)
SetTime(0);
_played.Restart();
_tick.Start();
}
else
_tick.Stop();
_play.Content = UiKit.PlayGlyph(value);
ToolTip.SetTip(_play, _strings[value ? "Pause" : "Play"]);
}
}
private void ShowWhole()
{
_follow.IsChecked = false;
_map.Fit();
_timeline.Fit();
}
/// <summary>Opens a black box, read whole on another thread.</summary>
public async Task OpenAsync(string path)
{
Playing = false;
ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Reading"], Path.GetFileName(path)));
try
{
var reader = await Task.Run(() =>
{
var r = new BlackBoxReader();
r.ReadMore(path);
return r;
});
_reader = reader;
_path = path;
Title = string.Format(CultureInfo.CurrentCulture, _strings["TitleOf"], Path.GetFileName(path));
_map.Recording = reader.Recording;
_timeline.Recording = reader.Recording;
_events.Recording = reader.Recording;
_timetable.Recording = reader.Recording;
_cab.Recording = reader.Recording;
_message.IsVisible = false;
// A file still written opens at its end, where the train is now.
SetTime(Live ? reader.Recording.Duration : 0);
ShowSummary();
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
{
ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Unreadable"], path, ex.Message));
}
}
private void ShowMessage(string text)
{
_message.Text = text;
_message.IsVisible = true;
_subtitle.Text = text;
_fileState.Text = "";
}
/// <summary>What the simulator added since the last read. Small, so read here, between frames.</summary>
public void ReadMore()
{
if (_reader is not { } reader || _path is null || _reading)
return;
_reading = true;
try
{
bool atEnd = _time >= reader.Recording.Duration - 0.5;
if (reader.ReadMore(_path) == 0)
return;
_events.Refresh();
if (atEnd && !Playing)
SetTime(reader.Recording.Duration);
else
SetTime(_time);
ShowSummary();
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
{
ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Unreadable"], _path, ex.Message));
}
finally
{
_reading = false;
}
}
private async Task PickAsync()
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = _strings["Open"],
AllowMultiple = false,
FileTypeFilter = new[] { new FilePickerFileType(_strings["FileFilter"]) { Patterns = new[] { "*.blackbox" } } }
});
if (files.Count > 0 && files[0].TryGetLocalPath() is { } path)
await OpenAsync(path);
}
private void Advance()
{
if (Recording is not { } recording)
return;
double real = _played.Elapsed.TotalSeconds;
_played.Restart();
double speed = Speeds[Math.Clamp(_speed.SelectedIndex, 0, Speeds.Length - 1)];
double next = _time + real * speed;
if (next >= recording.Duration)
{
next = recording.Duration;
// At the end of a file still written it waits for more.
if (!Live)
Playing = false;
}
SetTime(next);
}
private void SetTime(double t)
{
var recording = Recording;
double duration = recording?.Duration ?? 0;
_time = Math.Clamp(t, 0, duration);
_map.Time = _time;
_timeline.Time = _time;
_cab.Time = _time;
_timetable.Time = _time;
if (recording is null)
return;
_clock.Text = recording.ClockAt(_time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture);
_events.ShowTime(_time);
ShowReadout(null);
ShowDelay(recording);
}
/// <summary>How late the user's train was at the moment played, by the clock.</summary>
private void ShowDelay(Recording recording)
{
if (StationProgress.DelayAt(recording, _time) is not { } delay)
{
_delayText.Text = "";
return;
}
var culture = CultureInfo.CurrentCulture;
var brush = Palette.Delay(delay.Minutes);
_delayText.Text = Math.Abs(delay.Minutes) < 0.5
? _strings["OnTime"]
: string.Format(culture, _strings["DelayNow"], string.Format(culture, _strings["Minutes"], delay.Minutes));
_delayText.Foreground = brush;
ToolTip.SetTip(_delayText, string.Format(culture, _strings[delay.Standing ? "DelayStanding" : "DelayAfter"], delay.Train,
StationNames.Display(delay.Station)));
}
/// <summary>The user's train at the moment under the pointer of the timeline, or at the moment played.</summary>
private void ShowReadout(double? hovered)
{
if (Recording is not { } recording)
return;
double t = hovered ?? _time;
string clock = recording.ClockAt(t).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture);
var culture = CultureInfo.CurrentCulture;
if (recording.CabAt(t) is not { } s || t - s.Time > 1.0)
{
_readout.Text = string.Format(culture, _strings["CursorOut"], clock);
return;
}
if (!s.AiValid)
{
_readout.Text = string.Format(culture, _strings["CursorNoAi"], clock, s.AbsSpeedKmh);
return;
}
string limit = s.Ai.HasNextLimit
? string.Format(culture, _strings["LimitAt"], s.Ai.NextLimitKmh, s.Ai.NextLimitDistance)
: _strings["None"];
_readout.Text = string.Format(culture, _strings["Cursor"], clock, s.AbsSpeedKmh,
s.Ai.TargetKmh.ToString("0", culture) + " km/h", limit);
}
private void ShowSummary()
{
if (Recording is not { } recording)
return;
var summary = recording.Summarize();
var culture = CultureInfo.CurrentCulture;
// The scenery, the train and the vehicle the user started in.
string scenery = recording.Info?.Scenery ?? Path.GetFileName(_path) ?? "";
_title.Text = Path.GetFileNameWithoutExtension(scenery.TrimStart('$'));
var timetable = recording.TimetableAt(_time);
string vehicle = recording.Cab.Count > 0 ? recording.Vehicle(recording.Cab[^1].Vehicle)?.Name ?? "" : recording.Info?.StartVehicle ?? "";
var parts = new[]
{
timetable is null ? null : $"{timetable.Category} {timetable.Train}".Trim(),
timetable is null || timetable.From.Length + timetable.To.Length == 0 ? null : string.Format(culture, _strings["Relation"], timetable.From, timetable.To),
vehicle,
recording.Info?.Started.Replace('T', ' ')
};
_subtitle.Text = string.Join(" · ", parts.Where(p => !string.IsNullOrWhiteSpace(p)));
_distance.Show($"{summary.DistanceKm:0.0} km");
_maxSpeed.Show($"{summary.MaxSpeedKmh:0} km/h");
_driven.Show(Clock(summary.DrivenSeconds), string.Format(culture, _strings["StatAutopilotSub"], Clock(summary.AutopilotSeconds)));
_overAi.Show(Clock(summary.OverAiSeconds),
string.Format(culture, _strings["StatOverAiSub"], Clock(summary.WellOverAiSeconds), DrivingSummary.DefaultTolerance, summary.MaxOverAiKmh),
summary.WellOverAiSeconds > 0 ? Palette.Bad : summary.OverAiSeconds > 0 ? Palette.Warn : Palette.Good);
_signals.Show(summary.SignalsPassed.ToString(culture), string.Format(culture, _strings["StatSignalsSub"], summary.PassedAtDanger.Count),
summary.PassedAtDanger.Count > 0 ? Palette.Bad : null);
_stops.IsVisible = recording.Timetables.Count > 0;
_stops.Show(summary.Stops.ToString(culture), string.Format(culture, _strings["StatStopsSub"], summary.StopsMissed),
summary.StopsMissed > 0 ? Palette.Bad : null);
_delay.IsVisible = summary.MaxDelayMinutes is not null;
if (summary.MaxDelayMinutes is { } delay)
_delay.Show(string.Format(culture, _strings["Minutes"], delay),
summary.MaxDepartureDelayMinutes is { } departure
? string.Format(culture, _strings["StatDelaySub"], string.Format(culture, _strings["Minutes"], departure))
: null,
Palette.Delay(delay));
_platform.IsVisible = summary.Stops > 0;
_platform.Show(summary.OffPlatform.Count.ToString(culture),
summary.OffPlatform.Count > 0
? string.Join(" · ", summary.OffPlatform.Select(p => string.Format(culture, _strings["StatPlatformAt"], p.Station, p.Metres)))
: _strings["StatPlatformNone"],
summary.OffPlatform.Count > 0 ? Palette.Bad : Palette.Good);
_lights.IsVisible = summary.LightsJudgedSeconds > 0;
_lights.Show(summary.WrongLightsSeconds > 0 ? Clock(summary.WrongLightsSeconds) : _strings["StatLightsRight"],
summary.LightsFaults.Count > 0
? string.Join(" · ", summary.LightsFaults.Take(3).Select(f => LightsFaultText(recording, f, culture)))
+ (summary.LightsFaults.Count > 3 ? " …" : "")
: string.Format(culture, _strings["StatLightsSub"], Clock(summary.LightsJudgedSeconds)),
summary.WrongLightsSeconds > 0 ? Palette.Bad : Palette.Good);
_horn.IsVisible = summary.HornPoints > 0;
_horn.Show($"{summary.HornPoints - summary.HornsMissed}/{summary.HornPoints}", string.Format(culture, _strings["StatHornSub"], summary.HornsMissed),
summary.HornsMissed > 0 ? Palette.Bad : Palette.Good);
_acceleration.Show($"+{summary.MaxAcceleration:0.00} / −{summary.MaxDeceleration:0.00}",
string.Format(culture, _strings["StatAccelerationSub"], summary.MaxLateralAcceleration));
_security.Show(summary.SecurityBrakings.ToString(culture), null, summary.SecurityBrakings > 0 ? Palette.Bad : Palette.Good);
_state.Show(Clock(recording.Duration), _strings[recording.Complete ? "StatComplete" : "StatIncomplete"],
recording.Complete ? null : Palette.Warn);
_fileState.Text = $"{Path.GetFileName(_path)} · {Clock(recording.Duration)} · {_strings[recording.Complete ? "StatComplete" : "StatIncomplete"]}";
}
private static string Clock(double seconds)
{
var span = TimeSpan.FromSeconds(Math.Round(seconds));
return span.TotalHours >= 1
? span.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture)
: span.ToString(@"m\:ss", CultureInfo.InvariantCulture);
}
}

View File

@@ -0,0 +1,567 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>
/// The cab of the user's train at the moment played, for checking what the driver did: the
/// speed against the AI's, the accelerations, the controllers, the brakes (the pressures as
/// gauges, the setting), the traction as fits what drives the vehicle (the voltage and the
/// current of an electric one, the oil and the temperatures of a diesel), the lights at the
/// head and the tail of the train with what they are to show, and a lamp for every device in
/// groups (power, driving, safety, doors), the alerter (CA) and the cab signalling (SHP) apart.
/// </summary>
public sealed class CabPanel : Control
{
private const double Pad = 14;
private const double LampRow = 14;
private const int DoorColumns = 2;
private const double DriveRow = 34;
private Recording? _recording;
private double _time;
private int _measured = -1;
private int _controllerTop = 1;
private readonly HashSet<int> _powered = new();
private readonly HashSet<int> _feedPipe = new();
private readonly HashSet<int> _integrated = new();
private readonly HashSet<int> _hydraulic = new();
public CabPanel()
{
ClipToBounds = true;
Height = 696;
}
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
public Recording? Recording
{
get => _recording;
set
{
_recording = value;
_measured = -1;
InvalidateVisual();
}
}
public double Time
{
get => _time;
set
{
_time = value;
InvalidateVisual();
}
}
public override void Render(DrawingContext context)
{
var size = Bounds.Size;
double width = size.Width - 2 * Pad, right = size.Width - Pad;
// The last sample, unless the train was out of sight for longer than a moment.
if (_recording is not { } recording || recording.CabAt(_time) is not { } s || _time - s.Time > 1.5)
{
context.DrawText(MapView.Text(Strings["CabNoData"], Palette.DimText, 12), new Point(Pad, 40));
return;
}
Measure(recording);
var culture = CultureInfo.CurrentCulture;
var extra = s.Extra;
// The vehicle at the left; who drives, from which cab, at the right.
double chipX = right;
if (s.CabEnd != 0)
chipX -= ChipLeft(context, Strings[s.CabEnd > 0 ? "CabEndFront" : "CabEndRear"], Palette.Text, chipX, 4) + 5;
var (driverText, driverBrush) = !s.InCab ? (Strings["CabOutside"], Palette.DimText)
: s.Driving == Driver.Ai ? (Strings["CabDriverAi"], Palette.Autopilot)
: (Strings["CabDriverUser"], Palette.UserTrain);
chipX -= ChipLeft(context, driverText, driverBrush, chipX, 4) + 10;
using (context.PushClip(new Rect(Pad, 0, Math.Max(0, chipX - Pad), 28)))
context.DrawText(MapView.Text(recording.Vehicle(s.Vehicle)?.Name ?? "", Palette.Text, 12, FontWeight.SemiBold), new Point(Pad, 6));
// The speed, large, what the AI would do under it, the accelerations at the right.
var judgement = DrivingSummary.Judge(s);
var speed = MapView.Text(s.AbsSpeedKmh.ToString("0", culture), judgement is SpeedJudgement.Over ? Palette.Bad
: judgement is SpeedJudgement.Slightly ? Palette.Warn : Palette.Text, 36, FontWeight.SemiBold);
context.DrawText(speed, new Point(Pad, 24));
context.DrawText(MapView.Text("km/h", Palette.DimText, 12), new Point(Pad + speed.Width + 4, 24 + speed.Height - 21));
double gaugeX = Pad + Math.Max(150, width * 0.42);
if (s.AiValid)
{
using (context.PushClip(new Rect(Pad, 76, gaugeX - Pad - 8, 40)))
{
context.DrawText(MapView.Text(string.Format(culture, Strings["CabAiTarget"], s.Ai.TargetKmh), Palette.Target, 12), new Point(Pad, 76));
if (s.Ai.HasNextLimit)
context.DrawText(MapView.Text(string.Format(culture, Strings["LimitAt"], s.Ai.NextLimitKmh, s.Ai.NextLimitDistance), Palette.Limit, 11),
new Point(Pad, 93));
}
}
double gaugeWidth = right - gaugeX;
Balance(context, Strings["CabAcceleration"], s.Acceleration, 1.5, gaugeX, 34, gaugeWidth);
Balance(context, Strings["CabLateral"], s.LateralAcceleration, 1.5, gaugeX, 70, gaugeWidth);
// The controllers.
double top = Section(context, Strings["CabSectionControls"], Pad, 118, right);
double column = width / 4;
Value(context, Strings["CabController"], s.MainController.ToString(culture), Pad, top);
Steps(context, s.MainController, _controllerTop, Pad, top + 34, column - 14);
if (extra is { } handle && _integrated.Contains(s.Vehicle))
Value(context, Strings["CabIntegrated"], (handle.IntegratedController * 100).ToString("+0;−0;0", culture) + " %", Pad + column, top);
else
Value(context, Strings["CabShunt"], s.ScndController.ToString(culture), Pad + column, top);
Value(context, Strings["CabReverser"], Strings[s.Reverser > 0 ? "CabForward" : s.Reverser < 0 ? "CabBackward" : "CabNeutral"], Pad + 2 * column, top);
Value(context, Strings["CabTrainBrake"], s.TrainBrake.ToString("0.0", culture), Pad + 3 * column, top);
// The brakes, as gauges, the setting (G, P, R) at the right of the title.
string? setting = extra is { } e && (e.BrakeDelay | e.BrakeMode) != 0
? string.Format(culture, Strings["CabBrakeSetting"], string.Join(" · ", new[] { BrakeSetting(e.BrakeDelay), BrakeMode(e.BrakeMode) }.Where(t => t.Length > 0)))
: null;
top = Section(context, Strings["CabSectionBrakes"], Pad, 180, right, setting);
const double gauge = 17;
Gauge(context, Strings["CabPipe"], s.BrakePipe, 6, 5, $"{s.BrakePipe:0.00} bar", Palette.Station, Pad, top, width);
double y = top + gauge;
if (extra is { } x1)
{
// what the driver's brake valve shows: its equalizing reservoir, the FV4a's time reservoir
Gauge(context, Strings["CabDriverValve"], x1.DriverValve, 6, null, $"{x1.DriverValve:0.00} bar", Palette.Target, Pad, y, width);
y += gauge;
}
Gauge(context, Strings["CabCylinder"], s.BrakeCylinder, 6, null, $"{s.BrakeCylinder:0.0} bar", Palette.Brakes, Pad, y, width);
y += gauge;
if (extra is { } x3)
{
// the most braked vehicle of the train
Gauge(context, Strings["CabConsistCylinder"], x3.ConsistBrakeCylinder, 6, null, $"{x3.ConsistBrakeCylinder:0.0} bar", Palette.Brakes, Pad, y, width);
y += gauge;
}
Gauge(context, Strings["CabReservoir"], s.MainReservoir, 10, null, $"{s.MainReservoir:0.0} bar", Palette.Controller, Pad, y, width);
y += gauge;
if (extra is { } x2)
{
bool has = _feedPipe.Contains(s.Vehicle);
Gauge(context, Strings["CabFeedPipe"], has ? x2.FeedPipe : 0, 10, null, has ? $"{x2.FeedPipe:0.0} bar" : "—", Palette.Controller, Pad, y, width);
y += gauge;
}
Gauge(context, Strings["CabLocalBrake"], s.LocalBrake, 1, null, $"{s.LocalBrake * 100:0} %", Palette.Brakes, Pad, y, width);
// The traction at the left, as fits what drives the vehicle; the lights of both ends of the train at the right.
double half = Pad + width / 2;
double driveTop = y + gauge + 4;
top = Section(context, Strings["CabSectionDrive"], Pad, driveTop, half - 10);
Section(context, Strings["CabLights"], half + 4, driveTop, right);
DrawDrive(context, s, top, half - 10 - Pad, culture);
double row = top;
foreach (var (key, lights, equipped, required, isRight) in new[]
{
("CabLightsHead", s.LightsHead, s.EquippedHead, HeadCodes.RequiredHead(s), HeadCodes.HeadRight(s)),
("CabLightsTail", s.LightsTail, s.EquippedTail, HeadCodes.RequiredTail(s), HeadCodes.TailRight(s))
})
{
DrawFace(context, lights, new Point(half + 4 + 17, row + 15));
// the end, and under it the signal it shows: green as it is to be (with the lamps the vehicle has),
// red otherwise with the one it is to show
var shown = HeadCodes.Of(lights);
var text = HeadCodes.NoLamps(equipped) ? Strings["CabLightsNone"]
: isRight == false && required is { } want
? string.Format(culture, Strings["CabLightsWant"], HeadCodes.Name(shown), HeadCodes.Name(want))
: isRight == true && required is { } met ? HeadCodes.Name(met) : HeadCodes.Name(shown);
var brush = isRight is null ? Palette.DimText : isRight.Value ? Palette.Good : Palette.Bad;
using (context.PushClip(new Rect(half + 44, row, right - half - 44, 28)))
{
context.DrawText(MapView.Text(Strings[key], Palette.DimText, 10), new Point(half + 46, row));
context.DrawText(MapView.Text(text, brush, 12, FontWeight.SemiBold), new Point(half + 46, row + 11));
}
row += 27;
}
// The devices, in three groups side by side: lit as their lamps are in the cab, the ones off faint.
top = Section(context, Strings["CabSectionDevices"], Pad, top + 3 * DriveRow + 6, right);
var groups = Devices(s, culture);
var widths = new double[groups.Length];
for (int g = 0; g < groups.Length; g++)
{
widths[g] = MapView.Text(Strings[groups[g].Title], Palette.DimText, 10, FontWeight.SemiBold).Width;
foreach (var (text, _) in groups[g].Lamps)
widths[g] = Math.Max(widths[g], 14 + MapView.Text(text, Palette.Text, 10.5).Width);
}
// each group as wide as its longest name, what is left shared between them
double spare = (width - widths.Sum()) / groups.Length;
double x = Pad, bottom = top;
for (int g = 0; g < groups.Length; g++)
{
double groupWidth = Math.Max(20, widths[g] + spare);
using (context.PushClip(new Rect(x, top, groupWidth, size.Height - top)))
{
context.DrawText(MapView.Text(Strings[groups[g].Title], Palette.DimText, 10, FontWeight.SemiBold), new Point(x, top));
double lampY = top + 16;
foreach (var (text, brush) in groups[g].Lamps)
{
Lamp(context, text, brush, x, lampY);
lampY += LampRow;
}
bottom = Math.Max(bottom, lampY);
}
x += groupWidth;
}
// The doors under them, in two columns: open, permitted, the departure signal, the steps and the lock.
top = Math.Max(bottom, top + 16 + 9 * LampRow) + 6;
context.DrawText(MapView.Text(Strings["CabGroupDoors"], Palette.DimText, 10, FontWeight.SemiBold), new Point(Pad, top));
var doors = Doors(s);
double doorColumn = width / DoorColumns;
for (int i = 0; i < doors.Length; i++)
{
double doorX = Pad + i % DoorColumns * doorColumn;
using (context.PushClip(new Rect(doorX, top, doorColumn, size.Height - top)))
Lamp(context, doors[i].Text, doors[i].Lit, doorX, top + 16 + i / DoorColumns * LampRow);
}
}
/// <summary>
/// The traction, in two columns, as fits what drives the vehicle: the voltage, the current, the revolutions,
/// the power and the cruise control of an electric one; the revolutions, the current (or the clutch, or the
/// torque converter), the oil pressure and the temperatures of a diesel. A recording made before the engine
/// was written: those of an electric one it had then, no voltage for a vehicle which never had any.
/// </summary>
private void DrawDrive(DrawingContext context, CabSample s, double top, double width, CultureInfo culture)
{
var cells = new List<(string Label, string Value, IBrush? Brush)>();
void Add(string key, string value, IBrush? brush = null) => cells.Add((Strings[key], value, brush));
string voltage = _powered.Contains(s.Vehicle) ? $"{s.Voltage:0} V" : "—";
switch (s.Extra?.Engine)
{
case null:
Add("CabVoltage", voltage);
Add("CabCurrent", $"{s.MotorCurrent:0} A");
Add("CabRpm", $"{s.EngineRpm:0}");
break;
case EngineKind.Electric:
case EngineKind.ElectricInduction:
{
var e = s.Extra.Value;
Add("CabVoltage", voltage);
Add("CabCurrent", $"{s.MotorCurrent:0} A");
Add("CabRpm", $"{s.EngineRpm:0}");
Add("CabPower", $"{e.EnginePower:0} kW");
Add("CabSpeedControl", s.Has(CabDevices.SpeedControl) ? $"{e.SpeedControlKmh:0} km/h" : "—");
break;
}
case EngineKind.DieselElectric:
case EngineKind.Diesel:
{
var e = s.Extra.Value;
Add("CabRpm", $"{s.EngineRpm:0}");
if (e.Engine == EngineKind.DieselElectric)
Add("CabCurrent", $"{s.MotorCurrent:0} A");
else if (_hydraulic.Contains(s.Vehicle))
Add("CabConverterFill", $"{e.ConverterFill * 100:0} %");
else
Add("CabClutch", $"{e.Clutch * 100:0} %");
Add("CabOilPressure", string.Format(culture, "{0:0.00} MPa", e.OilPressure));
// the temperatures, warm when the engine is too hot
var heat = s.Has(CabDevices.EngineHot) ? Palette.Bad : null;
Add("CabWater", $"{e.WaterTemperature:0} °C", heat);
Add("CabOil", $"{e.OilTemperature:0} °C", heat);
Add("CabEngine", $"{e.EngineTemperature:0} °C", heat);
break;
}
case EngineKind.Steam:
Add("CabRpm", $"{s.EngineRpm:0}");
break;
default:
context.DrawText(MapView.Text(Strings["CabNoDrive"], Palette.DimText, 11), new Point(Pad, top + 2));
return;
}
double column = width / 2;
for (int i = 0; i < cells.Count; i++)
{
double x = Pad + i % 2 * column, y = top + i / 2 * DriveRow;
using (context.PushClip(new Rect(x, y, column - 4, DriveRow)))
Value(context, cells[i].Label, cells[i].Value, x, y, cells[i].Brush);
}
}
/// <summary>The setting of the brakes from its bits: G, P, R, R+Mg.</summary>
public static string BrakeSetting(byte bits)
{
var parts = new List<string>();
if ((bits & 1) != 0)
parts.Add("G");
if ((bits & 2) != 0)
parts.Add("P");
if ((bits & 4) != 0)
parts.Add("R");
if ((bits & 8) != 0)
parts.Add("Mg");
return string.Join("+", parts);
}
/// <summary>The mode of the brakes from its bits: PS, PN, EP, MED.</summary>
public static string BrakeMode(byte bits)
{
var parts = new List<string>();
if ((bits & 1) != 0)
parts.Add("PS");
if ((bits & 2) != 0)
parts.Add("PN");
if ((bits & 4) != 0)
parts.Add("EP");
if ((bits & 8) != 0)
parts.Add("MED");
return string.Join("+", parts);
}
/// <summary>The title of a part of the panel, with a line from it to the right (and a note at its end); the top of the part returned.</summary>
private static double Section(DrawingContext context, string title, double x, double y, double right, string? note = null)
{
var text = MapView.Text(title.ToUpper(CultureInfo.CurrentCulture), Palette.DimText, 9, FontWeight.SemiBold);
context.DrawText(text, new Point(x, y));
double end = right;
if (note is not null)
{
var noted = MapView.Text(note, Palette.Text, 10, FontWeight.SemiBold);
end = right - noted.Width - 8;
context.DrawText(noted, new Point(right - noted.Width, y + (text.Height - noted.Height) / 2));
}
double line = Math.Round(y + text.Height / 2) + 0.5;
if (x + text.Width + 8 < end)
context.DrawLine(new Pen(Palette.Grid, 1), new Point(x + text.Width + 8, line), new Point(end, line));
return y + 17;
}
/// <summary>
/// What is the same all the recording long for each vehicle: the highest step of the controller used, for
/// its bar, and which vehicles ever had a voltage, a main reservoir pipe, an integrated controller moved off
/// nought and a torque converter filled, to show the field shunting, the clutch or none for the others.
/// </summary>
private void Measure(Recording recording)
{
if (_measured == recording.Revision)
return;
int top = 1;
_powered.Clear();
_feedPipe.Clear();
_integrated.Clear();
_hydraulic.Clear();
foreach (var sample in recording.Cab)
{
top = Math.Max(top, sample.MainController);
if (sample.Voltage > 1)
_powered.Add(sample.Vehicle);
if (sample.Extra is not { } e)
continue;
if (e.FeedPipe > 0.1f)
_feedPipe.Add(sample.Vehicle);
if (Math.Abs(e.IntegratedController) > 0.01f)
_integrated.Add(sample.Vehicle);
if (e.ConverterFill > 0.01f)
_hydraulic.Add(sample.Vehicle);
}
_controllerTop = top;
_measured = recording.Revision;
}
private static void Value(DrawingContext context, string label, string value, double x, double y, IBrush? brush = null)
{
context.DrawText(MapView.Text(label, Palette.DimText, 10), new Point(x, y));
context.DrawText(MapView.Text(value, brush ?? Palette.Text, 14, FontWeight.SemiBold), new Point(x, y + 13));
}
/// <summary>The steps of the controller as a row of cells, lit up to the one it stands at.</summary>
private static void Steps(DrawingContext context, int step, int top, double x, double y, double width)
{
int cells = Math.Clamp(top, 1, 40);
double cell = width / cells;
for (int i = 0; i < cells; i++)
context.DrawRectangle(i < step ? Palette.Controller : Palette.LampOff, null,
new Rect(x + i * cell, y, Math.Max(1, cell - (cells > 20 ? 0.5 : 1.5)), 4), 1, 1);
}
/// <summary>A gauge: its name, a bar up to the value against the scale, a mark where it should stand, the value.</summary>
private static void Gauge(DrawingContext context, string label, float value, double scale, double? mark, string text,
IBrush brush, double x, double y, double width)
{
const double labelWidth = 118, valueWidth = 64;
context.DrawText(MapView.Text(label, Palette.DimText, 11), new Point(x, y));
double barX = x + labelWidth, barWidth = width - labelWidth - valueWidth;
var track = new Rect(barX, y + 5, barWidth, 6);
context.DrawRectangle(Palette.LampOff, null, track, 3, 3);
double filled = Math.Clamp(value / scale, 0, 1) * barWidth;
if (filled > 0.5)
context.DrawRectangle(brush, null, new Rect(barX, y + 5, filled, 6), 3, 3);
if (mark is { } m)
{
double mx = barX + m / scale * barWidth;
context.DrawLine(new Pen(Palette.Text, 1.5), new Point(mx, y + 2), new Point(mx, y + 14));
}
var v = MapView.Text(text, Palette.Text, 11, FontWeight.SemiBold);
context.DrawText(v, new Point(x + width - v.Width, y));
}
/// <summary>A value either way of nought: a bar from the middle, to the right above it.</summary>
private static void Balance(DrawingContext context, string label, float value, double scale, double x, double y, double width)
{
var culture = CultureInfo.CurrentCulture;
context.DrawText(MapView.Text(label, Palette.DimText, 10), new Point(x, y));
var v = MapView.Text($"{value.ToString("+0.00;−0.00;0.00", culture)} m/s²", Palette.Text, 11, FontWeight.SemiBold);
context.DrawText(v, new Point(x + width - v.Width, y));
double barY = y + 17, middle = x + width / 2;
context.DrawRectangle(Palette.LampOff, null, new Rect(x, barY, width, 5), 2.5, 2.5);
double length = Math.Clamp(value / scale, -1, 1) * width / 2;
var brush = Math.Abs(value) > scale * 0.7 ? Palette.Bad : value >= 0 ? Palette.Good : Palette.Brakes;
if (Math.Abs(length) > 0.5)
context.DrawRectangle(brush, null, new Rect(Math.Min(middle, middle + length), barY, Math.Abs(length), 5), 2.5, 2.5);
context.DrawLine(new Pen(Palette.DimText, 1), new Point(middle, barY - 2), new Point(middle, barY + 7));
}
/// <summary>A chip ending at x, its width returned.</summary>
private static double ChipLeft(DrawingContext context, string text, IBrush brush, double right, double y)
{
var label = MapView.Text(text, brush, 11, FontWeight.SemiBold);
var box = new Rect(right - label.Width - 12, y, label.Width + 12, label.Height + 4);
context.DrawRectangle(Palette.Faded(brush, 0.14), null, box, 4, 4);
context.DrawText(label, new Point(box.X + 6, y + 2));
return box.Width;
}
/// <summary>The lights of an end as they sit on the front of a locomotive: one up in the middle, two below.</summary>
private static void DrawFace(DrawingContext context, Lights lights, Point center)
{
context.DrawRectangle(null, new Pen(Palette.Grid, 1), new Rect(center.X - 17, center.Y - 15, 34, 22), 5, 5);
bool rearEnd = lights.HasFlag(Lights.RearEndSignals);
Light(context, center + new Vector(0, -8), lights.HasFlag(Lights.HeadlightUpper), false, false);
Light(context, center + new Vector(-10, 1), lights.HasFlag(Lights.HeadlightLeft) || lights.HasFlag(Lights.AuxiliaryLeft),
lights.HasFlag(Lights.RedMarkerLeft) || rearEnd, lights.HasFlag(Lights.HighBeamLeft));
Light(context, center + new Vector(10, 1), lights.HasFlag(Lights.HeadlightRight) || lights.HasFlag(Lights.AuxiliaryRight),
lights.HasFlag(Lights.RedMarkerRight) || rearEnd, lights.HasFlag(Lights.HighBeamRight));
}
private static void Light(DrawingContext context, Point center, bool white, bool red, bool high)
{
var fill = white ? Palette.LightWhite : red ? Palette.LightRed : Palette.LampOff;
double radius = high ? 4.5 : 3.5;
if (white || red)
context.DrawEllipse(Palette.Faded(fill, 0.25), null, center, radius + 3, radius + 3);
context.DrawEllipse(fill, null, center, radius, radius);
}
/// <summary>A device's lamp and its name: lit in its colour, the name bright; off, both faint.</summary>
private static void Lamp(DrawingContext context, string text, IBrush? lit, double x, double y)
{
var center = new Point(x + 4, y + 7);
if (lit is not null)
context.DrawEllipse(Palette.Faded(lit, 0.3), null, center, 6, 6);
context.DrawEllipse(lit ?? Palette.LampOff, null, center, 3.5, 3.5);
context.DrawText(MapView.Text(text, lit is null ? Palette.FaintText : Palette.Text, 10.5), new Point(x + 14, y));
}
/// <summary>
/// Every device but the doors, with the colour of its lamp if it was on, in the groups the panel shows. A
/// diesel has its pumps where an electric vehicle has its pantographs; a recording made before the engine
/// was written has the devices it had then.
/// </summary>
private (string Title, (string Text, IBrush? Lit)[] Lamps)[] Devices(CabSample s, CultureInfo culture)
{
(string, IBrush?) On(string key, CabDevices device, IBrush colour) => (Strings[key], s.Has(device) ? colour : null);
(string, IBrush?) Flag(string key, CabFlags flag, IBrush colour) => (Strings[key], (s.Flags & flag) != 0 ? colour : null);
IBrush? Security(SecurityFlags flag, IBrush colour) => s.Has(flag) ? colour : null;
var extra = s.Extra;
bool diesel = extra is { Engine: EngineKind.Diesel or EngineKind.DieselElectric };
(string, IBrush?) Horn()
{
if (!s.Has(CabDevices.Horn))
return (Strings["DevHorn"], null);
string? tone = extra?.Horn switch
{
1 => Strings["HornLow"],
2 => Strings["HornHigh"],
3 => Strings["HornBoth"],
_ => null
};
return (tone is null ? Strings["DevHorn"] : string.Format(culture, Strings["DevHornTone"], tone), Palette.LampWarning);
}
bool electric = extra is null or { Engine: EngineKind.Electric or EngineKind.ElectricInduction };
var power = new List<(string, IBrush?)> { On("DevBattery", CabDevices.Battery, Palette.LampOn), On("DevMainBreaker", CabDevices.MainBreaker, Palette.LampOn) };
if (electric)
{
power.Add(On("DevPantographFront", CabDevices.PantographFront, Palette.LampOn));
power.Add(On("DevPantographRear", CabDevices.PantographRear, Palette.LampOn));
}
if (diesel)
{
power.Add(On("DevFuelPump", CabDevices.FuelPump, Palette.LampOn));
power.Add(On("DevOilPump", CabDevices.OilPump, Palette.LampOn));
}
power.Add(On("DevConverter", CabDevices.Converter, Palette.LampOn));
power.Add(On("DevCompressor", CabDevices.Compressor, Palette.LampOn));
power.Add(On("DevLineContactors", CabDevices.LineContactors, Palette.LampOn));
if (extra is not null)
power.Add(On("DevMotorBlowers", CabDevices.MotorBlowers, Palette.LampOn));
power.Add(On("DevHeating", CabDevices.Heating, Palette.LampOn));
// the radio with its channel
var radio = extra is { RadioChannel: > 0 } r
? (string.Format(culture, Strings["DevRadioChannel"], r.RadioChannel), s.Has(CabDevices.Radio) ? Palette.LampOn : null)
: On("DevRadio", CabDevices.Radio, Palette.LampOn);
var driving = new List<(string, IBrush?)>
{
On("DevShuntMode", CabDevices.ShuntMode, Palette.LampOn), radio,
On("DevSanding", CabDevices.Sanding, Palette.LampWarning), Horn(),
On("DevSpringBrake", CabDevices.SpringBrake, Palette.LampWarning)
};
if (extra is not null)
{
driving.Add((Strings["DevHandBrake"], extra.Value.HandBrake > 0 ? Palette.LampWarning : null));
driving.Add(On("DevBrakeReleaser", CabDevices.BrakeReleaser, Palette.LampWarning));
driving.Add(On("DevSpeedControl", CabDevices.SpeedControl, Palette.LampOn));
}
var safety = new List<(string, IBrush?)>();
if (s.Security != SecurityFlags.None || s.CabEnd != 0)
{
// A recording which tells them apart.
safety.Add((Strings["DevVigilance"], Security(SecurityFlags.VigilanceBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.Vigilance, Palette.Vigilance)));
safety.Add((Strings["DevCabSignal"], Security(SecurityFlags.CabSignalBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.CabSignal, Palette.CabSignal)));
safety.Add((Strings["DevSecurityBraking"], Security(SecurityFlags.Braking, Palette.LampAlarm)));
}
else
safety.Add((Strings["DevAlerter"], (s.Flags & CabFlags.AlerterBeeping) != 0 ? Palette.LampAlarm
: (s.Flags & CabFlags.AlerterBlinking) != 0 ? Palette.LampWarning : null));
safety.Add(Flag("DevWheelSlip", CabFlags.WheelSlip, Palette.LampAlarm));
safety.Add(On("DevOverloadRelay", CabDevices.OverloadRelay, Palette.LampAlarm));
safety.Add(Flag("DevRadioStop", CabFlags.RadioStop, Palette.LampAlarm));
safety.Add(Flag("DevAlarmChain", CabFlags.AlarmChain, Palette.LampAlarm));
if (diesel)
safety.Add(On("DevEngineHot", CabDevices.EngineHot, Palette.LampAlarm));
return [("CabGroupPower", power.ToArray()), ("CabGroupDriving", driving.ToArray()), ("CabGroupSafety", safety.ToArray())];
}
/// <summary>The doors of the vehicle the user is in, in rows of two: open, the lock, the permits, the steps, the departure signal.</summary>
private (string Text, IBrush? Lit)[] Doors(CabSample s)
{
(string, IBrush?) On(string key, CabDevices device, IBrush colour) => (Strings[key], s.Has(device) ? colour : null);
var doors = new List<(string, IBrush?)>
{
On("DevDoorsLeft", CabDevices.DoorsLeft, Palette.LampWarning), On("DevDoorsRight", CabDevices.DoorsRight, Palette.LampWarning)
};
if (s.Extra is null)
return doors.ToArray();
doors.Add(On("DevDoorLock", CabDevices.DoorLock, Palette.LampOn));
doors.Add(On("DevDoorPermitLeft", CabDevices.DoorPermitLeft, Palette.LampOn));
doors.Add(On("DevDoorPermitRight", CabDevices.DoorPermitRight, Palette.LampOn));
doors.Add(On("DevDoorsLocked", CabDevices.DoorsLocked, Palette.LampOn));
doors.Add(On("DevDoorStep", CabDevices.DoorStep, Palette.LampOn));
doors.Add(On("DevDepartureSignal", CabDevices.DepartureSignal, Palette.LampWarning));
return doors.ToArray();
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- The window that replays a black box: the map, the timeline, the events. Code
only, no XAML loaded at run time, so it goes through NativeAOT and trimming
in the starter and in the viewer alike. -->
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<IsAotCompatible>true</IsAotCompatible>
<IsTrimmable>true</IsTrimmable>
<RootNamespace>Eu07.BlackBox.Views</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Eu07.BlackBox\Eu07.BlackBox.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,382 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>One line of the list of events.</summary>
public sealed record BlackBoxEvent(double Time, string Text, IBrush Brush, bool Important);
/// <summary>
/// What happened to the user's train, by the clock: the commands, the autopilot, the
/// signals passed, the stations, the horn points, the couplings, the train protection,
/// the cab and the camera of the user. A click goes to its moment.
/// </summary>
public sealed class EventList : ListBox
{
private Recording? _recording;
private int _shownRevision = -1;
private List<BlackBoxEvent> _events = new();
private bool _picking;
public event Action<double>? Seek;
// The look of a list box: a control of a type of its own has no theme otherwise, and shows nothing.
protected override Type StyleKeyOverride => typeof(ListBox);
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
public EventList()
{
SelectionMode = SelectionMode.Single;
Padding = new Thickness(4, 0, 4, 6);
// The picked line in the starter's green.
Resources["ListBoxItemBackgroundSelected"] = Palette.AccentFill;
Resources["ListBoxItemBackgroundSelectedPointerOver"] = Palette.AccentFillHover;
Resources["ListBoxItemBackgroundSelectedPressed"] = Palette.AccentFill;
Resources["ListBoxItemBackgroundPointerOver"] = Palette.CardHover;
SelectionChanged += (_, _) =>
{
if (!_picking && SelectedIndex >= 0 && SelectedIndex < _events.Count)
Seek?.Invoke(_events[SelectedIndex].Time);
};
}
public Recording? Recording
{
get => _recording;
set
{
_recording = value;
_shownRevision = -1;
Refresh();
}
}
public IReadOnlyList<BlackBoxEvent> Events => _events;
/// <summary>Takes in what the recording gained; the list grows at its end, so what is scrolled to stays.</summary>
public void Refresh()
{
if (_recording is not { } recording)
{
_events.Clear();
Items.Clear();
return;
}
if (recording.Revision == _shownRevision)
return;
_shownRevision = recording.Revision;
var events = Collect(recording, Strings);
bool grown = events.Count >= _events.Count &&
_events.Select((e, i) => e == events[i]).All(same => same);
if (!grown)
Items.Clear();
for (int i = grown ? _events.Count : 0; i < events.Count; i++)
Items.Add(new ListBoxItem
{
Content = Line(recording, events[i]),
Padding = new Thickness(8, 4),
MinHeight = 0,
CornerRadius = new CornerRadius(6),
Margin = new Thickness(0, 1)
});
_events = events;
}
/// <summary>The event at or just before t picked, without going to it.</summary>
public void ShowTime(double t)
{
int index = -1;
for (int i = 0; i < _events.Count && _events[i].Time <= t + 0.05; i++)
index = i;
if (index == SelectedIndex)
return;
_picking = true;
try
{
SelectedIndex = index;
if (index >= 0)
ScrollIntoView(index);
}
finally
{
_picking = false;
}
}
private static Control Line(Recording recording, BlackBoxEvent e)
{
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("58,14,*") };
var time = new TextBlock
{
Text = recording.ClockAt(e.Time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture),
Foreground = Palette.FaintText,
FontSize = 11.5,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 1, 0, 0)
};
var dot = new Border
{
Width = e.Important ? 8 : 6,
Height = e.Important ? 8 : 6,
CornerRadius = new CornerRadius(4),
Background = e.Brush,
VerticalAlignment = VerticalAlignment.Top,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, e.Important ? 5 : 6, 0, 0)
};
var text = new TextBlock
{
Text = e.Text,
Foreground = e.Important ? Palette.Text : Palette.DimText,
FontSize = 12.5,
FontWeight = e.Important ? FontWeight.SemiBold : FontWeight.Normal,
TextWrapping = TextWrapping.Wrap,
VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(dot, 1);
Grid.SetColumn(text, 2);
grid.Children.Add(time);
grid.Children.Add(dot);
grid.Children.Add(text);
return grid;
}
/// <summary>The events of the user's train, in the order they happened.</summary>
public static List<BlackBoxEvent> Collect(Recording recording, IBlackBoxStrings strings)
{
var culture = CultureInfo.CurrentCulture;
var events = new List<BlackBoxEvent>();
foreach (var c in recording.UserCommands())
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCommand"], c.Name), Palette.Command, false));
foreach (var d in recording.DriverChanges)
{
if (recording.CabAt(d.Time + 0.5) is not { } s || s.Vehicle != d.Vehicle)
continue;
events.Add(new BlackBoxEvent(d.Time, strings[d.Driving == Driver.Ai ? "EventAutopilotOn" : "EventAutopilotOff"],
Palette.Autopilot, true));
}
foreach (var p in recording.UserPassings())
{
string name = recording.Signal(p.Signal)?.Name ?? p.Event;
events.Add(p.AtDanger
? new BlackBoxEvent(p.Time, string.Format(culture, strings["EventDanger"], name, Math.Abs(p.SpeedKmh), p.Overrun), Palette.Over, true)
: new BlackBoxEvent(p.Time, string.Format(culture, strings["EventPassed"], name, MapView.AspectText(strings, p.Speed1, p.Speed2),
Math.Abs(p.SpeedKmh)), Palette.Of(new AspectChange(p.Time, p.Signal, p.Speed1, p.Speed2, "")), false));
}
AddUserVehicles(recording, strings, events, culture);
AddStations(recording, strings, events, culture);
foreach (var horn in recording.UserHornChecks())
events.Add(new BlackBoxEvent(horn.Request.Time, strings[horn.Sounded ? "EventHornSounded" : "EventHornMissed"],
horn.Sounded ? Palette.HornPoint : Palette.Bad, !horn.Sounded));
foreach (var span in recording.SecurityBrakings())
{
// Which of them was not acknowledged: the lamps blinking when it braked.
string who = recording.CabAt(span.Start) is { } s
? s.Has(SecurityFlags.CabSignal) && s.Has(SecurityFlags.Vigilance) ? "CA/SHP" : s.Has(SecurityFlags.CabSignal) ? "SHP" : "CA"
: "CA";
events.Add(new BlackBoxEvent(span.Start, string.Format(culture, strings["EventSecurityBraking"], who), Palette.Bad, true));
}
AddCameras(recording, strings, events, culture);
AddCouplings(recording, strings, events, culture);
AddCabChanges(recording, strings, events, culture);
foreach (var t in recording.Timetables)
events.Add(new BlackBoxEvent(t.Time, string.Format(culture, strings["EventTimetable"], $"{t.Category} {t.Train}".Trim(),
string.Format(culture, strings["Relation"], t.From, t.To)), Palette.Station, false));
if (recording.EndTime is { } end)
events.Add(new BlackBoxEvent(end, strings["EventEnd"], Palette.FaintText, false));
// Stable: what happened at the same moment stays in the order of its kind.
return events.OrderBy(e => e.Time).ToList();
}
private static void AddUserVehicles(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
{
UserVehicleChange? before = null;
foreach (var u in recording.UserVehicles)
{
string vehicle = recording.Vehicle(u.Vehicle)?.Name ?? "";
// Only the cab changed: told as such.
if (before is { } b && b.Vehicle == u.Vehicle && b.InCab == u.InCab && b.CabEnd != u.CabEnd && u.CabEnd != 0)
events.Add(new BlackBoxEvent(u.Time, string.Format(culture, strings["EventCab"], strings[u.CabEnd > 0 ? "CabEndFront" : "CabEndRear"], vehicle),
Palette.UserTrain, true));
else
{
string text = u.Vehicle == 0
? strings["EventNoVehicle"]
: string.Format(culture, strings[u.InCab ? "EventInVehicle" : "EventOutside"], vehicle);
events.Add(new BlackBoxEvent(u.Time, text, u.InCab ? Palette.UserTrain : Palette.DimText, true));
}
before = u;
}
}
private static void AddStations(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
{
foreach (var st in recording.UserStations())
{
switch (st.Kind)
{
case StationKind.Stopped:
{
float error = st.StopError;
string where = Math.Abs(error) < 1
? strings["TtStopExact"]
: string.Format(culture, strings[error > 0 ? "TtStopShort" : "TtStopPast"], Math.Abs(error));
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStopped"], StationNames.Display(st.Station), where), Palette.Station, true));
break;
}
case StationKind.Passed:
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStationPassed"], StationNames.Display(st.Station)), Palette.Station, false));
break;
case StationKind.Missed:
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStationMissed"], StationNames.Display(st.Station)), Palette.Bad, true));
break;
case StationKind.Ready:
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventReady"], StationNames.Display(st.Station)), Palette.Station, false));
break;
}
}
}
/// <summary>The camera of the user going to another mode: the cab, outside, free.</summary>
private static void AddCameras(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
{
CameraMode? mode = null;
foreach (var c in recording.Cameras)
{
if (c.Mode == mode)
continue;
if (mode is not null)
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCamera"], strings[CameraKey(c.Mode)]), Palette.DimText, false));
mode = c.Mode;
}
}
public static string CameraKey(CameraMode mode) => mode switch
{
CameraMode.Cab => "CameraCab",
CameraMode.External => "CameraExternal",
CameraMode.Debug => "CameraDebug",
_ => "CameraFree"
};
/// <summary>
/// The switches the driver worked, as the cab tells them: what came on and went off. Not the compressor and the
/// motor fans, which go on and off by themselves.
/// </summary>
private static readonly (CabDevices Device, string Key)[] Switched =
[
(CabDevices.MainBreaker, "DevMainBreaker"), (CabDevices.PantographFront, "DevPantographFront"),
(CabDevices.PantographRear, "DevPantographRear"), (CabDevices.FuelPump, "DevFuelPump"), (CabDevices.OilPump, "DevOilPump"),
(CabDevices.Converter, "DevConverter"),
(CabDevices.Heating, "DevHeating"), (CabDevices.Radio, "DevRadio"), (CabDevices.ShuntMode, "DevShuntMode"),
(CabDevices.SpringBrake, "DevSpringBrake"), (CabDevices.BrakeReleaser, "DevBrakeReleaser"),
(CabDevices.DoorPermitLeft, "DevDoorPermitLeft"), (CabDevices.DoorPermitRight, "DevDoorPermitRight"),
(CabDevices.DoorStep, "DevDoorStep"), (CabDevices.DoorLock, "DevDoorLock"), (CabDevices.SpeedControl, "DevSpeedControl")
];
/// <summary>
/// What the driver did in the cab, from one look to the next in the same vehicle: the switches on and off, the
/// doors opened and closed, the departure signal, the hand brake, the brakes' setting, the radio's channel, the
/// speed the cruise control is set to; and, as faults, moving with the doors open and the engine overheating.
/// </summary>
private static void AddCabChanges(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
{
CabSample? last = null;
bool movingOpen = false;
foreach (var s in recording.Cab)
{
if (last is not { } b || b.Vehicle != s.Vehicle)
{
last = s;
movingOpen = false;
continue;
}
bool Came(CabDevices device) => !b.Has(device) && s.Has(device);
bool Went(CabDevices device) => b.Has(device) && !s.Has(device);
foreach (var (device, key) in Switched)
{
if (Came(device))
events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventDeviceOn"], strings[key]), Palette.LampOn, false));
else if (Went(device))
events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventDeviceOff"], strings[key]), Palette.FaintText, false));
}
foreach (var (device, open, closed) in new[] { (CabDevices.DoorsLeft, "DevDoorsLeft", "EventDoorsLeftClosed"), (CabDevices.DoorsRight, "DevDoorsRight", "EventDoorsRightClosed") })
{
if (Came(device))
events.Add(new BlackBoxEvent(s.Time, strings[open], Palette.LampWarning, false));
else if (Went(device))
events.Add(new BlackBoxEvent(s.Time, strings[closed], Palette.FaintText, false));
}
if (Came(CabDevices.DepartureSignal))
events.Add(new BlackBoxEvent(s.Time, strings["DevDepartureSignal"], Palette.LampWarning, false));
if (Came(CabDevices.EngineHot))
events.Add(new BlackBoxEvent(s.Time, strings["DevEngineHot"], Palette.Bad, true));
// moving with the doors open: once, until they are closed or the train stands
bool doorsOpen = s.Has(CabDevices.DoorsLeft) || s.Has(CabDevices.DoorsRight);
if (doorsOpen && s.AbsSpeedKmh > 2 && !movingOpen)
{
events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventMovingDoorsOpen"], s.AbsSpeedKmh), Palette.Bad, true));
movingOpen = true;
}
else if (!doorsOpen || s.AbsSpeedKmh < 0.5)
movingOpen = false;
if (b.Extra is { } x && s.Extra is { } y)
{
if (x.HandBrake <= 0 && y.HandBrake > 0)
events.Add(new BlackBoxEvent(s.Time, strings["EventHandBrakeOn"], Palette.LampWarning, false));
else if (x.HandBrake > 0 && y.HandBrake <= 0)
events.Add(new BlackBoxEvent(s.Time, strings["EventHandBrakeOff"], Palette.FaintText, false));
if (x.BrakeDelay != y.BrakeDelay || x.BrakeMode != y.BrakeMode)
{
string setting = string.Join(" · ", new[] { CabPanel.BrakeSetting(y.BrakeDelay), CabPanel.BrakeMode(y.BrakeMode) }.Where(t => t.Length > 0));
events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventBrakeSetting"], setting), Palette.Brakes, false));
}
if (x.RadioChannel != y.RadioChannel && y.RadioChannel > 0)
events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventRadioChannel"], y.RadioChannel), Palette.LampOn, false));
// the speed set, while the cruise control holds one
if (s.Has(CabDevices.SpeedControl) && (Came(CabDevices.SpeedControl) || Math.Abs(x.SpeedControlKmh - y.SpeedControlKmh) >= 1))
events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventSpeedControlSet"], y.SpeedControlKmh), Palette.Target, false));
}
last = s;
}
}
private static void AddCouplings(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
{
string Name(int id) => recording.Vehicle(id)?.Name ?? id.ToString(culture);
foreach (var c in recording.UserCouplings())
{
if (c.Coupled)
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCoupled"], Name(c.Vehicle), Name(c.Other)), Palette.Good, true));
else if (c.Uncoupled)
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventUncoupled"], Name(c.Vehicle), Name(c.PreviousOther)), Palette.Warn, true));
else
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCouplingChanged"], Name(c.Vehicle), Name(c.Other),
CouplingText(strings, c.Flags)), Palette.DimText, false));
}
}
/// <summary>What a coupling joins, in words.</summary>
public static string CouplingText(IBlackBoxStrings strings, CouplingFlags flags)
{
var parts = new List<string>();
foreach (var (flag, key) in new[]
{
(CouplingFlags.Coupler, "CouplingCoupler"), (CouplingFlags.BrakePipe, "CouplingBrakePipe"),
(CouplingFlags.MainPipe, "CouplingMainPipe"), (CouplingFlags.Control, "CouplingControl"),
(CouplingFlags.Power, "CouplingPower"), (CouplingFlags.Heating, "CouplingHeating"),
(CouplingFlags.Gangway, "CouplingGangway")
})
if ((flags & flag) != 0)
parts.Add(strings[key]);
return parts.Count == 0 ? strings["CouplingNothing"] : string.Join(", ", parts);
}
}

View File

@@ -0,0 +1,766 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>
/// The map of the scenery at a moment of the recording: the rail tracks with the switches
/// as they lay, the signals in the colour of what they showed, every vehicle where it was,
/// and the way the user's train went, coloured by how its speed stood against the AI's.
/// North is up, as on the map in the simulator. Dragged to move, the wheel zooms, a
/// double click shows it all.
/// </summary>
public sealed class MapView : Control
{
private const double MinScale = 0.002;
private const double MaxScale = 40;
private const double FollowScale = 1.2;
private Recording? _recording;
private int _builtRevision = -1;
private int _builtTracks = -1;
private StreamGeometry? _tracks;
private readonly List<(int Index, int Branch, StreamGeometry Way)> _switchWays = new();
private readonly StreamGeometry?[] _trail = new StreamGeometry?[5];
private Rect _world;
private double _centerX, _centerZ;
private double _scale = 0.2; // pixels per metre
private bool _fitted;
private Point? _dragFrom;
private (double X, double Z) _dragCenter;
private Point? _pointer;
private double _time;
private bool _follow;
/// <summary>The user moved the map away from the train: following is off.</summary>
public event Action? FollowStopped;
public MapView()
{
ClipToBounds = true;
Focusable = true;
}
public Recording? Recording
{
get => _recording;
set
{
_recording = value;
_builtRevision = -1;
_builtTracks = -1;
_fitted = false;
InvalidateVisual();
}
}
public double Time
{
get => _time;
set
{
_time = value;
InvalidateVisual();
}
}
/// <summary>Keeps the user's train in the middle.</summary>
public bool Follow
{
get => _follow;
set
{
// Close enough to see the train go, unless already closer.
if (value && !_follow)
_scale = Math.Max(_scale, FollowScale);
_follow = value;
InvalidateVisual();
}
}
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
/// <summary>Closer by the factor given, about the middle.</summary>
public void ZoomBy(double factor)
{
_scale = Math.Clamp(_scale * factor, MinScale, MaxScale);
InvalidateVisual();
}
/// <summary>The map as a whole in view.</summary>
public void Fit()
{
FitWorld();
InvalidateVisual();
}
// Also from the render, which must not ask for another one.
private void FitWorld()
{
if (_world.Width <= 0 && _world.Height <= 0)
return;
var size = Bounds.Size;
if (size.Width <= 0 || size.Height <= 0)
return;
_centerX = -_world.Center.X;
_centerZ = -_world.Center.Y;
_scale = Math.Clamp(Math.Min(size.Width / Math.Max(_world.Width, 50), size.Height / Math.Max(_world.Height, 50)) * 0.92,
MinScale, MaxScale);
if (_follow)
_scale = Math.Max(_scale, FollowScale);
_fitted = true;
}
// The map's plane as the simulator's map draws it, seen from above: x to the left (the game's x runs the
// other way than a map's east), z up the screen.
private static Point Plane(double x, double z) => new(-x, -z);
private Point ToScreen(double x, double z) =>
new((_centerX - x) * _scale + Bounds.Width / 2, (_centerZ - z) * _scale + Bounds.Height / 2);
private (double X, double Z) ToWorld(Point screen) =>
(_centerX - (screen.X - Bounds.Width / 2) / _scale, _centerZ - (screen.Y - Bounds.Height / 2) / _scale);
private void Build(Recording recording)
{
if (recording.Tracks.Count != _builtTracks)
{
_builtTracks = recording.Tracks.Count;
_switchWays.Clear();
var tracks = new StreamGeometry();
double left = double.MaxValue, top = double.MaxValue, right = double.MinValue, bottom = double.MinValue;
using (var g = tracks.Open())
{
foreach (var track in recording.Tracks)
{
if (track.Points.Count < 2)
continue;
foreach (var p in track.Points)
{
left = Math.Min(left, -p.X);
right = Math.Max(right, -p.X);
top = Math.Min(top, -p.Z);
bottom = Math.Max(bottom, -p.Z);
}
if (track.Kind == TrackKind.SwitchBranch)
{
var way = new StreamGeometry();
using (var w = way.Open())
Polyline(w, track.Points);
_switchWays.Add((track.Index, track.Branch, way));
}
else
Polyline(g, track.Points);
}
}
_tracks = tracks;
_world = left <= right ? new Rect(new Point(left, top), new Point(right, bottom)) : default;
}
BuildTrail(recording);
_builtRevision = recording.Revision;
}
private static void Polyline(StreamGeometryContext g, IReadOnlyList<System.Numerics.Vector3> points)
{
g.BeginFigure(Plane(points[0].X, points[0].Z), false);
for (int i = 1; i < points.Count; i++)
g.LineTo(Plane(points[i].X, points[i].Z));
g.EndFigure(false);
}
/// <summary>The way the user's train went, a geometry for each judgement of its speed.</summary>
private void BuildTrail(Recording recording)
{
var lines = new List<List<Point>>[_trail.Length];
for (int i = 0; i < lines.Length; i++)
lines[i] = new List<List<Point>>();
var cab = recording.Cab;
int open = -1;
for (int i = 1; i < cab.Count; i++)
{
var before = cab[i - 1];
var sample = cab[i];
if (sample.Vehicle != before.Vehicle || sample.Time - before.Time > 1.0)
{
open = -1;
continue;
}
if (before.Position.DistanceTo(sample.Position) < 0.5 && open >= 0)
continue;
int kind = (int)DrivingSummary.Judge(sample);
if (kind != open)
{
lines[kind].Add(new List<Point> { Plane(before.Position.X, before.Position.Z) });
open = kind;
}
lines[kind][^1].Add(Plane(sample.Position.X, sample.Position.Z));
}
for (int kind = 0; kind < _trail.Length; kind++)
{
if (lines[kind].Count == 0)
{
_trail[kind] = null;
continue;
}
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
foreach (var line in lines[kind].Where(l => l.Count > 1))
{
g.BeginFigure(line[0], false);
foreach (var p in line.Skip(1))
g.LineTo(p);
g.EndFigure(false);
}
}
_trail[kind] = geometry;
}
}
public override void Render(DrawingContext context)
{
var size = Bounds.Size;
context.FillRectangle(Palette.MapBackground, new Rect(size));
var recording = _recording;
if (recording is null)
return;
if (recording.Revision != _builtRevision)
Build(recording);
if (!_fitted)
FitWorld();
if (_follow && recording.CabPositionAt(_time) is { } followed)
{
_centerX = followed.X;
_centerZ = followed.Z;
}
var toScreen = Matrix.CreateTranslation(_centerX, _centerZ) * Matrix.CreateScale(_scale, _scale) *
Matrix.CreateTranslation(size.Width / 2, size.Height / 2);
double pixel = 1 / _scale;
using (context.PushTransform(toScreen))
{
// The tracks on a dark casing, so they stand out where they cross and run close.
var casing = new Pen(Palette.TrackCasing, 5 * pixel, lineCap: PenLineCap.Round);
if (_tracks is not null)
context.DrawGeometry(null, casing, _tracks);
foreach (var (_, _, way) in _switchWays)
context.DrawGeometry(null, casing, way);
if (_tracks is not null)
context.DrawGeometry(null, new Pen(Palette.Track, 1.7 * pixel, lineCap: PenLineCap.Round), _tracks);
var off = new Pen(Palette.SwitchOff, 1.7 * pixel, lineCap: PenLineCap.Round);
var on = new Pen(Palette.Track, 1.7 * pixel, lineCap: PenLineCap.Round);
// The ways not taken under those taken.
foreach (var (index, branch, way) in _switchWays)
{
int state = recording.SwitchAt(index, _time);
if (state >= 0 && state != branch)
context.DrawGeometry(null, off, way);
}
foreach (var (index, branch, way) in _switchWays)
{
int state = recording.SwitchAt(index, _time);
if (state < 0 || state == branch)
context.DrawGeometry(null, on, way);
}
// The way the train went, in the colour of the judgement.
for (int kind = 0; kind < _trail.Length; kind++)
if (_trail[kind] is { } trail)
context.DrawGeometry(null, new Pen(Palette.Of((SpeedJudgement)kind), 3.5 * pixel, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round), trail);
}
DrawStopPoints(context, recording);
DrawHornPoints(context, recording);
DrawSignals(context, recording);
DrawPassings(context, recording);
DrawVehicles(context, recording);
DrawUserTrain(context, recording);
DrawCamera(context, recording);
DrawScale(context, size);
DrawLegend(context, size);
DrawHover(context, recording);
}
private bool Visible(Point p, double margin = 20) =>
p.X >= -margin && p.Y >= -margin && p.X <= Bounds.Width + margin && p.Y <= Bounds.Height + margin;
private void DrawSignals(DrawingContext context, Recording recording)
{
double radius = _scale > 0.5 ? 4.5 : _scale > 0.1 ? 3.5 : 2.5;
var outline = new Pen(Palette.Background, 1);
foreach (var signal in recording.Signals)
{
var p = ToScreen(signal.Position.X, signal.Position.Z);
if (!Visible(p))
continue;
context.DrawEllipse(Palette.Of(recording.AspectAt(signal.Index, _time)), outline, p, radius, radius);
}
}
/// <summary>
/// The stop points (W4): a white plate, blue where the user's train stopped, red where it
/// missed the stop; the name of the station once close enough to read it.
/// </summary>
// The way the trains a stop point is for go: a wedge ahead of the plate, as a train sees the W4 coming.
private static void DrawStopWay(DrawingContext context, Point plate, float heading, double size, IBrush fill, IPen outline)
{
// the map shows x to the left, z up: a heading from +z towards +x turns up the screen and to the left
var ahead = new Vector(-Math.Sin(heading), -Math.Cos(heading));
var across = new Vector(-ahead.Y, ahead.X);
var tip = plate + ahead * (size * 4.2);
var root = plate + ahead * (size * 1.8);
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
g.BeginFigure(tip, true);
g.LineTo(root + across * (size * 1.3));
g.LineTo(root - across * (size * 1.3));
g.EndFigure(true);
}
context.DrawGeometry(fill, outline, geometry);
}
private void DrawStopPoints(DrawingContext context, Recording recording)
{
if (recording.StopPoints.Count == 0)
return;
var outcomes = new Dictionary<(double, double), StationKind>();
foreach (var station in recording.UserStations())
{
if (station.Time > _time || station.Kind is not (StationKind.Stopped or StationKind.Missed))
continue;
var key = (Math.Round(station.Position.X, 1), Math.Round(station.Position.Z, 1));
if (!outcomes.TryGetValue(key, out var was) || was != StationKind.Missed)
outcomes[key] = station.Kind;
}
// Close up every plate is named; from afar each station once, where the names don't cover each other.
bool near = _scale > 0.35;
double size = _scale > 0.5 ? 4 : 3;
var outline = new Pen(Palette.MapBackground, 1);
var labels = new List<(Point At, string Name, IBrush Fill)>();
foreach (var point in recording.StopPoints)
{
var p = ToScreen(point.Position.X, point.Position.Z);
if (!Visible(p))
continue;
var fill = outcomes.TryGetValue((Math.Round(point.Position.X, 1), Math.Round(point.Position.Z, 1)), out var kind)
? kind == StationKind.Missed ? Palette.Bad : Palette.Station
: Palette.StopPoint;
var plate = new Rect(p.X - size, p.Y - size * 1.3, size * 2, size * 2.6);
// a W32 hollow, in the colour a W4 would be filled with
if (point.ForShortTrains)
context.DrawRectangle(Palette.MapBackground, new Pen(fill, 1.5), plate, 1.5, 1.5);
else
context.DrawRectangle(fill, outline, plate, 1.5, 1.5);
if (point.HasHeading)
DrawStopWay(context, p, point.Heading, size, fill, outline);
labels.Add((p, StationNames.Display(point.Station), fill));
}
var delays = StationProgress.StationDelays(recording, _time);
var named = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var taken = new List<Rect>();
// The label of the user's train stays clear.
if (recording.CabPositionAt(_time) is { } train)
{
var mark = ToScreen(train.X, train.Z);
taken.Add(new Rect(mark.X - 10, mark.Y - 24, 170, 34));
}
var culture = System.Globalization.CultureInfo.CurrentCulture;
foreach (var (p, name, fill) in labels)
{
if (!near && !named.Add(name))
continue;
var text = Text(name, Palette.Text, near ? 11 : 10);
FormattedText? late = delays.TryGetValue(name, out var minutes)
? Text(string.Format(culture, Strings["Minutes"], minutes), Palette.Delay(minutes), near ? 11 : 10)
: null;
double width = text.Width + (late is null ? 0 : late.Width + 6) + 8;
var box = new Rect(p.X + size + 3, p.Y - text.Height / 2 - 1, width, text.Height + 2);
if (taken.Exists(r => r.Inflate(2).Intersects(box)))
continue;
taken.Add(box);
context.DrawRectangle(Palette.Faded(Palette.MapBackground, 0.8), null, box);
context.DrawText(text, new Point(box.X + 4, box.Y + 1));
if (late is not null)
context.DrawText(late, new Point(box.X + 4 + text.Width + 6, box.Y + 1));
}
}
/// <summary>The horn points (W6): a diamond, green where the user sounded the horn, red where not.</summary>
private void DrawHornPoints(DrawingContext context, Recording recording)
{
if (recording.HornPoints.Count == 0)
return;
var checks = recording.UserHornChecks().Where(c => c.Request.Time <= _time).ToList();
double size = _scale > 0.5 ? 5 : 3.5;
var outline = new Pen(Palette.MapBackground, 1);
foreach (var point in recording.HornPoints)
{
var p = ToScreen(point.Position.X, point.Position.Z);
if (!Visible(p))
continue;
var check = checks.LastOrDefault(c => c.Request.Position.DistanceTo(point.Position) < 1);
var fill = check is null ? Palette.HornPoint : check.Sounded ? Palette.Good : Palette.Bad;
context.DrawGeometry(fill, outline, Diamond(p, size));
}
}
internal static StreamGeometry Diamond(Point p, double size)
{
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
g.BeginFigure(p + new Vector(0, -size), true);
g.LineTo(p + new Vector(size, 0));
g.LineTo(p + new Vector(0, size));
g.LineTo(p + new Vector(-size, 0));
g.EndFigure(true);
}
return geometry;
}
/// <summary>Where the user looked from, when not from the cab: a dot with a wedge of where it looked.</summary>
private void DrawCamera(DrawingContext context, Recording recording)
{
if (recording.CameraAt(_time) is not { } camera || camera.Mode == CameraMode.Cab)
return;
var p = ToScreen(camera.Position.X, camera.Position.Z);
if (!Visible(p, 60))
return;
// The heading from +z towards +x: up the screen and to the left.
const double reach = 46, half = 0.45;
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
g.BeginFigure(p, true);
g.LineTo(p + new Vector(-Math.Sin(camera.Heading - half), -Math.Cos(camera.Heading - half)) * reach);
g.ArcTo(p + new Vector(-Math.Sin(camera.Heading + half), -Math.Cos(camera.Heading + half)) * reach, new Size(reach, reach), 0,
false, SweepDirection.CounterClockwise);
g.EndFigure(true);
}
context.DrawGeometry(Palette.Faded(Palette.HornPoint, 0.18), new Pen(Palette.Faded(Palette.HornPoint, 0.5), 1), geometry);
context.DrawEllipse(Palette.HornPoint, new Pen(Palette.MapBackground, 1.5), p, 4.5, 4.5);
}
/// <summary>Where the user's train passed signals; those at danger ringed red.</summary>
private void DrawPassings(DrawingContext context, Recording recording)
{
var passed = new Pen(Palette.Within, 1.5);
var danger = new Pen(Palette.Over, 2.5);
foreach (var passing in recording.UserPassings())
{
if (passing.Time > _time)
continue;
var p = ToScreen(passing.Position.X, passing.Position.Z);
if (!Visible(p))
continue;
if (passing.AtDanger)
context.DrawEllipse(null, danger, p, 10, 10);
else
context.DrawEllipse(null, passed, p, 7, 7);
}
}
private void DrawVehicles(DrawingContext context, Recording recording)
{
int user = recording.CabAt(_time) is { } sample && _time - sample.Time < 1.5 ? sample.Vehicle : 0;
foreach (var state in recording.VehiclesAt(_time))
{
var info = recording.Vehicle(state.Id);
var brush = state.Id == user ? Palette.UserTrain : state.Driving == Driver.Ai ? Palette.VehicleAi : Palette.Vehicle;
DrawVehicle(context, state.Position.X, state.Position.Z, state.Heading, info?.Length ?? 15, info?.Width ?? 3, brush);
}
}
private void DrawVehicle(DrawingContext context, double x, double z, double heading, double length, double width,
IBrush brush)
{
var center = ToScreen(x, z);
if (!Visible(center, length * _scale + 10))
return;
// The front from +z towards +x: up the screen and to the left.
double fx = Math.Sin(heading), fz = Math.Cos(heading);
double half = Math.Max(length * _scale, 5) / 2, side = Math.Max(width * _scale, 3) / 2;
var front = new Vector(-fx, -fz);
var left = new Vector(-fz, fx);
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
g.BeginFigure(center + front * half + left * side, true);
g.LineTo(center + front * half - left * side);
g.LineTo(center - front * half - left * side);
g.LineTo(center - front * half + left * side);
g.EndFigure(true);
}
context.DrawGeometry(brush, new Pen(Palette.Background, 0.8), geometry);
}
/// <summary>The head of the user's train between its samples, ten a second, on top of everything.</summary>
private void DrawUserTrain(DrawingContext context, Recording recording)
{
if (recording.CabPositionAt(_time) is not { } position)
return;
var p = ToScreen(position.X, position.Z);
context.DrawEllipse(Palette.MapBackground, new Pen(Palette.UserTrain, 2), p, 7, 7);
context.DrawEllipse(Palette.UserTrain, null, p, 3, 3);
// The train and how late it is, by its mark.
if (StationProgress.DelayAt(recording, _time) is not { } delay)
return;
var culture = System.Globalization.CultureInfo.CurrentCulture;
var train = Text(delay.Train, Palette.UserTrain, 11, FontWeight.SemiBold);
var late = Text(Math.Abs(delay.Minutes) < 0.5 ? Strings["OnTime"] : string.Format(culture, Strings["Minutes"], delay.Minutes),
Palette.Delay(delay.Minutes), 11, FontWeight.SemiBold);
var box = TrainLabel(p, train, late);
context.DrawRectangle(Palette.Faded(Palette.MapBackground, 0.85), null, box);
context.DrawText(train, new Point(box.X + 4, box.Y + 2));
context.DrawText(late, new Point(box.X + train.Width + 12, box.Y + 2));
}
/// <summary>Where the label of the user's train goes, by its mark.</summary>
private static Rect TrainLabel(Point mark, FormattedText train, FormattedText late) =>
new(mark.X + 12, mark.Y - 22, train.Width + late.Width + 16, train.Height + 4);
private void DrawScale(DrawingContext context, Size size)
{
double metres = Nice(120 / _scale);
double length = metres * _scale;
var y = size.Height - 14;
var pen = new Pen(Palette.DimText, 1.5);
context.DrawLine(pen, new Point(size.Width - 16 - length, y), new Point(size.Width - 16, y));
context.DrawLine(pen, new Point(size.Width - 16 - length, y - 4), new Point(size.Width - 16 - length, y + 1));
context.DrawLine(pen, new Point(size.Width - 16, y - 4), new Point(size.Width - 16, y + 1));
var text = Text(metres >= 1000 ? $"{metres / 1000:0.#} km" : $"{metres:0} m", Palette.DimText, 11);
context.DrawText(text, new Point(size.Width - 16 - length / 2 - text.Width / 2, y - 18));
}
private void DrawLegend(DrawingContext context, Size size)
{
var rows = new (IBrush Brush, string Text)[]
{
(Palette.Within, Strings["LegendWithin"]),
(Palette.Slightly, string.Format(CultureInfo.CurrentCulture, Strings["LegendSlightly"], DrivingSummary.DefaultTolerance)),
(Palette.Over, string.Format(CultureInfo.CurrentCulture, Strings["LegendOver"], DrivingSummary.DefaultTolerance)),
(Palette.Autopilot, Strings["LegendAutopilot"]),
(Palette.NoData, Strings["LegendNoData"])
};
var marks = new List<(IBrush Brush, string Text, int Shape)>();
if (_recording is { } r)
{
if (r.StopPoints.Any(point => !point.ForShortTrains))
marks.Add((Palette.StopPoint, Strings["LegendStopPoint"], 0));
if (r.StopPoints.Any(point => point.ForShortTrains))
marks.Add((Palette.StopPoint, Strings["LegendShortStop"], 3));
if (r.HornPoints.Count > 0)
marks.Add((Palette.HornPoint, Strings["LegendHorn"], 1));
if (r.Cameras.Count > 0)
marks.Add((Palette.HornPoint, Strings["LegendCamera"], 2));
}
// Each part under a title of its own, in the text's colour: the dim one is hard to read on the map.
var title = Text(Strings["LegendTitle"], Palette.DimText, 11);
var marksTitle = Text(Strings["LegendMarks"], Palette.DimText, 11);
var texts = rows.Select(row => Text(row.Text, Palette.Text, 12)).Concat(marks.Select(m => Text(m.Text, Palette.Text, 12))).ToList();
const double line = 19, heading = 18, gap = 8;
double width = Math.Max(texts.Max(t => t.Width) + 44, Math.Max(title.Width, marks.Count > 0 ? marksTitle.Width : 0) + 24);
double height = 10 + heading + rows.Length * line + (marks.Count > 0 ? gap + heading + marks.Count * line : 0) + 6;
var box = new Rect(10, size.Height - height - 10, width, height);
context.DrawRectangle(Palette.Faded(Palette.Card, 0.96), new Pen(Palette.CardBorder, 1), box, 3, 3);
double y = box.Top + 8;
context.DrawText(title, new Point(box.Left + 12, y));
y += heading;
for (int i = 0; i < rows.Length; i++, y += line)
{
context.DrawLine(new Pen(rows[i].Brush, 5, lineCap: PenLineCap.Round), new Point(box.Left + 13, y + 9), new Point(box.Left + 27, y + 9));
context.DrawText(texts[i], new Point(box.Left + 36, y));
}
if (marks.Count > 0)
{
y += gap;
context.DrawLine(new Pen(Palette.Grid, 1), new Point(box.Left + 10, y - gap / 2), new Point(box.Right - 10, y - gap / 2));
context.DrawText(marksTitle, new Point(box.Left + 12, y));
y += heading;
}
for (int i = 0; i < marks.Count; i++, y += line)
{
var c = new Point(box.Left + 20, y + 9);
if (marks[i].Shape == 0)
context.DrawRectangle(marks[i].Brush, null, new Rect(c.X - 3, c.Y - 4, 6, 8), 1.5, 1.5);
else if (marks[i].Shape == 3)
context.DrawRectangle(null, new Pen(marks[i].Brush, 1.5), new Rect(c.X - 3, c.Y - 4, 6, 8), 1.5, 1.5);
else if (marks[i].Shape == 1)
context.DrawGeometry(marks[i].Brush, null, Diamond(c, 4.5));
else
context.DrawEllipse(marks[i].Brush, null, c, 4, 4);
context.DrawText(texts[rows.Length + i], new Point(box.Left + 36, y));
}
}
/// <summary>The name of the signal or the vehicle under the pointer.</summary>
private void DrawHover(DrawingContext context, Recording recording)
{
if (_pointer is not { } pointer || _dragFrom is not null)
return;
string? label = null;
double best = 10;
foreach (var signal in recording.Signals)
{
double d = Distance(ToScreen(signal.Position.X, signal.Position.Z), pointer);
if (d < best)
{
best = d;
var aspect = recording.AspectAt(signal.Index, _time);
label = aspect is { } a ? $"{signal.Name} · {AspectText(Strings, a.Speed1, a.Speed2)}" : signal.Name;
}
}
var culture = CultureInfo.CurrentCulture;
foreach (var point in recording.StopPoints)
{
double d = Distance(ToScreen(point.Position.X, point.Position.Z), pointer);
if (d < best)
{
best = d;
label = string.Format(culture, Strings[point.ForShortTrains ? "ShortStopHover" : "StopPointHover"], StationNames.Display(point.Station));
if (point.Platform > 0)
label += " · " + string.Format(culture, Strings["PlatformHover"], point.PlatformLength);
else if (point.Platform < 0)
label += " · " + string.Format(culture, Strings["ShortTrainsHover"], point.PlatformLength);
// the preferred place to stop, as the driver reads it: the head, or the middle of the train
if (point.StopAt < 0)
label += " · " + string.Format(culture, Strings["StopHeadHover"], -point.StopAt);
else if (point.StopAt > 0)
label += " · " + string.Format(culture, Strings["StopMiddleHover"], point.StopAt);
}
}
foreach (var point in recording.HornPoints)
{
double d = Distance(ToScreen(point.Position.X, point.Position.Z), pointer);
if (d < best)
{
best = d;
label = string.Format(culture, Strings["HornHover"], point.Name, point.Duration);
}
}
foreach (var state in recording.VehiclesAt(_time))
{
double d = Distance(ToScreen(state.Position.X, state.Position.Z), pointer);
if (d < best && recording.Vehicle(state.Id) is { } info)
{
best = d;
label = string.IsNullOrEmpty(info.Train)
? $"{info.Name} · {Math.Abs(state.SpeedKmh):0} km/h"
: $"{info.Name} ({info.Train}) · {Math.Abs(state.SpeedKmh):0} km/h";
}
}
if (label is null)
return;
var text = Text(label, Palette.Text, 12);
var box = new Rect(pointer.X + 14, pointer.Y - 12 - text.Height, text.Width + 16, text.Height + 8);
context.DrawRectangle(Palette.Faded(Palette.Card, 0.95), new Pen(Palette.CardBorder, 1), box, 6, 6);
context.DrawText(text, new Point(box.Left + 8, box.Top + 4));
}
/// <summary>An aspect as the speeds it gives: stop, a speed, or go.</summary>
public static string AspectText(IBlackBoxStrings strings, float speed1, float speed2)
{
if (speed1 == 0)
return strings["AspectStop"];
if (speed1 < 0 && speed2 < 0)
return strings["AspectFree"];
string first = speed1 < 0 ? "∞" : speed1.ToString("0", CultureInfo.InvariantCulture);
string second = speed2 < 0 ? "∞" : speed2.ToString("0", CultureInfo.InvariantCulture);
return $"{first}/{second}";
}
private static double Distance(Point a, Point b) => Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y));
private static double Nice(double value)
{
double power = Math.Pow(10, Math.Floor(Math.Log10(value)));
double unit = value / power;
return (unit < 2 ? 1 : unit < 5 ? 2 : 5) * power;
}
internal static FormattedText Text(string text, IBrush brush, double size, FontWeight weight = FontWeight.Normal) =>
new(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
weight == FontWeight.Normal ? Typeface.Default : new Typeface(Typeface.Default.FontFamily, FontStyle.Normal, weight),
size, brush);
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
var point = e.GetCurrentPoint(this);
if (!point.Properties.IsLeftButtonPressed)
return;
if (e.ClickCount == 2)
{
if (_follow)
StopFollowing();
Fit();
return;
}
_dragFrom = point.Position;
_dragCenter = (_centerX, _centerZ);
e.Pointer.Capture(this);
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
var position = e.GetPosition(this);
_pointer = position;
if (_dragFrom is { } from)
{
var moved = position - from;
if (_follow && (Math.Abs(moved.X) > 3 || Math.Abs(moved.Y) > 3))
StopFollowing();
if (!_follow)
{
_centerX = _dragCenter.X + moved.X / _scale;
_centerZ = _dragCenter.Z + moved.Y / _scale;
}
}
InvalidateVisual();
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
_dragFrom = null;
e.Pointer.Capture(null);
}
protected override void OnPointerExited(PointerEventArgs e)
{
base.OnPointerExited(e);
_pointer = null;
InvalidateVisual();
}
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{
base.OnPointerWheelChanged(e);
var at = e.GetPosition(this);
var (x, z) = ToWorld(at);
_scale = Math.Clamp(_scale * Math.Pow(1.25, e.Delta.Y), MinScale, MaxScale);
if (!_follow)
{
// The place under the pointer stays under it.
_centerX = x + (at.X - Bounds.Width / 2) / _scale;
_centerZ = z + (at.Y - Bounds.Height / 2) / _scale;
}
InvalidateVisual();
e.Handled = true;
}
private void StopFollowing()
{
_follow = false;
FollowStopped?.Invoke();
}
}

View File

@@ -0,0 +1,113 @@
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>
/// The colours the map, the timeline and the panels share: those of the starter's Carbon
/// look for the window, its green for what is picked, and colours of their own for the data.
/// </summary>
public static class Palette
{
// The window, the cards on it and the lines between.
public static readonly IBrush Background = Brush("#15171B");
public static readonly IBrush Panel = Brush("#1E2327");
public static readonly IBrush Card = Brush("#1E2327");
public static readonly IBrush CardHover = Brush("#2A3036");
public static readonly IBrush CardBorder = Brush("#3A424A");
public static readonly IBrush Grid = Brush("#2A3036");
public static readonly IBrush MapBackground = Brush("#121417");
public static readonly IBrush Text = Brush("#E6E8EA");
public static readonly IBrush DimText = Brush("#9098A0");
public static readonly IBrush FaintText = Brush("#646C74");
/// <summary>The headers of the panels, as the group boxes of the starter.</summary>
public static readonly IBrush Header = Brush("#2A3036");
/// <summary>The green of what is picked or on: bright for lines and text, deep for fills.</summary>
public static readonly IBrush Accent = Brush("#41C400");
public static readonly IBrush AccentFill = Brush("#177F00");
public static readonly IBrush AccentFillHover = Brush("#1E9000");
public static readonly IBrush AccentText = Brush("#FFFFFF");
public static readonly IBrush Good = Brush("#4cc46e");
/// <summary>A delay: green on time, yellow a little late, red late, blue early.</summary>
public static IBrush Delay(double? minutes) => minutes switch
{
null => Good,
> 3 => Bad,
> 0.5 => Warn,
< -0.5 => Station,
_ => Good
};
public static readonly IBrush Warn = Brush("#e8c547");
public static readonly IBrush Bad = Brush("#f0584a");
public static readonly IBrush Track = Brush("#6f7b88");
public static readonly IBrush TrackCasing = Brush("#1d232b");
public static readonly IBrush SwitchOff = Brush("#323a44");
public static readonly IBrush Vehicle = Brush("#c9a26b");
public static readonly IBrush VehicleAi = Brush("#9fb0c3");
public static readonly IBrush UserTrain = Brush("#ff9d1c");
public static readonly IBrush Cursor = Brush("#ffb000");
public static readonly IBrush Within = Brush("#4cc46e");
public static readonly IBrush Slightly = Brush("#e8c547");
public static readonly IBrush Over = Brush("#f0584a");
public static readonly IBrush Autopilot = Brush("#4a90e2");
public static readonly IBrush NoData = Brush("#5f6871");
public static readonly IBrush Speed = Brush("#f2f4f6");
public static readonly IBrush SpeedFill = new SolidColorBrush(Color.Parse("#f2f4f6"), 0.06).ToImmutable();
public static readonly IBrush Target = Brush("#4cc46e");
public static readonly IBrush Limit = Brush("#ff8c2a");
public static readonly IBrush SignalStop = Brush("#ff3b30");
public static readonly IBrush SignalLimited = Brush("#ffcc00");
public static readonly IBrush SignalFree = Brush("#34c759");
public static readonly IBrush SignalShunt = Brush("#e8f0ff");
public static readonly IBrush SignalShuntStop = Brush("#4a6cff");
public static readonly IBrush SignalUnknown = Brush("#808890");
public static readonly IBrush StopPoint = Brush("#f4f6f8");
public static readonly IBrush Station = Brush("#5aa9ff");
public static readonly IBrush HornPoint = Brush("#b9a6ff");
public static readonly IBrush Brakes = Brush("#e05a52");
public static readonly IBrush Controller = Brush("#2bb3a3");
public static readonly IBrush Vigilance = Brush("#e8c547");
public static readonly IBrush CabSignal = Brush("#f08a3a");
public static readonly IBrush LampOff = Brush("#252c35");
public static readonly IBrush LampOn = Brush("#4cc46e");
public static readonly IBrush LampWarning = Brush("#e8c547");
public static readonly IBrush LampAlarm = Brush("#f0584a");
public static readonly IBrush LightWhite = Brush("#fff6d8");
public static readonly IBrush LightRed = Brush("#ff3b30");
public static readonly IBrush Command = Brush("#8b96a3");
public static IBrush Of(SpeedJudgement judgement) => judgement switch
{
SpeedJudgement.Within => Within,
SpeedJudgement.Slightly => Slightly,
SpeedJudgement.Over => Over,
SpeedJudgement.Autopilot => Autopilot,
_ => NoData
};
/// <summary>What a signal shows: the colour of its main light, as the in-game map draws it.</summary>
public static IBrush Of(AspectChange? aspect)
{
if (aspect is not { } a)
return SignalUnknown;
bool shunt = a.Command.StartsWith("Shunt", System.StringComparison.OrdinalIgnoreCase);
if (a.Speed1 == 0)
return shunt ? SignalShuntStop : SignalStop;
if (shunt)
return SignalShunt;
return a.Speed1 < 0 && a.Speed2 != 0 ? SignalFree : SignalLimited;
}
/// <summary>A colour seen through: for fills under lines and glows.</summary>
public static IBrush Faded(IBrush brush, double opacity) =>
new SolidColorBrush(((ISolidColorBrush)brush).Color, opacity).ToImmutable();
private static IBrush Brush(string color) => new SolidColorBrush(Color.Parse(color)).ToImmutable();
}

View File

@@ -0,0 +1,704 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>
/// The user's train over the time of the recording, by the clock of the simulation: its
/// speed, the speed the AI would hold and the next limit it saw, below them bands of who
/// drove and how against the AI, of the controller, the brakes, the alerter (CA), the cab
/// signalling (SHP) and the camera, and marks of the commands, the signals passed, the
/// stations, the horn points and the couplings. A click goes to its moment; the wheel zooms
/// the time, dragging moves it.
/// </summary>
public sealed class TimelineView : Control
{
private const double Left = 56;
private const double Right = 12;
private const double Top = 36;
private const double BandHeight = 8;
private const double BandGap = 3;
private const double MarksHeight = 16;
private const double AxisHeight = 20;
private const int Bands = 6;
private Recording? _recording;
private double _time;
private double _start;
private double _secondsPerPixel = -1;
private Point? _pressed;
private double _pressedStart;
private bool _dragged;
private double? _hover;
private Point? _pointer;
private int _measured = -1;
private Recording? _measuredOf;
private double _speedTop = 40;
private int _controllerTop = 1;
/// <summary>A moment was clicked.</summary>
public event Action<double>? Seek;
/// <summary>The moment under the pointer, null once it leaves.</summary>
public event Action<double?>? Hovered;
public TimelineView()
{
ClipToBounds = true;
MinHeight = 160;
}
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
public Recording? Recording
{
get => _recording;
set
{
_recording = value;
_secondsPerPixel = -1;
_start = 0;
InvalidateVisual();
}
}
public double Time
{
get => _time;
set
{
_time = value;
// Kept in view while it plays.
if (_secondsPerPixel > 0 && _recording is not null)
{
double end = _start + PlotWidth * _secondsPerPixel;
if (value > end || value < _start)
_start = Math.Max(0, value - PlotWidth * _secondsPerPixel * 0.1);
}
InvalidateVisual();
}
}
/// <summary>All of the recording in view.</summary>
public void Fit()
{
_secondsPerPixel = -1;
_start = 0;
InvalidateVisual();
}
private double PlotWidth => Math.Max(10, Bounds.Width - Left - Right);
private double ChartBottom => Bounds.Height - AxisHeight - MarksHeight - Bands * (BandHeight + BandGap) - 6;
private double X(double t) => Left + (t - _start) / _secondsPerPixel;
private double TimeAt(double x) => _start + (x - Left) * _secondsPerPixel;
public override void Render(DrawingContext context)
{
var size = Bounds.Size;
var recording = _recording;
if (recording is null || recording.Duration <= 0)
return;
if (_secondsPerPixel <= 0)
_secondsPerPixel = Math.Max(recording.Duration, 60) / PlotWidth;
double end = _start + PlotWidth * _secondsPerPixel;
double chartBottom = ChartBottom;
Measure(recording);
double top = _speedTop;
double Y(double kmh) => chartBottom - Math.Clamp(kmh / top, 0, 1.05) * (chartBottom - Top);
using (context.PushClip(new Rect(Left, 0, PlotWidth, size.Height)))
{
DrawGrid(context, top, Y, size);
DrawCurves(context, recording, Y, end);
double bands = chartBottom + 6;
DrawBands(context, recording, bands, end);
DrawMarks(context, recording, bands + Bands * (BandHeight + BandGap), end);
DrawAxis(context, recording, size.Height - AxisHeight, end);
if (_hover is { } hover)
context.DrawLine(new Pen(Palette.DimText, 1, new DashStyle(new double[] { 2, 3 }, 0)),
new Point(X(hover), Top), new Point(X(hover), size.Height - AxisHeight));
double cursor = X(_time);
context.DrawLine(new Pen(Palette.Cursor, 1.5), new Point(cursor, Top - 6), new Point(cursor, size.Height - AxisHeight));
// The moment played, on the axis.
var clock = MapView.Text(recording.ClockAt(_time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture), Palette.Cursor, 10, FontWeight.SemiBold);
var bubble = new Rect(Math.Max(Left, Math.Min(cursor - clock.Width / 2 - 5, size.Width - Right - clock.Width - 10)), size.Height - AxisHeight + 2,
clock.Width + 10, clock.Height + 2);
context.DrawRectangle(Palette.Card, new Pen(Palette.Cursor, 1), bubble);
context.DrawText(clock, new Point(bubble.X + 5, bubble.Y + 1));
}
DrawSpeedLabels(context, top, Y);
DrawBandLabels(context, chartBottom + 6);
DrawKey(context);
if (MarkUnderPointer(recording, chartBottom) is { } mark)
DrawMarkLabel(context, mark, size);
}
/// <summary>The top of the speed scale and the highest step of the controller, once a reading.</summary>
private void Measure(Recording recording)
{
if (_measured == recording.Revision && ReferenceEquals(_measuredOf, recording))
return;
float max = 40;
int controller = 1;
foreach (var s in recording.Cab)
{
max = Math.Max(max, s.AbsSpeedKmh);
if (s.AiValid)
max = Math.Max(max, s.Ai.TargetKmh);
controller = Math.Max(controller, (int)s.MainController);
}
_speedTop = Math.Ceiling(max * 1.1 / 20) * 20;
_controllerTop = controller;
_measured = recording.Revision;
_measuredOf = recording;
}
private void DrawGrid(DrawingContext context, double top, Func<double, double> y, Size size)
{
var pen = new Pen(Palette.Grid, 1);
for (double v = 0; v <= top; v += top > 160 ? 40 : 20)
context.DrawLine(pen, new Point(Left, y(v)), new Point(size.Width - Right, y(v)));
}
private void DrawSpeedLabels(DrawingContext context, double top, Func<double, double> y)
{
for (double v = 0; v <= top; v += top > 160 ? 40 : 20)
{
var text = MapView.Text(v.ToString("0", CultureInfo.InvariantCulture), Palette.DimText, 10);
context.DrawText(text, new Point(Left - 6 - text.Width, y(v) - text.Height / 2));
}
}
/// <summary>
/// Each curve by columns of pixels: in a column the lowest and the highest speed,
/// so an hour draws as fast as a minute.
/// </summary>
private void DrawCurves(DrawingContext context, Recording recording, Func<double, double> y, double end)
{
var cab = recording.Cab;
int first = Math.Max(0, recording.CabIndexAt(_start));
var speed = new Curve();
var target = new Curve();
var limits = new List<Point>();
int column = int.MinValue;
CabSample? before = null;
for (int i = first; i < cab.Count; i++)
{
var s = cab[i];
if (s.Time > end + _secondsPerPixel * 2)
break;
bool gap = before is { } b && (s.Time - b.Time > 1.0 || s.Vehicle != b.Vehicle);
if (gap)
{
speed.Break();
target.Break();
}
int c = (int)Math.Round(X(s.Time));
speed.Add(c, y(s.AbsSpeedKmh));
// Under the autopilot too: what it aimed at.
if (s.AiValid)
target.Add(c, y(s.Ai.TargetKmh));
else
target.Break();
if (c != column && s.AiValid && s.Ai.HasNextLimit)
limits.Add(new Point(c, y(s.Ai.NextLimitKmh)));
column = c;
before = s;
}
if (speed.Fill(y(0)) is { } fill)
context.DrawGeometry(Palette.SpeedFill, null, fill);
if (target.Geometry() is { } t)
context.DrawGeometry(null, new Pen(Palette.Target, 1.5), t);
var limitPen = new Pen(Palette.Limit, 2);
foreach (var p in limits)
context.DrawLine(limitPen, p, p + new Vector(1, 0));
if (speed.Geometry() is { } v)
context.DrawGeometry(null, new Pen(Palette.Speed, 1.5), v);
}
private void DrawBands(DrawingContext context, Recording recording, double top, double end)
{
var cab = recording.Cab;
int first = Math.Max(0, recording.CabIndexAt(_start));
var cameras = recording.Cameras;
int camera = -1;
var rows = new BandRun[Bands];
for (int row = 0; row < rows.Length; row++)
rows[row] = new BandRun(context, top + row * (BandHeight + BandGap));
for (int i = first; i < cab.Count; i++)
{
var s = cab[i];
if (s.Time > end)
break;
double next = i + 1 < cab.Count && cab[i + 1].Time - s.Time <= 1.0 ? cab[i + 1].Time : s.Time + 0.1;
double x0 = X(s.Time), x1 = Math.Max(X(next), x0 + 0.6);
rows[0].Add(x0, x1, Palette.Of(DrivingSummary.Judge(s)), BandHeight);
// The controller as high as its step stands against the highest one used.
rows[1].Add(x0, x1, s.MainController > 0 ? Palette.Controller : null,
Math.Max(1.5, BandHeight * s.MainController / _controllerTop));
rows[2].Add(x0, x1, s.BrakeCylinder > 0.2f ? BrakeShade(s.BrakeCylinder) : null, BandHeight);
if (s.Security != SecurityFlags.None || s.CabEnd != 0)
{
// The alerter and the cab signalling apart, red when not acknowledged in time.
rows[3].Add(x0, x1, s.Has(SecurityFlags.VigilanceBeeping) ? Palette.LampAlarm
: s.Has(SecurityFlags.Vigilance) ? Palette.Vigilance : null, BandHeight);
rows[4].Add(x0, x1, s.Has(SecurityFlags.CabSignalBeeping) ? Palette.LampAlarm
: s.Has(SecurityFlags.CabSignal) ? Palette.CabSignal : null, BandHeight);
if (s.Has(SecurityFlags.Braking))
rows[s.Has(SecurityFlags.CabSignal) && !s.Has(SecurityFlags.Vigilance) ? 4 : 3].Add(x0, x1, Palette.Bad, BandHeight);
}
else
rows[3].Add(x0, x1, (s.Flags & CabFlags.AlerterBeeping) != 0 ? Palette.LampAlarm
: (s.Flags & CabFlags.AlerterBlinking) != 0 ? Palette.Vigilance : null, BandHeight);
// Where the user looked from: the samples go on in time, and so does the camera.
while (camera + 1 < cameras.Count && cameras[camera + 1].Time <= s.Time)
camera++;
rows[5].Add(x0, x1, camera < 0 ? null : CameraBrush(cameras[camera].Mode), BandHeight);
}
foreach (var row in rows)
row.Flush();
}
private static readonly IBrush CameraCab = Palette.Faded(Palette.UserTrain, 0.45);
private static readonly IBrush CameraOutside = Palette.Faded(Palette.HornPoint, 0.75);
private static readonly IBrush CameraFree = Palette.Faded(Palette.Station, 0.75);
private static IBrush CameraBrush(CameraMode mode) => mode switch
{
CameraMode.Cab => CameraCab,
CameraMode.External => CameraOutside,
_ => CameraFree
};
private static readonly IBrush[] BrakeShades = Shades(Palette.Brakes, 8);
private static IBrush[] Shades(IBrush brush, int count)
{
var colour = ((ISolidColorBrush)brush).Color;
var shades = new IBrush[count];
for (int i = 0; i < count; i++)
shades[i] = new SolidColorBrush(colour, 0.35 + 0.65 * i / (count - 1));
return shades;
}
/// <summary>The harder the brakes hold, the deeper the red.</summary>
private static IBrush BrakeShade(float cylinderBar) =>
BrakeShades[(int)Math.Round(Math.Clamp(cylinderBar / 5.0, 0, 1) * (BrakeShades.Length - 1))];
/// <summary>
/// The samples of a band drawn as few rectangles as the band changes: ten a second, each
/// narrower than a pixel, they would leave seams between them.
/// </summary>
private sealed class BandRun(DrawingContext context, double top)
{
private IBrush? _brush;
private double _height, _from, _to;
public void Add(double x0, double x1, IBrush? brush, double height)
{
if (brush is not null && ReferenceEquals(brush, _brush) && height == _height && x0 <= _to + 0.5)
{
_to = Math.Max(_to, x1);
return;
}
Flush();
_brush = brush;
_height = height;
_from = x0;
_to = x1;
}
public void Flush()
{
if (_brush is not null)
context.FillRectangle(_brush, new Rect(_from, top + BandHeight - _height, _to - _from, _height));
_brush = null;
}
}
private void DrawBandLabels(DrawingContext context, double top)
{
string[] names = { Strings["BandDriving"], Strings["BandController"], Strings["BandBrakes"], Strings["BandVigilance"],
Strings["BandCabSignal"], Strings["BandCamera"] };
for (int i = 0; i < names.Length; i++)
{
double y = top + i * (BandHeight + BandGap);
context.DrawRectangle(Palette.Faded(Palette.Grid, 0.5), null, new Rect(Left, y, Bounds.Width - Left - Right, BandHeight));
var text = MapView.Text(names[i], Palette.FaintText, 9);
context.DrawText(text, new Point(Left - 6 - text.Width, y + (BandHeight - text.Height) / 2));
}
var events = MapView.Text(Strings["BandEvents"], Palette.FaintText, 9);
double marks = top + Bands * (BandHeight + BandGap);
context.DrawText(events, new Point(Left - 6 - events.Width, marks + (MarksHeight - events.Height) / 2));
}
/// <summary>
/// The commands as short grey ticks, the autopilot switched in blue, the signals passed in
/// green, at danger tall and red; the stations as blue plates, the horn points as diamonds
/// green or red as the horn sounded, the couplings as yellow ticks.
/// </summary>
private void DrawMarks(DrawingContext context, Recording recording, double top, double end)
{
foreach (var station in recording.UserStations())
{
if (station.Time < _start || station.Time > end || station.Kind == StationKind.Ready)
continue;
double x = X(station.Time);
var brush = station.Kind == StationKind.Missed ? Palette.Bad : Palette.Station;
context.DrawLine(new Pen(Palette.Faded(brush, 0.35), 1), new Point(x, Top), new Point(x, top));
context.DrawRectangle(brush, null, new Rect(x - 3, Top - 13, 6, 8), 1.5, 1.5);
}
foreach (var horn in recording.UserHornChecks())
{
if (horn.Request.Time < _start || horn.Request.Time > end)
continue;
double x = X(horn.Request.Time);
var brush = horn.Sounded ? Palette.Good : Palette.Bad;
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
g.BeginFigure(new Point(x, Top - 14), true);
g.LineTo(new Point(x + 4, Top - 10));
g.LineTo(new Point(x, Top - 6));
g.LineTo(new Point(x - 4, Top - 10));
g.EndFigure(true);
}
context.DrawGeometry(brush, null, geometry);
}
var coupling = new Pen(Palette.Warn, 2);
foreach (var c in recording.UserCouplings())
if (c.Time >= _start && c.Time <= end)
context.DrawLine(coupling, new Point(X(c.Time), top + 1), new Point(X(c.Time), top + MarksHeight));
var command = new Pen(Palette.Command, 1);
foreach (var c in recording.UserCommands())
if (c.Time >= _start && c.Time <= end)
context.DrawLine(command, new Point(X(c.Time), top + MarksHeight - 5), new Point(X(c.Time), top + MarksHeight));
var autopilot = new Pen(Palette.Autopilot, 2);
foreach (var d in recording.DriverChanges)
if (d.Time >= _start && d.Time <= end)
context.DrawLine(autopilot, new Point(X(d.Time), top + 2), new Point(X(d.Time), top + MarksHeight));
foreach (var p in recording.UserPassings())
{
if (p.Time < _start || p.Time > end)
continue;
double x = X(p.Time);
if (p.AtDanger)
{
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
g.BeginFigure(new Point(x, Top - 4), true);
g.LineTo(new Point(x - 5, Top - 13));
g.LineTo(new Point(x + 5, Top - 13));
g.EndFigure(true);
}
context.DrawGeometry(Palette.Over, null, geometry);
context.DrawLine(new Pen(Palette.Over, 1.5), new Point(x, Top - 4), new Point(x, top + MarksHeight));
}
else
context.DrawLine(new Pen(Palette.Within, 1.5), new Point(x, top + 1), new Point(x, top + MarksHeight - 6));
}
}
private void DrawAxis(DrawingContext context, Recording recording, double y, double end)
{
double[] steps = { 10, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200 };
double step = steps[^1];
foreach (double candidate in steps)
if (candidate / _secondsPerPixel >= 80)
{
step = candidate;
break;
}
var pen = new Pen(Palette.DimText, 1);
// On the clock of the simulation: the marks fall on whole minutes and hours of it.
double offset = recording.ClockAt(0).TotalSeconds;
double first = Math.Ceiling((_start + offset) / step) * step - offset;
for (double t = first; t <= end; t += step)
{
double x = X(t);
context.DrawLine(pen, new Point(x, y), new Point(x, y + 4));
var clock = recording.ClockAt(t);
string label = step < 60 ? clock.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture) : clock.ToString(@"hh\:mm", CultureInfo.InvariantCulture);
var text = MapView.Text(label, Palette.DimText, 10);
context.DrawText(text, new Point(x - text.Width / 2, y + 4));
}
}
private void DrawKey(DrawingContext context)
{
const double y = 10;
double x = Left + 4, limit = Bounds.Width - Right;
bool Room(string key) => x + 18 + MapView.Text(Strings[key], Palette.DimText, 10).Width <= limit;
void Name(string key)
{
var text = MapView.Text(Strings[key], Palette.DimText, 10);
context.DrawText(text, new Point(x + 18, y - text.Height / 2));
x += 18 + text.Width + 14;
}
foreach (var (brush, key) in new[] { (Palette.Speed, "TimelineSpeed"), (Palette.Target, "TimelineTarget"), (Palette.Limit, "TimelineLimit") })
{
if (!Room(key))
return;
context.DrawLine(new Pen(brush, 2), new Point(x, y), new Point(x + 14, y));
Name(key);
}
// the marks, as they are drawn above the chart and in the row of the events
if (Room("KeyStation"))
{
context.DrawRectangle(Palette.Station, null, new Rect(x + 4, y - 4, 6, 8), 1.5, 1.5);
Name("KeyStation");
}
if (Room("KeyHorn"))
{
context.DrawGeometry(Palette.Good, null, MapView.Diamond(new Point(x + 7, y), 4));
Name("KeyHorn");
}
foreach (var (brush, key) in new[] { (Palette.Within, "KeySignal"), (Palette.Warn, "KeyCoupling"), (Palette.Autopilot, "KeyDriver"), (Palette.Command, "KeyCommand") })
{
if (!Room(key))
return;
context.DrawLine(new Pen(brush, 2), new Point(x + 7, y - 5), new Point(x + 7, y + 5));
Name(key);
}
}
/// <summary>What the mark under the pointer is: the nearest within a few pixels, in the row of the events or the plates above.</summary>
private string? MarkUnderPointer(Recording recording, double chartBottom)
{
if (_pointer is not { } pointer)
return null;
double marks = chartBottom + 6 + Bands * (BandHeight + BandGap);
bool inPlates = pointer.Y >= Top - 16 && pointer.Y <= Top - 2;
bool inMarks = pointer.Y >= marks - 2 && pointer.Y <= marks + MarksHeight + 2;
if (!inPlates && !inMarks || pointer.X < Left || pointer.X > Left + PlotWidth)
return null;
double end = _start + PlotWidth * _secondsPerPixel;
var culture = CultureInfo.CurrentCulture;
string? found = null;
double best = 5;
void Consider(double time, Func<string> text)
{
if (time < _start || time > end)
return;
double d = Math.Abs(X(time) - pointer.X);
if (d < best)
{
best = d;
found = text();
}
}
if (inPlates)
{
foreach (var station in recording.UserStations())
if (station.Kind != StationKind.Ready)
Consider(station.Time, () => string.Format(culture, Strings[station.Kind switch
{
StationKind.Stopped => "TlStationStopped",
StationKind.Missed => "TlStationMissed",
_ => "TlStationPassed"
}], StationNames.Display(station.Station)));
foreach (var horn in recording.UserHornChecks())
Consider(horn.Request.Time, () => Strings[horn.Sounded ? "TlHornSounded" : "TlHornSilent"]);
}
foreach (var p in recording.UserPassings())
{
if (!inMarks && !p.AtDanger)
continue;
Consider(p.Time, () =>
{
string name = recording.Signal(p.Signal)?.Name ?? p.Event;
return p.AtDanger
? string.Format(culture, Strings["TlSignalDanger"], name)
: string.Format(culture, Strings["TlSignal"], name, MapView.AspectText(Strings, p.Speed1, p.Speed2));
});
}
if (inMarks)
{
foreach (var c in recording.UserCouplings())
Consider(c.Time, () => Strings[c.Coupled ? "TlCoupled" : c.Uncoupled ? "TlUncoupled" : "TlCouplingChanged"]);
foreach (var d in recording.DriverChanges)
Consider(d.Time, () => Strings[d.Driving == Driver.Ai ? "TlAutopilotOn" : "TlAutopilotOff"]);
foreach (var c in recording.UserCommands())
Consider(c.Time, () => string.Format(culture, Strings["TlCommand"], c.Name));
}
return found;
}
private void DrawMarkLabel(DrawingContext context, string text, Size size)
{
if (_pointer is not { } pointer)
return;
var label = MapView.Text(text, Palette.Text, 11);
double x = Math.Max(Left, Math.Min(pointer.X + 10, size.Width - Right - label.Width - 12));
double y = Math.Max(2, pointer.Y - label.Height - 12);
var box = new Rect(x, y, label.Width + 12, label.Height + 6);
context.DrawRectangle(Palette.Card, new Pen(Palette.CardBorder, 1), box, 3, 3);
context.DrawText(label, new Point(x + 6, y + 3));
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
return;
if (e.ClickCount == 2)
{
Fit();
return;
}
_pressed = e.GetPosition(this);
_pressedStart = _start;
_dragged = false;
e.Pointer.Capture(this);
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
if (_recording is null || _secondsPerPixel <= 0)
return;
var position = e.GetPosition(this);
if (_pressed is { } from)
{
double moved = position.X - from.X;
if (Math.Abs(moved) > 3)
_dragged = true;
if (_dragged)
_start = Math.Clamp(_pressedStart - moved * _secondsPerPixel, 0, Math.Max(0, _recording.Duration - PlotWidth * _secondsPerPixel * 0.5));
}
_hover = position.X >= Left ? Math.Clamp(TimeAt(position.X), 0, _recording.Duration) : null;
_pointer = position;
Hovered?.Invoke(_hover);
InvalidateVisual();
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
if (_pressed is not null && !_dragged && _recording is not null && _secondsPerPixel > 0)
Seek?.Invoke(Math.Clamp(TimeAt(e.GetPosition(this).X), 0, _recording.Duration));
_pressed = null;
e.Pointer.Capture(null);
}
protected override void OnPointerExited(PointerEventArgs e)
{
base.OnPointerExited(e);
_hover = null;
_pointer = null;
Hovered?.Invoke(null);
InvalidateVisual();
}
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{
base.OnPointerWheelChanged(e);
if (_recording is null || _secondsPerPixel <= 0)
return;
double x = e.GetPosition(this).X;
double at = TimeAt(x);
double whole = Math.Max(_recording.Duration, 60) / PlotWidth;
_secondsPerPixel = Math.Clamp(_secondsPerPixel / Math.Pow(1.3, e.Delta.Y), 0.02, whole);
_start = Math.Max(0, at - (x - Left) * _secondsPerPixel);
InvalidateVisual();
e.Handled = true;
}
/// <summary>A line through columns of pixels: in each the first, the lowest, the highest and the last value.</summary>
private sealed class Curve
{
private readonly List<List<Point>> _lines = new();
private List<Point>? _line;
private int _column = int.MinValue;
private double _first, _low, _high, _last;
public void Add(int column, double y)
{
if (column != _column)
{
Flush();
_column = column;
_first = _low = _high = y;
}
_low = Math.Min(_low, y);
_high = Math.Max(_high, y);
_last = y;
}
public void Break()
{
Flush();
_line = null;
_column = int.MinValue;
}
private void Flush()
{
if (_column == int.MinValue)
return;
if (_line is null)
_lines.Add(_line = new List<Point>());
var (a, b) = _first <= _last ? (_low, _high) : (_high, _low);
_line.Add(new Point(_column, _first));
if (Math.Abs(_high - _low) > 0.5)
{
_line.Add(new Point(_column, a));
_line.Add(new Point(_column, b));
}
_line.Add(new Point(_column, _last));
}
/// <summary>The area under the line down to the bottom given, for a soft fill.</summary>
public StreamGeometry? Fill(double bottom)
{
Flush();
_column = int.MinValue;
var geometry = new StreamGeometry();
bool any = false;
using (var g = geometry.Open())
{
foreach (var line in _lines)
{
if (line.Count < 2)
continue;
g.BeginFigure(new Point(line[0].X, bottom), true);
foreach (var point in line)
g.LineTo(point);
g.LineTo(new Point(line[^1].X, bottom));
g.EndFigure(true);
any = true;
}
}
return any ? geometry : null;
}
public StreamGeometry? Geometry()
{
Flush();
_column = int.MinValue;
var geometry = new StreamGeometry();
bool any = false;
using (var g = geometry.Open())
{
foreach (var line in _lines)
{
if (line.Count < 2)
continue;
g.BeginFigure(line[0], false);
for (int i = 1; i < line.Count; i++)
g.LineTo(line[i]);
g.EndFigure(false);
any = true;
}
}
return any ? geometry : null;
}
}
}

View File

@@ -0,0 +1,269 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>
/// The timetable of the user's train, a row a station: what the timetable said and what
/// happened, by the end of the recording read so far. A stop shows the time of arrival and
/// of departure with the delay, how well the train was stopped against the stop point and
/// whether it stood along the platform; a station passed through the time of passing. The
/// station of the moment played is marked; a click goes to the moment the train was there.
/// </summary>
public sealed class TimetablePanel : Control
{
private const double Pad = 12;
private const double HeaderHeight = 50;
private const double RowHeight = 54;
private Recording? _recording;
private double _time;
private int _builtRevision = -1;
private IReadOnlyList<StationProgress> _rows = Array.Empty<StationProgress>();
private Timetable? _timetable;
private int _hover = -1;
public event Action<double>? Seek;
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
public TimetablePanel()
{
ClipToBounds = true;
Cursor = new Cursor(StandardCursorType.Hand);
}
public Recording? Recording
{
get => _recording;
set
{
_recording = value;
_builtRevision = -1;
Rebuild();
}
}
public double Time
{
get => _time;
set
{
_time = value;
Rebuild();
InvalidateVisual();
}
}
private void Rebuild()
{
if (_recording is not { } recording)
{
_rows = Array.Empty<StationProgress>();
_timetable = null;
InvalidateMeasure();
return;
}
var timetable = recording.TimetableAt(_time);
if (recording.Revision == _builtRevision && ReferenceEquals(timetable, _timetable))
return;
_builtRevision = recording.Revision;
_timetable = timetable;
_rows = StationProgress.Of(recording, _time);
InvalidateMeasure();
InvalidateVisual();
}
protected override Size MeasureOverride(Size availableSize) =>
new(0, _timetable is null ? 80 : HeaderHeight + _rows.Count * RowHeight + Pad);
/// <summary>The row of the station the train was at, or made for, at the moment played.</summary>
private int Current()
{
int current = -1;
for (int i = 0; i < _rows.Count; i++)
{
var row = _rows[i];
double? at = row.ArrivedAt ?? row.DepartedAt;
if (at is { } t && t <= _time + 0.05)
current = i;
}
// Still standing there, or on the way to the next one.
if (current >= 0 && _rows[current].DepartedAt is { } left && left <= _time && current + 1 < _rows.Count)
return current + 1;
return current < 0 ? (_rows.Count > 0 ? 0 : -1) : current;
}
public override void Render(DrawingContext context)
{
var size = Bounds.Size;
var culture = CultureInfo.CurrentCulture;
if (_timetable is not { } timetable)
{
context.DrawText(MapView.Text(Strings["TimetableNone"], Palette.DimText, 12), new Point(Pad, Pad));
return;
}
// The train: its number and category, from where to where.
var train = MapView.Text($"{timetable.Category} {timetable.Train}".Trim(), Palette.Text, 15, FontWeight.SemiBold);
context.DrawText(train, new Point(Pad, 6));
string relation = string.Format(culture, Strings["Relation"], timetable.From, timetable.To);
if (timetable.Vmax > 0)
relation += $" · {timetable.Vmax:0} km/h";
context.DrawText(MapView.Text(relation, Palette.DimText, 11), new Point(Pad, 6 + train.Height + 1));
int current = Current();
double rail = Pad + 6;
for (int i = 0; i < _rows.Count; i++)
{
double top = HeaderHeight + i * RowHeight;
var row = _rows[i];
if (i == current)
{
context.DrawRectangle(Palette.CardHover, null, new Rect(0, top, size.Width, RowHeight - 1));
context.DrawRectangle(Palette.Accent, null, new Rect(0, top, 2, RowHeight - 1));
}
else if (i == _hover)
context.DrawRectangle(Palette.Faded(Palette.CardHover, 0.6), null, new Rect(0, top, size.Width, RowHeight - 1));
// The line of the route through the stations, a dot each: hollow ahead, filled when done.
var outcome = row.Outcome;
var colour = outcome switch
{
StationOutcome.Missed => Palette.Bad,
StationOutcome.Ahead => Palette.FaintText,
_ => DelayBrush(row.ArrivalDelay ?? row.DepartureDelay)
};
double dotY = top + 15;
if (i + 1 < _rows.Count)
context.DrawLine(new Pen(Palette.Grid, 2), new Point(rail, dotY), new Point(rail, dotY + RowHeight));
bool stop = row.Row.IsStop;
double radius = stop ? 5.5 : 3.5;
context.DrawEllipse(outcome == StationOutcome.Ahead ? Palette.Card : colour, new Pen(colour, 2), new Point(rail, dotY), radius, radius);
// The station and what the timetable said.
double x = rail + 16;
var name = MapView.Text(StationNames.Display(row.Row.Station), outcome == StationOutcome.Ahead ? Palette.DimText : Palette.Text, 13,
stop ? FontWeight.SemiBold : FontWeight.Normal);
context.DrawText(name, new Point(x, dotY - name.Height / 2));
string planned = stop
? $"{Minutes(row.Row.Arrival)} → {Minutes(row.Row.Departure)}"
: Minutes(row.Row.Departure);
var plan = MapView.Text(planned, Palette.DimText, 12);
context.DrawText(plan, new Point(size.Width - Pad - plan.Width, dotY - plan.Height / 2));
if (row.Row.Km > 0 || i > 0)
{
var km = MapView.Text($"km {row.Row.Km:0.0}", Palette.FaintText, 10);
double kmX = size.Width - Pad - plan.Width - 12 - km.Width;
if (kmX > x + name.Width + 8)
context.DrawText(km, new Point(kmX, dotY - km.Height / 2));
}
// What happened there, under it: each part in its colour, on to a second line when long.
double partX = x, partY = top + 25;
bool first = true;
foreach (var (text, brush) in Chips(row, culture))
{
var part = MapView.Text(first ? text : "· " + text, brush, 11);
if (!first && partX + part.Width > size.Width - Pad)
{
partX = x;
partY += 14;
part = MapView.Text(text, brush, 11);
}
context.DrawText(part, new Point(partX, partY));
partX += part.Width + 6;
first = false;
}
}
}
/// <summary>What happened at a station, each part with its colour.</summary>
private IEnumerable<(string Text, IBrush Brush)> Chips(StationProgress row, CultureInfo culture)
{
switch (row.Outcome)
{
case StationOutcome.Ahead:
if (row.Row.Maintenance)
yield return (Strings["TtTechnical"], Palette.FaintText);
yield break;
case StationOutcome.Passed:
yield return (string.Format(culture, Strings["TtPassed"], Clock(row.DepartureClock)), Palette.DimText);
if (row.DepartureDelay is { } passDelay)
yield return (string.Format(culture, Strings["Minutes"], passDelay), DelayBrush(passDelay));
yield break;
}
if (row.ArrivedAt is null)
{
yield return (Strings["TtMissed"], Palette.Bad);
yield break;
}
yield return (string.Format(culture, Strings["TtArrived"], Clock(row.ArrivalClock)), Palette.DimText);
if (row.ArrivalDelay is { } arrival)
yield return (string.Format(culture, Strings["Minutes"], arrival), DelayBrush(arrival));
if (row.Outcome == StationOutcome.Missed)
yield return (Strings["TtLeftEarly"], Palette.Bad);
else if (row.DepartedAt is not null)
{
yield return (string.Format(culture, Strings["TtDeparted"], Clock(row.DepartureClock)), Palette.DimText);
if (row.DepartureDelay is { } departure)
yield return (string.Format(culture, Strings["Minutes"], departure), DelayBrush(departure));
}
else
yield return (Strings["TtStanding"], Palette.Station);
if (row.StopError is { } error)
yield return Math.Abs(error) < 1
? (Strings["TtStopExact"], Palette.Good)
: (string.Format(culture, Strings[error > 0 ? "TtStopShort" : "TtStopPast"], Math.Abs(error)),
Math.Abs(error) <= 5 ? Palette.Good : Math.Abs(error) <= 15 ? Palette.Warn : Palette.Bad);
if (row.OnPlatform is { } along)
yield return along
? (Strings["TtOnPlatform"], Palette.Good)
: (string.Format(culture, Strings["TtOffPlatform"], row.OffPlatform ?? 0), Palette.Bad);
}
private static IBrush DelayBrush(double? minutes) => Palette.Delay(minutes);
private static string Minutes(float minutes) =>
minutes < 0 ? "–" : TimeSpan.FromMinutes(minutes).ToString(@"hh\:mm", CultureInfo.InvariantCulture);
private static string Clock(double? minutes) =>
minutes is { } m ? TimeSpan.FromMinutes(m % 1440).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture) : "–";
private int RowAt(Point p)
{
int i = (int)Math.Floor((p.Y - HeaderHeight) / RowHeight);
return p.Y >= HeaderHeight && i >= 0 && i < _rows.Count ? i : -1;
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
int hover = RowAt(e.GetPosition(this));
if (hover != _hover)
{
_hover = hover;
InvalidateVisual();
}
}
protected override void OnPointerExited(PointerEventArgs e)
{
base.OnPointerExited(e);
_hover = -1;
InvalidateVisual();
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
int i = RowAt(e.GetPosition(this));
if (i >= 0 && (_rows[i].ArrivedAt ?? _rows[i].DepartedAt) is { } t)
Seek?.Invoke(Math.Max(0, t - 5));
}
}

View File

@@ -0,0 +1,283 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Layout;
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>
/// The pieces the window is built of, in one plain look: flat panels side by side, flat
/// buttons, a table of figures and tabs underlined.
/// </summary>
internal static class UiKit
{
/// <summary>The gutter between the panels.</summary>
public const double Gap = 6;
public static readonly CornerRadius Small = new(2);
/// <summary>
/// The theme's accent (the slider, the picked lines, the focus) in the orange of the viewer
/// instead of the system colour, for the window given and everything in it.
/// </summary>
public static void Accent(Control control)
{
// As the starter's App.axaml sets them, for the viewer on its own.
(string Key, string Colour)[] accents =
{
("SystemAccentColor", "#177f00"), ("SystemAccentColorDark1", "#136B00"), ("SystemAccentColorDark2", "#0F5600"),
("SystemAccentColorDark3", "#0B4200"), ("SystemAccentColorLight1", "#2A9A0F"), ("SystemAccentColorLight2", "#41C400"),
("SystemAccentColorLight3", "#5FD42A")
};
foreach (var (key, colour) in accents)
control.Resources[key] = Color.Parse(colour);
control.Resources["AccentFillColorDefaultBrush"] = Palette.AccentFill;
control.Resources["AccentFillColorSecondaryBrush"] = Palette.AccentFillHover;
control.Resources["SystemControlHighlightListAccentLowBrush"] = Palette.AccentFill;
control.Resources["SystemControlHighlightListAccentMediumBrush"] = Palette.AccentFillHover;
control.Resources["ListBoxItemBackgroundSelected"] = Palette.AccentFill;
control.Resources["ListBoxItemBackgroundSelectedPointerOver"] = Palette.AccentFillHover;
control.Resources["ListBoxItemBackgroundSelectedPressed"] = Palette.AccentFill;
control.Resources["ListBoxItemBackgroundPointerOver"] = Palette.CardHover;
}
/// <summary>A panel with its title on a bar, as the group boxes of the starter; what goes right of the title, if anything.</summary>
public static Border Group(string title, Control content, Control? tag = null)
{
var bar = new DockPanel();
if (tag is not null)
{
DockPanel.SetDock(tag, Dock.Right);
tag.VerticalAlignment = VerticalAlignment.Center;
bar.Children.Add(tag);
}
bar.Children.Add(new TextBlock
{
Text = title,
FontSize = 12,
FontWeight = FontWeight.SemiBold,
Foreground = Palette.Text,
TextTrimming = TextTrimming.CharacterEllipsis,
VerticalAlignment = VerticalAlignment.Center
});
var header = new Border
{
Background = Palette.Header,
BorderBrush = Palette.CardBorder,
BorderThickness = new Thickness(0, 0, 0, 1),
Padding = new Thickness(8, 3),
MinHeight = 24,
Child = bar
};
var panel = new DockPanel();
DockPanel.SetDock(header, Dock.Top);
panel.Children.Add(header);
panel.Children.Add(content);
return Frame(panel);
}
/// <summary>A panel in the frame of the starter's group boxes, without a title.</summary>
public static Border Frame(Control content) => new()
{
Background = Palette.Panel,
BorderBrush = Palette.CardBorder,
BorderThickness = new Thickness(1),
CornerRadius = Small,
ClipToBounds = true,
Child = content
};
/// <summary>A flat panel the views sit on.</summary>
public static Border Card(Control content, Thickness? padding = null) => new()
{
Background = Palette.Card,
Padding = padding ?? new Thickness(0),
ClipToBounds = true,
Child = content
};
/// <summary>A button of the toolbar: text on the bar, a lighter ground under the pointer.</summary>
public static Button Button(string text, string? tip = null)
{
var button = new Button
{
Content = text,
Padding = new Thickness(10, 2),
MinHeight = 25,
CornerRadius = Small,
FontSize = 12,
VerticalContentAlignment = VerticalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
if (tip is not null)
ToolTip.SetTip(button, tip);
return button;
}
/// <summary>The button of playing.</summary>
public static Button PlayButton()
{
var button = new Button
{
Width = 32,
Height = 25,
CornerRadius = Small,
Background = Palette.AccentFill,
Padding = new Thickness(0),
BorderThickness = new Thickness(0),
HorizontalContentAlignment = HorizontalAlignment.Center,
VerticalContentAlignment = VerticalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
// As the starter's start button: its green, brighter under the pointer.
Hover(button, Palette.Accent, Brushes.White);
return button;
}
/// <summary>The glyph of play or pause.</summary>
public static Control PlayGlyph(bool playing)
{
var geometry = playing
? Geometry.Parse("M0,0 H4 V12 H0 Z M7,0 H11 V12 H7 Z")
: Geometry.Parse("M1,0 L11,6 L1,12 Z");
return new Avalonia.Controls.Shapes.Path
{
Data = geometry,
Fill = Brushes.White,
Width = 12,
Height = 12,
Stretch = Stretch.None,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
}
/// <summary>A toggle of the toolbar: on, its text takes the accent.</summary>
public static ToggleButton Toggle(string text, string? tip = null)
{
var toggle = new ToggleButton
{
Content = text,
Padding = new Thickness(10, 2),
MinHeight = 25,
CornerRadius = Small,
FontSize = 12,
VerticalContentAlignment = VerticalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
foreach (var state in new[] { "", "PointerOver", "Pressed" })
{
toggle.Resources["ToggleButtonBackgroundChecked" + state] = state == "PointerOver" ? Palette.AccentFillHover : Palette.AccentFill;
toggle.Resources["ToggleButtonForegroundChecked" + state] = Brushes.White;
}
if (tip is not null)
ToolTip.SetTip(toggle, tip);
return toggle;
}
// The theme takes its own colours under the pointer and when pressed, unless the control gives them.
private static void Hover(Control control, IBrush background, IBrush foreground)
{
foreach (var kind in new[] { "Button", "ToggleButton" })
{
control.Resources[kind + "BackgroundPointerOver"] = background;
control.Resources[kind + "BackgroundPressed"] = background;
control.Resources[kind + "ForegroundPointerOver"] = foreground;
control.Resources[kind + "ForegroundPressed"] = foreground;
control.Resources[kind + "BorderBrushPointerOver"] = Brushes.Transparent;
control.Resources[kind + "BorderBrushPressed"] = Brushes.Transparent;
}
}
/// <summary>A line of the table of figures: what it is on the left, its value on the right, a note under it.</summary>
public sealed class Tile : Border
{
private readonly TextBlock _value = new() { FontSize = 12, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, HorizontalAlignment = HorizontalAlignment.Right };
private readonly TextBlock _sub = new() { FontSize = 11, Foreground = Palette.FaintText, TextTrimming = TextTrimming.CharacterEllipsis };
public Tile(string caption)
{
BorderBrush = Palette.Grid;
BorderThickness = new Thickness(0, 0, 0, 1);
Padding = new Thickness(8, 4);
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), RowDefinitions = new RowDefinitions("Auto,Auto") };
var name = new TextBlock { Text = caption, FontSize = 12, Foreground = Palette.DimText, VerticalAlignment = VerticalAlignment.Center };
Grid.SetColumn(_value, 1);
Grid.SetRow(_sub, 1);
Grid.SetColumnSpan(_sub, 2);
grid.Children.Add(name);
grid.Children.Add(_value);
grid.Children.Add(_sub);
Child = grid;
}
public void Show(string value, string? sub = null, IBrush? brush = null)
{
_value.Text = value;
_value.Foreground = brush ?? Palette.Text;
_sub.Text = sub ?? "";
_sub.IsVisible = !string.IsNullOrEmpty(sub);
}
}
/// <summary>Tabs side by side as the starter's: the one on on the panel's ground, with a bar of the accent on top.</summary>
public sealed class Segmented : Border
{
private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal };
private int _selected = -1;
public event Action<int>? SelectedChanged;
public Segmented(params string[] choices)
{
Background = Palette.Background;
BorderBrush = Palette.CardBorder;
BorderThickness = new Thickness(0, 0, 0, 1);
Child = _row;
for (int i = 0; i < choices.Length; i++)
{
int index = i;
var button = new Button
{
Content = choices[i],
Padding = new Thickness(14, 3),
MinHeight = 24,
CornerRadius = new CornerRadius(0),
FontSize = 12,
BorderThickness = new Thickness(0, 2, 0, 0)
};
button.Resources["ButtonBackgroundPointerOver"] = Palette.CardHover;
button.Resources["ButtonBackgroundPressed"] = Palette.CardHover;
button.Resources["ButtonForegroundPointerOver"] = Palette.Text;
button.Resources["ButtonForegroundPressed"] = Palette.Text;
button.Click += (_, _) => Selected = index;
_row.Children.Add(button);
}
Selected = 0;
}
public int Selected
{
get => _selected;
set
{
if (value == _selected)
return;
_selected = value;
for (int i = 0; i < _row.Children.Count; i++)
{
var button = (Button)_row.Children[i];
bool on = i == value;
var line = on ? Palette.Accent : Brushes.Transparent;
button.Background = on ? Palette.Panel : Brushes.Transparent;
button.BorderBrush = line;
button.Resources["ButtonBorderBrushPointerOver"] = line;
button.Resources["ButtonBorderBrushPressed"] = line;
button.Foreground = on ? Palette.Text : Palette.DimText;
}
SelectedChanged?.Invoke(value);
}
}
}
}

View File

@@ -0,0 +1,331 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using System.Text;
namespace Eu07.BlackBox;
/// <summary>
/// Reads a black box into a <see cref="Recording"/>, as much of it as is there. A file
/// the simulator still writes is read again from where the last read stopped: a record
/// cut short waits for the next read, so a running session can be followed every few
/// seconds without reading it from the start.
/// </summary>
public sealed class BlackBoxReader
{
public static ReadOnlySpan<byte> Magic => "EU07BBX\0"u8;
public const int FormatVersion = 1;
public const int HeaderSize = 12;
public const int RecordHeaderSize = 5;
private long _offset;
public Recording Recording { get; } = new();
/// <summary>Bytes taken so far: the header and the whole records.</summary>
public long Offset => _offset;
/// <summary>Opens the file shared, as the simulator may be writing it, and reads what is new.</summary>
public int ReadMore(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return ReadMore(stream);
}
/// <summary>Reads the whole records added since the last read. Returns how many were read.</summary>
/// <exception cref="InvalidDataException">Not a black box, or of a version this reader doesn't know.</exception>
public int ReadMore(Stream stream)
{
if (stream.CanSeek)
{
if (stream.Length < _offset)
throw new InvalidDataException("The black box got shorter than what was read of it: it was written anew.");
stream.Seek(_offset, SeekOrigin.Begin);
}
using var added = new MemoryStream();
stream.CopyTo(added);
var bytes = added.GetBuffer().AsSpan(0, (int)added.Length);
int at = 0;
if (_offset == 0)
{
if (bytes.Length < HeaderSize)
return 0;
ReadHeader(bytes[..HeaderSize]);
at = HeaderSize;
}
int read = 0;
while (bytes.Length - at >= RecordHeaderSize)
{
var type = (RecordType)bytes[at];
uint length = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(at + 1, 4));
if (length > int.MaxValue || bytes.Length - at - RecordHeaderSize < length)
break;
var payload = bytes.Slice(at + RecordHeaderSize, (int)length);
try
{
Take(type, new PayloadReader(payload));
}
catch (EndOfStreamException)
{
// Shorter than its type says: passed over, as one of an unknown type is.
Recording.Malformed++;
}
at += RecordHeaderSize + (int)length;
++read;
}
_offset += at;
if (read > 0)
Recording.Changed();
return read;
}
/// <summary>Reads a whole file at once.</summary>
public static Recording Read(string path)
{
var reader = new BlackBoxReader();
reader.ReadMore(path);
return reader.Recording;
}
public static Recording Read(Stream stream)
{
var reader = new BlackBoxReader();
reader.ReadMore(stream);
return reader.Recording;
}
/// <summary>Whether the bytes start as a black box does.</summary>
public static bool LooksLikeBlackBox(ReadOnlySpan<byte> start) => start.Length >= Magic.Length && start[..Magic.Length].SequenceEqual(Magic);
private void ReadHeader(ReadOnlySpan<byte> header)
{
if (!LooksLikeBlackBox(header))
throw new InvalidDataException("Not a black box of the simulator.");
int version = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(8, 2));
if (version != FormatVersion)
throw new InvalidDataException($"A black box of version {version}, this reader knows version {FormatVersion}.");
Recording.Version = version;
}
private void Take(RecordType type, PayloadReader r)
{
var rec = Recording;
switch (type)
{
case RecordType.Info:
rec.Info = new RecordingInfo(r.Text(), r.Text(), r.U16(), r.U16(), r.F64(), r.Text(), r.Text(),
r.F32(), r.F32(), r.F32());
break;
case RecordType.Track:
{
int index = (int)r.U32();
string name = r.Text();
var kind = (TrackKind)r.U8();
int branch = r.U8();
int category = r.U8();
uint count = r.U32();
if (count > r.Remaining / 12)
throw new EndOfStreamException();
var points = new Vector3[count];
for (int i = 0; i < points.Length; i++)
points[i] = r.Vec3();
rec.AddTrack(new MapTrack(index, name, kind, branch, category, points));
break;
}
case RecordType.Signal:
rec.AddSignal(new MapSignal((int)r.U32(), r.Text(), r.DVec3()));
break;
case RecordType.Vehicle:
rec.AddVehicle(new VehicleInfo((int)r.U32(), r.Text(), r.Text(), r.Text(), r.F32(), r.F32(), r.U8()));
break;
case RecordType.VehicleRemoved:
rec.RemoveVehicle(r.F64(), (int)r.U32());
break;
case RecordType.Cab:
{
var sample = new CabSample(r.F64(), (int)r.U32(), r.DVec3(), r.F32(), r.F32(), r.F32(), (Driver)r.U8(),
(CabFlags)r.U8(), r.I8(), r.I8(), r.I8(), r.F32(), r.F32(), r.F32(), r.F32(),
r.F32(),
new AiReference(r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32()),
(CabDevices)r.U32(), r.F32(), r.F32(), r.F32(), (Lights)r.U16(), (Lights)r.U16());
// The cab, the train protection and the lateral acceleration came later: a recording
// made before them ends here.
if (r.Remaining >= 6)
sample = sample with { CabEnd = r.I8(), Security = (SecurityFlags)r.U8(), LateralAcceleration = r.F32() };
// What the driver went by and the lights asked for came later still.
if (r.Remaining >= 5)
sample = sample with { Movement = (Movement)r.U8(), LightHintHead = (short)r.U16(), LightHintTail = (short)r.U16() };
// The lamps the vehicles at the ends have, which a pattern is checked against, later again.
if (r.Remaining >= 4)
sample = sample with { EquippedHead = (Lights)r.U16(), EquippedTail = (Lights)r.U16() };
// What drives it, the doors' permits and the rest, later again; the arguments are read in their order.
if (r.Remaining >= 54)
sample = sample with
{
Extra = new CabExtra((EngineKind)r.U8(), r.U8(), r.U8(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32(),
r.F32(), r.F32(), r.F32(), r.I8(), r.U8(), r.U8(), r.F32(), r.F32(), r.F32(), r.F32())
};
rec.AddCab(sample);
break;
}
case RecordType.StopPoint:
{
var point = new MapStopPoint((int)r.U32(), r.Text(), r.DVec3(), r.F32(), r.F32());
// The heading came later: a recording made before it ends here.
if (r.Remaining >= 4)
point = point with { Heading = r.F32() };
rec.AddStopPoint(point);
break;
}
case RecordType.HornPoint:
rec.AddHornPoint(new MapHornPoint((int)r.U32(), r.Text(), r.DVec3(), r.F32(), r.F32()));
break;
case RecordType.Timetable:
{
double time = r.F64();
int vehicle = (int)r.U32();
string train = r.Text(), category = r.Text(), from = r.Text(), to = r.Text();
float vmax = r.F32();
uint count = r.U32();
// A row holds at least its empty name, four numbers and a flag.
if (count > r.Remaining / 18)
throw new EndOfStreamException();
var rows = new TimetableRow[count];
for (int i = 0; i < rows.Length; i++)
rows[i] = new TimetableRow(r.Text(), r.F32(), r.F32(), r.F32(), r.F32(), r.U8() != 0);
rec.AddTimetable(new Timetable(time, vehicle, train, category, from, to, vmax, rows));
break;
}
case RecordType.Station:
rec.AddStation(new StationEvent(r.F64(), (int)r.U32(), (StationKind)r.U8(), r.U16(), r.Text(), r.DVec3(),
r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32()));
break;
case RecordType.Camera:
rec.AddCamera(new CameraState(r.F64(), (CameraMode)r.U8(), r.U8(), (int)r.U32(), r.DVec3(), r.F32(), r.F32()));
break;
case RecordType.Coupling:
rec.AddCoupling(new CouplingChange(r.F64(), (int)r.U32(), r.U8(), (int)r.U32(), r.U8(), (CouplingFlags)r.U8(),
(CouplingFlags)r.U8(), (int)r.U32(), r.DVec3()));
break;
case RecordType.HornRequest:
rec.AddHornRequest(new HornRequest(r.F64(), (int)r.U32(), r.F32(), r.F32(), r.DVec3()));
break;
case RecordType.Vehicles:
{
double time = r.F64();
bool keyframe = r.U8() != 0;
uint count = r.U32();
if (count > r.Remaining / 25)
throw new EndOfStreamException();
var states = new VehicleState[count];
for (int i = 0; i < states.Length; i++)
states[i] = new VehicleState((int)r.U32(), r.Vec3(), r.F32(), r.F32(), (Driver)r.U8());
rec.AddLook(new VehicleLook(time, keyframe, states));
break;
}
case RecordType.SwitchState:
rec.AddSwitch(new SwitchChange(r.F64(), (int)r.U32(), r.U8()));
break;
case RecordType.SignalAspect:
rec.AddAspect(new AspectChange(r.F64(), (int)r.U32(), r.F32(), r.F32(), r.Text()));
break;
case RecordType.Command:
{
double time = r.F64();
int vehicle = (int)r.U32();
bool pressed = r.U8() != 0;
double param1 = r.F64(), param2 = r.F64();
rec.AddCommand(new CommandEntry(time, vehicle, pressed, param1, param2, r.Text()));
break;
}
case RecordType.DriverChange:
rec.AddDriverChange(new DriverChange(r.F64(), (int)r.U32(), (Driver)r.U8()));
break;
case RecordType.SignalPassed:
{
double time = r.F64();
int vehicle = (int)r.U32();
int signal = unchecked((int)r.U32());
rec.AddPassing(new SignalPassing(time, vehicle, signal, r.Text(), r.DVec3(), r.F32(), r.F32(), r.F32(),
r.F32(), (PassingFlags)r.U8()));
break;
}
case RecordType.UserVehicle:
{
var change = new UserVehicleChange(r.F64(), (int)r.U32(), r.U8() != 0);
if (r.Remaining >= 1)
change = change with { CabEnd = r.I8() };
rec.AddUserVehicle(change);
break;
}
case RecordType.Clock:
rec.AddClock(new ClockEntry(r.F64(), r.U16(), r.U16(), r.F64()));
break;
case RecordType.End:
rec.EndTime = r.F64();
break;
default:
// A type of a later version: its length lets it be passed over.
rec.Unknown++;
break;
}
}
/// <summary>The fields of a record in the order they come, little-endian. A field past the end throws.</summary>
private ref struct PayloadReader
{
private readonly ReadOnlySpan<byte> _bytes;
private int _at;
public PayloadReader(ReadOnlySpan<byte> bytes)
{
_bytes = bytes;
_at = 0;
}
public int Remaining => _bytes.Length - _at;
private ReadOnlySpan<byte> Take(int count)
{
if (Remaining < count)
throw new EndOfStreamException();
var span = _bytes.Slice(_at, count);
_at += count;
return span;
}
public byte U8() => Take(1)[0];
public sbyte I8() => unchecked((sbyte)Take(1)[0]);
public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
public float F32() => BinaryPrimitives.ReadSingleLittleEndian(Take(4));
public double F64() => BinaryPrimitives.ReadDoubleLittleEndian(Take(8));
public Vector3 Vec3() => new(F32(), F32(), F32());
public Position DVec3() => new(F64(), F64(), F64());
/// <summary>UTF-8 after its length in 7 bit groups, low first, as BinaryWriter writes a string.</summary>
public string Text()
{
int length = 0;
for (int shift = 0; ; shift += 7)
{
if (shift > 28)
throw new EndOfStreamException();
byte b = U8();
length |= (b & 0x7f) << shift;
if (b < 0x80)
break;
}
if (length < 0)
throw new EndOfStreamException();
return length == 0 ? "" : Encoding.UTF8.GetString(Take(length));
}
}
}

View File

@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Eu07.BlackBox;
/// <summary>How a sample of the user's train stands against what the AI would have done.</summary>
public enum SpeedJudgement
{
/// <summary>Nothing to set it against: the consist not started, or the user out of the cab.</summary>
NoData,
/// <summary>The AI drove.</summary>
Autopilot,
/// <summary>At most the speed the AI would hold.</summary>
Within,
/// <summary>Faster, by no more than the tolerance.</summary>
Slightly,
/// <summary>Faster by more than the tolerance.</summary>
Over
}
/// <summary>What the user's driving came to, set against the AI's.</summary>
public sealed record DrivingSummary(
double Duration,
double DrivenSeconds,
double AutopilotSeconds,
double DistanceKm,
float MaxSpeedKmh,
double OverAiSeconds,
double WellOverAiSeconds,
float MaxOverAiKmh,
double MaxOverAiAt,
int SignalsPassed,
IReadOnlyList<SignalPassing> PassedAtDanger,
int Commands,
int AutopilotSwitches,
float MaxAcceleration,
float MaxDeceleration,
float MaxLateralAcceleration,
int Stops,
int StopsMissed,
double? MaxDelayMinutes,
int HornPoints,
int HornsMissed,
int SecurityBrakings)
{
/// <summary>The largest delay the user's train left a station or passed it with [min], null without any.</summary>
public double? MaxDepartureDelayMinutes { get; init; }
/// <summary>How long the train moved with its light signals known to be right or wrong [s].</summary>
public double LightsJudgedSeconds { get; init; }
/// <summary>How long of that its head or its tail showed other signals than it was to [s].</summary>
public double WrongLightsSeconds { get; init; }
/// <summary>Each stretch the train moved with the wrong signals, as they were.</summary>
public IReadOnlyList<LightsFault> LightsFaults { get; init; } = Array.Empty<LightsFault>();
/// <summary>The stops where the train did not fit along the platform, and how far its tail stood off it [m].</summary>
public IReadOnlyList<(string Station, double Metres)> OffPlatform { get; init; } = Array.Empty<(string, double)>();
/// <summary>[km/h] over the speed of the AI before it counts as well over.</summary>
public const float DefaultTolerance = 5f;
/// <summary>
/// The speeds are whole km/h for the AI and the needle of the user's train shakes:
/// half a km/h over is still within.
/// </summary>
private const float Rounding = 0.5f;
public static SpeedJudgement Judge(CabSample sample, float tolerance = DefaultTolerance)
{
if (sample.Driving == Driver.Ai)
return SpeedJudgement.Autopilot;
if (!sample.AiValid || sample.Driving != Driver.Human)
return SpeedJudgement.NoData;
float over = sample.AbsSpeedKmh - sample.Ai.TargetKmh;
return over <= Rounding ? SpeedJudgement.Within : over <= tolerance ? SpeedJudgement.Slightly : SpeedJudgement.Over;
}
internal static DrivingSummary Of(Recording recording, float tolerance)
{
var cab = recording.Cab;
double driven = 0, autopilot = 0, distance = 0, over = 0, wellOver = 0, maxOverAt = 0;
float maxSpeed = 0, maxOver = 0, maxAcceleration = 0, maxDeceleration = 0, maxLateral = 0;
// The accelerations over half a second: a sample alone jumps with each jolt of the couplers.
var window = new Queue<(float Along, float Across)>();
float sumAlong = 0, sumAcross = 0;
double lightsJudged = 0, lightsWrong = 0;
var faults = new List<LightsFault>();
// A step longer than this is a gap in the samples, not driving.
double gap = (recording.Info is { CabInterval: > 0 } info ? info.CabInterval : 0.1) * 5;
for (int i = 1; i < cab.Count; i++)
{
var before = cab[i - 1];
var sample = cab[i];
double step = sample.Time - before.Time;
if (step <= 0 || step > gap || sample.Vehicle != before.Vehicle)
{
window.Clear();
sumAlong = sumAcross = 0;
continue;
}
distance += (before.AbsSpeedKmh + sample.AbsSpeedKmh) / 2 / 3.6 * step / 1000;
window.Enqueue((sample.Acceleration, sample.LateralAcceleration));
sumAlong += sample.Acceleration;
sumAcross += sample.LateralAcceleration;
if (window.Count > 5)
{
var (along, across) = window.Dequeue();
sumAlong -= along;
sumAcross -= across;
}
if (window.Count == 5 && sample.AbsSpeedKmh > 1)
{
maxAcceleration = Math.Max(maxAcceleration, sumAlong / 5);
maxDeceleration = Math.Max(maxDeceleration, -sumAlong / 5);
maxLateral = Math.Max(maxLateral, Math.Abs(sumAcross / 5));
}
maxSpeed = Math.Max(maxSpeed, sample.AbsSpeedKmh);
if (sample.Driving == Driver.Ai)
autopilot += step;
else if (sample.Driving == Driver.Human && sample.AbsSpeedKmh > Rounding)
driven += step;
// the light signals at both ends, while the user drives the train and it moves: the autopilot's are its own
if (sample.Driving == Driver.Human && sample.AbsSpeedKmh > 1 &&
HeadCodes.HeadRight(sample) is { } headRight && HeadCodes.TailRight(sample) is { } tailRight)
{
lightsJudged += step;
if (!headRight || !tailRight)
{
lightsWrong += step;
var fault = new LightsFault(before.Time, sample.Time, HeadCodes.Of(sample.LightsHead), HeadCodes.RequiredHead(sample)!.Value,
!headRight, HeadCodes.Of(sample.LightsTail), HeadCodes.RequiredTail(sample)!.Value, !tailRight);
if (faults.Count > 0 && faults[^1].SameAs(fault) && fault.Start - faults[^1].End < 2)
faults[^1] = faults[^1] with { End = sample.Time };
else
faults.Add(fault);
}
}
var judgement = Judge(sample, tolerance);
if (judgement is SpeedJudgement.Slightly or SpeedJudgement.Over)
over += step;
if (judgement == SpeedJudgement.Over)
wellOver += step;
if (judgement is SpeedJudgement.Slightly or SpeedJudgement.Over)
{
float by = sample.AbsSpeedKmh - sample.Ai.TargetKmh;
if (by > maxOver)
{
maxOver = by;
maxOverAt = sample.Time;
}
}
}
var passed = recording.UserPassings().ToList();
int switches = recording.DriverChanges.Count(change =>
recording.CabAt(change.Time + 0.5) is { } s && s.Vehicle == change.Vehicle);
// Every timetable the user's train had, as one going on from another at a terminus.
var stations = recording.Timetables.SelectMany(timetable => StationProgress.Of(recording, timetable)).ToList();
// the largest delay at any station, coming to it or leaving it
var delays = stations.SelectMany(s => new[] { s.ArrivalDelay, s.DepartureDelay }).OfType<double>().ToList();
var departures = stations.Select(s => s.DepartureDelay).OfType<double>().ToList();
var offPlatform = stations.Where(s => s.OnPlatform == false)
.Select(s => (StationNames.Display(s.Row.Station), s.OffPlatform ?? 0))
.ToList();
var horns = recording.UserHornChecks().ToList();
return new DrivingSummary(recording.Duration, driven, autopilot, distance, maxSpeed, over, wellOver, maxOver,
maxOverAt, passed.Count, passed.Where(p => p.AtDanger).ToList(),
recording.UserCommands().Count(), switches, maxAcceleration, maxDeceleration, maxLateral,
stations.Count(s => s.Outcome == StationOutcome.Stopped),
stations.Count(s => s.Outcome == StationOutcome.Missed),
delays.Count > 0 ? delays.Max() : null,
horns.Count, horns.Count(h => !h.Sounded), recording.SecurityBrakings().Count)
{
MaxDepartureDelayMinutes = departures.Count > 0 ? departures.Max() : null,
LightsJudgedSeconds = lightsJudged,
WrongLightsSeconds = lightsWrong,
LightsFaults = faults,
OffPlatform = offPlatform
};
}
}
/// <summary>A stretch the user's train moved with other light signals than it was to show, at its head and its tail.</summary>
public sealed record LightsFault(double Start, double End, HeadCode Head, HeadCode HeadRequired, bool HeadWrong,
HeadCode Tail, HeadCode TailRequired, bool TailWrong)
{
public bool SameAs(LightsFault other) =>
Head == other.Head && HeadRequired == other.HeadRequired && HeadWrong == other.HeadWrong &&
Tail == other.Tail && TailRequired == other.TailRequired && TailWrong == other.TailWrong;
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Reads the black box the simulator writes to physicslog/*.blackbox. Only the
base library: the starter, the viewer and any other program can use it, and
it goes through NativeAOT and trimming as they do. -->
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<IsAotCompatible>true</IsAotCompatible>
<IsTrimmable>true</IsTrimmable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>Eu07.BlackBox</RootNamespace>
</PropertyGroup>
</Project>

167
Eu07.BlackBox/HeadCodes.cs Normal file
View File

@@ -0,0 +1,167 @@
using System;
namespace Eu07.BlackBox;
/// <summary>What the driver of the user's train went by: the lights it is to show depend on it.</summary>
public enum Movement : byte
{
/// <summary>No driver, or standing by.</summary>
Other = 0,
/// <summary>A train run: Pc 1 or Pc 2 at the head, Pc 5 at the tail.</summary>
Train = 1,
/// <summary>Shunting: Tb 1 at both ends.</summary>
Shunting = 2,
/// <summary>A recording made before it was written.</summary>
Unknown = 0xFF
}
/// <summary>The light signals at an end of a train.</summary>
public enum HeadCode
{
Off,
/// <summary>The head of a train: both headlights and the upper one.</summary>
Pc1,
/// <summary>The head of a train on the wrong track: the left red marker, the right headlight and the upper one.</summary>
Pc2,
/// <summary>The tail of a train: both red markers.</summary>
Pc5,
/// <summary>Shunting: one headlight.</summary>
Tb1,
/// <summary>Any other pattern.</summary>
Other
}
/// <summary>The light signals a train shows and those it is to show, as the simulator's driver sets them.</summary>
public static class HeadCodes
{
private const Lights Signals = Lights.HeadlightLeft | Lights.RedMarkerLeft | Lights.HeadlightUpper | Lights.HeadlightRight |
Lights.RedMarkerRight | Lights.RearEndSignals;
/// <summary>The signal the lamps given show; the high beams and the auxiliary lamps tell nothing.</summary>
public static HeadCode Of(Lights lights)
{
var lit = lights & Signals;
if ((lit & Lights.RearEndSignals) != 0)
lit = (lit & ~Lights.RearEndSignals) | Lights.RedMarkerLeft | Lights.RedMarkerRight;
return lit switch
{
Lights.None => HeadCode.Off,
Lights.HeadlightLeft | Lights.HeadlightRight | Lights.HeadlightUpper => HeadCode.Pc1,
Lights.RedMarkerLeft | Lights.HeadlightRight | Lights.HeadlightUpper => HeadCode.Pc2,
Lights.RedMarkerLeft | Lights.RedMarkerRight => HeadCode.Pc5,
Lights.HeadlightLeft or Lights.HeadlightRight => HeadCode.Tb1,
_ => HeadCode.Other
};
}
/// <summary>The signal the head of the train is to show, null when nothing tells it.</summary>
public static HeadCode? RequiredHead(CabSample sample) => sample.Movement switch
{
Movement.Train => sample.LightHintHead < 0 ? HeadCode.Pc1 : Of((Lights)sample.LightHintHead),
Movement.Shunting => HeadCode.Tb1,
_ => null
};
/// <summary>The signal the tail of the train is to show, null when nothing tells it.</summary>
public static HeadCode? RequiredTail(CabSample sample) => sample.Movement switch
{
Movement.Train => sample.LightHintTail < 0 ? HeadCode.Pc5 : Of((Lights)sample.LightHintTail),
Movement.Shunting => HeadCode.Tb1,
_ => null
};
/// <summary>Whether the head of the train showed what it was to; null when nothing tells it.</summary>
public static bool? HeadRight(CabSample sample) => NoLamps(sample.EquippedHead) ? null : sample.Movement switch
{
Movement.Train => Meets(sample.LightsHead, sample.EquippedHead, sample.LightHintHead, HeadCode.Pc1),
Movement.Shunting => Shows(HeadCode.Tb1, sample.LightsHead, sample.EquippedHead),
_ => null
};
/// <summary>Whether the tail of the train showed what it was to; null when nothing tells it.</summary>
public static bool? TailRight(CabSample sample) => NoLamps(sample.EquippedTail) ? null : sample.Movement switch
{
Movement.Train => Meets(sample.LightsTail, sample.EquippedTail, sample.LightHintTail, HeadCode.Pc5),
Movement.Shunting => Shows(HeadCode.Tb1, sample.LightsTail, sample.EquippedTail),
_ => null
};
/// <summary>
/// An end with no lamp at all, a draisine's: the simulator still asks it for a signal it can't show, so it
/// is not judged. A recording made before the lamps were written can't tell.
/// </summary>
public static bool NoLamps(Lights? equipped) => equipped == Lights.None;
/// <summary>Whether both ends showed what they were to; null when nothing tells it.</summary>
public static bool? Right(CabSample sample) =>
HeadRight(sample) is { } head && TailRight(sample) is { } tail ? head && tail : null;
private const Lights HighBeams = Lights.HighBeamLeft | Lights.HighBeamRight;
private const Lights Auxiliary = Lights.AuxiliaryLeft | Lights.AuxiliaryRight;
// the pattern asked for at an end, the usual one when none is: the driver sets one asked for as it is
private static bool Meets(Lights lit, Lights? equipped, short hint, HeadCode usual)
{
if (hint < 0)
return Shows(usual, lit, equipped);
var pattern = (Lights)hint;
if (usual == HeadCode.Pc1 && pattern == (Lights.RedMarkerLeft | Lights.HeadlightRight | Lights.HeadlightUpper))
return Shows(HeadCode.Pc2, lit, equipped);
// a recording made before the lamps were written: the signal the pattern shows, as before
return equipped is { } lamps ? (lit & ~HighBeams) == (pattern & lamps) : Of(lit) == Of(pattern);
}
/// <summary>
/// Whether the lamps lit at an end show the signal, as the simulator's driver checks it against the lamps
/// the vehicle has there: a vehicle without the upper headlight shows Pc 1 with the two others, one with
/// auxiliary lamps may show them instead, and one with no lamps for it the rear-end signals. A recording
/// made before the lamps were written has the pattern alone. The high beams tell nothing.
/// </summary>
public static bool Shows(HeadCode code, Lights lit, Lights? equipped)
{
if (equipped is not { } lamps)
return Of(lit) == code;
lit &= ~HighBeams;
bool auxiliary = (lamps & Auxiliary) != 0;
switch (code)
{
case HeadCode.Pc1:
{
var pattern = lamps & (Lights.HeadlightLeft | Lights.HeadlightRight | Lights.HeadlightUpper);
var fallback = lamps & (Auxiliary | Lights.HeadlightUpper);
return (pattern != 0 && lit == pattern) || (auxiliary && lit == fallback) ||
(pattern == 0 && fallback == 0 && lit == Lights.RearEndSignals);
}
case HeadCode.Pc2:
{
var pattern = lamps & (Lights.RedMarkerLeft | Lights.HeadlightRight | Lights.HeadlightUpper);
var fallback = lamps & (Lights.RedMarkerLeft | Lights.AuxiliaryRight | Lights.HeadlightUpper);
return lit == pattern || (auxiliary && lit == fallback);
}
case HeadCode.Pc5:
{
var pattern = lamps & (Lights.RedMarkerLeft | Lights.RedMarkerRight);
var fallback = lamps & Lights.RearEndSignals;
return (pattern != 0 && lit == pattern) || (fallback != 0 && lit == fallback);
}
case HeadCode.Tb1:
// one headlight, of those there are
return lit == (lamps & Lights.HeadlightLeft) || lit == (lamps & Lights.HeadlightRight);
case HeadCode.Off:
return lit == Lights.None;
default:
return false;
}
}
/// <summary>The name of a signal as the rules give it.</summary>
public static string Name(HeadCode code) => code switch
{
HeadCode.Pc1 => "Pc 1",
HeadCode.Pc2 => "Pc 2",
HeadCode.Pc5 => "Pc 5",
HeadCode.Tb1 => "Tb 1",
HeadCode.Off => "—",
_ => "?"
};
}

532
Eu07.BlackBox/Recording.cs Normal file
View File

@@ -0,0 +1,532 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
namespace Eu07.BlackBox;
/// <summary>A span of time, start included, end not.</summary>
public readonly record struct TimeSpanRange(double Start, double End)
{
public double Length => End - Start;
public bool Contains(double t) => t >= Start && t < End;
}
/// <summary>
/// What a black box holds, as far as it has been read: the map, the vehicles and what
/// happened to them over the time of the recording, which runs from 0 in seconds of
/// simulation. The lists grow as more of a file still written is read; the queries
/// answer for any moment of the time read so far.
/// </summary>
public sealed class Recording
{
private readonly List<MapTrack> _tracks = new();
private readonly Dictionary<int, List<MapTrack>> _trackIndex = new();
private readonly List<MapSignal> _signals = new();
private readonly Dictionary<int, MapSignal> _signalIndex = new();
private readonly Dictionary<int, VehicleInfo> _vehicles = new();
private readonly List<CabSample> _cab = new();
private readonly List<VehicleLook> _looks = new();
private readonly Dictionary<int, List<(double Time, VehicleState State)>> _history = new();
private readonly List<SwitchChange> _switches = new();
private readonly Dictionary<int, List<SwitchChange>> _switchIndex = new();
private readonly List<AspectChange> _aspects = new();
private readonly Dictionary<int, List<AspectChange>> _aspectIndex = new();
private readonly List<CommandEntry> _commands = new();
private readonly List<DriverChange> _driverChanges = new();
private readonly List<SignalPassing> _passings = new();
private readonly List<UserVehicleChange> _userVehicles = new();
private readonly List<ClockEntry> _clocks = new();
private readonly List<MapStopPoint> _stopPoints = new();
private readonly List<MapHornPoint> _hornPoints = new();
private readonly List<Timetable> _timetables = new();
private readonly List<StationEvent> _stations = new();
private readonly List<HornRequest> _hornRequests = new();
private readonly List<CameraState> _cameras = new();
private readonly List<CouplingChange> _couplings = new();
private double _lastTime;
public int Version { get; internal set; }
public RecordingInfo? Info { get; internal set; }
/// <summary>The time of the end record: null while the simulator still writes, or if it didn't end the recording.</summary>
public double? EndTime { get; internal set; }
public bool Complete => EndTime is not null;
/// <summary>Records of types this reader doesn't know, passed over.</summary>
public int Unknown { get; internal set; }
/// <summary>Records shorter than their type says, passed over.</summary>
public int Malformed { get; internal set; }
/// <summary>Goes up with every read that added something, for views to tell they have to look again.</summary>
public int Revision { get; private set; }
public IReadOnlyList<MapTrack> Tracks => _tracks;
public IReadOnlyList<MapSignal> Signals => _signals;
public IReadOnlyDictionary<int, VehicleInfo> Vehicles => _vehicles;
public IReadOnlyList<CabSample> Cab => _cab;
public IReadOnlyList<VehicleLook> Looks => _looks;
public IReadOnlyList<SwitchChange> Switches => _switches;
public IReadOnlyList<AspectChange> Aspects => _aspects;
public IReadOnlyList<CommandEntry> Commands => _commands;
public IReadOnlyList<DriverChange> DriverChanges => _driverChanges;
public IReadOnlyList<SignalPassing> Passings => _passings;
public IReadOnlyList<UserVehicleChange> UserVehicles => _userVehicles;
public IReadOnlyList<ClockEntry> Clocks => _clocks;
public IReadOnlyList<MapStopPoint> StopPoints => _stopPoints;
public IReadOnlyList<MapHornPoint> HornPoints => _hornPoints;
public IReadOnlyList<Timetable> Timetables => _timetables;
public IReadOnlyList<StationEvent> Stations => _stations;
public IReadOnlyList<HornRequest> HornRequests => _hornRequests;
public IReadOnlyList<CameraState> Cameras => _cameras;
public IReadOnlyList<CouplingChange> Couplings => _couplings;
/// <summary>The time the recording covers so far.</summary>
public double Duration => Math.Max(EndTime ?? 0, _lastTime);
/// <summary>The seconds between two looks at the vehicles.</summary>
public double LookInterval => Info is { VehiclesInterval: > 0 } info ? info.VehiclesInterval : 1.0;
// ----- filled by the reader -----
internal void Changed()
{
++Revision;
_memo.Clear();
_consists.Clear();
}
// What the questions about the user's train worked out, kept until a read adds something: the views ask them every frame.
private readonly Dictionary<object, object> _memo = new();
private readonly Dictionary<(int Vehicle, int Coupling), IReadOnlySet<int>> _consists = new();
private static readonly object UserStationsKey = new(), UserPassingsKey = new(), UserHornChecksKey = new(), UserCouplingsKey = new();
/// <summary>What make gives, worked out once until the recording changes.</summary>
internal T Memo<T>(object key, Func<T> make) where T : class
{
if (_memo.TryGetValue(key, out var kept))
return (T)kept;
var made = make();
_memo[key] = made;
return made;
}
private void Saw(double time) => _lastTime = Math.Max(_lastTime, time);
internal void AddTrack(MapTrack track)
{
_tracks.Add(track);
if (!_trackIndex.TryGetValue(track.Index, out var ways))
_trackIndex[track.Index] = ways = new List<MapTrack>(1);
ways.Add(track);
}
internal void AddSignal(MapSignal signal)
{
_signals.Add(signal);
_signalIndex[signal.Index] = signal;
}
internal void AddVehicle(VehicleInfo vehicle) => _vehicles[vehicle.Id] = vehicle;
internal void RemoveVehicle(double time, int id)
{
Saw(time);
if (_vehicles.TryGetValue(id, out var vehicle))
vehicle.RemovedAt = time;
}
internal void AddCab(CabSample sample)
{
Saw(sample.Time);
_cab.Add(sample);
}
internal void AddLook(VehicleLook look)
{
Saw(look.Time);
_looks.Add(look);
foreach (var state in look.States)
{
if (!_history.TryGetValue(state.Id, out var history))
_history[state.Id] = history = new List<(double, VehicleState)>();
history.Add((look.Time, state));
}
}
internal void AddSwitch(SwitchChange change)
{
Saw(change.Time);
_switches.Add(change);
if (!_switchIndex.TryGetValue(change.Track, out var list))
_switchIndex[change.Track] = list = new List<SwitchChange>();
list.Add(change);
}
internal void AddAspect(AspectChange change)
{
Saw(change.Time);
_aspects.Add(change);
if (!_aspectIndex.TryGetValue(change.Signal, out var list))
_aspectIndex[change.Signal] = list = new List<AspectChange>();
list.Add(change);
}
internal void AddCommand(CommandEntry command)
{
Saw(command.Time);
_commands.Add(command);
}
internal void AddDriverChange(DriverChange change)
{
Saw(change.Time);
_driverChanges.Add(change);
}
internal void AddPassing(SignalPassing passing)
{
Saw(passing.Time);
_passings.Add(passing);
}
internal void AddUserVehicle(UserVehicleChange change)
{
Saw(change.Time);
_userVehicles.Add(change);
}
internal void AddClock(ClockEntry clock)
{
Saw(clock.Time);
_clocks.Add(clock);
}
internal void AddStopPoint(MapStopPoint point) => _stopPoints.Add(point);
internal void AddHornPoint(MapHornPoint point) => _hornPoints.Add(point);
internal void AddTimetable(Timetable timetable)
{
Saw(timetable.Time);
_timetables.Add(timetable);
}
internal void AddStation(StationEvent station)
{
Saw(station.Time);
_stations.Add(station);
}
internal void AddCamera(CameraState camera)
{
Saw(camera.Time);
_cameras.Add(camera);
}
internal void AddCoupling(CouplingChange change)
{
Saw(change.Time);
_couplings.Add(change);
}
internal void AddHornRequest(HornRequest request)
{
Saw(request.Time);
_hornRequests.Add(request);
}
// ----- queries -----
public IReadOnlyList<MapTrack> TrackWays(int index) =>
_trackIndex.TryGetValue(index, out var ways) ? ways : Array.Empty<MapTrack>();
public MapSignal? Signal(int index) => _signalIndex.GetValueOrDefault(index);
public VehicleInfo? Vehicle(int id) => _vehicles.GetValueOrDefault(id);
/// <summary>The last of a list sorted by time at or before t: -1 if none is.</summary>
private static int LastAtOrBefore<T>(IReadOnlyList<T> list, double t, Func<T, double> time)
{
int low = 0, high = list.Count - 1, found = -1;
while (low <= high)
{
int middle = low + (high - low) / 2;
if (time(list[middle]) <= t)
{
found = middle;
low = middle + 1;
}
else
high = middle - 1;
}
return found;
}
/// <summary>The index of the sample of the user's train at or before t, -1 before the first.</summary>
public int CabIndexAt(double t) => LastAtOrBefore(_cab, t, s => s.Time);
/// <summary>The sample of the user's train at or before t.</summary>
public CabSample? CabAt(double t) => CabIndexAt(t) is var i and >= 0 ? _cab[i] : null;
/// <summary>
/// Where the user's train was at t, between the two samples around it. Null before
/// the first sample, and in a gap: the user out of any vehicle.
/// </summary>
public Position? CabPositionAt(double t)
{
int i = CabIndexAt(t);
if (i < 0)
return null;
var before = _cab[i];
if (i + 1 >= _cab.Count)
return t - before.Time <= 1.0 ? before.Position : null;
var after = _cab[i + 1];
if (after.Vehicle != before.Vehicle || after.Time - before.Time > 1.0)
return t - before.Time <= 1.0 ? before.Position : null;
double f = (t - before.Time) / Math.Max(1e-9, after.Time - before.Time);
return Position.Lerp(before.Position, after.Position, Math.Clamp(f, 0, 1));
}
/// <summary>
/// Every vehicle on the scenery at t, where it was. A vehicle is listed only when it
/// moved, so between two listings far apart it stood at the first place until the
/// look before the second.
/// </summary>
public IReadOnlyList<VehicleState> VehiclesAt(double t)
{
var states = new List<VehicleState>(_history.Count);
double interval = LookInterval;
foreach (var (id, history) in _history)
{
if (_vehicles.TryGetValue(id, out var info) && info.RemovedAt is { } removed && removed <= t)
continue;
int i = LastAtOrBefore(history, t, entry => entry.Time);
if (i < 0)
continue;
var (time, state) = history[i];
if (i + 1 < history.Count)
{
var (nextTime, next) = history[i + 1];
double from = Math.Max(time, nextTime - interval * 1.5);
if (t > from)
state = Lerp(state, next, (t - from) / Math.Max(1e-9, nextTime - from));
}
states.Add(state);
}
return states;
}
private static VehicleState Lerp(VehicleState a, VehicleState b, double f)
{
float k = (float)Math.Clamp(f, 0, 1);
float turn = b.Heading - a.Heading;
while (turn > MathF.PI) turn -= 2 * MathF.PI;
while (turn < -MathF.PI) turn += 2 * MathF.PI;
return a with
{
Position = Vector3.Lerp(a.Position, b.Position, k),
Heading = a.Heading + turn * k,
SpeedKmh = a.SpeedKmh + (b.SpeedKmh - a.SpeedKmh) * k,
Driving = k < 0.5f ? a.Driving : b.Driving
};
}
/// <summary>The aspect a signal showed at t, null before it was first seen.</summary>
public AspectChange? AspectAt(int signal, double t) =>
_aspectIndex.TryGetValue(signal, out var list) && LastAtOrBefore(list, t, c => c.Time) is var i and >= 0
? list[i]
: null;
/// <summary>The position of a switch at t, -1 if unknown.</summary>
public int SwitchAt(int track, double t) =>
_switchIndex.TryGetValue(track, out var list) && LastAtOrBefore(list, t, c => c.Time) is var i and >= 0
? list[i].State
: -1;
/// <summary>The time of day of the simulation at t.</summary>
public TimeSpan ClockAt(double t)
{
int i = LastAtOrBefore(_clocks, t, c => c.Time);
double second = i >= 0
? _clocks[i].SecondOfDay + (t - _clocks[i].Time)
: (Info?.SecondOfDay ?? 0) + t;
second %= 86400;
if (second < 0)
second += 86400;
return TimeSpan.FromSeconds(second);
}
/// <summary>The vehicle the user was in at t, and whether in its cab; null before it was known.</summary>
public UserVehicleChange? UserVehicleAt(double t) =>
LastAtOrBefore(_userVehicles, t, c => c.Time) is var i and >= 0 ? _userVehicles[i] : null;
/// <summary>The stretches of the samples of the user's train the condition holds over.</summary>
public IReadOnlyList<TimeSpanRange> CabSpans(Func<CabSample, bool> condition)
{
var spans = new List<TimeSpanRange>();
double? start = null;
double gap = (Info is { CabInterval: > 0 } info ? info.CabInterval : 0.1) * 5;
for (int i = 0; i < _cab.Count; i++)
{
var sample = _cab[i];
bool holds = condition(sample);
bool broken = i > 0 && sample.Time - _cab[i - 1].Time > gap;
if (start is { } open && (!holds || broken))
{
spans.Add(new TimeSpanRange(open, broken ? _cab[i - 1].Time : sample.Time));
start = null;
}
if (holds && start is null)
start = sample.Time;
}
if (start is { } last && _cab.Count > 0)
spans.Add(new TimeSpanRange(last, _cab[^1].Time));
return spans;
}
/// <summary>When the AI drove the user's train: the autopilot.</summary>
public IReadOnlyList<TimeSpanRange> AutopilotSpans() => CabSpans(s => s.Driving == Driver.Ai);
/// <summary>The signals the user's train passed: those passed by the vehicle the samples are of at the time.</summary>
public IReadOnlyList<SignalPassing> UserPassings() =>
Memo(UserPassingsKey, () => _passings.Where(p => IsUserTrain(p.Time + 0.5, p.Vehicle, p.Position, Math.Max(0, p.Overrun) + 250)).ToList());
/// <summary>
/// Whether a vehicle was of the user's train at t. The records of a train give the
/// vehicle at its head, which is not the one the user is in when the train is driven
/// from its other end or from a car in the middle: those count when the place the
/// record is about lies within reach of the user's vehicle.
/// </summary>
public bool IsUserTrain(double t, int vehicle, Position place, double reach)
{
if (CabAt(t) is not { } s || t - s.Time > 1.5)
return false;
if (s.Vehicle == vehicle)
return true;
// The couplings tell the train exactly; a recording made before them has the distance only.
return CouplingsWritten ? ConsistAt(s.Vehicle, t).Contains(vehicle) : s.Position.DistanceTo(place) <= reach;
}
/// <summary>
/// Whether the couplings were written: a recording made since has the movement in its cab samples, and one with
/// no train of more than one vehicle has no coupling at all.
/// </summary>
private bool CouplingsWritten => _couplings.Count > 0 || (_cab.Count > 0 && _cab[0].Movement != Movement.Unknown);
/// <summary>The vehicles coupled into one train with the one given at t, itself included.</summary>
public IReadOnlySet<int> ConsistAt(int vehicle, double t)
{
// The trains change only at a coupling: the consist is the same until the next one.
int last = LastAtOrBefore(_couplings, t, c => c.Time);
if (_consists.TryGetValue((vehicle, last), out var kept))
return kept;
var consist = Consist(vehicle, last);
_consists[(vehicle, last)] = consist;
return consist;
}
private HashSet<int> Consist(int vehicle, int last)
{
// What each coupler held after the coupling given: the couplings in the order written, each telling both ends.
var held = new Dictionary<(int Vehicle, int Side), (int Other, int OtherSide)>();
for (int i = 0; i <= last; i++)
{
var change = _couplings[i];
if (held.Remove((change.Vehicle, change.Side), out var before))
held.Remove((before.Other, before.OtherSide));
if (change.Other != 0 && (change.Flags & CouplingFlags.Coupler) != 0)
{
held[(change.Vehicle, change.Side)] = (change.Other, change.OtherSide);
held[(change.Other, change.OtherSide)] = (change.Vehicle, change.Side);
}
}
var consist = new HashSet<int> { vehicle };
var open = new Stack<int>();
open.Push(vehicle);
while (open.Count > 0)
{
int at = open.Pop();
for (int side = 0; side < 2; side++)
if (held.TryGetValue((at, side), out var next) && consist.Add(next.Other))
open.Push(next.Other);
}
return consist;
}
/// <summary>Where the user looked from at t, null before it was known.</summary>
public CameraState? CameraAt(double t) =>
LastAtOrBefore(_cameras, t, c => c.Time) is var i and >= 0 ? _cameras[i] : null;
/// <summary>
/// The couplings made and undone after the start: those at t = 0 are what the trains
/// were made of, not something done.
/// </summary>
public IEnumerable<CouplingChange> CouplingEvents() =>
_couplings.Where(c => c.Time > 0.01 && (c.Coupled || c.Uncoupled || c.Flags != c.PreviousFlags));
/// <summary>The couplings the user's train took part in, before or after them.</summary>
public IReadOnlyList<CouplingChange> UserCouplings() =>
Memo(UserCouplingsKey, () => CouplingEvents().Where(c => CabAt(c.Time) is { } s && c.Time - s.Time <= 1.5 &&
(ConsistAt(s.Vehicle, c.Time - 0.01).Contains(c.Vehicle) ||
ConsistAt(s.Vehicle, c.Time + 0.01).Contains(c.Vehicle)))
.ToList());
/// <summary>
/// The timetable of the train the user was in at t: the last one written by then, else the first; none when the
/// user had gone over to a vehicle of another consist, a train without a timetable or one standing on its own.
/// </summary>
public Timetable? TimetableAt(double t)
{
var timetable = LastAtOrBefore(_timetables, t, x => x.Time) is var i and >= 0 ? _timetables[i] : _timetables.FirstOrDefault();
// A recording made before the couplings were written can't tell the trains apart: the timetable stays.
if (timetable is null || CabAt(t) is not { } sample || sample.Vehicle == timetable.Vehicle || !CouplingsWritten)
return timetable;
// another vehicle of the same train (the other cab, a car of it) keeps the timetable
return ConsistAt(sample.Vehicle, t).Contains(timetable.Vehicle) ? timetable : null;
}
/// <summary>The stations of the timetable of the user's train, as it came to them.</summary>
public IReadOnlyList<StationEvent> UserStations() =>
Memo(UserStationsKey, () => _stations.Where(s => IsUserTrain(s.Time, s.Vehicle, s.Position, Math.Max(s.TrainLength, 50) + 150)).ToList());
/// <summary>The horn points the user's train passed, and whether the horn sounded there.</summary>
public IReadOnlyList<HornCheck> UserHornChecks() => Memo(UserHornChecksKey, () => HornChecks().ToList());
private IEnumerable<HornCheck> HornChecks()
{
foreach (var request in _hornRequests)
{
if (!IsUserTrain(request.Time, request.Vehicle, request.Position, 300))
continue;
bool autopilot = CabAt(request.Time) is { Driving: Driver.Ai };
yield return new HornCheck(request, HornSounded(request), autopilot);
}
}
/// <summary>The time given to sound the horn after a horn point, beyond how long it is to sound.</summary>
public const double HornGrace = 8;
/// <summary>Whether the horn of the user's train sounded from just before the horn point until a while after.</summary>
private bool HornSounded(HornRequest request)
{
double until = request.Time + Math.Max(request.Duration, 1) + HornGrace;
for (int i = Math.Max(0, CabIndexAt(request.Time - 2)); i < _cab.Count && _cab[i].Time <= until; i++)
if (_cab[i].Has(CabDevices.Horn))
return true;
return false;
}
/// <summary>When the train protection braked the user's train: the alerter or the cab signalling not acknowledged.</summary>
public IReadOnlyList<TimeSpanRange> SecurityBrakings() => CabSpans(s => s.Has(SecurityFlags.Braking));
/// <summary>The commands the user gave in a vehicle, presses only.</summary>
public IEnumerable<CommandEntry> UserCommands() => _commands.Where(c => c.Pressed && c.Vehicle != 0);
public DrivingSummary Summarize(float tolerance = DrivingSummary.DefaultTolerance) => DrivingSummary.Of(this, tolerance);
}
/// <summary>A horn point the user's train passed: whether the horn sounded, and whether the autopilot drove then.</summary>
public sealed record HornCheck(HornRequest Request, bool Sounded, bool Autopilot);

406
Eu07.BlackBox/Records.cs Normal file
View File

@@ -0,0 +1,406 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace Eu07.BlackBox;
/// <summary>The types of the records, as the simulator writes them (blackbox_doc.md in its repository).</summary>
public enum RecordType : byte
{
Info = 0x01,
Track = 0x10,
Signal = 0x11,
Vehicle = 0x12,
VehicleRemoved = 0x13,
StopPoint = 0x14,
HornPoint = 0x15,
Cab = 0x20,
Vehicles = 0x21,
SwitchState = 0x22,
SignalAspect = 0x23,
Timetable = 0x24,
Camera = 0x25,
Command = 0x30,
DriverChange = 0x31,
SignalPassed = 0x32,
UserVehicle = 0x33,
Clock = 0x34,
Station = 0x35,
HornRequest = 0x36,
Coupling = 0x37,
End = 0x3f
}
/// <summary>Who drives a vehicle.</summary>
public enum Driver : byte
{
None = 0,
Human = 1,
Ai = 2
}
[Flags]
public enum CabFlags : byte
{
None = 0,
/// <summary>The user is in the cab, not looking around outside.</summary>
InCab = 0x01,
/// <summary>The consist is started, so what the AI would do is worked out.</summary>
AiValid = 0x02,
/// <summary>The alerter or the cab signalling blinks.</summary>
AlerterBlinking = 0x04,
/// <summary>The alerter or the cab signalling beeps.</summary>
AlerterBeeping = 0x08,
WheelSlip = 0x10,
RadioStop = 0x20,
AlarmChain = 0x40
}
/// <summary>What is switched on in the user's train.</summary>
[Flags]
public enum CabDevices : uint
{
None = 0,
Battery = 0x0001,
MainBreaker = 0x0002,
PantographFront = 0x0004,
PantographRear = 0x0008,
Converter = 0x0010,
Compressor = 0x0020,
/// <summary>The motors are connected.</summary>
LineContactors = 0x0040,
/// <summary>Tripped.</summary>
OverloadRelay = 0x0080,
Sanding = 0x0100,
Horn = 0x0200,
DoorsLeft = 0x0400,
DoorsRight = 0x0800,
Radio = 0x1000,
Heating = 0x2000,
/// <summary>Applied.</summary>
SpringBrake = 0x4000,
ShuntMode = 0x8000,
/// <summary>Sounding.</summary>
DepartureSignal = 0x0001_0000,
/// <summary>The doors may be opened.</summary>
DoorPermitLeft = 0x0002_0000,
DoorPermitRight = 0x0004_0000,
/// <summary>The steps come out with the doors.</summary>
DoorStep = 0x0008_0000,
/// <summary>The doors lock above a speed.</summary>
DoorLock = 0x0010_0000,
/// <summary>Locked now.</summary>
DoorsLocked = 0x0020_0000,
/// <summary>Releasing the brakes of the vehicle.</summary>
BrakeReleaser = 0x0040_0000,
FuelPump = 0x0080_0000,
OilPump = 0x0100_0000,
/// <summary>The fans of the traction motors.</summary>
MotorBlowers = 0x0200_0000,
/// <summary>The diesel engine is over its temperature.</summary>
EngineHot = 0x0400_0000,
/// <summary>The cruise control holds a speed.</summary>
SpeedControl = 0x0800_0000
}
/// <summary>What drives the vehicle whose motors the cab drives.</summary>
public enum EngineKind : byte
{
/// <summary>Nothing of its own: a draisine, a car.</summary>
None = 0,
Electric = 1,
ElectricInduction = 2,
/// <summary>With a mechanical or a hydraulic transmission.</summary>
Diesel = 3,
DieselElectric = 4,
Steam = 5
}
/// <summary>
/// The rest of a look at the user's train, written from the same version as the doors' permits: what drives it,
/// the brakes' setting and mode, the hand brake, the radio, the horn, the pressure the driver's brake valve shows
/// (its equalizing reservoir, the FV4a's time reservoir) and that of the main reservoir pipe, a diesel's oil, temperatures, clutch and torque converter, the cruise control,
/// the power and the integrated controller of a unit, the highest brake cylinder pressure of the train. The
/// temperatures and the oil mean something for a diesel only.
/// </summary>
public readonly record struct CabExtra(EngineKind Engine, byte BrakeDelay, byte RadioChannel, float DriverValve,
float FeedPipe, float OilPressure, float WaterTemperature, float OilTemperature,
float EngineTemperature, float SpeedControlKmh, float EnginePower, sbyte HandBrake,
byte BrakeMode, byte Horn, float IntegratedController, float Clutch, float ConverterFill,
float ConsistBrakeCylinder);
/// <summary>The train protection: the alerter (CA) and the cab signalling (SHP) apart.</summary>
[Flags]
public enum SecurityFlags : byte
{
None = 0,
/// <summary>The alerter lamp blinks: the driver is to acknowledge.</summary>
Vigilance = 0x01,
/// <summary>The cab signalling lamp blinks: an SHP magnet was passed.</summary>
CabSignal = 0x02,
VigilanceBeeping = 0x04,
CabSignalBeeping = 0x08,
/// <summary>Not acknowledged in time: the train protection brakes the train.</summary>
Braking = 0x10
}
/// <summary>What a train did at a station of its timetable.</summary>
public enum StationKind : byte
{
/// <summary>Stopped at the stop point of a stop of its timetable.</summary>
Stopped = 1,
/// <summary>Passed a station where the timetable has no stop.</summary>
Passed = 2,
/// <summary>Left the stop point behind before it was time to go: without stopping, or leaving early.</summary>
Missed = 3,
/// <summary>Still at the stop point when it was time to go.</summary>
Ready = 4
}
/// <summary>The lights lit at an end of a vehicle.</summary>
[Flags]
public enum Lights : ushort
{
None = 0,
HeadlightLeft = 0x001,
RedMarkerLeft = 0x002,
HeadlightUpper = 0x004,
HeadlightRight = 0x010,
RedMarkerRight = 0x020,
RearEndSignals = 0x040,
AuxiliaryLeft = 0x080,
AuxiliaryRight = 0x100,
HighBeamLeft = 0x200,
HighBeamRight = 0x400
}
[Flags]
public enum PassingFlags : byte
{
None = 0,
/// <summary>A shunting signal.</summary>
Shunt = 0x01,
/// <summary>Passed showing stop: a signal passed at danger.</summary>
AtStop = 0x02,
/// <summary>Stop of an automatic block signal, which may be passed slowly after a stop.</summary>
AutomaticBlock = 0x04
}
public enum TrackKind : byte
{
Normal = 0,
SwitchBranch = 1
}
/// <summary>A position in the frame of the scenery: metres, y up.</summary>
public readonly record struct Position(double X, double Y, double Z)
{
public static Position Lerp(Position a, Position b, double f) =>
new(a.X + (b.X - a.X) * f, a.Y + (b.Y - a.Y) * f, a.Z + (b.Z - a.Z) * f);
public double DistanceTo(Position other)
{
double dx = X - other.X, dy = Y - other.Y, dz = Z - other.Z;
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
}
}
public sealed record RecordingInfo(string Scenery, string Started, int Year, int YearDay, double SecondOfDay,
string StartVehicle, string SimulatorVersion, float CabInterval,
float VehiclesInterval, float KeyframeInterval)
{
/// <summary>The time of day the simulation started at.</summary>
public TimeSpan StartClock => TimeSpan.FromSeconds(SecondOfDay);
}
/// <summary>One rail track of the map, or one of the two ways of a switch, which share the number.</summary>
public sealed record MapTrack(int Index, string Name, TrackKind Kind, int Branch, int Category,
IReadOnlyList<Vector3> Points);
public sealed record MapSignal(int Index, string Name, Position Position);
/// <summary>
/// A stop point of a passenger train (W4). StopAt: below 0 the head stops that many metres
/// before it, above 0 the middle of the train. Platform: above 0 its length, below 0 the
/// stop is for trains shorter than that (W32), 0 not given; its last digit also tells the side of
/// the platform (see <see cref="Platforms"/>).
/// </summary>
public sealed record MapStopPoint(int Index, string Station, Position Position, float StopAt, float Platform)
{
/// <summary>
/// The heading of the trains it is for [rad, from +z towards +x], NaN when not known: on no track, on tracks of
/// both ways, or in a recording made before it was written.
/// </summary>
public float Heading { get; init; } = float.NaN;
public bool HasHeading => !float.IsNaN(Heading);
public bool HasPlatform => Platform > 0;
/// <summary>A W32: the stop of the trains shorter than its length; the longer ones go on to another stop point.</summary>
public bool ForShortTrains => Platform < 0;
/// <summary>The length of the platform, or of the trains a W32 is for [m].</summary>
public double PlatformLength => Platforms.Length(Platform);
}
/// <summary>
/// The second value of a stop point, as the simulator's driver reads it: the whole of it the metres of the
/// platform, and its last digit, besides, the side the doors open on (1, 2, both 3); 201 is 201 m.
/// </summary>
public static class Platforms
{
public static double Length(float value) => Math.Abs(value);
public static int Side(float value) => (int)Math.Floor(Math.Abs(value)) % 10;
}
/// <summary>A horn point (W6): the trains passing it are to sound the horn this long, in this tone (1 low, 2 high).</summary>
public sealed record MapHornPoint(int Index, string Name, Position Position, float Duration, float Tone);
/// <summary>A station of a timetable; the times in minutes of the day, below 0 none.</summary>
public sealed record TimetableRow(string Station, float Km, float Vmax, float Arrival, float Departure, bool Maintenance)
{
/// <summary>The train stops there: a station passed through has a departure only.</summary>
public bool IsStop => Arrival >= 0;
}
/// <summary>The timetable of the train the user was in. The rows are numbered from 1, the station records give their number.</summary>
public sealed record Timetable(double Time, int Vehicle, string Train, string Category, string From, string To, float Vmax,
IReadOnlyList<TimetableRow> Rows)
{
public TimetableRow? Row(int number) => number >= 1 && number <= Rows.Count ? Rows[number - 1] : null;
}
/// <summary>
/// A train at a station of its timetable, at its stop point. Distance: of its head before
/// the stop point, below 0 past it; StopAt: where the head was to stop, that many metres before it.
/// </summary>
public sealed record StationEvent(double Time, int Vehicle, StationKind Kind, int Row, string Station, Position Position,
float Distance, float StopAt, float TrainLength, float Platform, float Arrival,
float Departure)
{
/// <summary>How far from the place it was to stop the head stood: above 0 short of it, below 0 past it.</summary>
public float StopError => Distance - StopAt;
/// <summary>
/// Whether the whole train stood along the platform, when its length is known: as the driver takes it, the length of
/// the trains a W32 is for is the room it has there too.
/// </summary>
public bool? OnPlatform => Platform != 0 ? -Distance <= PlatformTolerance && Distance + TrainLength - PlatformLength <= PlatformTolerance : null;
/// <summary>How far off the platform a train may stand at either end and still count as along it [m].</summary>
public const double PlatformTolerance = 2;
/// <summary>The length of the platform [m], without the digit of its side.</summary>
public double PlatformLength => Platforms.Length(Platform);
/// <summary>How much of the train stood off the platform [m]: the head past the stop point, the tail before the platform's end; 0 along it.</summary>
public double OffPlatform => Platform != 0 ? Math.Max(0, -Distance) + Math.Max(0, Distance + TrainLength - PlatformLength) : 0;
}
public enum CameraMode : byte
{
/// <summary>From the cab of the vehicle the user is in.</summary>
Cab = 0,
/// <summary>One of the outside views of the user's train.</summary>
External = 1,
/// <summary>The free camera.</summary>
Free = 2,
Debug = 3
}
/// <summary>Where the user looked from. Heading as that of a vehicle: towards (sin, cos) in (x, z).</summary>
public readonly record struct CameraState(double Time, CameraMode Mode, int View, int Vehicle, Position Position,
float Heading, float Pitch);
/// <summary>What a coupling joins: the bits of the simulator's coupling.</summary>
[Flags]
public enum CouplingFlags : byte
{
None = 0,
Coupler = 0x01,
BrakePipe = 0x02,
Control = 0x04,
Power = 0x08,
Gangway = 0x10,
MainPipe = 0x20,
Heating = 0x40,
/// <summary>Not undone in shunting: between the cars of a unit.</summary>
Permanent = 0x80
}
/// <summary>A coupler coupled, uncoupled, or its hoses and cables changed. Side 0 the front coupler, 1 the rear one.</summary>
public sealed record CouplingChange(double Time, int Vehicle, int Side, int Other, int OtherSide, CouplingFlags Flags,
CouplingFlags PreviousFlags, int PreviousOther, Position Position)
{
public bool Coupled => Other != 0 && (Flags & CouplingFlags.Coupler) != 0 && PreviousOther != Other;
public bool Uncoupled => Other == 0 && PreviousOther != 0;
}
/// <summary>A train told to sound the horn at a horn point.</summary>
public readonly record struct HornRequest(double Time, int Vehicle, float Duration, float Tone, Position Position);
/// <summary>A vehicle as it is first seen. Id 0 is no vehicle.</summary>
public sealed record VehicleInfo(int Id, string Name, string Type, string Train, float Length, float Width,
int Category)
{
public double? RemovedAt { get; internal set; }
}
/// <summary>What the AI driver would do at that moment, from that place and at that speed.</summary>
public readonly record struct AiReference(float TargetKmh, float NextLimitKmh, float NextLimitDistance,
float Acceleration, float BrakingDistance, float TimetableVmaxKmh,
float TrackVmaxKmh)
{
public bool HasNextLimit => NextLimitKmh >= 0 && NextLimitDistance >= 0;
}
/// <summary>One look at the user's train, ten a second.</summary>
public readonly record struct CabSample(double Time, int Vehicle, Position Position, float SpeedKmh,
float Acceleration, float OdometerKm, Driver Driving, CabFlags Flags,
sbyte MainController, sbyte ScndController, sbyte Reverser,
float TrainBrake, float LocalBrake, float BrakeCylinder, float BrakePipe,
float MotorCurrent, AiReference Ai, CabDevices Devices,
float MainReservoir, float Voltage, float EngineRpm,
Lights LightsHead, Lights LightsTail, sbyte CabEnd = 0,
SecurityFlags Security = SecurityFlags.None, float LateralAcceleration = 0,
Movement Movement = Movement.Unknown, short LightHintHead = -1, short LightHintTail = -1,
Lights? EquippedHead = null, Lights? EquippedTail = null, CabExtra? Extra = null)
{
public bool Has(CabDevices device) => (Devices & device) == device;
public bool Has(SecurityFlags flag) => (Security & flag) == flag;
public bool AiValid => (Flags & CabFlags.AiValid) != 0;
public bool InCab => (Flags & CabFlags.InCab) != 0;
public float AbsSpeedKmh => Math.Abs(SpeedKmh);
}
/// <summary>One vehicle in a look at the vehicles.</summary>
public readonly record struct VehicleState(int Id, Vector3 Position, float Heading, float SpeedKmh, Driver Driving);
/// <summary>A look at the vehicles: those which moved, or all of them in a keyframe.</summary>
public sealed record VehicleLook(double Time, bool Keyframe, IReadOnlyList<VehicleState> States);
public readonly record struct SwitchChange(double Time, int Track, int State);
/// <summary>What a signal shows: the speeds its memory cell holds, 0 stop, -1 no limit, and the command.</summary>
public readonly record struct AspectChange(double Time, int Signal, float Speed1, float Speed2, string Command);
public sealed record CommandEntry(double Time, int Vehicle, bool Pressed, double Param1, double Param2, string Name);
public readonly record struct DriverChange(double Time, int Vehicle, Driver Driving);
public sealed record SignalPassing(double Time, int Vehicle, int Signal, string Event, Position Position,
float Speed1, float Speed2, float SpeedKmh, float Overrun, PassingFlags Flags)
{
/// <summary>No signal of the map: the passing came from an event the map doesn't hold.</summary>
public const int NoSignal = -1;
public bool AtDanger => (Flags & PassingFlags.AtStop) != 0;
}
/// <summary>The vehicle the user was in, whether in its cab, and which cab: 1 the front one, -1 the rear one, 0 none.</summary>
public readonly record struct UserVehicleChange(double Time, int Vehicle, bool InCab, sbyte CabEnd = 0);
public readonly record struct ClockEntry(double Time, int Year, int YearDay, double SecondOfDay);

View File

@@ -0,0 +1,13 @@
namespace Eu07.BlackBox;
/// <summary>The names of the stations as shown: the scenery tells the stop points of one station apart after a <c>#</c>.</summary>
public static class StationNames
{
/// <summary>The name up to a <c>#</c>, with the underscores of the scenery as spaces.</summary>
public static string Display(string name)
{
int hash = name.IndexOf('#');
var shown = hash >= 0 ? name[..hash] : name;
return shown.Replace('_', ' ').Trim();
}
}

View File

@@ -0,0 +1,188 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Eu07.BlackBox;
/// <summary>How the user's train kept to a station of its timetable.</summary>
public enum StationOutcome
{
/// <summary>Not reached yet in the recording.</summary>
Ahead,
/// <summary>Stopped where the timetable has a stop.</summary>
Stopped,
/// <summary>Passed where the timetable has no stop.</summary>
Passed,
/// <summary>Left a stop behind without stopping, or before the departure.</summary>
Missed
}
/// <summary>How late the user's train was at a moment [min, above 0 late], after the station named, standing there or not.</summary>
public sealed record TrainDelay(string Train, double Minutes, string Station, bool Standing);
/// <summary>
/// A row of the timetable of the user's train, with what happened there. The times are
/// minutes of the day, as in the timetable; the delays are in minutes, above 0 late.
/// </summary>
public sealed record StationProgress(
int Number,
TimetableRow Row,
StationOutcome Outcome,
double? ArrivedAt,
double? DepartedAt,
double? ArrivalClock,
double? DepartureClock,
StationEvent? Event)
{
public double? ArrivalDelay => Delay(ArrivalClock, Row.Arrival);
/// <summary>Of the departure, or of the pass for a station passed through.</summary>
public double? DepartureDelay => Delay(DepartureClock, Row.Departure);
/// <summary>Above 0 the head stood short of where it was to stop, below 0 past it [m].</summary>
public float? StopError => Outcome == StationOutcome.Stopped && Event is { } e ? e.StopError : null;
public bool? OnPlatform => Outcome == StationOutcome.Stopped ? Event?.OnPlatform : null;
/// <summary>How far the tail stood off the platform [m], when it stopped.</summary>
public double? OffPlatform => Outcome == StationOutcome.Stopped ? Event?.OffPlatform : null;
/// <summary>The difference of two times of the day in minutes, across midnight the short way.</summary>
internal static double? Delay(double? actual, float planned)
{
if (actual is not { } a || planned < 0)
return null;
double d = a - planned;
if (d > 720) d -= 1440;
if (d < -720) d += 1440;
return d;
}
/// <summary>
/// The rows of the timetable the user's train had at t, each with what the train did
/// there by the end of the recording read so far.
/// </summary>
public static IReadOnlyList<StationProgress> Of(Recording recording, double t) =>
recording.TimetableAt(t) is { } timetable ? Of(recording, timetable) : Array.Empty<StationProgress>();
/// <summary>The rows of a timetable of the recording, each with what the train did there.</summary>
public static IReadOnlyList<StationProgress> Of(Recording recording, Timetable timetable) =>
recording.Memo((typeof(StationProgress), timetable), () => Work(recording, timetable));
private static IReadOnlyList<StationProgress> Work(Recording recording, Timetable timetable)
{
// The records of this train: from its timetable on, until another one is written. A train given its next
// timetable may come to the first station of it at once, and a recording made before the simulator wrote the
// timetable first has that station up to a look (1 s) before it: it goes with the timetable after.
int next = IndexOf(recording.Timetables, timetable) + 1;
double until = next < recording.Timetables.Count ? recording.Timetables[next].Time - 1 : double.MaxValue;
var events = recording.UserStations()
.Where(e => e.Time >= timetable.Time - 1 && e.Time < until)
.ToList();
var rows = new List<StationProgress>(timetable.Rows.Count);
for (int number = 1; number <= timetable.Rows.Count; number++)
{
var row = timetable.Rows[number - 1];
var mine = events.Where(e => e.Row == number).ToList();
var stopped = mine.FirstOrDefault(e => e.Kind == StationKind.Stopped);
var passed = mine.FirstOrDefault(e => e.Kind == StationKind.Passed);
var missed = mine.FirstOrDefault(e => e.Kind == StationKind.Missed);
if (stopped is not null)
{
double? departed = DepartureAfter(recording, stopped.Time);
rows.Add(new StationProgress(number, row, missed is not null ? StationOutcome.Missed : StationOutcome.Stopped,
stopped.Time, departed, Minutes(recording, stopped.Time),
departed is { } d ? Minutes(recording, d) : null, stopped));
}
else if (passed is not null)
rows.Add(new StationProgress(number, row, StationOutcome.Passed, null, passed.Time, null,
Minutes(recording, passed.Time), passed));
else if (missed is not null)
rows.Add(new StationProgress(number, row, StationOutcome.Missed, null, missed.Time, null,
Minutes(recording, missed.Time), missed));
else
rows.Add(new StationProgress(number, row, StationOutcome.Ahead, null, null, null, null, null));
}
return rows;
}
/// <summary>When the user's train got moving again after a stop, null while it still stood.</summary>
private static double? DepartureAfter(Recording recording, double stoppedAt)
{
var cab = recording.Cab;
for (int i = Math.Max(0, recording.CabIndexAt(stoppedAt)); i < cab.Count; i++)
if (cab[i].Time > stoppedAt && cab[i].AbsSpeedKmh > 2)
return cab[i].Time;
return null;
}
/// <summary>
/// The delay of the user's train at t, from the last station of its timetable it had come to
/// by then: of the departure or the pass once it left, of the arrival while it stands, growing
/// while it stands past the departure, and growing on the way once it is past the time it was
/// to be at the next station. Null before the first station or without a timetable.
/// </summary>
public static TrainDelay? DelayAt(Recording recording, double t)
{
if (recording.TimetableAt(t) is not { } timetable || t < timetable.Time - 1)
return null;
double clock = recording.ClockAt(t).TotalMinutes;
TrainDelay? delay = null;
var rows = Of(recording, timetable);
int reached = -1;
for (int i = 0; i < rows.Count; i++)
{
var row = rows[i];
if (row.DepartedAt is { } departed && departed <= t)
delay = new TrainDelay(timetable.Train, row.DepartureDelay ?? row.ArrivalDelay ?? delay?.Minutes ?? 0, row.Row.Station, false);
else if (row.ArrivedAt is { } arrived && arrived <= t)
{
double minutes = row.ArrivalDelay ?? delay?.Minutes ?? 0;
if (Delay(clock, row.Row.Departure) is { } overdue)
minutes = Math.Max(minutes, overdue);
delay = new TrainDelay(timetable.Train, minutes, row.Row.Station, true);
}
else
continue;
reached = i;
}
// On the way, past the time it was to be at the next station: at least that late, and more as it stays away.
if (delay is { Standing: false } && reached + 1 < rows.Count)
{
var next = rows[reached + 1].Row;
if (Delay(clock, next.Arrival >= 0 ? next.Arrival : next.Departure) is { } overdue && overdue > delay.Minutes)
delay = delay with { Minutes = overdue };
}
return delay;
}
/// <summary>The delays the user's train had at the stations it had left or stood at by t, by the name shown.</summary>
public static IReadOnlyDictionary<string, double> StationDelays(Recording recording, double t)
{
var delays = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var timetable in recording.Timetables)
{
if (timetable.Time > t)
break;
foreach (var row in Of(recording, timetable))
{
double? at = row.ArrivedAt ?? row.DepartedAt;
double? delay = row.DepartedAt <= t ? row.DepartureDelay ?? row.ArrivalDelay : row.ArrivalDelay;
if (at <= t && delay is { } d)
delays[StationNames.Display(row.Row.Station)] = d;
}
}
return delays;
}
private static int IndexOf(IReadOnlyList<Timetable> timetables, Timetable timetable)
{
for (int i = 0; i < timetables.Count; i++)
if (ReferenceEquals(timetables[i], timetable))
return i;
return -1;
}
private static double Minutes(Recording recording, double t) => recording.ClockAt(t).TotalMinutes;
}

View File

@@ -0,0 +1,397 @@
using System.Numerics;
using System.Text;
using Eu07.BlackBox;
namespace StarterNG.Tests.BlackBox;
/// <summary>
/// Writes a black box the way the simulator does (blackbox_doc.md of its repository),
/// field by field, so the reader is checked against the layout and not against itself.
/// </summary>
internal sealed class BlackBoxFile
{
private readonly MemoryStream _bytes = new();
private readonly BinaryWriter _file;
public BlackBoxFile(ushort version = 1, bool header = true)
{
_file = new BinaryWriter(_bytes, Encoding.UTF8, leaveOpen: true);
if (header)
{
_file.Write("EU07BBX\0"u8);
_file.Write(version);
_file.Write((ushort)0);
}
}
public byte[] Bytes => _bytes.ToArray();
public MemoryStream Stream() => new(Bytes);
/// <summary>A record of a type, its length and what the writer puts in it.</summary>
public BlackBoxFile Record(byte type, Action<BinaryWriter> payload)
{
using var body = new MemoryStream();
using (var w = new BinaryWriter(body, Encoding.UTF8, leaveOpen: true))
payload(w);
_file.Write(type);
_file.Write((uint)body.Length);
_file.Write(body.ToArray());
return this;
}
public BlackBoxFile Info(double secondOfDay = 10 * 3600 + 34 * 60, string scenery = "td.scn") =>
Record(0x01, w =>
{
w.Write(scenery);
w.Write("2026-11-08T22:56:30");
w.Write((ushort)2026);
w.Write((ushort)312);
w.Write(secondOfDay);
w.Write("ep07-bb");
w.Write("EU07 test");
w.Write(0.1f);
w.Write(1.0f);
w.Write(60.0f);
});
public BlackBoxFile Track(uint index, string name, params Vector3[] points) =>
Track(index, name, 0, 0, points);
public BlackBoxFile Track(uint index, string name, byte kind, byte branch, params Vector3[] points) =>
Record(0x10, w =>
{
w.Write(index);
w.Write(name);
w.Write(kind);
w.Write(branch);
w.Write((byte)1);
w.Write((uint)points.Length);
foreach (var p in points)
{
w.Write(p.X);
w.Write(p.Y);
w.Write(p.Z);
}
});
public BlackBoxFile Signal(uint index, string name, double x, double y, double z) =>
Record(0x11, w =>
{
w.Write(index);
w.Write(name);
w.Write(x);
w.Write(y);
w.Write(z);
});
public BlackBoxFile Vehicle(uint id, string name, string train = "", float length = 16.3f) =>
Record(0x12, w =>
{
w.Write(id);
w.Write(name);
w.Write("303e-ep-tv");
w.Write(train);
w.Write(length);
w.Write(2.9f);
w.Write((byte)1);
});
public BlackBoxFile Removed(double time, uint id) =>
Record(0x13, w =>
{
w.Write(time);
w.Write(id);
});
public BlackBoxFile Cab(double time, uint vehicle, double z, float speed, byte driving = 1, byte flags = 0x03,
float target = 40, float nextLimit = -1, float nextDistance = -1, float brakeCylinder = 0,
uint devices = 0x0007, bool extended = true, sbyte cabEnd = 1, byte security = 0,
float lateral = 0.2f, ushort lightsHead = 0x15, ushort lightsTail = 0x22, byte? movement = null,
short hintHead = -1, short hintTail = -1, ushort? equippedHead = null, ushort? equippedTail = null,
byte? engine = null, float oilTemperature = 70) =>
Record(0x20, w =>
{
w.Write(time);
w.Write(vehicle);
w.Write(5.0);
w.Write(0.4);
w.Write(z);
w.Write(speed);
w.Write(0.1f); // acceleration
w.Write(1.5f); // odometer
w.Write(driving);
w.Write(flags);
w.Write((sbyte)3); // main controller
w.Write((sbyte)0);
w.Write((sbyte)1);
w.Write(0.0f); // train brake
w.Write(0.0f);
w.Write(brakeCylinder);
w.Write(5.0f); // brake pipe
w.Write(320.0f); // current
w.Write(target);
w.Write(nextLimit);
w.Write(nextDistance);
w.Write(0.3f);
w.Write(150.0f);
w.Write(60.0f);
w.Write(-1.0f);
w.Write(devices);
w.Write(8.5f); // main reservoir
w.Write(3000.0f); // voltage
w.Write(1100.0f); // revolutions
w.Write(lightsHead); // lights at the head of the train
w.Write(lightsTail);
// The cab, the train protection and the lateral acceleration: a recording made before them ends above.
if (extended)
{
w.Write(cabEnd);
w.Write(security);
w.Write(lateral);
// what the driver goes by and the lights asked for: a recording made before them ends above
if (movement is { } kind)
{
w.Write(kind);
w.Write(hintHead);
w.Write(hintTail);
// the lamps the vehicles at the ends have: a recording made before them ends above
if (equippedHead is { } head)
{
w.Write(head);
w.Write(equippedTail ?? head);
// what drives it and the rest: a recording made before them ends above
if (engine is { } kind2)
{
w.Write(kind2);
w.Write((byte)2); // brake setting P
w.Write((byte)3); // radio channel
w.Write(4.8f); // equalizing reservoir
w.Write(8.0f); // main reservoir pipe
w.Write(0.45f); // oil pressure
w.Write(75.0f); // water
w.Write(oilTemperature);
w.Write(80.0f); // engine
w.Write(0.0f); // cruise control
w.Write(900.0f); // power
w.Write((sbyte)0); // hand brake
w.Write((byte)1); // brake mode
w.Write((byte)0); // horn
w.Write(0.5f); // integrated controller
w.Write(1.0f); // clutch
w.Write(0.0f); // torque converter
w.Write(2.5f); // the highest brake cylinder of the train
}
}
}
}
});
public BlackBoxFile Vehicles(double time, bool keyframe, params (uint Id, float X, float Z, float Speed)[] states) =>
Record(0x21, w =>
{
w.Write(time);
w.Write((byte)(keyframe ? 1 : 0));
w.Write((uint)states.Length);
foreach (var s in states)
{
w.Write(s.Id);
w.Write(s.X);
w.Write(0.4f);
w.Write(s.Z);
w.Write(0.0f); // heading
w.Write(s.Speed);
w.Write((byte)2);
}
});
public BlackBoxFile Switch(double time, uint track, byte state) =>
Record(0x22, w =>
{
w.Write(time);
w.Write(track);
w.Write(state);
});
public BlackBoxFile Aspect(double time, uint signal, float speed1, float speed2, string command = "SetVelocity") =>
Record(0x23, w =>
{
w.Write(time);
w.Write(signal);
w.Write(speed1);
w.Write(speed2);
w.Write(command);
});
public BlackBoxFile Command(double time, uint vehicle, bool pressed, string name) =>
Record(0x30, w =>
{
w.Write(time);
w.Write(vehicle);
w.Write((byte)(pressed ? 1 : 0));
w.Write(1.0);
w.Write(2.0);
w.Write(name);
});
public BlackBoxFile Driver(double time, uint vehicle, byte driving) =>
Record(0x31, w =>
{
w.Write(time);
w.Write(vehicle);
w.Write(driving);
});
public BlackBoxFile Passed(double time, uint vehicle, uint signal, string name, float speed1, float speed,
byte flags = 0, double z = 1300) =>
Record(0x32, w =>
{
w.Write(time);
w.Write(vehicle);
w.Write(signal);
w.Write(name);
w.Write(5.0);
w.Write(0.4);
w.Write(z);
w.Write(speed1);
w.Write(-1.0f);
w.Write(speed);
w.Write(2.5f);
w.Write(flags);
});
public BlackBoxFile User(double time, uint vehicle, bool inCab, sbyte? cabEnd = null) =>
Record(0x33, w =>
{
w.Write(time);
w.Write(vehicle);
w.Write((byte)(inCab ? 1 : 0));
if (cabEnd is { } end)
w.Write(end);
});
public BlackBoxFile StopPoint(uint index, string station, double z, float stopAt, float platform, float? heading = null) =>
Record(0x14, w =>
{
w.Write(index);
w.Write(station);
w.Write(5.0);
w.Write(0.4);
w.Write(z);
w.Write(stopAt);
w.Write(platform);
if (heading is { } way)
w.Write(way);
});
public BlackBoxFile HornPoint(uint index, string name, double z, float duration = 2, float tone = 1) =>
Record(0x15, w =>
{
w.Write(index);
w.Write(name);
w.Write(5.0);
w.Write(0.4);
w.Write(z);
w.Write(duration);
w.Write(tone);
});
public BlackBoxFile Timetable(double time, uint vehicle, string train, string from, string to,
params (string Station, float Arrival, float Departure)[] rows) =>
Record(0x24, w =>
{
w.Write(time);
w.Write(vehicle);
w.Write(train);
w.Write("ROJ");
w.Write(from);
w.Write(to);
w.Write(100.0f);
w.Write((uint)rows.Length);
float km = 0;
foreach (var row in rows)
{
w.Write(row.Station);
w.Write(km += 2.5f);
w.Write(100.0f);
w.Write(row.Arrival);
w.Write(row.Departure);
w.Write((byte)0);
}
});
public BlackBoxFile Station(double time, uint vehicle, byte kind, ushort row, string station, double z,
float distance = 1, float stopAt = 0, float trainLength = 87, float platform = 150,
float arrival = -1, float departure = -1) =>
Record(0x35, w =>
{
w.Write(time);
w.Write(vehicle);
w.Write(kind);
w.Write(row);
w.Write(station);
w.Write(5.0);
w.Write(0.4);
w.Write(z);
w.Write(distance);
w.Write(stopAt);
w.Write(trainLength);
w.Write(platform);
w.Write(arrival);
w.Write(departure);
});
public BlackBoxFile HornRequest(double time, uint vehicle, double z, float duration = 2) =>
Record(0x36, w =>
{
w.Write(time);
w.Write(vehicle);
w.Write(duration);
w.Write(1.0f);
w.Write(5.0);
w.Write(0.4);
w.Write(z);
});
public BlackBoxFile Camera(double time, byte mode, uint vehicle, double z, byte view = 0, float heading = 0.5f) =>
Record(0x25, w =>
{
w.Write(time);
w.Write(mode);
w.Write(view);
w.Write(vehicle);
w.Write(5.0);
w.Write(2.4);
w.Write(z);
w.Write(heading);
w.Write(-0.1f);
});
public BlackBoxFile Coupling(double time, uint vehicle, byte side, uint other, byte otherSide, byte flags,
byte previousFlags, uint previousOther) =>
Record(0x37, w =>
{
w.Write(time);
w.Write(vehicle);
w.Write(side);
w.Write(other);
w.Write(otherSide);
w.Write(flags);
w.Write(previousFlags);
w.Write(previousOther);
w.Write(5.0);
w.Write(0.4);
w.Write(1150.0);
});
public BlackBoxFile Clock(double time, double secondOfDay) =>
Record(0x34, w =>
{
w.Write(time);
w.Write((ushort)2026);
w.Write((ushort)312);
w.Write(secondOfDay);
});
public BlackBoxFile End(double time) => Record(0x3f, w => w.Write(time));
}

View File

@@ -0,0 +1,253 @@
using Eu07.BlackBox;
namespace StarterNG.Tests.BlackBox;
/// <summary>
/// A journey of two timetables one after another: the stations, the horn points, the train
/// protection, the camera and the couplings, as the simulator writes them, and what the
/// recording tells of them.
/// </summary>
public class BlackBoxJourneyTests
{
private const uint Head = 1, Car = 2, Tail = 3, Stranger = 9;
private const byte Coupled = 0x83;
/// <summary>
/// The user in vehicle 1 of a unit 1-2-3, the clock at 10:34 (634 min). ROJ1 stands at A, stops at B
/// at t = 60 until 70, passes C at 100 without stopping; ROJ2 from t = 110 stops at D at 115.
/// </summary>
private static Recording Journey()
{
var f = new BlackBoxFile().Info()
.StopPoint(0, "Stacja_B#1", 1300, 10, 150)
.StopPoint(1, "Stacja_B#2", 1310, 10, 150)
.HornPoint(0, "w6_1", 1200)
.User(0, Head, true, -1)
.Coupling(0, Head, 1, Car, 0, Coupled, 0, 0)
.Coupling(0, Car, 1, Tail, 0, Coupled, 0, 0)
.Camera(0, 0, Head, 1150)
.Timetable(0, Head, "ROJ1", "A", "C", ("A", -1, 634), ("Stacja_B", 635, 635.5f), ("C", 637.5f, 638))
.Station(1, Head, 4, 1, "A", 1150);
for (double t = 0; t <= 130; t += 0.5)
{
float speed = t is >= 55 and < 70 || t >= 112 && t < 120 ? 0 : 40;
uint devices = 0x0007 | (t is >= 22 and < 24 ? 0x0200u : 0);
byte security = (byte)(t is >= 30 and < 32 ? 0x11 : 0x01);
f.Cab(t, Head, 1150 + t * 10, speed, devices: devices, security: security, lateral: t is >= 45 and < 48 ? 1.2f : 0.1f);
}
f.HornRequest(20, Head, 1200)
.HornRequest(40, Head, 1550)
.Camera(50, 2, 0, 1650)
.Station(60, Head, 1, 2, "Stacja_B#2", 1300)
.Station(60, Stranger, 1, 2, "Stacja_B#1", 9000)
.Coupling(80, Car, 1, 0, 0, 0, Coupled, Tail)
.Station(100, Head, 3, 3, "C", 2150)
.Timetable(110, Head, "ROJ2", "C", "D", ("C", -1, 638), ("D", 640, -1))
.Station(115, Head, 1, 2, "D", 2300);
return BlackBoxReader.Read(f.End(130).Stream());
}
[Fact]
public void The_new_records_are_read_field_by_field()
{
var r = Journey();
Assert.Equal(0, r.Unknown);
Assert.Equal(0, r.Malformed);
Assert.Equal(2, r.StopPoints.Count);
Assert.Equal("Stacja_B#2", r.StopPoints[1].Station);
Assert.Equal(10f, r.StopPoints[1].StopAt);
Assert.Equal(150f, r.StopPoints[1].Platform);
Assert.Equal("w6_1", Assert.Single(r.HornPoints).Name);
Assert.Equal(2, r.Timetables.Count);
Assert.Equal("ROJ2", r.Timetables[1].Train);
Assert.Equal(640f, r.Timetables[1].Row(2)!.Arrival);
Assert.False(r.Timetables[1].Row(2)!.Maintenance);
Assert.Equal(5, r.Stations.Count);
Assert.Equal(StationKind.Missed, r.Stations.Single(s => s.Row == 3 && s.Vehicle == Head).Kind);
Assert.Equal(2, r.HornRequests.Count);
Assert.Equal(2, r.Cameras.Count);
Assert.Equal(CameraMode.Free, r.Cameras[1].Mode);
Assert.Equal(-0.1f, r.Cameras[1].Pitch);
Assert.Equal(3, r.Couplings.Count);
Assert.True(r.Couplings[2].Uncoupled);
Assert.Equal(-1, Assert.Single(r.UserVehicles).CabEnd);
var cab = r.Cab[0];
Assert.Equal(1, cab.CabEnd);
Assert.Equal(SecurityFlags.Vigilance, cab.Security);
Assert.Equal(0.1f, cab.LateralAcceleration);
Assert.Equal(Lights.HeadlightLeft | Lights.HeadlightUpper | Lights.HeadlightRight, cab.LightsHead);
}
[Fact]
public void A_cab_written_before_the_protection_and_the_lateral_acceleration_is_still_read()
{
var r = BlackBoxReader.Read(new BlackBoxFile().Info().Cab(0, Head, 1150, 40, extended: false).User(0, Head, true).End(1).Stream());
var cab = Assert.Single(r.Cab);
Assert.Equal(0, cab.CabEnd);
Assert.Equal(SecurityFlags.None, cab.Security);
Assert.Equal(0f, cab.LateralAcceleration);
Assert.Equal(0, Assert.Single(r.UserVehicles).CabEnd);
Assert.Equal(0, r.Malformed);
}
[Fact]
public void The_couplings_tell_the_train_at_any_moment()
{
var r = Journey();
Assert.Equal(new HashSet<int> { 1, 2, 3 }, r.ConsistAt((int)Head, 79));
Assert.Equal(new HashSet<int> { 1, 2 }, r.ConsistAt((int)Head, 81));
Assert.Equal(new HashSet<int> { 3 }, r.ConsistAt((int)Tail, 81));
// What the trains were made of at the start is no coupling done.
var change = Assert.Single(r.CouplingEvents());
Assert.Equal(80, change.Time);
Assert.Single(r.UserCouplings());
}
[Fact]
public void The_stations_of_someone_else_s_train_are_not_the_user_s()
{
var r = Journey();
Assert.DoesNotContain(r.UserStations(), s => s.Vehicle == Stranger);
Assert.Equal(4, r.UserStations().Count());
}
[Fact]
public void The_timetable_shown_is_the_one_the_train_had_then()
{
var r = Journey();
Assert.Equal("ROJ1", r.TimetableAt(100)!.Train);
Assert.Equal("ROJ2", r.TimetableAt(110)!.Train);
var first = StationProgress.Of(r, 50);
Assert.Equal(3, first.Count);
Assert.Equal(StationOutcome.Stopped, first[1].Outcome);
Assert.Equal(60, first[1].ArrivedAt);
Assert.Equal(70, first[1].DepartedAt);
Assert.Equal(0, first[1].ArrivalDelay!.Value, 2);
Assert.Equal(-1 / 3.0, first[1].DepartureDelay!.Value, 2);
Assert.Equal(StationOutcome.Missed, first[2].Outcome);
Assert.Equal(StationOutcome.Stopped, StationProgress.Of(r, 120)[1].Outcome);
}
[Fact]
public void The_delay_follows_the_train_from_station_to_station()
{
var r = Journey();
Assert.Null(StationProgress.DelayAt(r, 0.5));
var standing = StationProgress.DelayAt(r, 65)!;
Assert.True(standing.Standing);
Assert.Equal("Stacja_B", standing.Station);
Assert.Equal(0, standing.Minutes, 2);
var left = StationProgress.DelayAt(r, 80)!;
Assert.False(left.Standing);
Assert.Equal(-1 / 3.0, left.Minutes, 2);
// At D, arrived at 10:35:55 for 10:40: early, and nothing to depart by.
var second = StationProgress.DelayAt(r, 118)!;
Assert.Equal("ROJ2", second.Train);
Assert.True(second.Minutes < -4);
// Left B at 10:35:10, C planned 10:37:30: by 10:35:50 (t = 110 of ROJ1's time) not yet overdue.
Assert.Equal(-1 / 3.0, StationProgress.DelayAt(r, 90)!.Minutes, 2);
var delays = StationProgress.StationDelays(r, 80);
Assert.Equal(-1 / 3.0, delays["Stacja B"], 2);
}
[Fact]
public void The_names_of_the_stations_are_shown_without_what_tells_their_stop_points_apart()
{
Assert.Equal("Bałtyk Główny", StationNames.Display("Bałtyk_Główny#2"));
Assert.Equal("Turów", StationNames.Display("Turów"));
Assert.Equal("Psie Pole po", StationNames.Display("Psie_Pole_po#"));
}
[Fact]
public void The_horn_counts_when_it_sounded_after_the_horn_point()
{
var checks = Journey().UserHornChecks().ToList();
Assert.Equal(2, checks.Count);
Assert.True(checks[0].Sounded);
Assert.False(checks[1].Sounded);
}
[Fact]
public void The_camera_is_known_from_its_first_record()
{
var r = Journey();
Assert.Null(r.CameraAt(-1));
Assert.Equal(CameraMode.Cab, r.CameraAt(49)!.Value.Mode);
Assert.Equal(CameraMode.Free, r.CameraAt(51)!.Value.Mode);
}
[Fact]
public void The_summary_counts_the_stops_of_every_timetable_the_horns_and_the_protection()
{
var summary = Journey().Summarize();
Assert.Equal(2, summary.Stops);
Assert.Equal(1, summary.StopsMissed);
Assert.Equal(2, summary.HornPoints);
Assert.Equal(1, summary.HornsMissed);
Assert.Equal(1, summary.SecurityBrakings);
// Taken over half a second, as the lateral acceleration jumps from sample to sample.
Assert.Equal(1.2f, summary.MaxLateralAcceleration, 3);
}
}
public class TrainDelayTests
{
[Fact]
public void A_train_held_on_the_way_grows_late_by_the_time_of_the_next_station()
{
// Clock 10:34 at t = 0. Departs A at 10:35 (t = 60) on time, held short of B planned at 10:37.
var f = new BlackBoxFile().Info()
.Timetable(0, 1, "ROJ9", "A", "B", ("A", -1, 635), ("B", 637, 638))
.Station(1, 1, 1, 1, "A", 1150);
for (double t = 0; t <= 400; t += 0.5)
f.Cab(t, 1, 1150 + Math.Max(0, Math.Min(t, 90) - 60) * 10, t is > 60 and < 90 ? 40 : 0);
var r = BlackBoxReader.Read(f.End(400).Stream());
Assert.Equal(0, StationProgress.DelayAt(r, 100)!.Minutes, 1);
// 10:40 at t = 360: three minutes past B's arrival, and not there.
var held = StationProgress.DelayAt(r, 360)!;
Assert.False(held.Standing);
Assert.Equal(3, held.Minutes, 2);
}
}
/// <summary>A train given its next timetable, one recording holding both.</summary>
public class NextTimetableTests
{
[Fact]
public void A_station_written_just_before_the_next_timetable_goes_with_it()
{
// ROJ1 stops at A and ends at B; at B it is given ROJ2, which starts there. A recording made before
// the simulator wrote the timetable first has B of ROJ2 half a second ahead of ROJ2 itself (here left behind,
// which on ROJ1 would mark A missed).
var f = new BlackBoxFile().Info()
.Timetable(0, 1, "ROJ1", "A", "B", ("A", -1, 635), ("B", 637, -1))
.Station(1, 1, 1, 1, "A", 1150)
.Station(50, 1, 1, 2, "B", 1150)
.Station(99.5, 1, 3, 1, "B", 1150)
.Timetable(100, 1, "ROJ2", "B", "C", ("B", -1, 640), ("C", 645, -1));
for (double t = 0; t <= 120; t += 0.5)
f.Cab(t, 1, 1150, 0);
var r = BlackBoxReader.Read(f.End(120).Stream());
var first = StationProgress.Of(r, r.Timetables[0]);
Assert.Equal(StationOutcome.Stopped, first[0].Outcome);
Assert.Equal(1, first[0].Event!.Time);
var second = StationProgress.Of(r, r.Timetables[1]);
Assert.Equal(StationOutcome.Missed, second[0].Outcome);
Assert.Equal(99.5, second[0].Event!.Time);
}
}

View File

@@ -0,0 +1,96 @@
using StarterNG.Domain.Tapes;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests.BlackBox;
public class BlackBoxListingTests
{
private static readonly DateTime Launched = new(2025, 12, 31, 23, 0, 0, DateTimeKind.Utc);
private static string Log(string name) => TestInstallation.At("physicslog", name);
[Fact]
public void Each_run_keeps_its_own_black_box_next_to_its_tapes()
{
var files = new InMemoryFileSystem()
.WithFile(Log("ra.dat"), "t")
.WithFile(Log("td_2025-12-31_23-00-05.blackbox"), "b")
.WithFile(Log("td_2026-01-01_00-00-05.blackbox"), "b");
var installation = new TestInstallation(files);
var tapes = installation.Tapes;
installation.Clock.Now = Launched;
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra" }, "A");
installation.Clock.Now = Launched.AddHours(1);
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra" }, "B");
files.WrittenAt(Log("td_2025-12-31_23-00-05.blackbox"), Launched.AddMinutes(30))
.WrittenAt(Log("td_2026-01-01_00-00-05.blackbox"), Launched.AddMinutes(90))
.WrittenAt(Log("ra.dat"), Launched.AddMinutes(90));
var sessions = tapes.Sessions();
Assert.Equal(new[] { "B", "A" }, sessions.Select(session => session.Launch!.SceneryName));
Assert.EndsWith("00-00-05.blackbox", Assert.Single(sessions[0].BlackBoxes).Path);
Assert.Single(sessions[0].Tapes);
// The tape of the first run was written over by the second; its black box was not.
Assert.EndsWith("23-00-05.blackbox", Assert.Single(sessions[1].BlackBoxes).Path);
Assert.Empty(sessions[1].Tapes);
Assert.Equal(Launched.AddMinutes(30), sessions[1].LastWrittenUtc);
}
[Fact]
public void A_black_box_is_not_taken_for_a_tape()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Log("ra.dat"), "t")
.WithFile(Log("td_2026-03-01_12-00-00.blackbox"), "b"));
var session = Assert.Single(installation.Tapes.Sessions());
Assert.Equal("ra", Assert.Single(session.Tapes).Vehicle);
Assert.Single(session.BlackBoxes);
Assert.Equal("ra", Assert.Single(installation.Tapes.List()).Vehicle);
}
[Fact]
public void Black_boxes_with_no_launch_noted_go_with_the_tapes_written_about_the_same_time()
{
var early = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Log("a.dat"), "t")
.WithFile(Log("td_2026-03-01_11-00-00.blackbox"), "b")
.WithFile(Log("td_2026-03-01_12-30-00.blackbox"), "b")
.WrittenAt(Log("a.dat"), early)
.WrittenAt(Log("td_2026-03-01_11-00-00.blackbox"), early.AddSeconds(2))
.WrittenAt(Log("td_2026-03-01_12-30-00.blackbox"), early.AddHours(1)));
var sessions = installation.Tapes.Sessions();
Assert.Equal(2, sessions.Count);
Assert.All(sessions, session => Assert.Null(session.Launch));
// Newest first: the black box on its own, then the run with the tape.
Assert.Empty(sessions[0].Tapes);
Assert.EndsWith("12-30-00.blackbox", Assert.Single(sessions[0].BlackBoxes).Path);
Assert.Single(sessions[1].Tapes);
Assert.EndsWith("11-00-00.blackbox", Assert.Single(sessions[1].BlackBoxes).Path);
Assert.Equal(early, sessions[1].StartedUtc);
}
[Fact]
public void The_black_box_of_a_running_session_is_listed_while_it_is_written()
{
var files = new InMemoryFileSystem();
var installation = new TestInstallation(files);
installation.Clock.Now = Launched;
var tapes = installation.Tapes;
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra");
files.WithFile(Log("td_2025-12-31_23-00-20.blackbox"), "b")
.WrittenAt(Log("td_2025-12-31_23-00-20.blackbox"), Launched.AddMinutes(5));
installation.SimulatorRunning = true;
var running = Assert.Single(tapes.Sessions());
Assert.True(running.Running);
Assert.Empty(running.Tapes);
Assert.Single(running.BlackBoxes);
}
}

View File

@@ -0,0 +1,229 @@
using System.Numerics;
using Eu07.BlackBox;
namespace StarterNG.Tests.BlackBox;
public class BlackBoxReaderTests
{
private static BlackBoxFile Session() => new BlackBoxFile()
.Info()
.Track(0, "start", new Vector3(5, 0.4f, 1100), new Vector3(5, 0.4f, 1200))
.Track(1, "zwr01", 1, 0, new Vector3(5, 0.4f, 1200), new Vector3(5, 0.4f, 1234))
.Track(1, "zwr01", 1, 1, new Vector3(5, 0.4f, 1200), new Vector3(8, 0.4f, 1234))
.Signal(0, "stacja1_a", 2.5, 3.0, 1300)
.Vehicle(1, "ep07-bb", "ROJ65113")
.Vehicle(2, "bdnu")
.Switch(0, 1, 0)
.Aspect(0, 0, 0, -1)
.Clock(0, 10 * 3600 + 34 * 60)
.User(0, 1, true)
.Vehicles(0, true, (1, 5, 1150, 0), (2, 5, 1130, 0))
.Cab(0.1, 1, 1150, 0)
.Command(1.0, 1, true, "aidriverenable")
.Command(1.1, 1, false, "aidriverenable")
.Driver(1.0, 1, 2)
.Aspect(2.0, 0, 40, -1)
.Switch(3.0, 1, 1)
.Passed(4.0, 1, 0, "stacja1_a_sem", 40, 38.5f, 0x01)
.Removed(5.0, 2)
.End(6.0);
[Fact]
public void Every_record_of_a_session_is_read_as_the_simulator_wrote_it()
{
var r = BlackBoxReader.Read(Session().Stream());
Assert.Equal(1, r.Version);
Assert.Equal("td.scn", r.Info!.Scenery);
Assert.Equal(new TimeSpan(10, 34, 0), r.Info.StartClock);
Assert.Equal(60f, r.Info.KeyframeInterval);
Assert.Equal(3, r.Tracks.Count);
Assert.Equal(2, r.TrackWays(1).Count);
Assert.Equal(TrackKind.SwitchBranch, r.TrackWays(1)[1].Kind);
Assert.Equal(new Vector3(8, 0.4f, 1234), r.TrackWays(1)[1].Points[1]);
Assert.Equal("stacja1_a", r.Signal(0)!.Name);
Assert.Equal(1300, r.Signal(0)!.Position.Z);
Assert.Equal("ROJ65113", r.Vehicle(1)!.Train);
Assert.Equal(5.0, r.Vehicle(2)!.RemovedAt);
var cab = Assert.Single(r.Cab);
Assert.Equal(1, cab.Vehicle);
Assert.Equal(new Position(5, 0.4, 1150), cab.Position);
Assert.Equal(Driver.Human, cab.Driving);
Assert.True(cab.AiValid);
Assert.Equal(3, cab.MainController);
Assert.Equal(5.0f, cab.BrakePipe);
Assert.Equal(40f, cab.Ai.TargetKmh);
Assert.Equal(60f, cab.Ai.TimetableVmaxKmh);
Assert.False(cab.Ai.HasNextLimit);
Assert.Equal(CabDevices.Battery | CabDevices.MainBreaker | CabDevices.PantographFront, cab.Devices);
Assert.True(cab.Has(CabDevices.MainBreaker));
Assert.False(cab.Has(CabDevices.Compressor));
Assert.Equal(8.5f, cab.MainReservoir);
Assert.Equal(3000f, cab.Voltage);
Assert.Equal(1100f, cab.EngineRpm);
Assert.Equal(Lights.HeadlightLeft | Lights.HeadlightUpper | Lights.HeadlightRight, cab.LightsHead);
Assert.Equal(Lights.RedMarkerLeft | Lights.RedMarkerRight, cab.LightsTail);
Assert.Equal(2, r.Commands.Count);
Assert.Equal("aidriverenable", r.Commands[0].Name);
Assert.True(r.Commands[0].Pressed);
Assert.Equal(2.0, r.Commands[0].Param2);
Assert.Equal(Driver.Ai, Assert.Single(r.DriverChanges).Driving);
var passed = Assert.Single(r.Passings);
Assert.Equal("stacja1_a_sem", passed.Event);
Assert.Equal(PassingFlags.Shunt, passed.Flags);
Assert.Equal(38.5f, passed.SpeedKmh);
Assert.False(passed.AtDanger);
Assert.True(r.Complete);
Assert.Equal(6.0, r.Duration);
Assert.Equal(0, r.Unknown);
Assert.Equal(0, r.Malformed);
}
[Fact]
public void The_switches_and_the_aspects_answer_for_any_moment()
{
var r = BlackBoxReader.Read(Session().Stream());
Assert.Equal(0, r.SwitchAt(1, 2.9));
Assert.Equal(1, r.SwitchAt(1, 3.0));
Assert.Equal(-1, r.SwitchAt(7, 3.0));
Assert.Equal(0f, r.AspectAt(0, 1.9)!.Value.Speed1);
Assert.Equal(40f, r.AspectAt(0, 2.0)!.Value.Speed1);
Assert.Null(r.AspectAt(3, 2.0));
}
[Fact]
public void A_file_still_written_is_read_on_from_where_the_last_read_stopped()
{
var bytes = Session().Bytes;
var whole = BlackBoxReader.Read(new MemoryStream(bytes));
// Cut at every byte: a record cut short waits for the rest, nothing is read twice.
for (int cut = 0; cut <= bytes.Length; cut++)
{
var reader = new BlackBoxReader();
reader.ReadMore(new MemoryStream(bytes, 0, cut));
Assert.False(reader.Recording.Complete && cut < bytes.Length);
reader.ReadMore(new MemoryStream(bytes));
Assert.Equal(whole.Tracks.Count, reader.Recording.Tracks.Count);
Assert.Equal(whole.Commands.Count, reader.Recording.Commands.Count);
Assert.Equal(whole.Cab.Count, reader.Recording.Cab.Count);
Assert.Equal(whole.Aspects.Count, reader.Recording.Aspects.Count);
Assert.True(reader.Recording.Complete);
Assert.Equal(bytes.Length, reader.Offset);
}
}
[Fact]
public void A_record_of_a_later_version_is_passed_over_by_its_length()
{
var file = new BlackBoxFile().Info()
.Record(0x7e, w => w.Write(new byte[40]))
.Command(1.0, 1, true, "hornhighactivate")
.End(2.0);
var r = BlackBoxReader.Read(file.Stream());
Assert.Equal(1, r.Unknown);
Assert.Equal("hornhighactivate", Assert.Single(r.Commands).Name);
Assert.True(r.Complete);
}
[Fact]
public void A_record_shorter_than_its_type_is_passed_over_and_counted()
{
var file = new BlackBoxFile().Record(0x20, w => w.Write(1.0)).Clock(1.0, 3600).End(2.0);
var r = BlackBoxReader.Read(file.Stream());
Assert.Empty(r.Cab);
Assert.Equal(1, r.Malformed);
Assert.Single(r.Clocks);
}
[Fact]
public void Texts_longer_than_a_byte_can_count_and_letters_beyond_ascii_read_back()
{
string name = "żółw_" + new string('x', 300);
var r = BlackBoxReader.Read(new BlackBoxFile().Signal(0, name, 0, 0, 0).Signal(1, "", 1, 1, 1).Stream());
Assert.Equal(name, r.Signal(0)!.Name);
Assert.Equal("", r.Signal(1)!.Name);
}
[Fact]
public void Something_else_than_a_black_box_or_a_version_not_known_is_refused()
{
Assert.Throws<InvalidDataException>(() => BlackBoxReader.Read(new MemoryStream("Time[s] Velocity[m/s]\r\n"u8.ToArray())));
Assert.Throws<InvalidDataException>(() => BlackBoxReader.Read(new BlackBoxFile(version: 2).Stream()));
}
[Fact]
public void A_file_written_anew_under_the_reader_is_told_so()
{
var reader = new BlackBoxReader();
reader.ReadMore(Session().Stream());
Assert.Throws<InvalidDataException>(() => reader.ReadMore(new BlackBoxFile().Info().Stream()));
}
[Fact]
public void A_vehicle_listed_only_when_it_moved_stands_until_the_look_before_it_was_seen_moving()
{
var r = BlackBoxReader.Read(new BlackBoxFile()
.Info()
.Vehicle(2, "et22")
.Vehicles(0, true, (2, 0, 0, 0))
.Vehicles(60, true, (2, 0, 0, 0))
.Vehicles(90, false, (2, 0, 10, 36))
.Vehicles(91, false, (2, 0, 20, 36))
.Stream());
Assert.Equal(0f, r.VehiclesAt(30).Single().Position.Z);
// Not listed from 60 to 90: it stood until 88.5, the look interval and a half before.
Assert.Equal(0f, r.VehiclesAt(88).Single().Position.Z);
Assert.InRange(r.VehiclesAt(89.25).Single().Position.Z, 4.9f, 5.1f);
Assert.InRange(r.VehiclesAt(90.5).Single().Position.Z, 14.9f, 15.1f);
Assert.Equal(20f, r.VehiclesAt(200).Single().Position.Z);
}
[Fact]
public void A_vehicle_removed_is_on_the_map_no_more()
{
var r = BlackBoxReader.Read(Session().Stream());
Assert.Equal(2, r.VehiclesAt(4.9).Count);
Assert.Equal(1, Assert.Single(r.VehiclesAt(5.0)).Id);
}
[Fact]
public void The_clock_goes_on_from_the_last_tick_and_over_midnight()
{
var r = BlackBoxReader.Read(new BlackBoxFile()
.Info(secondOfDay: 86390)
.Clock(0, 86390)
.Clock(1, 86391)
.Stream());
Assert.Equal(new TimeSpan(23, 59, 51), r.ClockAt(1));
Assert.Equal(86395.5, r.ClockAt(5.5).TotalSeconds, 3);
Assert.Equal(1.0, r.ClockAt(11).TotalSeconds, 3);
}
[Fact]
public void The_position_of_the_train_runs_between_its_samples_and_not_across_a_gap()
{
var r = BlackBoxReader.Read(new BlackBoxFile()
.Cab(0.0, 1, 100, 36)
.Cab(0.1, 1, 101, 36)
.Cab(5.0, 1, 200, 36)
.Stream());
Assert.Equal(100.5, r.CabPositionAt(0.05)!.Value.Z, 6);
Assert.Null(r.CabPositionAt(3.0));
Assert.Null(r.CabPositionAt(-1));
}
}

View File

@@ -0,0 +1,247 @@
using System.Linq;
using Eu07.BlackBox;
namespace StarterNG.Tests.BlackBox;
/// <summary>The light signals at the head and the tail of the user's train, against those it was to show.</summary>
public class HeadCodeTests
{
[Theory]
[InlineData(Lights.HeadlightLeft | Lights.HeadlightRight | Lights.HeadlightUpper, HeadCode.Pc1)]
[InlineData(Lights.HeadlightLeft | Lights.HeadlightRight | Lights.HeadlightUpper | Lights.HighBeamLeft | Lights.HighBeamRight, HeadCode.Pc1)]
[InlineData(Lights.RedMarkerLeft | Lights.HeadlightRight | Lights.HeadlightUpper, HeadCode.Pc2)]
[InlineData(Lights.RedMarkerLeft | Lights.RedMarkerRight, HeadCode.Pc5)]
[InlineData(Lights.RearEndSignals, HeadCode.Pc5)]
[InlineData(Lights.HeadlightRight, HeadCode.Tb1)]
[InlineData(Lights.HeadlightLeft, HeadCode.Tb1)]
[InlineData(Lights.None, HeadCode.Off)]
[InlineData(Lights.HeadlightLeft | Lights.HeadlightRight, HeadCode.Other)]
public void The_lamps_lit_tell_the_signal(Lights lights, HeadCode code) => Assert.Equal(code, HeadCodes.Of(lights));
private const ushort Pc1 = 0x15, Pc5 = 0x22, Tb1 = 0x10;
private static Recording Drive(params (double From, double To, ushort Head, ushort Tail, byte Movement, short HintHead)[] stretches)
{
var f = new BlackBoxFile().Info();
foreach (var (from, to, head, tail, movement, hint) in stretches)
for (double t = from; t < to; t += 0.5)
f.Cab(t, 1, 1000 + t * 10, 40, lightsHead: head, lightsTail: tail, movement: movement, hintHead: hint);
return BlackBoxReader.Read(f.End(stretches[^1].To).Stream());
}
[Fact]
public void A_train_run_is_to_show_Pc1_and_Pc5()
{
var summary = Drive((0, 60, Pc1, Pc5, 1, -1)).Summarize();
Assert.True(summary.LightsJudgedSeconds > 55);
Assert.Equal(0, summary.WrongLightsSeconds);
Assert.Empty(summary.LightsFaults);
}
[Fact]
public void Shunting_lights_on_a_train_run_count_as_wrong_once_for_the_whole_stretch()
{
var summary = Drive((0, 30, Pc1, Pc5, 1, -1), (30, 60, Tb1, Tb1, 1, -1), (60, 90, Pc1, Pc5, 1, -1)).Summarize();
Assert.Equal(30, summary.WrongLightsSeconds, 0);
var fault = Assert.Single(summary.LightsFaults);
Assert.Equal(HeadCode.Tb1, fault.Head);
Assert.Equal(HeadCode.Pc1, fault.HeadRequired);
Assert.True(fault.HeadWrong && fault.TailWrong);
}
[Fact]
public void Shunting_is_to_show_Tb1()
{
var summary = Drive((0, 30, Tb1, Tb1, 2, -1), (30, 40, Pc1, Pc5, 2, -1)).Summarize();
var fault = Assert.Single(summary.LightsFaults);
Assert.Equal(HeadCode.Tb1, fault.HeadRequired);
}
[Fact]
public void The_pattern_the_timetable_asks_for_is_the_one_to_show()
{
// Pc 2 asked for at the head: the left red marker, the right headlight and the upper one.
const short pc2 = 0x16;
var summary = Drive((0, 30, Pc1, Pc5, 1, pc2)).Summarize();
var fault = Assert.Single(summary.LightsFaults);
Assert.Equal(HeadCode.Pc2, fault.HeadRequired);
Assert.False(fault.TailWrong);
}
private static Recording DriveWith(ushort head, ushort tail, ushort equippedHead, ushort equippedTail, byte movement = 1, byte driving = 1)
{
var f = new BlackBoxFile().Info();
for (double t = 0; t < 30; t += 0.5)
f.Cab(t, 1, 1000 + t * 10, 40, driving: driving, lightsHead: head, lightsTail: tail, movement: movement,
equippedHead: equippedHead, equippedTail: equippedTail);
return BlackBoxReader.Read(f.End(30).Stream());
}
[Fact]
public void The_lamps_the_vehicles_have_are_read()
{
var sample = DriveWith(Pc1, Pc5, 0x1B5, 0x22).Cab[0];
Assert.Equal((Lights)0x1B5, sample.EquippedHead);
Assert.Equal((Lights)0x22, sample.EquippedTail);
}
[Theory]
// without the upper headlight Pc 1 is the two others
[InlineData(0x11, 0x11)]
// with auxiliary lamps, those and the upper headlight
[InlineData(0x184, 0x1B5)]
// a vehicle with no lamps for it: the rear-end signals
[InlineData(0x40, 0x40)]
public void Pc1_is_what_the_vehicle_can_show_of_it(ushort lit, ushort equipped)
{
var summary = DriveWith(lit, Pc5, equipped, Pc5).Summarize();
Assert.True(summary.LightsJudgedSeconds > 25);
Assert.Empty(summary.LightsFaults);
}
[Fact]
public void A_tail_with_the_rear_end_signals_only_shows_Pc5_with_them()
{
Assert.Empty(DriveWith(Pc1, 0x40, Pc1, 0x40).Summarize().LightsFaults);
var fault = Assert.Single(DriveWith(Pc1, 0x00, Pc1, 0x40).Summarize().LightsFaults);
Assert.False(fault.HeadWrong);
Assert.True(fault.TailWrong);
}
[Fact]
public void What_drives_the_train_and_the_rest_are_read_when_written()
{
var f = new BlackBoxFile().Info();
f.Cab(0, 1, 1000, 40, movement: 1, equippedHead: 0x15, equippedTail: 0x22, engine: 4, oilTemperature: 71.5f);
f.Cab(0.5, 1, 1000, 40, movement: 1, equippedHead: 0x15, equippedTail: 0x22);
var cab = BlackBoxReader.Read(f.End(1).Stream()).Cab;
var extra = Assert.NotNull(cab[0].Extra);
Assert.Equal(EngineKind.DieselElectric, extra.Engine);
Assert.Equal(3, extra.RadioChannel);
Assert.Equal(71.5f, extra.OilTemperature);
Assert.Equal(1.0f, extra.Clutch);
Assert.Equal(2.5f, extra.ConsistBrakeCylinder);
// an older sample has none
Assert.Null(cab[1].Extra);
}
[Fact]
public void A_vehicle_with_no_lamps_is_not_judged()
{
var recording = DriveWith(0, 0, 0, 0);
Assert.Null(HeadCodes.HeadRight(recording.Cab[0]));
Assert.Null(HeadCodes.TailRight(recording.Cab[0]));
var summary = recording.Summarize();
Assert.Equal(0, summary.LightsJudgedSeconds);
Assert.Empty(summary.LightsFaults);
}
[Fact]
public void A_vehicle_with_all_the_lamps_is_to_light_all_three_for_Pc1()
{
var fault = Assert.Single(DriveWith(0x11, Pc5, 0x35, Pc5).Summarize().LightsFaults);
Assert.True(fault.HeadWrong);
Assert.Equal(HeadCode.Pc1, fault.HeadRequired);
}
[Fact]
public void The_lights_the_autopilot_sets_are_not_the_users()
{
var summary = DriveWith(Tb1, Tb1, Pc1, Pc5, driving: 2).Summarize();
Assert.Equal(0, summary.LightsJudgedSeconds);
Assert.Empty(summary.LightsFaults);
}
[Fact]
public void An_older_recording_reads_a_pattern_asked_for_as_the_signal_it_shows()
{
// Pc 5 asked for at the tail, the rear-end signals lit, no lamps written: Pc 5 as before
var f = new BlackBoxFile().Info();
for (double t = 0; t < 30; t += 0.5)
f.Cab(t, 1, 1000 + t * 10, 40, lightsHead: Pc1, lightsTail: 0x40, movement: 1, hintTail: 0x22);
var summary = BlackBoxReader.Read(f.End(30).Stream()).Summarize();
Assert.True(summary.LightsJudgedSeconds > 25);
Assert.Empty(summary.LightsFaults);
}
[Fact]
public void An_older_recording_tells_nothing_of_the_signals()
{
var f = new BlackBoxFile().Info();
for (double t = 0; t < 30; t += 0.5)
f.Cab(t, 1, 1000 + t * 10, 40, lightsHead: Tb1);
var summary = BlackBoxReader.Read(f.End(30).Stream()).Summarize();
Assert.Equal(0, summary.LightsJudgedSeconds);
}
}
/// <summary>The user going over to another vehicle: the timetable of the train is the one of the train the user is in.</summary>
public class UserVehicleTests
{
private const byte Coupled = 0x83;
[Fact]
public void Another_vehicle_of_the_same_train_keeps_its_timetable_another_train_has_none()
{
// The user in vehicle 1 of the unit 1-2 with ROJ1; at t = 50 over to the other cab, at t = 100 into vehicle 9, on its own.
var f = new BlackBoxFile().Info()
.Coupling(0, 1, 1, 2, 0, Coupled, 0, 0)
.Timetable(0, 1, "ROJ1", "A", "B", ("A", -1, 635), ("B", 640, -1));
for (double t = 0; t < 150; t += 0.5)
f.Cab(t, t < 50 ? 1u : t < 100 ? 2u : 9u, 1000, 0);
var r = BlackBoxReader.Read(f.End(150).Stream());
Assert.Equal("ROJ1", r.TimetableAt(20)!.Train);
Assert.Equal("ROJ1", r.TimetableAt(70)!.Train);
Assert.Null(r.TimetableAt(120));
}
[Fact]
public void Single_vehicles_with_no_coupling_written_are_still_told_apart()
{
// A recording made with the movement written, where no train is longer than one vehicle: no coupling at all.
var f = new BlackBoxFile().Info()
.Timetable(0, 1, "ROJ1", "A", "B", ("A", -1, 635), ("B", 640, -1));
for (double t = 0; t < 100; t += 0.5)
f.Cab(t, t < 50 ? 1u : 9u, 1000, 0, movement: 1);
var r = BlackBoxReader.Read(f.End(100).Stream());
Assert.Equal("ROJ1", r.TimetableAt(20)!.Train);
Assert.Null(r.TimetableAt(70));
}
[Fact]
public void An_older_recording_with_no_coupling_keeps_the_timetable()
{
var f = new BlackBoxFile().Info()
.Timetable(0, 1, "ROJ1", "A", "B", ("A", -1, 635), ("B", 640, -1));
for (double t = 0; t < 100; t += 0.5)
f.Cab(t, t < 50 ? 1u : 2u, 1000, 0);
var r = BlackBoxReader.Read(f.End(100).Stream());
Assert.Equal("ROJ1", r.TimetableAt(70)!.Train);
}
}
/// <summary>The departures and the platforms in the summary.</summary>
public class StationSummaryTests
{
[Fact]
public void The_summary_tells_the_largest_delay_leaving_and_the_stops_off_the_platform()
{
// Clock 10:34 at t = 0, leaves A at once. Stops at B (10:36 to 10:37) from t = 120, leaves at t = 240 (10:38): a minute late.
var f = new BlackBoxFile().Info()
.Timetable(0, 1, "ROJ1", "A", "C", ("A", -1, 634), ("B", 636, 637), ("C", 640, -1))
.Station(1, 1, 1, 1, "A", 1000)
.Station(120, 1, 1, 2, "Stacja_B", 1000, distance: 74.7f, trainLength: 129.8f, platform: 201);
for (double t = 0; t < 300; t += 0.5)
f.Cab(t, 1, 1000, t is >= 120 and < 240 or < 2 ? 0 : 40);
var r = BlackBoxReader.Read(f.End(300).Stream());
var summary = r.Summarize();
Assert.Equal(1, summary.MaxDepartureDelayMinutes!.Value, 1);
var off = Assert.Single(summary.OffPlatform);
Assert.Equal("B", off.Station);
Assert.Equal(3.5, off.Metres, 1);
}
}

View File

@@ -0,0 +1,98 @@
using Eu07.BlackBox;
namespace StarterNG.Tests.BlackBox;
public class DrivingSummaryTests
{
private static Recording Drive(Func<BlackBoxFile, BlackBoxFile> more)
{
var file = new BlackBoxFile().Info();
return BlackBoxReader.Read(more(file).Stream());
}
/// <summary>Ten samples a second from t, at a speed against an AI holding target.</summary>
private static BlackBoxFile Stretch(BlackBoxFile file, double from, double seconds, float speed, float target,
byte driving = 1, byte flags = 0x03)
{
for (int i = 0; i < seconds * 10; i++)
file.Cab(from + i * 0.1, 1, 1000 + (from + i * 0.1) * speed / 3.6, speed, driving, flags, target);
return file;
}
[Theory]
[InlineData(40f, 40f, SpeedJudgement.Within)]
[InlineData(40.4f, 40f, SpeedJudgement.Within)]
[InlineData(44f, 40f, SpeedJudgement.Slightly)]
[InlineData(46f, 40f, SpeedJudgement.Over)]
[InlineData(-46f, 40f, SpeedJudgement.Over)]
public void A_sample_is_judged_by_how_much_faster_than_the_AI_it_went(float speed, float target, SpeedJudgement expected)
{
var r = Drive(f => f.Cab(0, 1, 0, speed, target: target));
Assert.Equal(expected, DrivingSummary.Judge(r.Cab[0]));
}
[Fact]
public void Without_the_AI_worked_out_or_with_the_AI_driving_there_is_nothing_to_judge()
{
var r = Drive(f => f.Cab(0, 1, 0, 80, flags: 0x01, target: 40).Cab(0.1, 1, 0, 80, driving: 2, target: 40));
Assert.Equal(SpeedJudgement.NoData, DrivingSummary.Judge(r.Cab[0]));
Assert.Equal(SpeedJudgement.Autopilot, DrivingSummary.Judge(r.Cab[1]));
}
[Fact]
public void The_summary_counts_the_time_over_the_AI_the_autopilot_and_the_signals_passed_at_danger()
{
var r = Drive(f =>
{
Stretch(f, 0, 10, 40, 40);
Stretch(f, 10, 10, 50, 40);
Stretch(f, 20, 10, 43, 40);
Stretch(f, 30, 10, 60, 60, driving: 2);
f.Passed(15, 1, 0, "s1", 0, 50, 0x02);
f.Passed(25, 1, 1, "s2", 40, 43);
// Another train, on a track of its own far away.
f.Passed(25, 7, 2, "someone else's", 0, 20, 0x02, z: 9000);
f.Command(5, 1, true, "mastercontrollerincrease");
f.Command(5.1, 1, false, "mastercontrollerincrease");
f.Command(6, 0, true, "timejump");
return f.Driver(30, 1, 2);
});
var summary = r.Summarize();
Assert.Equal(20, summary.OverAiSeconds, 0);
Assert.Equal(10, summary.WellOverAiSeconds, 0);
Assert.Equal(10, summary.MaxOverAiKmh, 3);
Assert.InRange(summary.MaxOverAiAt, 10, 20);
Assert.Equal(10, summary.AutopilotSeconds, 0);
Assert.Equal(30, summary.DrivenSeconds, 0);
Assert.Equal(60, summary.MaxSpeedKmh);
Assert.Equal(2, summary.SignalsPassed);
Assert.Equal("s1", Assert.Single(summary.PassedAtDanger).Event);
Assert.Equal(1, summary.Commands);
Assert.Equal(1, summary.AutopilotSwitches);
// 10 s at 40, 50, 43 and 60 km/h.
Assert.InRange(summary.DistanceKm, 0.530, 0.540);
}
[Fact]
public void The_autopilot_is_told_as_spans_of_time()
{
var r = Drive(f =>
{
Stretch(f, 0, 5, 40, 40);
Stretch(f, 5, 5, 40, 40, driving: 2);
Stretch(f, 10, 5, 40, 40);
return Stretch(f, 15, 2, 40, 40, driving: 2);
});
var spans = r.AutopilotSpans();
Assert.Equal(2, spans.Count);
Assert.Equal(5, spans[0].Start, 3);
Assert.Equal(10, spans[0].End, 3);
Assert.Equal(15, spans[1].Start, 3);
}
}

View File

@@ -0,0 +1,106 @@
using System.IO;
using Eu07.BlackBox;
namespace StarterNG.Tests.BlackBox;
/// <summary>The second value of a stop point as the driver reads it: all of it the metres, the last digit also the side.</summary>
public class PlatformTests
{
[Theory]
[InlineData(201f, 201, 1)]
[InlineData(242f, 242, 2)]
[InlineData(553f, 553, 3)]
[InlineData(150f, 150, 0)]
[InlineData(-151f, 151, 1)]
public void The_whole_value_is_metres_and_the_last_digit_the_side(float value, double length, int side)
{
Assert.Equal(length, Platforms.Length(value));
Assert.Equal(side, Platforms.Side(value));
}
private static StationEvent Stop(float distance, float length, float platform) =>
new(0, 1, StationKind.Stopped, 1, "Swoszowice", new Position(0, 0, 0), distance, 63.2f, length, platform, 600, 601);
[Fact]
public void A_train_whose_tail_reaches_past_the_platform_of_201_is_off_it()
{
// Two EN57 at Swoszowice: the head 74.7 m before the W4, the tail 204.5 m, the platform 201 m.
var stop = Stop(74.7f, 129.8f, 201);
Assert.False(stop.OnPlatform);
Assert.Equal(3.5, stop.OffPlatform, 1);
Assert.Equal(201, stop.PlatformLength);
}
[Fact]
public void A_train_whose_head_stands_past_the_stop_point_is_off_the_platform_by_that_much()
{
var stop = Stop(-5f, 129.8f, 201);
Assert.False(stop.OnPlatform);
Assert.Equal(5, stop.OffPlatform, 1);
}
[Fact]
public void A_metre_off_still_counts_as_along_the_platform()
{
var stop = Stop(-1f, 129.8f, 201);
Assert.True(stop.OnPlatform);
Assert.Equal(1, stop.OffPlatform, 1);
}
[Fact]
public void Each_end_has_its_own_tolerance()
{
// the head 1.5 m past the stop point, the tail 1.5 m past the platform's end
var stop = Stop(-1.5f, 204f, 201);
Assert.True(stop.OnPlatform);
Assert.Equal(3, stop.OffPlatform, 1);
}
[Fact]
public void A_train_within_the_platform_is_along_it()
{
var stop = Stop(63.2f, 129.8f, 201);
Assert.True(stop.OnPlatform);
Assert.Equal(0, stop.OffPlatform);
}
}
/// <summary>The way a stop point faces and the W32, as the simulator writes them.</summary>
public class StopPointRecordTests
{
[Fact]
public void A_stop_point_tells_the_way_of_the_trains_it_is_for()
{
var recording = BlackBoxReader.Read(new MemoryStream(new BlackBoxFile().Info().StopPoint(0, "Swoszowice", 100, 201, 201, 1.25f).End(10).Bytes));
var point = Assert.Single(recording.StopPoints);
Assert.True(point.HasHeading);
Assert.Equal(1.25f, point.Heading);
}
[Fact]
public void A_stop_point_of_an_older_recording_has_no_way()
{
var recording = BlackBoxReader.Read(new MemoryStream(new BlackBoxFile().Info().StopPoint(0, "Swoszowice", 100, 201, 201).End(10).Bytes));
Assert.False(Assert.Single(recording.StopPoints).HasHeading);
}
[Fact]
public void A_stop_point_on_tracks_of_both_ways_has_no_way()
{
var recording = BlackBoxReader.Read(new MemoryStream(new BlackBoxFile().Info().StopPoint(0, "Swoszowice", 100, 201, 201, float.NaN).End(10).Bytes));
Assert.False(Assert.Single(recording.StopPoints).HasHeading);
}
[Fact]
public void A_W32_is_for_the_trains_shorter_than_its_length_and_that_is_their_room()
{
var recording = BlackBoxReader.Read(new MemoryStream(new BlackBoxFile().Info().StopPoint(0, "Tumlin_po", 100, -1, -121).End(10).Bytes));
var point = Assert.Single(recording.StopPoints);
Assert.True(point.ForShortTrains);
Assert.False(point.HasPlatform);
Assert.Equal(121, point.PlatformLength);
var stop = new StationEvent(0, 1, StationKind.Stopped, 1, "Tumlin_po", new Position(0, 0, 0), 60, 50, 64.9f, -121, 600, 601);
Assert.False(stop.OnPlatform);
Assert.Equal(3.9, stop.OffPlatform, 1);
}
}

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,186 @@
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 A_line_above_another_moves_right_below_it()
{
var config = ConfigFile.Parse("gfx.antialiasing taa\nwidth 800\nmultisampling 2\nheight 600\n");
config.MoveAfter("gfx.antialiasing", "multisampling");
Assert.Equal(new[] { "width 800", "multisampling 2", "gfx.antialiasing taa", "height 600", "" }, Lines(config));
Assert.Equal("taa", config.GetRaw("gfx.antialiasing"));
Assert.Equal("600", config.GetRaw("height"));
}
[Theory]
[InlineData("multisampling 2\nwidth 800\ngfx.antialiasing taa\n")]
[InlineData("multisampling 2\n")]
[InlineData("gfx.antialiasing taa\n")]
public void A_line_below_or_alone_stays_where_it_is(string text)
{
var config = ConfigFile.Parse(text);
config.MoveAfter("gfx.antialiasing", "multisampling");
Assert.Equal(ConfigFile.Parse(text).ToText(), config.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);
}
[Fact]
public void A_pick_from_another_installation_gives_way_to_the_search()
{
var files = new InMemoryFileSystem().WithExecutable(TestInstallation.At("eu07"), Elf);
var installation = new TestInstallation(files);
var settings = new SimulatorSettings { SelectExeAutomatically = false, ExecutablePath = "eu07-elsewhere" };
string resolved = installation.Executables.Resolve(settings, out var problem);
Assert.EndsWith("eu07", resolved);
Assert.Equal(ExeProblem.None, problem);
}
[Fact]
public void A_missing_pick_is_reported_when_the_installation_has_none_either()
{
var installation = new TestInstallation();
var settings = new SimulatorSettings { SelectExeAutomatically = false, ExecutablePath = "eu07-elsewhere" };
string resolved = installation.Executables.Resolve(settings, out var problem);
Assert.Equal("eu07-elsewhere", resolved);
Assert.Equal(ExeProblem.NotFound, problem);
}
[Fact]
public void Data_files_beside_the_binary_are_never_offered_as_candidates()
{
@@ -79,4 +104,138 @@ public class ExecutableLocatorTests
Assert.Equal(new[] { "eu07" }, installation.Executables.ListCandidates());
}
private static readonly string Wine = Path.Combine("/usr/bin", "wine");
private static TestInstallation WithWine(InMemoryFileSystem files)
{
var installation = new TestInstallation(files.WithExecutable(Wine));
installation.Environment.With("PATH", "/usr/bin");
return installation;
}
[Fact]
public void Without_wine_a_windows_build_on_linux_is_not_a_candidate()
{
var installation = new TestInstallation(
new InMemoryFileSystem().WithFile(TestInstallation.At("eu07.exe"), PortableExecutable));
installation.Executables.Resolve(new SimulatorSettings(), out var problem);
Assert.Equal(ExeProblem.NotFound, problem);
Assert.Null(installation.Executables.Wine());
}
[Fact]
public void With_wine_a_windows_build_on_linux_is_found_even_without_the_execute_bit()
{
var installation = WithWine(
new InMemoryFileSystem().WithFile(TestInstallation.At("eu07.exe"), PortableExecutable));
string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem);
Assert.EndsWith("eu07.exe", resolved);
Assert.Equal(ExeProblem.WrongPlatform, problem);
Assert.Equal(new[] { "eu07.exe" }, installation.Executables.ListCandidates());
}
[Fact]
public void A_native_build_wins_over_the_windows_one()
{
var installation = WithWine(new InMemoryFileSystem()
.WithFile(TestInstallation.At("eu07.exe"), PortableExecutable)
.WithExecutable(TestInstallation.At("eu07-dev"), Elf));
string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem);
Assert.EndsWith("eu07-dev", resolved);
Assert.Equal(ExeProblem.None, problem);
}
[Fact]
public void Wine_starts_the_windows_build_and_a_native_one_starts_itself()
{
string exe = TestInstallation.At("eu07.exe");
string native = TestInstallation.At("eu07");
var installation = WithWine(new InMemoryFileSystem()
.WithFile(exe, PortableExecutable)
.WithExecutable(native, Elf));
string[] arguments = { "-s", "$td.scn" };
var (wine, wineArguments) = installation.Executables.LaunchCommand(exe, arguments);
var (program, programArguments) = installation.Executables.LaunchCommand(native, arguments);
Assert.Equal(Wine, wine);
Assert.Equal(new[] { exe, "-s", "$td.scn" }, wineArguments);
Assert.Equal(native, program);
Assert.Equal(arguments, programArguments);
}
[Fact]
public void 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>
/// <remarks>
/// 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>
public sealed class InMemoryFileSystem : IFileSystem
{
private readonly Dictionary<string, byte[]> _files = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _directories = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _executable = new(StringComparer.OrdinalIgnoreCase);
private readonly StringComparison _comparison;
private readonly Dictionary<string, byte[]> _files;
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);
/// <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()
{
// 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 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));
@@ -63,6 +111,8 @@ public sealed class InMemoryFileSystem : IFileSystem
public string[] ReadAllLines(string path) =>
ReadAllText(path).Split('\n').Select(line => line.TrimEnd('\r')).ToArray();
public byte[] ReadAllBytes(string path) => Bytes(path);
public Stream OpenRead(string path) => new MemoryStream(Bytes(path), writable: false);
public void WriteAllText(string path, string contents) => WithFile(path, contents);
@@ -74,6 +124,10 @@ public sealed class InMemoryFileSystem : IFileSystem
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) =>
WithFile(path, string.Join("\n", lines));
@@ -101,7 +155,7 @@ public sealed class InMemoryFileSystem : IFileSystem
string prefix = Normalize(path) + "/";
var pattern = ToRegex(searchPattern);
return _files.Keys
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.Where(key => key.StartsWith(prefix, _comparison))
.Where(key => !key[prefix.Length..].Contains('/'))
.Where(key => pattern.IsMatch(key[prefix.Length..]))
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
@@ -113,7 +167,8 @@ public sealed class InMemoryFileSystem : IFileSystem
string prefix = Normalize(path) + "/";
var pattern = ToRegex(searchPattern);
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)..]))
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
.ToList();
@@ -121,17 +176,33 @@ public sealed class InMemoryFileSystem : IFileSystem
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) + "/";
return _directories
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.Where(key => key.StartsWith(prefix, _comparison))
.Where(key => !key[prefix.Length..].Contains('/'))
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
.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>
public long GetFileSize(string path) => Bytes(path).LongLength;
public (DateTime LastWriteUtc, long Size)? LastWriteAndSize(string path) =>
FileExists(path) ? (GetLastWriteTimeUtc(path), GetFileSize(path)) : null;
public bool IsExecutable(string path) => _executable.Contains(Normalize(path));
public InMemoryFileSystem WithExecutable(string path, string contents = "\u007fELF")
@@ -141,10 +212,56 @@ public sealed class InMemoryFileSystem : IFileSystem
return this;
}
private byte[] Bytes(string path) =>
_files.TryGetValue(Normalize(path), out var bytes)
public string? LinkTarget(string path) => _links.GetValueOrDefault(Normalize(path));
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
: throw new FileNotFoundException(path);
}
private void AddParents(string key)
{

View File

@@ -26,14 +26,18 @@ public sealed class TestInstallation
MiniTextures = new MiniTextureIndex(Files, Paths);
Physics = new FizPhysicsRepository(Files, Paths, Log);
Vehicles = new TexturesTxtVehicleRepository(Files, Paths, Log, new TexturesTxtParser());
VehicleJson = new VehicleJsonSerializer();
Sceneries = new SceneryRepository(Files, Paths, Log, new SceneryParser(Clock, Random));
MissingAssets = new MissingAssetScanner(Files, Paths);
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, Log);
Environment = new FakeEnvironment();
SettingsPaths = new SettingsPaths(Environment, Paths);
VehicleCache = new VehicleCacheRepository(Files, Paths, Log, Vehicles, VehicleJson,
SettingsPaths.VehicleCacheDirectory());
Library = new GameLibrary(VehicleCache, Sceneries, MiniTextures, Physics, Log);
Executables = new ExecutableLocator(Files, Paths, Environment, Log);
SettingsStore = new SettingsStore(Files, Clock, Log, SettingsPaths, new SettingsSerializer(), Executables);
Tapes = new HaslerTapes(Files, Paths, Log, Clock, new TimetableLocator(Files), () => SimulatorRunning);
}
public InMemoryFileSystem Files { get; }
@@ -45,6 +49,8 @@ public sealed class TestInstallation
public MiniTextureIndex MiniTextures { get; }
public FizPhysicsRepository Physics { get; }
public TexturesTxtVehicleRepository Vehicles { get; }
public VehicleJsonSerializer VehicleJson { get; }
public VehicleCacheRepository VehicleCache { get; }
public SceneryRepository Sceneries { get; }
public MissingAssetScanner MissingAssets { get; }
public GameLibrary Library { get; }
@@ -52,6 +58,8 @@ public sealed class TestInstallation
public SettingsPaths SettingsPaths { get; }
public ExecutableLocator Executables { get; }
public SettingsStore SettingsStore { get; }
public HaslerTapes Tapes { get; }
public bool SimulatorRunning { get; set; }
/// <summary>Path of a file inside the fake installation.</summary>
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);
}
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);
}
[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,156 @@
using StarterNG.Domain.Settings;
using StarterNG.Infrastructure;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class GraphicsCardsTests
{
private const long GiB = 1L << 30;
private const string PciIds =
"# List of PCI ID's\n" +
"\n" +
"10de NVIDIA Corporation\n" +
"\t1000 GK110 [Nothing Here]\n" +
"\t2d19 GB206M [GeForce RTX 5060 Max-Q / Mobile]\n" +
"\t\t1043 1e0c A subsystem\n" +
"1002 Advanced Micro Devices, Inc. [AMD/ATI]\n" +
"\t1900 Phoenix1\n" +
"\t2d19 Not this vendor's card\n" +
"8086 Intel Corporation\n" +
"\t46a6 Alder Lake-P GT2 [Iris Xe Graphics]\n" +
"C 00 Unclassified device\n" +
"\t2d19 Not a card at all\n";
private static GraphicsCards Reader(InMemoryFileSystem files, FakeEnvironment environment,
params string[] nvidiaSmi) =>
new(files, environment, new LinuxGpus(files, environment), () => nvidiaSmi);
private static InMemoryFileSystem Card(InMemoryFileSystem files, string card, string driver, string id, string slot,
long memory = 0)
{
string device = $"/sys/class/drm/{card}/device";
files.WithDirectory($"/sys/class/drm/{card}")
.WithFile($"{device}/uevent", $"DRIVER={driver}\nPCI_ID={id}\nPCI_SLOT_NAME={slot}\n");
if (memory > 0)
files.WithFile($"{device}/mem_info_vram_total", memory + "\n");
return files;
}
[Theory]
[InlineData("10DE:2D19", "GeForce RTX 5060 Max-Q / Mobile")]
[InlineData("1002:1900", "Phoenix1")]
[InlineData("8086:46A6", "Iris Xe Graphics")]
[InlineData("10DE:FFFF", null)]
[InlineData("1234:2D19", null)]
[InlineData("nonsense", null)]
public void The_pci_list_names_a_card_by_its_vendor_and_device(string id, string? expected)
{
Assert.Equal(expected, GraphicsCards.ModelInPciList(PciIds.Split('\n'), id));
}
[Fact]
public void A_linux_card_takes_its_name_from_the_pci_list_and_its_memory_from_the_driver()
{
var files = Card(new InMemoryFileSystem(), "card0", "amdgpu", "1002:73DF", "0000:03:00.0", 12 * GiB)
.WithFile("/usr/share/hwdata/pci.ids", "1002 AMD\n\t73df Navi 22 [Radeon RX 6700 XT]\n");
var card = Assert.Single(Reader(files, new FakeEnvironment()).List());
Assert.Equal("AMD Radeon RX 6700 XT", card.Name);
Assert.Equal(12, card.MemoryGb);
}
[Fact]
public void The_proprietary_driver_names_its_card_and_nvidia_smi_tells_its_memory()
{
var files = Card(new InMemoryFileSystem(), "card0", "nvidia", "10DE:2D19", "0000:01:00.0")
.WithFile("/proc/driver/nvidia/gpus/0000:01:00.0/information",
"Model: \t\t NVIDIA GeForce RTX 5060 Laptop GPU\nIRQ: \t\t 182\n");
var card = Assert.Single(Reader(files, new FakeEnvironment(), "00000000:01:00.0, 8188").List());
Assert.Equal("NVIDIA GeForce RTX 5060 Laptop GPU", card.Name);
Assert.Equal(8, card.MemoryGb);
}
[Fact]
public void A_card_keeps_its_name_when_nothing_tells_its_memory()
{
var files = Card(new InMemoryFileSystem(), "card0", "nouveau", "10DE:2D19", "0000:01:00.0");
var card = Assert.Single(Reader(files, new FakeEnvironment()).List());
Assert.Equal("NVIDIA 10DE:2D19", card.Name);
Assert.Equal(0, card.MemoryBytes);
}
[Fact]
public void The_discrete_card_comes_first_whatever_order_the_system_lists_them()
{
var files = new InMemoryFileSystem().WithFile(
"/usr/share/misc/pci.ids", "1002 AMD\n\t1900 Rembrandt [Radeon 780M]\n\t73df Navi 22 [Radeon RX 6700 XT]\n");
Card(files, "card0", "amdgpu", "1002:1900", "0000:01:00.0", GiB / 2);
Card(files, "card1", "amdgpu", "1002:73DF", "0000:65:00.0", 12 * GiB);
var cards = Reader(files, new FakeEnvironment()).List();
Assert.Equal(new[] { "AMD Radeon RX 6700 XT", "AMD Radeon 780M" }, cards.Select(card => card.Name));
}
[Fact]
public void There_are_no_cards_on_a_mac()
{
var files = Card(new InMemoryFileSystem(), "card0", "amdgpu", "1002:73DF", "0000:03:00.0", 12 * GiB);
Assert.Empty(Reader(files, new FakeEnvironment { IsLinux = false, IsMacOS = true }).List());
}
[Theory]
[InlineData("00000000:01:00.0, 8188", "0000:01:00.0", 8188L << 20)]
[InlineData("0000:65:00.0, 16384", "0000:65:00.0", 16384L << 20)]
public void Nvidia_smi_is_read_by_the_address_sys_gives(string line, string slot, long bytes)
{
Assert.Equal(bytes, GraphicsCards.ParseNvidiaMemory(new[] { line })[slot]);
}
[Theory]
[InlineData("Failed to initialize NVML")]
[InlineData("00000000:01:00.0, [N/A]")]
public void Nvidia_smi_saying_something_else_tells_nothing(string line)
{
Assert.Empty(GraphicsCards.ParseNvidiaMemory(new[] { line }));
}
[Theory]
[InlineData("AMD Radeon 780M Graphics", true)]
[InlineData("AMD Radeon(TM) Graphics", true)]
[InlineData("AMD Radeon(TM) Vega 8 Graphics", true)]
[InlineData("AMD Radeon RX Vega 10 Graphics", true)]
[InlineData("AMD Radeon Vega Series / Radeon Vega Mobile Series", true)]
[InlineData("Intel(R) UHD Graphics 770", true)]
[InlineData("Intel(R) Iris(R) Xe Graphics", true)]
[InlineData("Intel(R) Arc(TM) Graphics", true)]
[InlineData("Intel(R) Arc(TM) A770 Graphics", false)]
[InlineData("Intel Arc B580", false)]
[InlineData("AMD Radeon RX 6800M", false)]
[InlineData("AMD Radeon RX Vega 56", false)]
[InlineData("NVIDIA GeForce RTX 5060 Laptop GPU", false)]
public void A_card_in_a_processor_is_told_by_its_name(string name, bool shared)
{
Assert.Equal(shared, new GraphicsCard(name, 8 * GiB).SharedMemory);
}
[Fact]
public void A_card_reporting_under_a_gigabyte_counts_as_one_in_a_processor()
{
Assert.True(new GraphicsCard("AMD Phoenix1", GiB / 2).SharedMemory);
}
[Fact]
public void The_reported_memory_is_rounded_the_way_a_box_says_it()
{
Assert.Equal(8, new GraphicsCard("NVIDIA GeForce RTX 5060 Laptop GPU", 8546942976).MemoryGb);
}
}

View File

@@ -0,0 +1,743 @@
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();
// Ray tracing is one of them, and only Vulkan has it.
low.RenderEngine = top.RenderEngine = SimulatorSettings.VulkanEngine;
(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 => (int)p.AntiAliasing, 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) && key != "gfx.buffer.percent");
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 High_is_what_the_new_simulator_does_with_nothing_set()
{
Assert.Equal(GraphicsPreset.High.NewRenderer, GraphicsNewRenderer.Of(Defaults()));
}
[Fact]
public void The_new_renderers_scales_never_go_down()
{
void NeverDown(Func<GraphicsNewRenderer, double> value) => Climbs(p => value(p.NewRenderer), 5);
void TurnsOn(Func<GraphicsNewRenderer, bool> on)
{
var values = GraphicsPreset.All.Select(p => on(p.NewRenderer)).ToList();
Assert.Equal(values.OrderBy(value => value), values);
}
NeverDown(r => r.LampShadows);
NeverDown(r => r.ContactShadows);
NeverDown(r => r.Occlusion);
NeverDown(r => r.OcclusionSlices);
NeverDown(r => r.OcclusionSteps);
NeverDown(r => r.CabProbe);
NeverDown(r => r.ScreenReflections);
NeverDown(r => r.FogQuality);
NeverDown(r => r.Clouds);
TurnsOn(r => r.OcclusionLighting);
TurnsOn(r => r.CabProbeReflections);
TurnsOn(r => r.RayTracing);
TurnsOn(r => r.VolumetricFog);
TurnsOn(r => r.CloudShadows);
TurnsOn(r => r.Grass);
TurnsOn(r => r.Bloom);
Climbs(p => p.NewRenderer.GrassDistance, 0);
Climbs(p => p.NewRenderer.GrassDensity, 0);
Climbs(p => p.NewRenderer.Precipitation, 1);
// Frames between refreshes, so the dearer the fewer.
Climbs(p => -p.NewRenderer.CabProbeInterval, 2);
}
[Fact]
public void Ultra_is_the_top_of_the_new_renderers_scales()
{
var ultra = GraphicsPreset.Ultra.NewRenderer;
Assert.Equal(3, ultra.LampShadows);
Assert.Equal(3, ultra.ContactShadows);
Assert.Equal(2, ultra.Occlusion);
Assert.Equal((6, 8), (ultra.OcclusionSlices, ultra.OcclusionSteps));
Assert.Equal(3, ultra.CabProbe);
Assert.Equal(3, ultra.ScreenReflections);
Assert.True(ultra.RayTracing && ultra.VolumetricFog);
Assert.Equal(4, ultra.FogQuality);
Assert.Equal(4, ultra.Clouds);
Assert.Equal(300, ultra.GrassDistance);
Assert.Equal(2.0, ultra.GrassDensity);
Assert.Equal(262144, ultra.Precipitation);
}
[Fact]
public void Grass_fades_out_beyond_where_it_is_drawn_in_full()
{
var store = new TestInstallation().SettingsStore;
foreach (var preset in GraphicsPreset.All)
{
var settings = Defaults().Copy();
preset.ApplyTo(settings);
Assert.True(int.Parse(store.Render(settings)["gfx.grass.fadedistance"]) > preset.NewRenderer.GrassDistance, preset.NameKey);
}
}
[Fact]
public void A_level_set_in_the_new_renderers_rows_is_told_from_the_others()
{
var settings = Defaults().Copy();
GraphicsPreset.Medium.ApplyTo(settings);
Assert.Same(GraphicsPreset.Medium, GraphicsPreset.Matching(settings));
settings.FogQuality = 4;
Assert.Null(GraphicsPreset.Matching(settings));
}
[Fact]
public void What_is_left_to_the_player_stays_through_a_level()
{
var settings = Defaults().Copy();
settings.RenderEngine = SimulatorSettings.VulkanEngine;
settings.AntiAliasing = AntiAliasingMode.Dlss;
settings.FrameGeneration = 2;
settings.VulkanDevice = "RTX";
foreach (var preset in GraphicsPreset.All)
{
preset.ApplyTo(settings);
Assert.Same(preset, GraphicsPreset.Matching(settings));
Assert.Equal(AntiAliasingMode.Dlss, settings.AntiAliasing);
Assert.Equal(2, settings.FrameGeneration);
Assert.Equal("RTX", settings.VulkanDevice);
}
}
[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(AntiAliasingMode.Msaa8, ultra.AntiAliasing);
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.InRange(preset.NewRenderer.CabProbeInterval, 1, 120);
Assert.InRange(preset.NewRenderer.GrassDistance, 20, 300);
Assert.Equal(0, preset.NewRenderer.GrassDistance % 10);
Assert.Equal(0, preset.NewRenderer.GrassDensity % 0.25);
Assert.Contains(preset.NewRenderer.Precipitation, new[] { 16384, 32768, 65536, 131072, 262144 });
Assert.True(Steps.IndexOf(Steps.Anisotropy, preset.Anisotropy) >= 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_those_of_the_whole_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[] { "gfx.antialiasing", "gfx.buffer.percent", "gfx.extraeffects", "gfx.postfx.bloom", "multisampling", "starter.bufferscale" },
GraphicsPreset.Keys.Except(inGroups).OrderBy(key => key, StringComparer.Ordinal));
}
[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.Lights]);
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;
settings.AntiAliasing = level.AntiAliasing;
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;
settings.Smoke = false;
var nature = Groups().Apply(GraphicsGroup.Nature, GraphicsPreset.VeryHigh, settings);
Assert.False(nature.Smoke);
Assert.Equal(GraphicsPreset.VeryHigh.SmokeFidelity, nature.SmokeFidelity);
}
}

View File

@@ -0,0 +1,593 @@
using StarterNG.Classes;
using StarterNG.Domain.Settings;
using StarterNG.Infrastructure.Settings;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class GraphicsSettingsTests
{
private static readonly string UserIni = Path.Combine("/home/kolejarz", ".config", "MaSzyna", "eu07.ini");
private static TestInstallation WithIni(string text)
{
var installation = new TestInstallation(new InMemoryFileSystem().WithFile(UserIni, text));
installation.Environment.With("HOME", "/home/kolejarz");
installation.SettingsStore.Load();
return installation;
}
private static SimulatorSettings Read(string text, bool dlssPlatform = true)
{
var s = new SimulatorSettings();
new SettingsSerializer(dlssPlatform).Read(s, ConfigFile.Parse(text));
return s;
}
private static string Write(SimulatorSettings s, string text = "", bool dlssPlatform = true)
{
var c = ConfigFile.Parse(text);
new SettingsSerializer(dlssPlatform).Write(s, c);
return c.ToText();
}
private static string[] Lines(string text) =>
text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);
private static string? Line(string text, string key) =>
Lines(text).FirstOrDefault(l => l == key || l.StartsWith(key + " ", StringComparison.Ordinal));
[Theory]
[InlineData("full", GraphicsBackend.OpenGL)]
[InlineData("default", GraphicsBackend.OpenGL)]
[InlineData("gl46", GraphicsBackend.OpenGL)]
[InlineData("legacy", GraphicsBackend.OpenGL)]
[InlineData("experimental", GraphicsBackend.OpenGL)]
[InlineData("vulkan", GraphicsBackend.Vulkan)]
[InlineData("vulkan14", GraphicsBackend.Vulkan)]
public void The_renderer_is_read_with_the_aliases_the_simulator_takes(string value, GraphicsBackend expected)
{
Assert.Equal(expected, Read($"gfxrenderer {value}\n").Backend);
}
[Theory]
[InlineData("full", "full")]
[InlineData("gl46", "full")]
[InlineData("legacy", "legacy")]
[InlineData("simpleshader", "legacy")]
[InlineData("simple", "simple")]
[InlineData("experimental", "experimental")]
[InlineData("vulkan14", "vulkan")]
public void A_renderer_is_written_by_its_name(string read, string written)
{
Assert.Equal($"gfxrenderer {written}", Line(Write(Read($"gfxrenderer {read}\n")), "gfxrenderer"));
}
[Fact]
public void An_older_renderer_stays_chosen_and_can_be_chosen_again()
{
var installation = WithIni("gfxrenderer legacy\n");
var store = installation.SettingsStore;
store.Settings.Clouds = 3;
store.Save();
Assert.Contains("gfxrenderer legacy", installation.Files.ReadAllText(UserIni));
store.Settings.RenderEngine = SimulatorSettings.VulkanEngine;
store.Save();
Assert.Contains("gfxrenderer vulkan", installation.Files.ReadAllText(UserIni));
Assert.Equal(GraphicsBackend.Vulkan, store.Settings.Backend);
store.Settings.RenderEngine = SimulatorSettings.RenderEngineOf("simple");
store.Save();
Assert.Contains("gfxrenderer simple", installation.Files.ReadAllText(UserIni));
Assert.Equal(GraphicsBackend.OpenGL, store.Settings.Backend);
}
[Fact]
public void Keys_only_an_older_simulator_reads_stay_in_the_file()
{
string[] old =
{
"usevbo no", "gfx.angleplatform vulkan", "gfx.usegles yes",
"gfx.framebuffer.width 1920", "gfx.framebuffer.height 1080", "gfx.shadows.cab.range 20"
};
var installation = WithIni(string.Join("\n", old) + "\n");
installation.SettingsStore.Settings.Clouds = 3;
installation.SettingsStore.Save();
string saved = installation.Files.ReadAllText(UserIni);
Assert.All(old, line => Assert.Contains(line, saved));
}
[Fact]
public void Levels_leave_the_switches_only_an_older_simulator_reads()
{
var s = new SimulatorSettings { UseVbo = false, UseGLES = true, RenderAngleVulkan = true };
GraphicsPreset.Ultra.ApplyTo(s);
Assert.Equal((false, true, true), (s.UseVbo, s.UseGLES, s.RenderAngleVulkan));
}
[Fact]
public void The_new_graphics_keys_are_read()
{
var s = Read("gfxrenderer vulkan\n" +
"gfx.antialiasing dlss\ngfx.dlss.preset K\ngfx.framegeneration x3\ngfx.reflex boost\n" +
"gfx.raytracing yes\ngfx.raytracing.range 800\ngfx.ssr high\ngfx.postfx.ao ssao\ngfx.postfx.ao.quality 4 6\n" +
"gfx.lights.shadows low\ngfx.contactshadows 3\ngfx.fog analytic\ngfx.fog.quality ultra\n" +
"gfx.clouds ultra\ngfx.clouds.shadows no\ngfx.grass no\ngfx.grass.distance 120\ngfx.grass.density 1.5\n" +
"gfx.buffer.percent 0.67\ngfx.vulkan.device RTX\n");
Assert.Equal(GraphicsBackend.Vulkan, s.Backend);
Assert.Equal(AntiAliasingMode.Dlss, s.AntiAliasing);
Assert.Equal("k", s.DlssPreset);
Assert.Equal(3, s.FrameGeneration);
Assert.Equal(2, s.Reflex);
Assert.True(s.RayTracing);
Assert.Equal(800, s.RayTracingRange);
Assert.Equal(3, s.Ssr);
Assert.Equal(1, s.AmbientOcclusion);
Assert.Equal((4, 6), (s.AoSlices, s.AoSteps));
Assert.Equal(1, s.LightShadows);
Assert.Equal(3, s.ContactShadows);
Assert.False(s.VolumetricFog);
Assert.Equal(4, s.FogQuality);
Assert.Equal(4, s.Clouds);
Assert.False(s.CloudShadows);
Assert.False(s.Grass);
Assert.Equal(120, s.GrassDistance);
Assert.Equal(1.5, s.GrassDensity);
Assert.Equal(67, s.BufferScalePercent);
Assert.Equal("RTX", s.VulkanDevice);
}
[Fact]
public void Missing_keys_take_the_simulators_defaults()
{
var s = Read("");
Assert.Equal(GraphicsBackend.OpenGL, s.Backend);
Assert.Equal(AntiAliasingMode.Msaa4, s.AntiAliasing);
Assert.Equal(2, s.Ssr);
Assert.Equal(2, s.AmbientOcclusion);
Assert.Equal(2, s.LightShadows);
Assert.Equal(0, s.ContactShadows);
Assert.True(s.VolumetricFog);
Assert.Equal(2, s.FogQuality);
Assert.Equal(2, s.Clouds);
Assert.False(s.RayTracing);
Assert.Equal(1, s.FrameGeneration);
}
[Fact]
public void The_tokens_written_are_the_ones_the_simulator_parses()
{
var s = Read("gfxrenderer vulkan\n");
s.AntiAliasing = AntiAliasingMode.Dlaa;
s.FrameGeneration = 4;
s.Reflex = 1;
s.LightShadows = 3;
s.ContactShadows = 0;
s.Clouds = 4;
s.VolumetricFog = false;
s.FogQuality = 1;
s.AmbientOcclusion = 1;
s.Ssr = 2;
s.RayTracing = true;
string text = Write(s);
Assert.Equal("gfx.antialiasing dlaa", Line(text, "gfx.antialiasing"));
Assert.Equal("multisampling 0", Line(text, "multisampling"));
Assert.Equal("gfx.framegeneration x4", Line(text, "gfx.framegeneration"));
Assert.Equal("gfx.reflex on", Line(text, "gfx.reflex"));
Assert.Equal("gfx.lights.shadows high", Line(text, "gfx.lights.shadows"));
Assert.Equal("gfx.contactshadows no", Line(text, "gfx.contactshadows"));
Assert.Equal("gfx.clouds ultra", Line(text, "gfx.clouds"));
Assert.Equal("gfx.fog analytic", Line(text, "gfx.fog"));
Assert.Equal("gfx.fog.quality low", Line(text, "gfx.fog.quality"));
Assert.Equal("gfx.postfx.ao ssao", Line(text, "gfx.postfx.ao"));
Assert.Equal("gfx.ssr medium", Line(text, "gfx.ssr"));
Assert.Equal("gfx.raytracing yes", Line(text, "gfx.raytracing"));
Assert.Equal("gfx.buffer.percent 100", Line(text, "gfx.buffer.percent"));
}
[Fact]
public void Msaa_is_written_to_both_keys_for_older_simulators()
{
var s = Read("multisampling 1\n");
Assert.Equal(AntiAliasingMode.Msaa2, s.AntiAliasing);
string text = Write(s);
Assert.Equal("multisampling 1", Line(text, "multisampling"));
Assert.Equal("gfx.antialiasing msaax2", Line(text, "gfx.antialiasing"));
}
[Fact]
public void Of_the_two_anti_aliasing_keys_the_later_one_wins_as_in_the_simulator()
{
Assert.Equal(AntiAliasingMode.Taa, Read("multisampling 2\ngfx.antialiasing taa\n").AntiAliasing);
Assert.Equal(AntiAliasingMode.Msaa4, Read("gfx.antialiasing taa\nmultisampling 2\n").AntiAliasing);
}
[Fact]
public void On_opengl_the_vulkan_only_features_are_written_off()
{
var s = Read("gfxrenderer full\ngfx.antialiasing dlss\ngfx.framegeneration x2\ngfx.reflex on\ngfx.raytracing yes\n");
Assert.Equal(AntiAliasingMode.Taa, s.AntiAliasing);
Assert.Equal(1, s.FrameGeneration);
Assert.Equal(0, s.Reflex);
Assert.False(s.RayTracing);
string text = Write(s);
Assert.Equal("gfx.antialiasing taa", Line(text, "gfx.antialiasing"));
Assert.Equal("gfx.framegeneration off", Line(text, "gfx.framegeneration"));
Assert.Equal("gfx.reflex off", Line(text, "gfx.reflex"));
Assert.Equal("gfx.raytracing no", Line(text, "gfx.raytracing"));
}
[Fact]
public void Frame_generation_needs_dlss_even_on_vulkan()
{
var s = Read("gfxrenderer vulkan\ngfx.antialiasing taa\ngfx.framegeneration x2\ngfx.reflex on\n");
Assert.Equal(1, s.FrameGeneration);
Assert.Equal(1, s.Reflex);
Assert.False(GraphicsRules.ReflexForced(s, true));
}
[Fact]
public void Frame_generation_turns_reflex_on()
{
var s = Read("gfxrenderer vulkan\ngfx.antialiasing dlss\ngfx.framegeneration x2\n");
Assert.True(GraphicsRules.ReflexForced(s, true));
}
[Fact]
public void Without_a_dlss_platform_dlss_falls_back_to_taa()
{
var s = Read("gfxrenderer vulkan\ngfx.antialiasing dlss\n", dlssPlatform: false);
Assert.Equal(AntiAliasingMode.Taa, s.AntiAliasing);
}
[Fact]
public void Dlaa_ignores_the_render_scale()
{
var s = Read("gfxrenderer vulkan\ngfx.antialiasing dlaa\n");
Assert.False(GraphicsRules.BufferScaleUsed(s, true));
s.AntiAliasing = AntiAliasingMode.Dlss;
Assert.True(GraphicsRules.BufferScaleUsed(s, true));
}
[Fact]
public void Sizes_are_rounded_down_to_what_the_simulator_uses()
{
var s = Read("maxtexturesize 16384\nmaxcabtexturesize 3000\nshadowtune 3000 0 250 0\n");
Assert.Equal(8192, s.MaxTextureSize);
Assert.Equal(2048, s.MaxCabTextureSize);
Assert.Equal(2048, s.ShadowMapResolution);
}
[Fact]
public void The_render_scale_reads_the_old_starter_key_when_the_new_one_is_missing()
{
Assert.Equal(75, Read("starter.bufferscale 75\n").BufferScalePercent);
Assert.Equal(50, Read("starter.bufferscale 75\ngfx.buffer.percent 50\n").BufferScalePercent);
}
[Fact]
public void A_level_is_recognised_after_it_is_applied_and_survives_a_save()
{
for (int engine = 0; engine < SimulatorSettings.RenderEngines.Length; engine++)
{
foreach (var level in GraphicsPreset.All)
{
var installation = WithIni("");
var store = installation.SettingsStore;
store.Settings.RenderEngine = engine;
level.ApplyTo(store.Settings);
Assert.Same(level, GraphicsPreset.Matching(store.Settings));
store.Save();
var again = new TestInstallation(installation.Files);
again.Environment.With("HOME", "/home/kolejarz");
again.SettingsStore.Load();
Assert.Same(level, GraphicsPreset.Matching(again.SettingsStore.Settings));
}
}
}
[Fact]
public void A_change_after_a_level_leaves_the_level()
{
var s = new SimulatorSettings();
GraphicsPreset.High.ApplyTo(s);
s.Clouds = 4;
Assert.Null(GraphicsPreset.Matching(s));
}
[Fact]
public void Levels_leave_alone_what_is_not_a_matter_of_quality()
{
var s = new SimulatorSettings { Width = 2560, Height = 1440, VSync = true, FieldOfView = 60, MotionBlur = false };
GraphicsPreset.Low.ApplyTo(s);
Assert.Equal((2560, 1440, true, 60, false), (s.Width, s.Height, s.VSync, s.FieldOfView, s.MotionBlur));
}
[Fact]
public void Ray_tracing_comes_with_ultra_on_vulkan_only()
{
var vulkan = new SimulatorSettings { RenderEngine = SimulatorSettings.VulkanEngine };
var opengl = new SimulatorSettings();
GraphicsPreset.Ultra.ApplyTo(vulkan);
GraphicsPreset.Ultra.ApplyTo(opengl);
Assert.True(vulkan.RayTracing);
Assert.False(opengl.RayTracing);
Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(opengl));
}
[Fact]
public void A_level_keeps_dlss_on_vulkan()
{
var s = new SimulatorSettings { RenderEngine = SimulatorSettings.VulkanEngine, AntiAliasing = AntiAliasingMode.Dlss };
GraphicsPreset.Ultra.ApplyTo(s);
Assert.Equal(AntiAliasingMode.Dlss, s.AntiAliasing);
Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(s));
}
[Fact]
public void A_level_replaces_taa_with_its_own_multisampling()
{
var s = new SimulatorSettings { AntiAliasing = AntiAliasingMode.Taa };
GraphicsPreset.Medium.ApplyTo(s);
Assert.Equal(AntiAliasingMode.Msaa2, s.AntiAliasing);
}
[Fact]
public void An_import_takes_the_settings_but_keeps_this_installations_simulator_and_the_unmanaged_lines()
{
var installation = WithIni("starter.exe.auto no\nstarter.exe.path eu07_local.exe\ncalibratein 0 0 0 0\ngfx.clouds low\n");
installation.Files.WithFile("/backup/eu07.ini", "starter.exe.auto no\nstarter.exe.path eu07_other.exe\ngfx.clouds ultra\ngfxrenderer vulkan\n");
Assert.True(installation.SettingsStore.ImportFrom("/backup/eu07.ini"));
var s = installation.SettingsStore.Settings;
Assert.Equal(4, s.Clouds);
Assert.Equal(GraphicsBackend.Vulkan, s.Backend);
Assert.Equal("eu07_local.exe", s.ExecutablePath);
// Nothing is written until saved, and the save keeps the user's own lines.
Assert.Contains("gfx.clouds low", installation.Files.ReadAllText(UserIni));
installation.SettingsStore.Save();
string saved = installation.Files.ReadAllText(UserIni);
Assert.Contains("calibratein 0 0 0 0", saved);
Assert.Contains("gfx.clouds ultra", saved);
Assert.Contains("starter.exe.path eu07_local.exe", saved);
}
[Fact]
public void An_import_of_a_missing_file_changes_nothing()
{
var installation = WithIni("gfx.clouds low\n");
Assert.False(installation.SettingsStore.ImportFrom("/nowhere/eu07.ini"));
Assert.Equal(1, installation.SettingsStore.Settings.Clouds);
}
[Fact]
public void An_export_writes_a_complete_file_elsewhere_and_leaves_the_users_one_alone()
{
var installation = WithIni("calibratein 0 0 0 0\ngfx.clouds low\n");
installation.SettingsStore.Settings.Clouds = 3;
Assert.True(installation.SettingsStore.ExportTo("/export/eu07.ini"));
string exported = installation.Files.ReadAllText("/export/eu07.ini");
Assert.Contains("calibratein 0 0 0 0", exported);
Assert.Contains("gfx.clouds high", exported);
Assert.Contains("gfx.clouds low", installation.Files.ReadAllText(UserIni));
}
[Fact]
public void An_export_keeps_the_older_renderer_word_of_the_file()
{
var installation = WithIni("gfxrenderer legacy\n");
Assert.True(installation.SettingsStore.ExportTo("/export/eu07.ini"));
Assert.Contains("gfxrenderer legacy", installation.Files.ReadAllText("/export/eu07.ini"));
}
[Fact]
public void The_dlss_libraries_are_looked_for_next_to_a_windows_build()
{
var files = new InMemoryFileSystem()
.WithDirectory(TestInstallation.Root)
.WithExecutable(TestInstallation.At("eu07.exe"), "MZ binary")
.WithFile(TestInstallation.At("nvngx_dlss.dll"), "dll");
var installation = new TestInstallation(files);
Assert.Equal((true, false), installation.Executables.DlssLibraries(TestInstallation.At("eu07.exe")));
files.WithFile(TestInstallation.At("nvngx_dlssg.dll"), "dll");
Assert.Equal((true, true), installation.Executables.DlssLibraries(TestInstallation.At("eu07.exe")));
}
[Fact]
public void The_dlss_libraries_of_a_linux_build_are_the_shared_objects()
{
var files = new InMemoryFileSystem()
.WithDirectory(TestInstallation.Root)
.WithExecutable(TestInstallation.At("eu07"), "\u007fELF binary")
.WithFile(TestInstallation.At("libnvidia-ngx-dlssg.so.310.4.0"), "so");
var installation = new TestInstallation(files);
Assert.Equal((false, true), installation.Executables.DlssLibraries(TestInstallation.At("eu07")));
}
[Fact]
public void A_later_line_moved_up_keeps_the_index_of_every_key()
{
var c = ConfigFile.Parse("a 1\nb 2\nc 3\n");
c.MoveAfter("a", "c");
Assert.Equal("b 2\nc 3\na 1\n", c.ToText().Replace("\r\n", "\n"));
Assert.True(c.LineOf("a") > c.LineOf("c"));
c.Set("b", "9");
Assert.Equal("b 9\nc 3\na 1\n", c.ToText().Replace("\r\n", "\n"));
}
[Fact]
public void The_ao_lighting_and_cab_probe_keys_survive_a_round_trip_in_the_simulators_form()
{
var s = Read("gfx.postfx.ao.lighting no\ngfx.cabprobe high\ngfx.cabprobe.interval 12\ngfx.cabprobe.bounce 2.5\ngfx.cabprobe.reflections no\n");
Assert.False(s.AoLighting);
Assert.Equal(3, s.CabProbe);
Assert.Equal(12, s.CabProbeInterval);
Assert.Equal(2.5, s.CabProbeBounce);
Assert.False(s.CabProbeReflections);
string text = Write(s);
Assert.Equal("gfx.postfx.ao.lighting no", Line(text, "gfx.postfx.ao.lighting"));
Assert.Equal("gfx.cabprobe high", Line(text, "gfx.cabprobe"));
Assert.Equal("gfx.cabprobe.interval 12", Line(text, "gfx.cabprobe.interval"));
Assert.Equal("gfx.cabprobe.bounce 2.5", Line(text, "gfx.cabprobe.bounce"));
Assert.Equal("gfx.cabprobe.reflections no", Line(text, "gfx.cabprobe.reflections"));
var again = Read(text);
Assert.Equal((s.AoLighting, s.CabProbe, s.CabProbeInterval, s.CabProbeBounce, s.CabProbeReflections),
(again.AoLighting, again.CabProbe, again.CabProbeInterval, again.CabProbeBounce, again.CabProbeReflections));
}
[Fact]
public void Missing_ao_lighting_and_cab_probe_keys_take_the_simulators_defaults_and_are_written_like_its_export()
{
var s = Read("");
Assert.Equal((true, 2, 4, 1.0, true), (s.AoLighting, s.CabProbe, s.CabProbeInterval, s.CabProbeBounce, s.CabProbeReflections));
string text = Write(s);
Assert.Equal("gfx.postfx.ao.lighting yes", Line(text, "gfx.postfx.ao.lighting"));
Assert.Equal("gfx.cabprobe medium", Line(text, "gfx.cabprobe"));
Assert.Equal("gfx.cabprobe.interval 4", Line(text, "gfx.cabprobe.interval"));
Assert.Equal("gfx.cabprobe.bounce 1", Line(text, "gfx.cabprobe.bounce"));
Assert.Equal("gfx.cabprobe.reflections yes", Line(text, "gfx.cabprobe.reflections"));
}
[Fact]
public void Unknown_ao_lighting_and_cab_probe_values_fall_back_to_the_simulators_defaults()
{
var s = Read("gfx.postfx.ao.lighting maybe\ngfx.cabprobe bogus\ngfx.cabprobe.interval abc\ngfx.cabprobe.bounce x\ngfx.cabprobe.reflections ?\n");
Assert.Equal((true, 2, 4, 1.0, true), (s.AoLighting, s.CabProbe, s.CabProbeInterval, s.CabProbeBounce, s.CabProbeReflections));
}
[Theory]
[InlineData("no", 0)]
[InlineData("0", 0)]
[InlineData("low", 1)]
[InlineData("1", 1)]
[InlineData("medium", 2)]
[InlineData("yes", 2)]
[InlineData("2", 2)]
[InlineData("high", 3)]
[InlineData("3", 3)]
public void The_cab_probe_level_is_read_with_the_aliases_the_simulator_takes(string value, int expected)
{
Assert.Equal(expected, Read($"gfx.cabprobe {value}\n").CabProbe);
}
[Fact]
public void The_cab_probe_numbers_are_kept_within_the_simulators_ranges()
{
var s = Read("gfx.cabprobe.interval 500\ngfx.cabprobe.bounce 9\n");
Assert.Equal((120, 4.0), (s.CabProbeInterval, s.CabProbeBounce));
s = Read("gfx.cabprobe.interval 0\ngfx.cabprobe.bounce -1\n");
Assert.Equal((1, 0.0), (s.CabProbeInterval, s.CabProbeBounce));
}
[Fact]
public void The_ao_lighting_counts_with_gtao_only()
{
Assert.True(GraphicsRules.AoLightingUsed(Read("gfx.postfx.ao gtao\n")));
Assert.False(GraphicsRules.AoLightingUsed(Read("gfx.postfx.ao ssao\n")));
Assert.False(GraphicsRules.AoLightingUsed(Read("gfx.postfx.ao no\n")));
}
[Theory]
[InlineData("gfx.ssr no\ngfx.contactshadows no\n", true)]
[InlineData("gfx.ssr low\ngfx.contactshadows no\n", false)]
[InlineData("gfx.ssr no\ngfx.contactshadows low\n", false)]
[InlineData("gfx.ssr no\ngfx.contactshadows no\ngfx.depthprepass.scene yes\n", false)]
[InlineData("gfx.ssr no\ngfx.contactshadows no\ngfx.postfx.ao.lighting no\n", false)]
[InlineData("gfx.ssr no\ngfx.contactshadows no\ngfx.postfx.ao ssao\n", false)]
public void The_ao_lighting_adds_the_depth_pass_only_when_nothing_else_draws_it(string text, bool expected)
{
Assert.Equal(expected, GraphicsRules.AoLightingAddsDepthPrepass(Read(text)));
}
[Theory]
[InlineData("gfx.cabprobe medium\n", true, true)]
[InlineData("gfx.cabprobe no\n", false, false)]
[InlineData("gfx.cabprobe low\ngfx.lights.clustered no\n", true, false)]
[InlineData("gfx.cabprobe high\ngfx.lights.cab no\n", true, false)]
public void The_cab_probe_lights_the_cab_only_with_its_lamp_as_a_light(string text, bool used, bool lights)
{
var s = Read(text);
Assert.Equal(used, GraphicsRules.CabProbeUsed(s));
Assert.Equal(lights, GraphicsRules.CabProbeLights(s));
}
[Theory]
[InlineData(0, false, 0, 8, false)]
[InlineData(1, false, 0, 8, false)]
[InlineData(2, false, 1, 8, true)]
[InlineData(3, true, 2, 4, true)]
[InlineData(4, true, 3, 3, true)]
[InlineData(5, true, 3, 2, true)]
public void A_level_sets_the_ao_lighting_and_the_cab_probe(int place, bool aoLighting, int probe, int interval, bool reflections)
{
var level = GraphicsPreset.All[place];
var s = new SimulatorSettings { CabProbeBounce = 2.5 };
level.ApplyTo(s);
Assert.Equal((aoLighting, probe, interval, reflections), (s.AoLighting, s.CabProbe, s.CabProbeInterval, s.CabProbeReflections));
Assert.Equal(2.5, s.CabProbeBounce);
Assert.Same(level, GraphicsPreset.Matching(s));
}
[Fact]
public void An_import_keeps_the_lamp_switch_of_the_users_own_file()
{
var installation = WithIni("gfx.lights.cab no\n");
installation.Files.WithFile("/backup/eu07.ini", "gfx.lights.cab yes\ngfx.cabprobe high\n");
Assert.True(installation.SettingsStore.ImportFrom("/backup/eu07.ini"));
Assert.False(installation.SettingsStore.Settings.CabLampLight);
Assert.Equal(3, installation.SettingsStore.Settings.CabProbe);
}
}

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("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,251 @@
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 An_old_starters_key_descriptions_are_read_in_its_own_code_page()
{
using var folders = new Folders();
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
File.WriteAllBytes(Path.Combine(folders.OldStarter, "lang-cz.txt"), LegacyText.CodePage1250.GetBytes(
"[labels]\r\n<eu07_input-keyboard.ini\r\nhornhighactivate=Houkačka výšková\r\n\r\n"));
var loc = folders.Service();
loc.Load("cz");
Assert.Equal("Houkačka výšková", loc.Opt("Cmd.hornhighactivate"));
}
[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"));
}
[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]
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);
}
[Fact]
public void Starred_sceneries_are_gathered_on_top_and_starred_where_they_belong()
{
var (builder, sceneries) = Build("//$l Śląsk", "//$l Śląsk", "//$n Aaa luzem");
string starred = SceneryTreeBuilder.Star + sceneries.Single(scenery => scenery.Path.EndsWith("s1.scn")).DisplayName;
string plain = sceneries.Single(scenery => scenery.Path.EndsWith("s0.scn")).DisplayName;
var nodes = builder.Build(sceneries, includeArchival: true, "en",
scenery => scenery.Path.EndsWith("s1.scn"), "Ulubione");
var top = nodes[0];
Assert.True(top.IsGroup);
Assert.Equal(SceneryTreeBuilder.Star + "Ulubione", top.Label);
Assert.Equal(starred, Assert.Single(top.Children).Label);
var group = Assert.Single(nodes, node => node.Label == "Śląsk");
Assert.Contains(group.Children, node => node.Label == starred);
Assert.Contains(group.Children, node => node.Label == plain);
}
[Fact]
public void Without_favourites_there_is_no_favourites_group()
{
var (builder, sceneries) = Build("//$n Bytom");
var nodes = builder.Build(sceneries, includeArchival: true, "en", _ => false, "Ulubione");
Assert.DoesNotContain(nodes, node => node.Label.StartsWith(SceneryTreeBuilder.Star));
}
[Fact]
public void Scenarios_declaring_a_group_are_gathered_under_it()
{

View File

@@ -1,13 +1,9 @@
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryExportTests
{
private static SceneryParser Parser() =>
new(new FixedClock(new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
[Fact]
public void An_untouched_scenery_exports_its_trainsets_back_verbatim()
{
@@ -18,7 +14,7 @@ public class SceneryExportTests
endtrainset
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());
@@ -27,6 +23,45 @@ public class SceneryExportTests
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]
public void Selected_optional_includes_are_written_back_before_FirstInit()
{
@@ -36,7 +71,7 @@ public class SceneryExportTests
endoptional
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());
@@ -54,7 +89,7 @@ public class SceneryExportTests
endoptional
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.Includes[0].Selected = false;
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
@@ -71,7 +106,7 @@ public class SceneryExportTests
endoptional
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.HasCompanionTimetable = false;
Assert.Contains("include rozklad.inc",
@@ -95,7 +130,7 @@ public class SceneryExportTests
time 8:30 0 0 endtime
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.Weather.Day = 200;
scenery.Weather.Temperature = 25;
@@ -120,7 +155,7 @@ public class SceneryExportTests
endtrainset
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.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
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());
Assert.Contains("wagon_1", 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;
namespace StarterNG.Tests;
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]
public void Directives_become_the_scenery_headline()
{
@@ -19,7 +16,7 @@ public class SceneryParserTests
//$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("Osobowy do Katowic", scenery.Name);
@@ -32,7 +29,7 @@ public class SceneryParserTests
[Fact]
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);
}
@@ -46,7 +43,7 @@ public class SceneryParserTests
//$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,
first =>
@@ -76,7 +73,7 @@ public class SceneryParserTests
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var include = Assert.Single(scenery.Includes);
Assert.Equal("zima.inc", include.FilePath);
@@ -95,7 +92,7 @@ public class SceneryParserTests
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.False(include.Selected);
@@ -112,7 +109,7 @@ public class SceneryParserTests
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var trainset = Assert.Single(scenery.Trainsets);
Assert.True(trainset.Parsed);
@@ -126,11 +123,11 @@ public class SceneryParserTests
public void Vehicles_outside_a_trainset_are_collected_as_loose()
{
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
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var vehicle = Assert.Single(scenery.LooseVehicles);
Assert.Equal("wagon_1", vehicle.Name);
@@ -142,13 +139,43 @@ public class SceneryParserTests
[Fact]
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.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);
}
[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

@@ -98,4 +98,25 @@ public class SceneryRepositoryTests
Assert.Null(new SceneryImageLocator(files).Resolve(scenery));
}
[Fact]
public void The_copy_written_for_a_start_keeps_each_file_in_its_own_encoding()
{
const string cars =
"trainset none droga 0 0\r\n" +
"node -1 0 żuk dynamic road\\zuk zuk zuk 0 headdriver 0 0 enddynamic\r\n" +
"endtrainset\r\n";
var files = new InMemoryFileSystem()
.WithLegacyFile(Path.Combine("game", "scenery", "kato.scn"),
"//$n Łódź\r\nFirstInit\r\ninclude auta.scm end\r\n")
.WithFile(Path.Combine("game", "scenery", "auta.scm"), cars);
var scenery = Assert.Single(Build(files, out _).LoadAll());
byte[] exported = scenery.BuildExportBytes(skipDecorTrainsets: false, new StubRandom());
static bool Has(byte[] text, byte[] part) => text.AsSpan().IndexOf(part) >= 0;
Assert.True(Has(exported, LegacyText.CodePage1250.GetBytes("//$n Łódź\r\n")));
Assert.True(Has(exported, Encoding.UTF8.GetBytes("żuk dynamic")));
Assert.False(Has(exported, Encoding.UTF8.GetBytes("Łódź")));
}
}

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,580 @@
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";
// The application's own, which registers the code pages first; this class may be
// the first to need them, before any InMemoryFileSystem exists.
private static Encoding Cp1250 => LegacyText.CodePage1250;
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.Infrastructure.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryWeatherTests
{
private static SceneryParser Parser(DateTime now) => new(new FixedClock(now), new StubRandom());
[Fact]
public void Authored_weather_is_read_from_the_config_block()
{
@@ -21,7 +18,7 @@ public class SceneryWeatherTests
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.Equal(172, weather.Day);
@@ -35,7 +32,7 @@ public class SceneryWeatherTests
[Fact]
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.False(weather.IsAuthored);
@@ -44,7 +41,7 @@ public class SceneryWeatherTests
[Fact]
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);
}
@@ -52,7 +49,7 @@ public class SceneryWeatherTests
[Fact]
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.Temperature = -12;
@@ -84,4 +81,168 @@ public class SceneryWeatherTests
Assert.Equal(-1.5, weather.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,154 @@
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("vulkan", IdleReason.None)]
[InlineData("vulkan14", IdleReason.None)]
[InlineData("full", IdleReason.VulkanOnly)]
[InlineData("gl46", IdleReason.VulkanOnly)]
[InlineData("legacy", IdleReason.VulkanOnly)]
public void What_only_vulkan_reads_follows_the_renderer(string renderer, IdleReason expected)
{
Assert.Equal(expected, SettingReach.Why("gfx.dlss.preset", Linux(renderer)));
Assert.Equal(expected, SettingReach.Why("gfx.framegeneration", Linux(renderer)));
Assert.Equal(expected, SettingReach.Why("gfx.raytracing", Linux(renderer)));
// Both renderers of the new simulator read this one.
Assert.Equal(IdleReason.None, SettingReach.Why("gfx.postfx.ao", Linux(renderer)));
}
[Theory]
[InlineData("vulkan")]
[InlineData("vulkan14")]
[InlineData("opengl")]
[InlineData("gl46")]
public void The_new_simulators_renderers_read_what_the_full_one_does(string renderer)
{
Assert.Equal(IdleReason.None, SettingReach.Why("gfx.envmap.enabled", Linux(renderer)));
Assert.Equal(IdleReason.None, SettingReach.Why("multisampling", 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,416 @@
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;
}
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

@@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\StarterNG\StarterNG.csproj" />
<ProjectReference Include="..\Eu07.BlackBox\Eu07.BlackBox.csproj" />
</ItemGroup>
</Project>

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>
private static VehicleEntry ParsedEntry(string directory, params string[] lines) =>
new StarterNG.Infrastructure.Vehicles.TexturesTxtParser().Parse(directory, lines)!;
// The ET42-026: one skin for both halves, told apart by the model.
private static VehicleCatalog SharedSkinPair()
{
var files = new InMemoryFileSystem()
.WithFile(TexturesFile("pkp", "et42_v2"),
"!=e,ET42-A,ET42-B\n^2\n112E-026.mat=112E-A,ET42-A,ET42-026-A\n112E-026.mat=112E-B,ET42-B,ET42-026-B");
var installation = new TestInstallation(files);
installation.Vehicles.Load(installation.Library.Vehicles);
return installation.Library.Vehicles;
}
[Fact]
public void A_skin_shared_by_two_models_is_found_by_the_model()
{
var catalog = SharedSkinPair();
Assert.Equal("ET42-026-A", catalog.TextureFor(@"PKP\ET42_V2", "112E-026", "112E-A")!.TextureMini);
Assert.Equal("ET42-026-B", catalog.TextureFor(@"PKP\ET42_V2", "112E-026", "112E-B")!.TextureMini);
}
[Fact]
public void The_second_half_on_a_shared_skin_follows_the_first_in_its_set()
{
var catalog = SharedSkinPair();
var b = catalog.TextureFor(@"PKP\ET42_V2", "112E-026", "112E-B")!;
Assert.True(catalog.IsSetFollower(b));
Assert.Equal(new[] { "112E-A", "112E-B" }, catalog.ResolveSet(b)!.Select(t => t.Model));
}
[Fact]
public void A_skin_name_used_in_two_folders_is_found_by_the_folder()
{
var files = new InMemoryFileSystem()
.WithFile(TexturesFile("road", "clio"), "red.mat=clio,CLIO")
.WithFile(TexturesFile("industrial", "wls50_v1"), "red.mat=wls50,WLS50");
var installation = new TestInstallation(files);
installation.Vehicles.Load(installation.Library.Vehicles);
var catalog = installation.Library.Vehicles;
Assert.Equal("clio", catalog.TextureFor(@"road\clio", "red", "clio")!.Model);
Assert.Equal("wls50", catalog.TextureFor("industrial/wls50_v1", "red", "wls50.mmd")!.Model);
}
}

View File

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

View File

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

@@ -1,42 +1,93 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.1.11312.151 d18.0
VisualStudioVersion = 18.1.11312.151
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG", "StarterNG\StarterNG.csproj", "{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG.Tests", "StarterNG.Tests\StarterNG.Tests.csproj", "{00B068D7-2F49-4CAA-BC21-506503EBDFC8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eu07.BlackBox", "Eu07.BlackBox\Eu07.BlackBox.csproj", "{B94F5951-3DC8-4DD8-A694-133B76395A5A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eu07.BlackBox.Views", "Eu07.BlackBox.Views\Eu07.BlackBox.Views.csproj", "{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eu07.BlackBox.Viewer", "Eu07.BlackBox.Viewer\Eu07.BlackBox.Viewer.csproj", "{0C131487-66CE-42B9-8BB6-93AAD8C47719}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x64.ActiveCfg = Debug|x64
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x64.Build.0 = Debug|x64
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x86.ActiveCfg = Debug|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x86.Build.0 = Debug|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|Any CPU.Build.0 = Release|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.ActiveCfg = Release|x64
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.Build.0 = Release|x64
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x86.ActiveCfg = Release|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x86.Build.0 = Release|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.ActiveCfg = Debug|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.Build.0 = Debug|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x86.ActiveCfg = Debug|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x86.Build.0 = Debug|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.Build.0 = Release|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.ActiveCfg = Release|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.Build.0 = Release|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x86.ActiveCfg = Release|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x86.Build.0 = Release|Any CPU
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x64.ActiveCfg = Debug|Any CPU
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x64.Build.0 = Debug|Any CPU
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x86.ActiveCfg = Debug|Any CPU
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x86.Build.0 = Debug|Any CPU
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|Any CPU.Build.0 = Release|Any CPU
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x64.ActiveCfg = Release|Any CPU
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x64.Build.0 = Release|Any CPU
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x86.ActiveCfg = Release|Any CPU
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x86.Build.0 = Release|Any CPU
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x64.ActiveCfg = Debug|Any CPU
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x64.Build.0 = Debug|Any CPU
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x86.ActiveCfg = Debug|Any CPU
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x86.Build.0 = Debug|Any CPU
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|Any CPU.Build.0 = Release|Any CPU
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x64.ActiveCfg = Release|Any CPU
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x64.Build.0 = Release|Any CPU
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x86.ActiveCfg = Release|Any CPU
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x86.Build.0 = Release|Any CPU
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x64.ActiveCfg = Debug|Any CPU
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x64.Build.0 = Debug|Any CPU
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x86.ActiveCfg = Debug|Any CPU
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x86.Build.0 = Debug|Any CPU
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|Any CPU.Build.0 = Release|Any CPU
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x64.ActiveCfg = Release|Any CPU
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x64.Build.0 = Release|Any CPU
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x86.ActiveCfg = Release|Any CPU
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(RiderSharedRunConfigurations) = postSolution
File = linuix.run\StarterNG (linux64).run.xml
File = windows.run\StarterNG (win64).run.xml
EndGlobalSection
EndGlobal

View File

@@ -12,6 +12,17 @@
<StyleInclude Source="avares://Starter/Styles/Carbon.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]">
<Setter Property="IsVisible" Value="False" />
</Style>

View File

@@ -1,10 +1,7 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
@@ -30,6 +27,7 @@ public partial class App : Avalonia.Application
AvaloniaXamlLoader.Load(this);
AppServices.Current.SettingsStore.Load();
AppServices.Current.SettingsStore.StartAtHighWithoutOwnSettings();
KeyboardConfig.Instance.Load();
@@ -48,6 +46,10 @@ public partial class App : Avalonia.Application
// actually display.
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);
// 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)
{
var resolvedName = Loc.Load(langNameOrCode);
// The service is the authority on which language actually loaded: the code
// goes to eu07.ini for the simulator, the name to the picker.
AppServices.Current.Settings.Language = resolvedName;
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)
@@ -88,10 +101,11 @@ public partial class App : Avalonia.Application
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.DefaultThreadCurrentUICulture = culture;
CultureInfo.CurrentCulture = culture;
CultureInfo.CurrentUICulture = culture;
}
public override void OnFrameworkInitializationCompleted()
@@ -176,13 +190,20 @@ public partial class App : Avalonia.Application
$"{Loc["FaultLoadData"]}{Environment.NewLine}{Environment.NewLine}{Loc["FaultDetail"]} {inner.Message}");
}
var faults = Diagnostics.CheckInstallation();
var check = Diagnostics.InstallationCheck();
var faults = check.Run();
if (faults.Count > 0)
{
await Diagnostics.ReportAsync(
string.Join(Environment.NewLine, faults) + Environment.NewLine + Environment.NewLine +
Loc["FaultBadInstall"]);
}
if (check.ExecutableMissing())
await main.OfferExecutablePickAsync();
if (CpuFeatures.LacksAvx2 && AppServices.Current.Settings.CpuNotice)
await main.ShowCpuNoticeAsync();
});
});
}

View File

@@ -17,12 +17,24 @@ public interface IFileSystem
string[] ReadAllLines(string path);
byte[] ReadAllBytes(string path);
Stream OpenRead(string path);
void WriteAllText(string path, string contents);
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 WriteAllBytes(string path, byte[] bytes);
@@ -41,5 +53,38 @@ public interface IFileSystem
DateTime GetLastWriteTimeUtc(string path);
long GetFileSize(string path);
/// <summary>A file's time and size in one look; null when there is no file.</summary>
(DateTime LastWriteUtc, long Size)? LastWriteAndSize(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; }
/// <summary>Where the JSON vehicle database and the packages layered on it live.</summary>
string VehicleDatabase { get; }
string StarterConfig { get; }
string DiagnosticsLog { get; }

View File

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

View File

@@ -11,4 +11,7 @@ public interface ISceneryRepository
IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null);
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.IO;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Settings;
using StarterNG.Infrastructure;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Infrastructure.Settings;
@@ -25,27 +27,53 @@ public sealed class AppServices
var log = new FileDiagnosticsLog(files, clock, paths);
Log = log;
Random = new SystemRandomSource();
var random = new SystemRandomSource();
Processes = new SystemProcessLauncher(log);
Localization = new LocalizationService(log);
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random));
var settingsPaths = new SettingsPaths(environment, paths);
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);
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);
MissingAssets = new MissingAssetScanner(files, paths);
var missingAssets = new MissingAssetScanner(files, paths);
SceneryTexts = new SceneryTranslations(files, log);
Timetables = new TimetableLocator(files);
LoadWeights = new LoadWeightsRepository(files, paths, log);
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
State = new AppState();
SceneryFiles = new SceneryFileWatch(files, Sceneries, Library, State);
var settingsPaths = new SettingsPaths(environment, paths);
Executables = new ExecutableLocator(files, paths, environment, log);
SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(), Executables);
SettingsPaths = settingsPaths;
MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, MissingAssets, Library, log);
StartSimulation = new StartSimulation(State, SettingsStore, files, Processes, Random, log);
SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(dlssPlatform: !environment.IsMacOS), Executables);
MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, missingAssets, Library, log);
Favorites = new FavoriteSceneries(files, log, settingsPaths.FavoritesPath());
Tapes = new HaslerTapes(files, paths, log, clock, Timetables, () => State.SimulatorRunning);
Gpus = new LinuxGpus(files, environment);
GraphicsCards = new GraphicsCards(files, environment, Gpus);
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 =>
@@ -75,10 +103,10 @@ public sealed class AppServices
public IProcessLauncher Processes { get; }
public IRandomSource Random { get; }
public ISceneryRepository Sceneries { get; }
public SceneryFileWatch SceneryFiles { get; }
public SceneryImageLocator SceneryImages { get; }
public IMiniTextureIndex MiniTextures { get; }
@@ -87,8 +115,6 @@ public sealed class AppServices
public IPhysicsRepository Physics { get; }
public MissingAssetScanner MissingAssets { get; }
public ISceneryTranslations SceneryTexts { get; }
public TimetableLocator Timetables { get; }
@@ -105,11 +131,25 @@ public sealed class AppServices
public ExecutableLocator Executables { get; }
public LinuxGpus Gpus { get; }
public GraphicsCards GraphicsCards { get; }
public Displays Displays { get; }
public SettingsPaths SettingsPaths { get; }
public MissingVehicleLog MissingVehicleLog { get; }
public FavoriteSceneries Favorites { get; }
public StartSimulation StartSimulation { get; }
public SimulatorAftermath Aftermath { get; }
public HaslerTapes Tapes { get; }
public SaveSceneryTrainset SaveTrainset { get; }
public LocalizationService Localization { get; }
}

View File

@@ -46,5 +46,17 @@ public sealed class AppState
}
}
private volatile bool _simulatorRunning;
/// <summary>
/// The simulator the starter watches is still running: the one it started, not
/// any of the same name. Read off the UI thread too.
/// </summary>
public bool SimulatorRunning
{
get => _simulatorRunning;
set => _simulatorRunning = value;
}
public void NotifyChanged() => Changed?.Invoke();
}

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;
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
? 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,350 @@
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>The black box of a run: the whole session in one file, a new one each run.</summary>
public sealed record BlackBoxRecording(string Path, DateTime LastWriteUtc, long Size);
/// <summary>
/// One run of the simulator and what is left of its tapes: a later run overwrites those of the
/// vehicles it has too. Its black box stays, newest first should there be more.
/// </summary>
public sealed record TapeSession(TapeLaunch? Launch, DateTime StartedUtc, DateTime LastWrittenUtc, bool Running,
IReadOnlyList<TapeFile> Tapes, IReadOnlyList<BlackBoxRecording> BlackBoxes);
/// <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 - and the black boxes, one a 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.WriteAllTextAtomic(LaunchFile,
string.Concat(noted.TakeLast(RememberedLaunches).Select(l => l.Format())));
lock (_playerLock)
_launches = null;
}
catch (Exception ex)
{
_log.Log($"writing {LaunchFile}", ex);
}
}
/// <summary>
/// Reads what the log of the last launch has gained since the last look. A start
/// notes that down before the log begins anew, and after a long run, with nothing
/// read of it yet, it would wait on the whole log.
/// </summary>
public void FollowLastRun()
{
if (Launches().LastOrDefault() is { } launch)
PlayerIn(launch);
}
/// <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 boxes = _files.DirectoryExists(Folder)
? _files.GetFiles(Folder, "*.blackbox").Select(ReadBlackBox).ToList()
: new List<BlackBoxRecording>();
var boxesBehind = boxes.GroupBy(box => launches.LastOrDefault(l => l.Covers(box.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) && !boxesBehind.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) }),
boxesBehind.GetValueOrDefault(launch, new List<BlackBoxRecording>())));
}
// What no launch covers goes together by when it was written: a run writes it all at its end.
var loose = tapes.Where(t => !launches.Any(l => l.Covers(t.LastWriteUtc)))
.Select(t => (Written: t.LastWriteUtc, Tape: (TapeFile?)t, Box: (BlackBoxRecording?)null))
.Concat(boxes.Where(b => !launches.Any(l => l.Covers(b.LastWriteUtc)))
.Select(b => (Written: b.LastWriteUtc, Tape: (TapeFile?)null, Box: (BlackBoxRecording?)b)))
.OrderBy(file => file.Written);
var run = new List<TapeFile>();
var runBoxes = new List<BlackBoxRecording>();
DateTime? lastWritten = null;
foreach (var (written, tape, box) in loose)
{
if (lastWritten is { } before && written - before > QuietBetweenRuns)
{
sessions.Add(Session(null, running: false, run, runBoxes));
run = new List<TapeFile>();
runBoxes = new List<BlackBoxRecording>();
}
if (tape is not null)
run.Add(tape);
if (box is not null)
runBoxes.Add(box);
lastWritten = written;
}
if (lastWritten is not null)
sessions.Add(Session(null, running: false, run, runBoxes));
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));
private BlackBoxRecording ReadBlackBox(string path) =>
new(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,
IEnumerable<BlackBoxRecording> boxes)
{
var listed = tapes.OrderByDescending(tape => tape.Role).ThenByDescending(tape => tape.LastWriteUtc).ToList();
var recorded = boxes.OrderByDescending(box => box.LastWriteUtc).ToList();
var written = listed.Select(tape => tape.LastWriteUtc).Concat(recorded.Select(box => box.LastWriteUtc)).ToList();
var started = launch?.LaunchedUtc ?? written.Min();
return new TapeSession(launch, started, written.Count > 0 ? written.Max() : started, running, listed, recorded);
}
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;
}
}
}

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