1 Commits

Author SHA1 Message Date
dependabot[bot]
664fa17a66 Bump actions/setup-dotnet from 6.0.0 to 6.1.0
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](a98b56852c...0f4a354dd5)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-10 15:35:01 +00:00
41 changed files with 22 additions and 7926 deletions

View File

@@ -52,7 +52,7 @@ jobs:
persist-credentials: false
- name: Setup .NET 9
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
with:
dotnet-version: 9.0.x
@@ -142,7 +142,7 @@ jobs:
persist-credentials: false
- name: Setup .NET 9
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
with:
dotnet-version: 9.0.x

View File

@@ -1,30 +0,0 @@
<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

@@ -1,109 +0,0 @@
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

@@ -1,29 +0,0 @@
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

@@ -1,18 +0,0 @@
<?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

@@ -1,579 +0,0 @@
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

@@ -1,571 +0,0 @@
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

@@ -1,567 +0,0 @@
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

@@ -1,20 +0,0 @@
<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

@@ -1,382 +0,0 @@
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

@@ -1,766 +0,0 @@
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

@@ -1,113 +0,0 @@
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

@@ -1,704 +0,0 @@
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

@@ -1,269 +0,0 @@
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

@@ -1,283 +0,0 @@
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

@@ -1,331 +0,0 @@
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

@@ -1,196 +0,0 @@
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

@@ -1,14 +0,0 @@
<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>

View File

@@ -1,167 +0,0 @@
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 => "—",
_ => "?"
};
}

View File

@@ -1,532 +0,0 @@
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);

View File

@@ -1,406 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,188 +0,0 @@
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

@@ -1,397 +0,0 @@
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

@@ -1,253 +0,0 @@
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

@@ -1,96 +0,0 @@
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

@@ -1,229 +0,0 @@
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

@@ -1,247 +0,0 @@
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

@@ -1,98 +0,0 @@
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

@@ -1,106 +0,0 @@
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

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

View File

@@ -1,93 +1,42 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.1.11312.151
VisualStudioVersion = 18.1.11312.151 d18.0
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

@@ -16,21 +16,15 @@ 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>
/// <summary>One run of the simulator and what is left of its tapes: a later run overwrites those of the vehicles it has too.</summary>
public sealed record TapeSession(TapeLaunch? Launch, DateTime StartedUtc, DateTime LastWrittenUtc, bool Running,
IReadOnlyList<TapeFile> Tapes, IReadOnlyList<BlackBoxRecording> BlackBoxes);
IReadOnlyList<TapeFile> Tapes);
/// <summary>What is known about a tape beyond its samples: the clock it started at, the recorder it is read as, the scenario and the train.</summary>
public sealed record TapeContext(TimeSpan? StartClock, RecorderKind Recorder,
string? Scenery = null, string? Train = null);
/// <summary>The tapes the simulator left in physicslog/ - one per driven vehicle, overwritten every run - and the black boxes, one a run.</summary>
/// <summary>The tapes the simulator left in physicslog/ - one per driven vehicle, overwritten every run.</summary>
public sealed class HaslerTapes
{
private const int RememberedLaunches = 30;
@@ -257,52 +251,32 @@ public sealed class HaslerTapes
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))
if (!running && !behind.ContainsKey(launch))
continue;
var player = last ? PlayerIn(launch) : launch.Player ?? PlayerVehicles.Unknown;
sessions.Add(Session(launch, running, behind.GetValueOrDefault(launch, new List<TapeFile>())
.Select(t => t with { Role = launch.RoleOf(t.Vehicle, player) }),
boxesBehind.GetValueOrDefault(launch, new List<BlackBoxRecording>())));
.Select(t => t with { Role = launch.RoleOf(t.Vehicle, player) })));
}
// 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)
foreach (var tape in tapes.Where(t => !launches.Any(l => l.Covers(t.LastWriteUtc))).OrderBy(t => t.LastWriteUtc))
{
if (lastWritten is { } before && written - before > QuietBetweenRuns)
if (run.Count > 0 && tape.LastWriteUtc - run[^1].LastWriteUtc > QuietBetweenRuns)
{
sessions.Add(Session(null, running: false, run, runBoxes));
sessions.Add(Session(null, running: false, run));
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));
if (run.Count > 0)
sessions.Add(Session(null, running: false, run));
return sessions.OrderByDescending(session => session.Running)
.ThenByDescending(session => session.LastWrittenUtc)
@@ -318,18 +292,13 @@ public sealed class HaslerTapes
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)
private static TapeSession Session(TapeLaunch? launch, bool running, IEnumerable<TapeFile> tapes)
{
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);
var started = launch?.LaunchedUtc ?? listed.Min(tape => tape.LastWriteUtc);
return new TapeSession(launch, started, listed.Count > 0 ? listed.Max(tape => tape.LastWriteUtc) : started,
running, listed);
}
public HaslerTape? Load(TapeFile file)

View File

@@ -37,12 +37,6 @@
<PackageReference Include="Material.Icons.Avalonia" Version="3.0.2" />
</ItemGroup>
<ItemGroup>
<!-- The black box of the simulator: its reader and the window it is played back in. -->
<ProjectReference Include="..\Eu07.BlackBox\Eu07.BlackBox.csproj" />
<ProjectReference Include="..\Eu07.BlackBox.Views\Eu07.BlackBox.Views.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Assets\Logo\" />
</ItemGroup>

View File

@@ -6,13 +6,11 @@ using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using Eu07.BlackBox.Views;
using StarterNG.Application;
using StarterNG.Domain.Settings;
using StarterNG.Domain.Tapes;
@@ -59,8 +57,6 @@ public sealed class HaslerTapeWindow : Window
private readonly Control _recordingOff;
private readonly Control _logOff;
private readonly Dictionary<DateTime, bool> _expanded = new();
private readonly Dictionary<string, BlackBoxWindow> _blackBoxes = new(StringComparer.OrdinalIgnoreCase);
private HashSet<string> _liveBlackBoxes = new(StringComparer.OrdinalIgnoreCase);
private TapeFile? _shown;
private string? _followed;
@@ -106,19 +102,8 @@ public sealed class HaslerTapeWindow : Window
ToolTip.SetTip(_startClock, App.Loc["HaslerStartClockTip"]);
ScrollViewer.SetHorizontalScrollBarVisibility(_list, ScrollBarVisibility.Disabled);
_list.KeyDown += async (_, e) =>
{
if (e.Key == Key.Enter && _list.SelectedItem is TreeViewItem { Tag: BlackBoxRecording box })
{
e.Handled = true;
await OpenBlackBoxAsync(box);
}
};
_list.SelectionChanged += async (_, _) =>
{
// A black box opens in a window of its own on a click or Enter, not when the arrows go over it; the tape shown stays.
if (_list.SelectedItem is TreeViewItem { Tag: BlackBoxRecording })
return;
// A session stands for its first tape, the player's own if it has one.
if (_list.SelectedItem is TreeViewItem { Tag: TapeSession } session)
session.IsExpanded = true;
@@ -306,8 +291,7 @@ public sealed class HaslerTapeWindow : Window
private static string Fingerprint(IEnumerable<TapeSession> sessions) =>
string.Join('\n', sessions.Select(session =>
$"{session.StartedUtc:O} {session.LastWrittenUtc:yyyyMMddHHmm} {session.Running} " +
string.Join(' ', session.Tapes.Select(tape => $"{tape.Vehicle}:{tape.Role}")) + " " +
string.Join(' ', session.BlackBoxes.Select(box => box.Path))));
string.Join(' ', session.Tapes.Select(tape => $"{tape.Vehicle}:{tape.Role}"))));
/// <summary>Sessions newest first, the newest one open until the reader has said otherwise.</summary>
private void ShowSessions(IReadOnlyList<TapeSession> sessions, bool toDriven)
@@ -320,7 +304,6 @@ public sealed class HaslerTapeWindow : Window
if (toDriven)
_followed = driven;
string? selected = toDriven && driven is not null ? driven : _shown?.Path;
FollowBlackBoxes(sessions);
foreach (var node in _list.Items.OfType<TreeViewItem>())
_expanded[((TapeSession)node.Tag!).StartedUtc] = node.IsExpanded;
@@ -331,7 +314,7 @@ public sealed class HaslerTapeWindow : Window
{
var node = SessionItem(sessions[i], _expanded.GetValueOrDefault(sessions[i].StartedUtc, i == 0));
_list.Items.Add(node);
foreach (var tape in node.Items.OfType<TreeViewItem>().Where(item => item.Tag is TapeFile))
foreach (var tape in node.Items.OfType<TreeViewItem>())
{
first ??= (node, tape);
if (((TapeFile)tape.Tag!).Path == selected)
@@ -354,7 +337,7 @@ public sealed class HaslerTapeWindow : Window
_list.SelectedItem = chosen.Tape;
}
private TreeViewItem SessionItem(TapeSession session, bool open)
private static TreeViewItem SessionItem(TapeSession session, bool open)
{
string title = session.Launch is { } launch
? string.Join(" · ", new[] { string.IsNullOrWhiteSpace(launch.SceneryName) ? launch.Scenery : launch.SceneryName, launch.Train }
@@ -389,65 +372,11 @@ public sealed class HaslerTapeWindow : Window
Tag = session,
IsExpanded = open
};
foreach (var box in session.BlackBoxes)
item.Items.Add(BlackBoxItem(box));
foreach (var tape in session.Tapes)
item.Items.Add(TapeItem(tape));
return item;
}
private TreeViewItem BlackBoxItem(BlackBoxRecording box)
{
var text = new StackPanel { Spacing = 1 };
text.Children.Add(new TextBlock { Text = App.Loc["HaslerBlackBox"], FontWeight = FontWeight.SemiBold });
text.Children.Add(new TextBlock
{
Text = $"{box.LastWriteUtc.ToLocalTime():HH:mm} · {box.Size / (1024.0 * 1024.0):0.0} MB",
FontSize = 11,
Opacity = 0.7
});
ToolTip.SetTip(text, App.Loc["HaslerBlackBoxTip"]);
var item = new TreeViewItem { Header = text, Tag = box };
// Also when it is the one picked already, and its window was closed since.
item.Tapped += async (_, _) => await OpenBlackBoxAsync(box);
return item;
}
/// <summary>The black box in its window, read on while the session runs; one window a file.</summary>
private async Task OpenBlackBoxAsync(BlackBoxRecording box)
{
if (_blackBoxes.TryGetValue(box.Path, out var open))
{
open.Activate();
return;
}
var window = new BlackBoxWindow(new StarterBlackBoxStrings())
{
CanOpenFiles = false,
Live = _liveBlackBoxes.Contains(box.Path)
};
_blackBoxes[box.Path] = window;
window.Closed += (_, _) => _blackBoxes.Remove(box.Path);
window.Show(this);
await window.OpenAsync(box.Path);
}
/// <summary>The newest black box of a running session is still written; once the session ends, its last part is read and that is all.</summary>
private void FollowBlackBoxes(IReadOnlyList<TapeSession> sessions)
{
_liveBlackBoxes = sessions.Where(session => session.Running)
.SelectMany(session => session.BlackBoxes.Take(1))
.Select(box => box.Path)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var (path, window) in _blackBoxes)
{
if (!window.Live || _liveBlackBoxes.Contains(path))
continue;
window.ReadMore();
window.Live = false;
}
}
private static TreeViewItem TapeItem(TapeFile file)
{
var text = new StackPanel { Spacing = 1 };

View File

@@ -1,21 +0,0 @@
using Eu07.BlackBox.Views;
namespace StarterNG.Views.Tapes;
/// <summary>
/// The texts of the black box window in the starter's language: a translation may give any of
/// them as BlackBox + its key; the rest are the window's own, in Polish or else in English.
/// </summary>
internal sealed class StarterBlackBoxStrings : IBlackBoxStrings
{
private readonly DefaultBlackBoxStrings _own = new(App.Loc.CurrentLangCode);
public string this[string key]
{
get
{
string text = App.Loc.Opt("BlackBox" + key);
return text.Length > 0 ? text : _own[key];
}
}
}

View File

@@ -850,8 +850,6 @@
<String key="HaslerSession">Relace</String>
<String key="HaslerSessionTapes">pásek: {0}</String>
<String key="HaslerSessionRunning">běží</String>
<String key="HaslerBlackBox">Černá skříňka</String>
<String key="HaslerBlackBoxTip">Celá relace na mapě: jízda oproti AI, povely, návěstidla, kabina</String>
<String key="WarehouseSaved">Ve skladu: {0}</String>
<String key="WarehouseNothingToAdd">Vyberte soupravu s vozidly</String>
<String key="GfxGroupLevelTip">Úroveň jen pro tuto část grafiky</String>

View File

@@ -1079,8 +1079,6 @@
<String key="HaslerSession">Session</String>
<String key="HaslerSessionTapes">tapes: {0}</String>
<String key="HaslerSessionRunning">running</String>
<String key="HaslerBlackBox">Black box</String>
<String key="HaslerBlackBoxTip">The whole session on a map: the driving against the AI, the commands, the signals, the cab</String>
<String key="WarehouseSaved">In the warehouse: {0}</String>
<String key="WarehouseNothingToAdd">Pick a consist with vehicles</String>
<String key="GfxGroupLevelTip">Level for this part of the graphics only</String>

View File

@@ -850,8 +850,6 @@
<String key="HaslerSession">Munkamenet</String>
<String key="HaslerSessionTapes">szalag: {0}</String>
<String key="HaslerSessionRunning">folyamatban</String>
<String key="HaslerBlackBox">Fekete doboz</String>
<String key="HaslerBlackBoxTip">A teljes menet a térképen: vezetés az AI-hoz képest, parancsok, jelzők, vezetőfülke</String>
<String key="WarehouseSaved">A raktárban: {0}</String>
<String key="WarehouseNothingToAdd">Válassz járműveket tartalmazó szerelvényt</String>
<String key="GfxGroupLevelTip">Csak a grafika ezen részének szintje</String>

View File

@@ -901,8 +901,6 @@
<String key="HaslerSession">Sesja</String>
<String key="HaslerSessionTapes">taśm: {0}</String>
<String key="HaslerSessionRunning">trwa</String>
<String key="HaslerBlackBox">Czarna skrzynka</String>
<String key="HaslerBlackBoxTip">Cała sesja na mapie: jazda względem AI, komendy, sygnalizatory, kabina</String>
<String key="WarehouseSaved">W magazynie: {0}</String>
<String key="WarehouseNothingToAdd">Wybierz skład z pojazdami</String>
<String key="GfxGroupLevelTip">Poziom tylko tej części grafiki</String>

View File

@@ -850,8 +850,6 @@
<String key="HaslerSession">Сеанс</String>
<String key="HaslerSessionTapes">лент: {0}</String>
<String key="HaslerSessionRunning">идёт</String>
<String key="HaslerBlackBox">Чёрный ящик</String>
<String key="HaslerBlackBoxTip">Вся сессия на карте: езда по сравнению с ИИ, команды, светофоры, кабина</String>
<String key="WarehouseSaved">На складе: {0}</String>
<String key="WarehouseNothingToAdd">Выберите состав с транспортом</String>
<String key="GfxGroupLevelTip">Уровень только для этой части графики</String>