diff --git a/Eu07.BlackBox.Viewer/Eu07.BlackBox.Viewer.csproj b/Eu07.BlackBox.Viewer/Eu07.BlackBox.Viewer.csproj
new file mode 100644
index 0000000..925103a
--- /dev/null
+++ b/Eu07.BlackBox.Viewer/Eu07.BlackBox.Viewer.csproj
@@ -0,0 +1,30 @@
+
+
+
+ WinExe
+ net9.0
+ enable
+ true
+ true
+ true
+ true
+ app.manifest
+ ..\StarterNG\Assets\starter.ico
+ BlackBoxViewer
+ Eu07.BlackBox.Viewer
+ 1.0
+ eu07.pl
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Eu07.BlackBox.Viewer/Program.cs b/Eu07.BlackBox.Viewer/Program.cs
new file mode 100644
index 0000000..c99429e
--- /dev/null
+++ b/Eu07.BlackBox.Viewer/Program.cs
@@ -0,0 +1,109 @@
+using System;
+using System.IO;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Styling;
+using Avalonia.Themes.Fluent;
+using Eu07.BlackBox.Views;
+
+namespace Eu07.BlackBox.Viewer;
+
+///
+/// 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.
+///
+internal static class Program
+{
+ [STAThread]
+ public static int Main(string[] args)
+ {
+ return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
+
+ public static AppBuilder BuildAvaloniaApp() =>
+ AppBuilder.Configure()
+ .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());
+ 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);
+ }
+}
diff --git a/Eu07.BlackBox.Viewer/Screenshot.cs b/Eu07.BlackBox.Viewer/Screenshot.cs
new file mode 100644
index 0000000..a982db2
--- /dev/null
+++ b/Eu07.BlackBox.Viewer/Screenshot.cs
@@ -0,0 +1,28 @@
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media.Imaging;
+using Eu07.BlackBox.Views;
+
+namespace Eu07.BlackBox.Viewer;
+
+/// The window as it shows a moment, saved to a PNG; then the viewer closes.
+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);
+ if (window.Content is Control content)
+ {
+ 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();
+ }
+}
diff --git a/Eu07.BlackBox.Viewer/app.manifest b/Eu07.BlackBox.Viewer/app.manifest
new file mode 100644
index 0000000..6a5568d
--- /dev/null
+++ b/Eu07.BlackBox.Viewer/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Eu07.BlackBox.Views/BlackBoxStrings.cs b/Eu07.BlackBox.Views/BlackBoxStrings.cs
new file mode 100644
index 0000000..06f150f
--- /dev/null
+++ b/Eu07.BlackBox.Views/BlackBoxStrings.cs
@@ -0,0 +1,435 @@
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace Eu07.BlackBox.Views;
+
+///
+/// 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.
+///
+public interface IBlackBoxStrings
+{
+ string this[string key] { get; }
+}
+
+public sealed class DefaultBlackBoxStrings : IBlackBoxStrings
+{
+ private static readonly Dictionary 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}",
+ ["LegendWithin"] = "within the AI's speed",
+ ["LegendSlightly"] = "up to {0:0} km/h over",
+ ["LegendOver"] = "more over",
+ ["LegendAutopilot"] = "autopilot",
+ ["LegendNoData"] = "no AI data",
+ ["TimelineSpeed"] = "speed",
+ ["TimelineTarget"] = "AI",
+ ["TimelineLimit"] = "next limit",
+ ["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",
+ ["DevDoorsRight"] = "Right doors",
+ ["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"] = "the tail {0:0.0} m 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",
+ ["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 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}",
+ ["LegendWithin"] = "w granicach prędkości AI",
+ ["LegendSlightly"] = "do {0:0} km/h ponad",
+ ["LegendOver"] = "więcej ponad",
+ ["LegendAutopilot"] = "autopilot",
+ ["LegendNoData"] = "brak danych AI",
+ ["TimelineSpeed"] = "prędkość",
+ ["TimelineTarget"] = "AI",
+ ["TimelineLimit"] = "następne ograniczenie",
+ ["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",
+ ["DevDoorsRight"] = "Drzwi prawe",
+ ["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"] = "koniec składu {0:0.0} m 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",
+ ["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 _texts;
+
+ public DefaultBlackBoxStrings() : this(CultureInfo.CurrentUICulture)
+ {
+ }
+
+ public DefaultBlackBoxStrings(CultureInfo culture) : this(culture.TwoLetterISOLanguageName)
+ {
+ }
+
+ /// Polish for "pl", English for any other language.
+ public DefaultBlackBoxStrings(string languageCode)
+ {
+ _texts = languageCode == "pl" ? Polish : English;
+ }
+
+ /// The keys there are texts for, for a translation to give them all.
+ public static IEnumerable Keys => English.Keys;
+
+ public string this[string key] => _texts.TryGetValue(key, out var text) ? text : English.GetValueOrDefault(key, key);
+}
diff --git a/Eu07.BlackBox.Views/BlackBoxWindow.cs b/Eu07.BlackBox.Views/BlackBoxWindow.cs
new file mode 100644
index 0000000..076376a
--- /dev/null
+++ b/Eu07.BlackBox.Views/BlackBoxWindow.cs
@@ -0,0 +1,569 @@
+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;
+
+///
+/// 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.
+///
+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"], _cab);
+ cabCard.Margin = new Thickness(0, UiKit.Gap, 0, 0);
+ var sideColumn = new DockPanel();
+ 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();
+ 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);
+ }
+
+ /// The button for opening another file: the starter opens the one picked in its list.
+ public bool CanOpenFiles
+ {
+ get => _open.IsVisible;
+ set => _open.IsVisible = value;
+ }
+
+ /// Reads what the simulator adds to the file every two seconds.
+ public bool Live
+ {
+ get => _live.IsChecked == true;
+ set => _live.IsChecked = value;
+ }
+
+ public string? FilePath => _path;
+
+ public Recording? Recording => _reader?.Recording;
+
+ public double CurrentTime => _time;
+
+ /// Goes to a moment of the recording, in seconds from its start.
+ public void SeekTo(double t) => SetTime(t);
+
+ /// Keeps the user's train in the middle of the map.
+ public bool FollowTrain
+ {
+ get => _follow.IsChecked == true;
+ set => _follow.IsChecked = value;
+ }
+
+ /// The timetable beside the map instead of the events.
+ public bool ShowTimetable
+ {
+ get => _tabs.Selected == 1;
+ set => _tabs.Selected = value ? 1 : 0;
+ }
+
+ /// The summary of the driving beside the map instead of the events.
+ 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();
+ }
+
+ /// Opens a black box, read whole on another thread.
+ 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 = "";
+ }
+
+ /// What the simulator added since the last read. Small, so read here, between frames.
+ 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);
+ }
+
+ /// How late the user's train was at the moment played, by the clock.
+ 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)));
+ }
+
+ /// The user's train at the moment under the pointer of the timeline, or at the moment played.
+ 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);
+ }
+}
diff --git a/Eu07.BlackBox.Views/CabPanel.cs b/Eu07.BlackBox.Views/CabPanel.cs
new file mode 100644
index 0000000..a5a9e45
--- /dev/null
+++ b/Eu07.BlackBox.Views/CabPanel.cs
@@ -0,0 +1,307 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace Eu07.BlackBox.Views;
+
+///
+/// The cab of the user's train at the moment played: the speed against the AI's, the
+/// accelerations, the controllers, the pressures of the brakes as gauges, the traction,
+/// the lights at the head and the tail of the train, and a lamp for every device, the
+/// alerter (CA) and the cab signalling (SHP) apart.
+///
+public sealed class CabPanel : Control
+{
+ private const double Pad = 14;
+ private const double LampRow = 16;
+ private const int LampColumns = 3;
+
+ private Recording? _recording;
+ private double _time;
+ private int _measured = -1;
+ private int _controllerTop = 1;
+
+ public CabPanel()
+ {
+ ClipToBounds = true;
+ Height = 428;
+ }
+
+ 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;
+
+ // 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;
+
+ // Who drives, from which cab of which vehicle.
+ double chipX = size.Width - Pad;
+ string name = recording.Vehicle(s.Vehicle)?.Name ?? "";
+ if (s.CabEnd != 0)
+ chipX -= ChipLeft(context, Strings[s.CabEnd > 0 ? "CabEndFront" : "CabEndRear"], Palette.Text, chipX, 10) + 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, 10) + 8;
+ var vehicle = MapView.Text(name, Palette.FaintText, 11);
+ if (chipX - vehicle.Width > Pad + 70)
+ context.DrawText(vehicle, new Point(chipX - vehicle.Width, 13));
+
+ // The speed, large, and what the AI would do.
+ double top = 38;
+ 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, 38, FontWeight.SemiBold);
+ context.DrawText(speed, new Point(Pad, top - 4));
+ context.DrawText(MapView.Text("km/h", Palette.DimText, 12), new Point(Pad + speed.Width + 4, top + speed.Height - 24));
+ if (s.AiValid)
+ {
+ context.DrawText(MapView.Text(string.Format(culture, Strings["CabAiTarget"], s.Ai.TargetKmh), Palette.Target, 12),
+ new Point(Pad, top + speed.Height - 4));
+ 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, top + speed.Height + 12));
+ }
+
+ // The accelerations, as needles from the middle: along the track and across it.
+ double gaugeX = Pad + Math.Max(150, width * 0.42);
+ double gaugeWidth = size.Width - Pad - gaugeX;
+ Balance(context, Strings["CabAcceleration"], s.Acceleration, 1.5, gaugeX, top + 2, gaugeWidth);
+ Balance(context, Strings["CabLateral"], s.LateralAcceleration, 1.5, gaugeX, top + 34, gaugeWidth);
+
+ // The controllers.
+ top = 112;
+ Divider(context, top - 8, size.Width);
+ double column = width / 4;
+ Value(context, Strings["CabController"], s.MainController.ToString(culture), Pad, top);
+ Steps(context, s.MainController, _controllerTop, Pad, top + 34, column - 14);
+ 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.
+ top = 160;
+ Divider(context, top - 6, size.Width);
+ Gauge(context, Strings["CabPipe"], s.BrakePipe, 6, 5, $"{s.BrakePipe:0.00} bar", Palette.Station, Pad, top, width);
+ Gauge(context, Strings["CabCylinder"], s.BrakeCylinder, 6, null, $"{s.BrakeCylinder:0.0} bar", Palette.Brakes, Pad, top + 18, width);
+ Gauge(context, Strings["CabReservoir"], s.MainReservoir, 10, null, $"{s.MainReservoir:0.0} bar", Palette.Controller, Pad, top + 36, width);
+ Gauge(context, Strings["CabLocalBrake"], s.LocalBrake, 1, null, $"{s.LocalBrake * 100:0} %", Palette.Brakes, Pad, top + 54, width);
+
+ // The traction and the lights at both ends of the train.
+ top = 242;
+ Divider(context, top - 6, size.Width);
+ double third = width * 0.62 / 3;
+ Value(context, Strings["CabVoltage"], $"{s.Voltage:0} V", Pad, top);
+ Value(context, Strings["CabCurrent"], $"{s.MotorCurrent:0} A", Pad + third, top);
+ Value(context, Strings["CabRpm"], $"{s.EngineRpm:0}", Pad + 2 * third, top);
+ double lightsX = Pad + 3 * third + 4;
+ context.DrawText(MapView.Text(Strings["CabLights"], Palette.DimText, 10), new Point(lightsX, top));
+ double faceX = lightsX;
+ foreach (var (key, lights, required) in new[]
+ {
+ ("CabLightsHead", s.LightsHead, HeadCodes.RequiredHead(s)),
+ ("CabLightsTail", s.LightsTail, HeadCodes.RequiredTail(s))
+ })
+ {
+ DrawFace(context, lights, new Point(faceX + 18, top + 24));
+ // the end, and the signal it shows: green as it is to be, red otherwise with the one it is to show
+ var shown = HeadCodes.Of(lights);
+ var text = required is { } want && want != shown
+ ? $"{HeadCodes.Name(shown)} ≠ {HeadCodes.Name(want)}"
+ : HeadCodes.Name(shown);
+ var brush = required is null ? Palette.FaintText : required == shown ? Palette.Good : Palette.Bad;
+ var label = MapView.Text($"{Strings[key]} {text}", brush, 9);
+ context.DrawText(label, new Point(faceX + 18 - label.Width / 2, top + 34));
+ faceX += 62;
+ }
+
+ // The lamps.
+ top = 292;
+ Divider(context, top - 6, size.Width);
+ int i = 0;
+ double lampColumn = width / LampColumns;
+ foreach (var (key, brush) in Lamps(s))
+ {
+ var at = new Point(Pad + i % LampColumns * lampColumn, top + i / LampColumns * LampRow);
+ Led(context, at + new Vector(5, 7), brush);
+ context.DrawText(MapView.Text(Strings[key], brush is null ? Palette.FaintText : Palette.Text, 10.5), at + new Vector(14, 0));
+ i++;
+ }
+ }
+
+ /// The highest step of the controller used in the recording, for its bar.
+ private void Measure(Recording recording)
+ {
+ if (_measured == recording.Revision)
+ return;
+ int top = 1;
+ foreach (var sample in recording.Cab)
+ top = Math.Max(top, sample.MainController);
+ _controllerTop = top;
+ _measured = recording.Revision;
+ }
+
+ private static void Divider(DrawingContext context, double y, double width) =>
+ context.DrawLine(new Pen(Palette.Grid, 1), new Point(Pad, y + 0.5), new Point(width - Pad, y + 0.5));
+
+ private static void Value(DrawingContext context, string label, string value, double x, double y)
+ {
+ context.DrawText(MapView.Text(label, Palette.DimText, 10), new Point(x, y));
+ context.DrawText(MapView.Text(value, Palette.Text, 14, FontWeight.SemiBold), new Point(x, y + 13));
+ }
+
+ /// The steps of the controller as a row of cells, lit up to the one it stands at.
+ 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);
+ }
+
+ /// A gauge: its name, a bar up to the value against the scale, a mark where it should stand, the value.
+ 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));
+ }
+
+ /// A value either way of nought: a bar from the middle, to the right above it.
+ 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));
+ }
+
+ /// A chip ending at x, its width returned.
+ 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;
+ }
+
+ /// The lights of an end as they sit on the front of a locomotive: one up in the middle, two below.
+ 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);
+ Lamp(context, center + new Vector(0, -8), lights.HasFlag(Lights.HeadlightUpper), false, false);
+ Lamp(context, center + new Vector(-10, 1), lights.HasFlag(Lights.HeadlightLeft) || lights.HasFlag(Lights.AuxiliaryLeft),
+ lights.HasFlag(Lights.RedMarkerLeft) || rearEnd, lights.HasFlag(Lights.HighBeamLeft));
+ Lamp(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 Lamp(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);
+ }
+
+ private static void Led(DrawingContext context, Point center, IBrush? lit)
+ {
+ 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);
+ }
+
+ /// Every device, with the colour of its lamp if it was on.
+ private static IEnumerable<(string Key, IBrush? Lit)> Lamps(CabSample s)
+ {
+ IBrush? On(CabDevices device, IBrush colour) => s.Has(device) ? colour : null;
+ IBrush? Flag(CabFlags flag, IBrush colour) => (s.Flags & flag) != 0 ? colour : null;
+ IBrush? Security(SecurityFlags flag, IBrush colour) => s.Has(flag) ? colour : null;
+
+ yield return ("DevBattery", On(CabDevices.Battery, Palette.LampOn));
+ yield return ("DevMainBreaker", On(CabDevices.MainBreaker, Palette.LampOn));
+ yield return ("DevConverter", On(CabDevices.Converter, Palette.LampOn));
+ yield return ("DevPantographFront", On(CabDevices.PantographFront, Palette.LampOn));
+ yield return ("DevPantographRear", On(CabDevices.PantographRear, Palette.LampOn));
+ yield return ("DevCompressor", On(CabDevices.Compressor, Palette.LampOn));
+ yield return ("DevLineContactors", On(CabDevices.LineContactors, Palette.LampOn));
+ yield return ("DevHeating", On(CabDevices.Heating, Palette.LampOn));
+ yield return ("DevRadio", On(CabDevices.Radio, Palette.LampOn));
+ yield return ("DevShuntMode", On(CabDevices.ShuntMode, Palette.LampOn));
+ yield return ("DevDoorsLeft", On(CabDevices.DoorsLeft, Palette.LampWarning));
+ yield return ("DevDoorsRight", On(CabDevices.DoorsRight, Palette.LampWarning));
+ yield return ("DevSanding", On(CabDevices.Sanding, Palette.LampWarning));
+ yield return ("DevHorn", On(CabDevices.Horn, Palette.LampWarning));
+ yield return ("DevSpringBrake", On(CabDevices.SpringBrake, Palette.LampWarning));
+ if (s.Security != SecurityFlags.None || s.CabEnd != 0)
+ {
+ // A recording which tells them apart.
+ yield return ("DevVigilance", Security(SecurityFlags.VigilanceBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.Vigilance, Palette.Vigilance));
+ yield return ("DevCabSignal", Security(SecurityFlags.CabSignalBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.CabSignal, Palette.CabSignal));
+ yield return ("DevSecurityBraking", Security(SecurityFlags.Braking, Palette.LampAlarm));
+ }
+ else
+ yield return ("DevAlerter", Flag(CabFlags.AlerterBeeping, Palette.LampAlarm) ?? Flag(CabFlags.AlerterBlinking, Palette.LampWarning));
+ yield return ("DevWheelSlip", Flag(CabFlags.WheelSlip, Palette.LampAlarm));
+ yield return ("DevOverloadRelay", On(CabDevices.OverloadRelay, Palette.LampAlarm));
+ yield return ("DevRadioStop", Flag(CabFlags.RadioStop, Palette.LampAlarm));
+ yield return ("DevAlarmChain", Flag(CabFlags.AlarmChain, Palette.LampAlarm));
+ }
+}
diff --git a/Eu07.BlackBox.Views/Eu07.BlackBox.Views.csproj b/Eu07.BlackBox.Views/Eu07.BlackBox.Views.csproj
new file mode 100644
index 0000000..53efe37
--- /dev/null
+++ b/Eu07.BlackBox.Views/Eu07.BlackBox.Views.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net9.0
+ enable
+ true
+ true
+ Eu07.BlackBox.Views
+
+
+
+
+
+
+
+
+
+
diff --git a/Eu07.BlackBox.Views/EventList.cs b/Eu07.BlackBox.Views/EventList.cs
new file mode 100644
index 0000000..201bd52
--- /dev/null
+++ b/Eu07.BlackBox.Views/EventList.cs
@@ -0,0 +1,299 @@
+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;
+
+/// One line of the list of events.
+public sealed record BlackBoxEvent(double Time, string Text, IBrush Brush, bool Important);
+
+///
+/// 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.
+///
+public sealed class EventList : ListBox
+{
+ private Recording? _recording;
+ private int _shownRevision = -1;
+ private List _events = new();
+ private bool _picking;
+
+ public event Action? 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 Events => _events;
+
+ /// Takes in what the recording gained; the list grows at its end, so what is scrolled to stays.
+ 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;
+ }
+
+ /// The event at or just before t picked, without going to it.
+ 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;
+ }
+
+ /// The events of the user's train, in the order they happened.
+ public static List Collect(Recording recording, IBlackBoxStrings strings)
+ {
+ var culture = CultureInfo.CurrentCulture;
+ var events = new List();
+ 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);
+ 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 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 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;
+ }
+ }
+ }
+
+ /// The camera of the user going to another mode: the cab, outside, free.
+ private static void AddCameras(Recording recording, IBlackBoxStrings strings, List 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"
+ };
+
+ private static void AddCouplings(Recording recording, IBlackBoxStrings strings, List 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));
+ }
+ }
+
+ /// What a coupling joins, in words.
+ public static string CouplingText(IBlackBoxStrings strings, CouplingFlags flags)
+ {
+ var parts = new List();
+ 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);
+ }
+}
diff --git a/Eu07.BlackBox.Views/MapView.cs b/Eu07.BlackBox.Views/MapView.cs
new file mode 100644
index 0000000..791647f
--- /dev/null
+++ b/Eu07.BlackBox.Views/MapView.cs
@@ -0,0 +1,756 @@
+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;
+
+///
+/// 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.
+///
+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;
+
+ /// The user moved the map away from the train: following is off.
+ 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();
+ }
+ }
+
+ /// Keeps the user's train in the middle.
+ 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();
+
+ /// Closer by the factor given, about the middle.
+ public void ZoomBy(double factor)
+ {
+ _scale = Math.Clamp(_scale * factor, MinScale, MaxScale);
+ InvalidateVisual();
+ }
+
+ /// The map as a whole in view.
+ 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 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);
+ }
+
+ /// The way the user's train went, a geometry for each judgement of its speed.
+ private void BuildTrail(Recording recording)
+ {
+ var lines = new List>[_trail.Length];
+ for (int i = 0; i < lines.Length; i++)
+ lines[i] = new List>();
+ 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 { 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);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ // 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(StringComparer.OrdinalIgnoreCase);
+ var taken = new List();
+ // 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));
+ }
+ }
+
+ /// The horn points (W6): a diamond, green where the user sounded the horn, red where not.
+ 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));
+ }
+ }
+
+ private 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;
+ }
+
+ /// Where the user looked from, when not from the cab: a dot with a wedge of where it looked.
+ 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);
+ }
+
+ /// Where the user's train passed signals; those at danger ringed red.
+ 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())
+ {
+ var p = ToScreen(passing.Position.X, passing.Position.Z);
+ if (!Visible(p))
+ continue;
+ if (passing.AtDanger)
+ context.DrawEllipse(null, danger, p, 10, 10);
+ else if (passing.Time <= _time)
+ 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);
+ }
+
+ /// The head of the user's train between its samples, ten a second, on top of everything.
+ 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));
+ }
+
+ /// Where the label of the user's train goes, by its mark.
+ 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, Strings["LegendOver"]),
+ (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));
+ }
+ var texts = rows.Select(row => Text(row.Text, Palette.DimText, 11)).Concat(marks.Select(m => Text(m.Text, Palette.DimText, 11))).ToList();
+ const double line = 17;
+ double width = texts.Max(t => t.Width) + 44, height = texts.Count * line + 14 + (marks.Count > 0 ? 6 : 0);
+ var box = new Rect(10, size.Height - height - 10, width, height);
+ context.DrawRectangle(Palette.Faded(Palette.Card, 0.9), new Pen(Palette.CardBorder, 1), box);
+ double y = box.Top + 7;
+ for (int i = 0; i < rows.Length; i++, y += line)
+ {
+ context.DrawLine(new Pen(rows[i].Brush, 4, lineCap: PenLineCap.Round), new Point(box.Left + 12, y + 8), new Point(box.Left + 26, y + 8));
+ context.DrawText(texts[i], new Point(box.Left + 34, y));
+ }
+ if (marks.Count > 0)
+ {
+ context.DrawLine(new Pen(Palette.Grid, 1), new Point(box.Left + 10, y + 3), new Point(box.Right - 10, y + 3));
+ y += 6;
+ }
+ for (int i = 0; i < marks.Count; i++, y += line)
+ {
+ var c = new Point(box.Left + 19, y + 8);
+ 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 + 34, y));
+ }
+ }
+
+ /// The name of the signal or the vehicle under the pointer.
+ 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));
+ }
+
+ /// An aspect as the speeds it gives: stop, a speed, or go.
+ 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();
+ }
+}
diff --git a/Eu07.BlackBox.Views/Palette.cs b/Eu07.BlackBox.Views/Palette.cs
new file mode 100644
index 0000000..91319b3
--- /dev/null
+++ b/Eu07.BlackBox.Views/Palette.cs
@@ -0,0 +1,113 @@
+using Avalonia.Media;
+
+namespace Eu07.BlackBox.Views;
+
+///
+/// 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.
+///
+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");
+ /// The headers of the panels, as the group boxes of the starter.
+ public static readonly IBrush Header = Brush("#2A3036");
+ /// The green of what is picked or on: bright for lines and text, deep for fills.
+ 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");
+
+ /// A delay: green on time, yellow a little late, red late, blue early.
+ 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
+ };
+
+ /// What a signal shows: the colour of its main light, as the in-game map draws it.
+ 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;
+ }
+
+ /// A colour seen through: for fills under lines and glows.
+ 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();
+}
diff --git a/Eu07.BlackBox.Views/TimelineView.cs b/Eu07.BlackBox.Views/TimelineView.cs
new file mode 100644
index 0000000..4e86af2
--- /dev/null
+++ b/Eu07.BlackBox.Views/TimelineView.cs
@@ -0,0 +1,596 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+
+namespace Eu07.BlackBox.Views;
+
+///
+/// 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.
+///
+public sealed class TimelineView : Control
+{
+ private const double Left = 56;
+ private const double Right = 12;
+ private const double Top = 22;
+ 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 int _measured = -1;
+ private Recording? _measuredOf;
+ private double _speedTop = 40;
+ private int _controllerTop = 1;
+
+ /// A moment was clicked.
+ public event Action? Seek;
+
+ /// The moment under the pointer, null once it leaves.
+ public event Action? 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();
+ }
+ }
+
+ /// All of the recording in view.
+ 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.Clamp(cursor - clock.Width / 2 - 5, Left, 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);
+ }
+
+ /// The top of the speed scale and the highest step of the controller, once a reading.
+ 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 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 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));
+ }
+ }
+
+ ///
+ /// Each curve by columns of pixels: in a column the lowest and the highest speed,
+ /// so an hour draws as fast as a minute.
+ ///
+ private void DrawCurves(DrawingContext context, Recording recording, Func 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();
+ 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;
+ }
+
+ /// The harder the brakes hold, the deeper the red.
+ private static IBrush BrakeShade(float cylinderBar) =>
+ BrakeShades[(int)Math.Round(Math.Clamp(cylinderBar / 5.0, 0, 1) * (BrakeShades.Length - 1))];
+
+ ///
+ /// 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.
+ ///
+ 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));
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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)
+ {
+ double x = Left + 4;
+ foreach (var (brush, key) in new[] { (Palette.Speed, "TimelineSpeed"), (Palette.Target, "TimelineTarget"), (Palette.Limit, "TimelineLimit") })
+ {
+ context.DrawLine(new Pen(brush, 2), new Point(x, 10), new Point(x + 14, 10));
+ var text = MapView.Text(Strings[key], Palette.DimText, 10);
+ context.DrawText(text, new Point(x + 18, 10 - text.Height / 2));
+ x += 18 + text.Width + 14;
+ }
+ }
+
+ 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;
+ 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;
+ 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;
+ }
+
+ /// A line through columns of pixels: in each the first, the lowest, the highest and the last value.
+ private sealed class Curve
+ {
+ private readonly List> _lines = new();
+ private List? _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());
+ 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));
+ }
+
+ /// The area under the line down to the bottom given, for a soft fill.
+ 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;
+ }
+ }
+}
diff --git a/Eu07.BlackBox.Views/TimetablePanel.cs b/Eu07.BlackBox.Views/TimetablePanel.cs
new file mode 100644
index 0000000..625c2f4
--- /dev/null
+++ b/Eu07.BlackBox.Views/TimetablePanel.cs
@@ -0,0 +1,269 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+
+namespace Eu07.BlackBox.Views;
+
+///
+/// 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.
+///
+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 _rows = Array.Empty();
+ private Timetable? _timetable;
+ private int _hover = -1;
+
+ public event Action? 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();
+ _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);
+
+ /// The row of the station the train was at, or made for, at the moment played.
+ 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;
+ }
+ }
+ }
+
+ /// What happened at a station, each part with its colour.
+ 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));
+ }
+}
diff --git a/Eu07.BlackBox.Views/UiKit.cs b/Eu07.BlackBox.Views/UiKit.cs
new file mode 100644
index 0000000..0fbbd72
--- /dev/null
+++ b/Eu07.BlackBox.Views/UiKit.cs
@@ -0,0 +1,283 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace Eu07.BlackBox.Views;
+
+///
+/// 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.
+///
+internal static class UiKit
+{
+ /// The gutter between the panels.
+ public const double Gap = 6;
+ public static readonly CornerRadius Small = new(2);
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// A panel with its title on a bar, as the group boxes of the starter; what goes right of the title, if anything.
+ 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);
+ }
+
+ /// A panel in the frame of the starter's group boxes, without a title.
+ public static Border Frame(Control content) => new()
+ {
+ Background = Palette.Panel,
+ BorderBrush = Palette.CardBorder,
+ BorderThickness = new Thickness(1),
+ CornerRadius = Small,
+ ClipToBounds = true,
+ Child = content
+ };
+
+ /// A flat panel the views sit on.
+ public static Border Card(Control content, Thickness? padding = null) => new()
+ {
+ Background = Palette.Card,
+ Padding = padding ?? new Thickness(0),
+ ClipToBounds = true,
+ Child = content
+ };
+
+ /// A button of the toolbar: text on the bar, a lighter ground under the pointer.
+ 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;
+ }
+
+ /// The button of playing.
+ 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;
+ }
+
+ /// The glyph of play or pause.
+ 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
+ };
+ }
+
+ /// A toggle of the toolbar: on, its text takes the accent.
+ 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;
+ }
+ }
+
+ /// A line of the table of figures: what it is on the left, its value on the right, a note under it.
+ 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);
+ }
+ }
+
+ /// Tabs side by side as the starter's: the one on on the panel's ground, with a bar of the accent on top.
+ public sealed class Segmented : Border
+ {
+ private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal };
+ private int _selected = -1;
+
+ public event Action? 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);
+ }
+ }
+ }
+}
diff --git a/Eu07.BlackBox/BlackBoxReader.cs b/Eu07.BlackBox/BlackBoxReader.cs
new file mode 100644
index 0000000..f241da5
--- /dev/null
+++ b/Eu07.BlackBox/BlackBoxReader.cs
@@ -0,0 +1,320 @@
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.IO;
+using System.Numerics;
+using System.Text;
+
+namespace Eu07.BlackBox;
+
+///
+/// Reads a black box into a , 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.
+///
+public sealed class BlackBoxReader
+{
+ public static ReadOnlySpan 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();
+
+ /// Bytes taken so far: the header and the whole records.
+ public long Offset => _offset;
+
+ /// Opens the file shared, as the simulator may be writing it, and reads what is new.
+ public int ReadMore(string path)
+ {
+ using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
+ return ReadMore(stream);
+ }
+
+ /// Reads the whole records added since the last read. Returns how many were read.
+ /// Not a black box, or of a version this reader doesn't know.
+ 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;
+ Recording.Changed();
+ return read;
+ }
+
+ /// Reads a whole file at once.
+ 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;
+ }
+
+ /// Whether the bytes start as a black box does.
+ public static bool LooksLikeBlackBox(ReadOnlySpan start) => start.Length >= Magic.Length && start[..Magic.Length].SequenceEqual(Magic);
+
+ private void ReadHeader(ReadOnlySpan 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() };
+ 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;
+ }
+ }
+
+ /// The fields of a record in the order they come, little-endian. A field past the end throws.
+ private ref struct PayloadReader
+ {
+ private readonly ReadOnlySpan _bytes;
+ private int _at;
+
+ public PayloadReader(ReadOnlySpan bytes)
+ {
+ _bytes = bytes;
+ _at = 0;
+ }
+
+ public int Remaining => _bytes.Length - _at;
+
+ private ReadOnlySpan 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());
+
+ /// UTF-8 after its length in 7 bit groups, low first, as BinaryWriter writes a string.
+ 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));
+ }
+ }
+}
diff --git a/Eu07.BlackBox/DrivingSummary.cs b/Eu07.BlackBox/DrivingSummary.cs
new file mode 100644
index 0000000..a95f8f2
--- /dev/null
+++ b/Eu07.BlackBox/DrivingSummary.cs
@@ -0,0 +1,196 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Eu07.BlackBox;
+
+/// How a sample of the user's train stands against what the AI would have done.
+public enum SpeedJudgement
+{
+ /// Nothing to set it against: the consist not started, or the user out of the cab.
+ NoData,
+ /// The AI drove.
+ Autopilot,
+ /// At most the speed the AI would hold.
+ Within,
+ /// Faster, by no more than the tolerance.
+ Slightly,
+ /// Faster by more than the tolerance.
+ Over
+}
+
+/// What the user's driving came to, set against the AI's.
+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 PassedAtDanger,
+ int Commands,
+ int AutopilotSwitches,
+ float MaxAcceleration,
+ float MaxDeceleration,
+ float MaxLateralAcceleration,
+ int Stops,
+ int StopsMissed,
+ double? MaxDelayMinutes,
+ int HornPoints,
+ int HornsMissed,
+ int SecurityBrakings)
+{
+ /// The largest delay the user's train left a station or passed it with [min], null without any.
+ public double? MaxDepartureDelayMinutes { get; init; }
+
+ /// How long the train moved with its light signals known to be right or wrong [s].
+ public double LightsJudgedSeconds { get; init; }
+
+ /// How long of that its head or its tail showed other signals than it was to [s].
+ public double WrongLightsSeconds { get; init; }
+
+ /// Each stretch the train moved with the wrong signals, as they were.
+ public IReadOnlyList LightsFaults { get; init; } = Array.Empty();
+
+ /// The stops where the train did not fit along the platform, and how far its tail stood off it [m].
+ public IReadOnlyList<(string Station, double Metres)> OffPlatform { get; init; } = Array.Empty<(string, double)>();
+
+ /// [km/h] over the speed of the AI before it counts as well over.
+ public const float DefaultTolerance = 5f;
+
+ ///
+ /// 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.
+ ///
+ 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();
+ // 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 train moves
+ if (sample.AbsSpeedKmh > 1 && HeadCodes.Right(sample) is { } right)
+ {
+ lightsJudged += step;
+ if (!right)
+ {
+ lightsWrong += step;
+ var fault = new LightsFault(before.Time, sample.Time, HeadCodes.Of(sample.LightsHead), HeadCodes.RequiredHead(sample)!.Value,
+ HeadCodes.Of(sample.LightsTail), HeadCodes.RequiredTail(sample)!.Value);
+ 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().ToList();
+ var departures = stations.Select(s => s.DepartureDelay).OfType().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
+ };
+ }
+}
+
+/// A stretch the user's train moved with other light signals than it was to show, at its head and its tail.
+public sealed record LightsFault(double Start, double End, HeadCode Head, HeadCode HeadRequired, HeadCode Tail, HeadCode TailRequired)
+{
+ public bool HeadWrong => Head != HeadRequired;
+ public bool TailWrong => Tail != TailRequired;
+
+ public bool SameAs(LightsFault other) =>
+ Head == other.Head && HeadRequired == other.HeadRequired && Tail == other.Tail && TailRequired == other.TailRequired;
+}
diff --git a/Eu07.BlackBox/Eu07.BlackBox.csproj b/Eu07.BlackBox/Eu07.BlackBox.csproj
new file mode 100644
index 0000000..9f84149
--- /dev/null
+++ b/Eu07.BlackBox/Eu07.BlackBox.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net9.0
+ enable
+ true
+ true
+ true
+ $(NoWarn);CS1591
+ Eu07.BlackBox
+
+
diff --git a/Eu07.BlackBox/HeadCodes.cs b/Eu07.BlackBox/HeadCodes.cs
new file mode 100644
index 0000000..e4657f4
--- /dev/null
+++ b/Eu07.BlackBox/HeadCodes.cs
@@ -0,0 +1,91 @@
+using System;
+
+namespace Eu07.BlackBox;
+
+/// What the driver of the user's train went by: the lights it is to show depend on it.
+public enum Movement : byte
+{
+ /// No driver, or standing by.
+ Other = 0,
+ /// A train run: Pc 1 or Pc 2 at the head, Pc 5 at the tail.
+ Train = 1,
+ /// Shunting: Tb 1 at both ends.
+ Shunting = 2,
+ /// A recording made before it was written.
+ Unknown = 0xFF
+}
+
+/// The light signals at an end of a train.
+public enum HeadCode
+{
+ Off,
+ /// The head of a train: both headlights and the upper one.
+ Pc1,
+ /// The head of a train on the wrong track: the left red marker, the right headlight and the upper one.
+ Pc2,
+ /// The tail of a train: both red markers.
+ Pc5,
+ /// Shunting: one headlight.
+ Tb1,
+ /// Any other pattern.
+ Other
+}
+
+/// The light signals a train shows and those it is to show, as the simulator's driver sets them.
+public static class HeadCodes
+{
+ private const Lights Signals = Lights.HeadlightLeft | Lights.RedMarkerLeft | Lights.HeadlightUpper | Lights.HeadlightRight |
+ Lights.RedMarkerRight | Lights.RearEndSignals;
+
+ /// The signal the lamps given show; the high beams and the auxiliary lamps tell nothing.
+ 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
+ };
+ }
+
+ /// The signal the head of the train is to show, null when nothing tells it.
+ 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
+ };
+
+ /// The signal the tail of the train is to show, null when nothing tells it.
+ 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
+ };
+
+ /// Whether both ends showed what they were to while the train moved; null when nothing tells it.
+ public static bool? Right(CabSample sample)
+ {
+ if (RequiredHead(sample) is not { } head || RequiredTail(sample) is not { } tail)
+ return null;
+ return Of(sample.LightsHead) == head && Of(sample.LightsTail) == tail;
+ }
+
+ /// The name of a signal as the rules give it.
+ 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 => "—",
+ _ => "?"
+ };
+}
diff --git a/Eu07.BlackBox/Recording.cs b/Eu07.BlackBox/Recording.cs
new file mode 100644
index 0000000..c9694e2
--- /dev/null
+++ b/Eu07.BlackBox/Recording.cs
@@ -0,0 +1,492 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+
+namespace Eu07.BlackBox;
+
+/// A span of time, start included, end not.
+public readonly record struct TimeSpanRange(double Start, double End)
+{
+ public double Length => End - Start;
+ public bool Contains(double t) => t >= Start && t < End;
+}
+
+///
+/// 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.
+///
+public sealed class Recording
+{
+ private readonly List _tracks = new();
+ private readonly Dictionary> _trackIndex = new();
+ private readonly List _signals = new();
+ private readonly Dictionary _signalIndex = new();
+ private readonly Dictionary _vehicles = new();
+ private readonly List _cab = new();
+ private readonly List _looks = new();
+ private readonly Dictionary> _history = new();
+ private readonly List _switches = new();
+ private readonly Dictionary> _switchIndex = new();
+ private readonly List _aspects = new();
+ private readonly Dictionary> _aspectIndex = new();
+ private readonly List _commands = new();
+ private readonly List _driverChanges = new();
+ private readonly List _passings = new();
+ private readonly List _userVehicles = new();
+ private readonly List _clocks = new();
+ private readonly List _stopPoints = new();
+ private readonly List _hornPoints = new();
+ private readonly List _timetables = new();
+ private readonly List _stations = new();
+ private readonly List _hornRequests = new();
+ private readonly List _cameras = new();
+ private readonly List _couplings = new();
+ private double _lastTime;
+
+ public int Version { get; internal set; }
+ public RecordingInfo? Info { get; internal set; }
+
+ /// The time of the end record: null while the simulator still writes, or if it didn't end the recording.
+ public double? EndTime { get; internal set; }
+
+ public bool Complete => EndTime is not null;
+
+ /// Records of types this reader doesn't know, passed over.
+ public int Unknown { get; internal set; }
+
+ /// Records shorter than their type says, passed over.
+ public int Malformed { get; internal set; }
+
+ /// Goes up with every read that added something, for views to tell they have to look again.
+ public int Revision { get; private set; }
+
+ public IReadOnlyList Tracks => _tracks;
+ public IReadOnlyList Signals => _signals;
+ public IReadOnlyDictionary Vehicles => _vehicles;
+ public IReadOnlyList Cab => _cab;
+ public IReadOnlyList Looks => _looks;
+ public IReadOnlyList Switches => _switches;
+ public IReadOnlyList Aspects => _aspects;
+ public IReadOnlyList Commands => _commands;
+ public IReadOnlyList DriverChanges => _driverChanges;
+ public IReadOnlyList Passings => _passings;
+ public IReadOnlyList UserVehicles => _userVehicles;
+ public IReadOnlyList Clocks => _clocks;
+ public IReadOnlyList StopPoints => _stopPoints;
+ public IReadOnlyList HornPoints => _hornPoints;
+ public IReadOnlyList Timetables => _timetables;
+ public IReadOnlyList Stations => _stations;
+ public IReadOnlyList HornRequests => _hornRequests;
+ public IReadOnlyList Cameras => _cameras;
+ public IReadOnlyList Couplings => _couplings;
+
+ /// The time the recording covers so far.
+ public double Duration => Math.Max(EndTime ?? 0, _lastTime);
+
+ /// The seconds between two looks at the vehicles.
+ public double LookInterval => Info is { VehiclesInterval: > 0 } info ? info.VehiclesInterval : 1.0;
+
+ // ----- filled by the reader -----
+
+ internal void Changed() => ++Revision;
+
+ 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(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();
+ 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();
+ 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 TrackWays(int index) =>
+ _trackIndex.TryGetValue(index, out var ways) ? ways : Array.Empty();
+
+ public MapSignal? Signal(int index) => _signalIndex.GetValueOrDefault(index);
+
+ public VehicleInfo? Vehicle(int id) => _vehicles.GetValueOrDefault(id);
+
+ /// The last of a list sorted by time at or before t: -1 if none is.
+ private static int LastAtOrBefore(IReadOnlyList list, double t, Func 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;
+ }
+
+ /// The index of the sample of the user's train at or before t, -1 before the first.
+ public int CabIndexAt(double t) => LastAtOrBefore(_cab, t, s => s.Time);
+
+ /// The sample of the user's train at or before t.
+ public CabSample? CabAt(double t) => CabIndexAt(t) is var i and >= 0 ? _cab[i] : null;
+
+ ///
+ /// 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.
+ ///
+ 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));
+ }
+
+ ///
+ /// 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.
+ ///
+ public IReadOnlyList VehiclesAt(double t)
+ {
+ var states = new List(_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
+ };
+ }
+
+ /// The aspect a signal showed at t, null before it was first seen.
+ 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;
+
+ /// The position of a switch at t, -1 if unknown.
+ 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;
+
+ /// The time of day of the simulation at t.
+ 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);
+ }
+
+ /// The vehicle the user was in at t, and whether in its cab; null before it was known.
+ public UserVehicleChange? UserVehicleAt(double t) =>
+ LastAtOrBefore(_userVehicles, t, c => c.Time) is var i and >= 0 ? _userVehicles[i] : null;
+
+ /// The stretches of the samples of the user's train the condition holds over.
+ public IReadOnlyList CabSpans(Func condition)
+ {
+ var spans = new List();
+ 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;
+ }
+
+ /// When the AI drove the user's train: the autopilot.
+ public IReadOnlyList AutopilotSpans() => CabSpans(s => s.Driving == Driver.Ai);
+
+ /// The signals the user's train passed: those passed by the vehicle the samples are of at the time.
+ public IEnumerable UserPassings() =>
+ _passings.Where(p => IsUserTrain(p.Time + 0.5, p.Vehicle, p.Position, Math.Max(0, p.Overrun) + 250));
+
+ ///
+ /// 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.
+ ///
+ 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 _couplings.Count > 0 ? ConsistAt(s.Vehicle, t).Contains(vehicle) : s.Position.DistanceTo(place) <= reach;
+ }
+
+ /// The vehicles coupled into one train with the one given at t, itself included.
+ public IReadOnlySet ConsistAt(int vehicle, double t)
+ {
+ // What each coupler held at t: the couplings in the order written, each telling both ends.
+ var held = new Dictionary<(int Vehicle, int Side), (int Other, int OtherSide)>();
+ foreach (var change in _couplings)
+ {
+ if (change.Time > t)
+ break;
+ 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 { vehicle };
+ var open = new Stack();
+ 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;
+ }
+
+ /// Where the user looked from at t, null before it was known.
+ public CameraState? CameraAt(double t) =>
+ LastAtOrBefore(_cameras, t, c => c.Time) is var i and >= 0 ? _cameras[i] : null;
+
+ ///
+ /// The couplings made and undone after the start: those at t = 0 are what the trains
+ /// were made of, not something done.
+ ///
+ public IEnumerable CouplingEvents() =>
+ _couplings.Where(c => c.Time > 0.01 && (c.Coupled || c.Uncoupled || c.Flags != c.PreviousFlags));
+
+ /// The couplings the user's train took part in, before or after them.
+ public IEnumerable UserCouplings() =>
+ 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)));
+
+ ///
+ /// 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.
+ ///
+ public Timetable? TimetableAt(double t)
+ {
+ var timetable = LastAtOrBefore(_timetables, t, x => x.Time) is var i and >= 0 ? _timetables[i] : _timetables.FirstOrDefault();
+ if (timetable is null || CabAt(t) is not { } sample || sample.Vehicle == timetable.Vehicle)
+ 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;
+ }
+
+ /// The stations of the timetable of the user's train, as it came to them.
+ public IEnumerable UserStations() =>
+ _stations.Where(s => IsUserTrain(s.Time, s.Vehicle, s.Position, Math.Max(s.TrainLength, 50) + 150));
+
+ /// The horn points the user's train passed, and whether the horn sounded there.
+ public IEnumerable UserHornChecks()
+ {
+ 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);
+ }
+ }
+
+ /// The time given to sound the horn after a horn point, beyond how long it is to sound.
+ public const double HornGrace = 8;
+
+ /// Whether the horn of the user's train sounded from just before the horn point until a while after.
+ 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;
+ }
+
+ /// When the train protection braked the user's train: the alerter or the cab signalling not acknowledged.
+ public IReadOnlyList SecurityBrakings() => CabSpans(s => s.Has(SecurityFlags.Braking));
+
+ /// The commands the user gave in a vehicle, presses only.
+ public IEnumerable UserCommands() => _commands.Where(c => c.Pressed && c.Vehicle != 0);
+
+ public DrivingSummary Summarize(float tolerance = DrivingSummary.DefaultTolerance) => DrivingSummary.Of(this, tolerance);
+}
+
+/// A horn point the user's train passed: whether the horn sounded, and whether the autopilot drove then.
+public sealed record HornCheck(HornRequest Request, bool Sounded, bool Autopilot);
diff --git a/Eu07.BlackBox/Records.cs b/Eu07.BlackBox/Records.cs
new file mode 100644
index 0000000..00f4cc4
--- /dev/null
+++ b/Eu07.BlackBox/Records.cs
@@ -0,0 +1,355 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace Eu07.BlackBox;
+
+/// The types of the records, as the simulator writes them (blackbox_doc.md in its repository).
+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
+}
+
+/// Who drives a vehicle.
+public enum Driver : byte
+{
+ None = 0,
+ Human = 1,
+ Ai = 2
+}
+
+[Flags]
+public enum CabFlags : byte
+{
+ None = 0,
+ /// The user is in the cab, not looking around outside.
+ InCab = 0x01,
+ /// The consist is started, so what the AI would do is worked out.
+ AiValid = 0x02,
+ /// The alerter or the cab signalling blinks.
+ AlerterBlinking = 0x04,
+ /// The alerter or the cab signalling beeps.
+ AlerterBeeping = 0x08,
+ WheelSlip = 0x10,
+ RadioStop = 0x20,
+ AlarmChain = 0x40
+}
+
+/// What is switched on in the user's train.
+[Flags]
+public enum CabDevices : uint
+{
+ None = 0,
+ Battery = 0x0001,
+ MainBreaker = 0x0002,
+ PantographFront = 0x0004,
+ PantographRear = 0x0008,
+ Converter = 0x0010,
+ Compressor = 0x0020,
+ /// The motors are connected.
+ LineContactors = 0x0040,
+ /// Tripped.
+ OverloadRelay = 0x0080,
+ Sanding = 0x0100,
+ Horn = 0x0200,
+ DoorsLeft = 0x0400,
+ DoorsRight = 0x0800,
+ Radio = 0x1000,
+ Heating = 0x2000,
+ /// Applied.
+ SpringBrake = 0x4000,
+ ShuntMode = 0x8000
+}
+
+/// The train protection: the alerter (CA) and the cab signalling (SHP) apart.
+[Flags]
+public enum SecurityFlags : byte
+{
+ None = 0,
+ /// The alerter lamp blinks: the driver is to acknowledge.
+ Vigilance = 0x01,
+ /// The cab signalling lamp blinks: an SHP magnet was passed.
+ CabSignal = 0x02,
+ VigilanceBeeping = 0x04,
+ CabSignalBeeping = 0x08,
+ /// Not acknowledged in time: the train protection brakes the train.
+ Braking = 0x10
+}
+
+/// What a train did at a station of its timetable.
+public enum StationKind : byte
+{
+ /// Stopped at the stop point of a stop of its timetable.
+ Stopped = 1,
+ /// Passed a station where the timetable has no stop.
+ Passed = 2,
+ /// Left the stop point behind before it was time to go: without stopping, or leaving early.
+ Missed = 3,
+ /// Still at the stop point when it was time to go.
+ Ready = 4
+}
+
+/// The lights lit at an end of a vehicle.
+[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,
+ /// A shunting signal.
+ Shunt = 0x01,
+ /// Passed showing stop: a signal passed at danger.
+ AtStop = 0x02,
+ /// Stop of an automatic block signal, which may be passed slowly after a stop.
+ AutomaticBlock = 0x04
+}
+
+public enum TrackKind : byte
+{
+ Normal = 0,
+ SwitchBranch = 1
+}
+
+/// A position in the frame of the scenery: metres, y up.
+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)
+{
+ /// The time of day the simulation started at.
+ public TimeSpan StartClock => TimeSpan.FromSeconds(SecondOfDay);
+}
+
+/// One rail track of the map, or one of the two ways of a switch, which share the number.
+public sealed record MapTrack(int Index, string Name, TrackKind Kind, int Branch, int Category,
+ IReadOnlyList Points);
+
+public sealed record MapSignal(int Index, string Name, Position Position);
+
+///
+/// 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 ).
+///
+public sealed record MapStopPoint(int Index, string Station, Position Position, float StopAt, float Platform)
+{
+ ///
+ /// 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.
+ ///
+ public float Heading { get; init; } = float.NaN;
+
+ public bool HasHeading => !float.IsNaN(Heading);
+
+ public bool HasPlatform => Platform > 0;
+
+ /// A W32: the stop of the trains shorter than its length; the longer ones go on to another stop point.
+ public bool ForShortTrains => Platform < 0;
+
+ /// The length of the platform, or of the trains a W32 is for [m].
+ public double PlatformLength => Platforms.Length(Platform);
+}
+
+///
+/// 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.
+///
+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;
+}
+
+/// A horn point (W6): the trains passing it are to sound the horn this long, in this tone (1 low, 2 high).
+public sealed record MapHornPoint(int Index, string Name, Position Position, float Duration, float Tone);
+
+/// A station of a timetable; the times in minutes of the day, below 0 none.
+public sealed record TimetableRow(string Station, float Km, float Vmax, float Arrival, float Departure, bool Maintenance)
+{
+ /// The train stops there: a station passed through has a departure only.
+ public bool IsStop => Arrival >= 0;
+}
+
+/// The timetable of the train the user was in. The rows are numbered from 1, the station records give their number.
+public sealed record Timetable(double Time, int Vehicle, string Train, string Category, string From, string To, float Vmax,
+ IReadOnlyList Rows)
+{
+ public TimetableRow? Row(int number) => number >= 1 && number <= Rows.Count ? Rows[number - 1] : null;
+}
+
+///
+/// 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.
+///
+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)
+{
+ /// How far from the place it was to stop the head stood: above 0 short of it, below 0 past it.
+ public float StopError => Distance - StopAt;
+
+ ///
+ /// 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.
+ ///
+ public bool? OnPlatform => Platform != 0 ? Distance >= -2 && Distance + TrainLength <= PlatformLength + 2 : null;
+
+ /// The length of the platform [m], without the digit of its side.
+ public double PlatformLength => Platforms.Length(Platform);
+
+ /// How far the tail stood off the platform [m], 0 along it.
+ public double OffPlatform => Platform != 0 ? Math.Max(0, Distance + TrainLength - PlatformLength) : 0;
+}
+
+public enum CameraMode : byte
+{
+ /// From the cab of the vehicle the user is in.
+ Cab = 0,
+ /// One of the outside views of the user's train.
+ External = 1,
+ /// The free camera.
+ Free = 2,
+ Debug = 3
+}
+
+/// Where the user looked from. Heading as that of a vehicle: towards (sin, cos) in (x, z).
+public readonly record struct CameraState(double Time, CameraMode Mode, int View, int Vehicle, Position Position,
+ float Heading, float Pitch);
+
+/// What a coupling joins: the bits of the simulator's coupling.
+[Flags]
+public enum CouplingFlags : byte
+{
+ None = 0,
+ Coupler = 0x01,
+ BrakePipe = 0x02,
+ Control = 0x04,
+ Power = 0x08,
+ Gangway = 0x10,
+ MainPipe = 0x20,
+ Heating = 0x40,
+ /// Not undone in shunting: between the cars of a unit.
+ Permanent = 0x80
+}
+
+/// A coupler coupled, uncoupled, or its hoses and cables changed. Side 0 the front coupler, 1 the rear one.
+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;
+}
+
+/// A train told to sound the horn at a horn point.
+public readonly record struct HornRequest(double Time, int Vehicle, float Duration, float Tone, Position Position);
+
+/// A vehicle as it is first seen. Id 0 is no vehicle.
+public sealed record VehicleInfo(int Id, string Name, string Type, string Train, float Length, float Width,
+ int Category)
+{
+ public double? RemovedAt { get; internal set; }
+}
+
+/// What the AI driver would do at that moment, from that place and at that speed.
+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;
+}
+
+/// One look at the user's train, ten a second.
+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)
+{
+ 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);
+}
+
+/// One vehicle in a look at the vehicles.
+public readonly record struct VehicleState(int Id, Vector3 Position, float Heading, float SpeedKmh, Driver Driving);
+
+/// A look at the vehicles: those which moved, or all of them in a keyframe.
+public sealed record VehicleLook(double Time, bool Keyframe, IReadOnlyList States);
+
+public readonly record struct SwitchChange(double Time, int Track, int State);
+
+/// What a signal shows: the speeds its memory cell holds, 0 stop, -1 no limit, and the command.
+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)
+{
+ /// No signal of the map: the passing came from an event the map doesn't hold.
+ public const int NoSignal = -1;
+
+ public bool AtDanger => (Flags & PassingFlags.AtStop) != 0;
+}
+
+/// The vehicle the user was in, whether in its cab, and which cab: 1 the front one, -1 the rear one, 0 none.
+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);
diff --git a/Eu07.BlackBox/StationNames.cs b/Eu07.BlackBox/StationNames.cs
new file mode 100644
index 0000000..c8e43a8
--- /dev/null
+++ b/Eu07.BlackBox/StationNames.cs
@@ -0,0 +1,13 @@
+namespace Eu07.BlackBox;
+
+/// The names of the stations as shown: the scenery tells the stop points of one station apart after a #.
+public static class StationNames
+{
+ /// The name up to a #, with the underscores of the scenery as spaces.
+ public static string Display(string name)
+ {
+ int hash = name.IndexOf('#');
+ var shown = hash >= 0 ? name[..hash] : name;
+ return shown.Replace('_', ' ').Trim();
+ }
+}
diff --git a/Eu07.BlackBox/TimetableProgress.cs b/Eu07.BlackBox/TimetableProgress.cs
new file mode 100644
index 0000000..179a60b
--- /dev/null
+++ b/Eu07.BlackBox/TimetableProgress.cs
@@ -0,0 +1,185 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Eu07.BlackBox;
+
+/// How the user's train kept to a station of its timetable.
+public enum StationOutcome
+{
+ /// Not reached yet in the recording.
+ Ahead,
+ /// Stopped where the timetable has a stop.
+ Stopped,
+ /// Passed where the timetable has no stop.
+ Passed,
+ /// Left a stop behind without stopping, or before the departure.
+ Missed
+}
+
+/// How late the user's train was at a moment [min, above 0 late], after the station named, standing there or not.
+public sealed record TrainDelay(string Train, double Minutes, string Station, bool Standing);
+
+///
+/// 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.
+///
+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);
+
+ /// Of the departure, or of the pass for a station passed through.
+ public double? DepartureDelay => Delay(DepartureClock, Row.Departure);
+
+ /// Above 0 the head stood short of where it was to stop, below 0 past it [m].
+ public float? StopError => Outcome == StationOutcome.Stopped && Event is { } e ? e.StopError : null;
+
+ public bool? OnPlatform => Outcome == StationOutcome.Stopped ? Event?.OnPlatform : null;
+
+ /// How far the tail stood off the platform [m], when it stopped.
+ public double? OffPlatform => Outcome == StationOutcome.Stopped ? Event?.OffPlatform : null;
+
+ /// The difference of two times of the day in minutes, across midnight the short way.
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ public static IReadOnlyList Of(Recording recording, double t) =>
+ recording.TimetableAt(t) is { } timetable ? Of(recording, timetable) : Array.Empty();
+
+ /// The rows of a timetable of the recording, each with what the train did there.
+ public static IReadOnlyList Of(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(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;
+ }
+
+ /// When the user's train got moving again after a stop, null while it still stood.
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// The delays the user's train had at the stations it had left or stood at by t, by the name shown.
+ public static IReadOnlyDictionary StationDelays(Recording recording, double t)
+ {
+ var delays = new Dictionary(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 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;
+}
diff --git a/StarterNG.Tests/BlackBox/BlackBoxFile.cs b/StarterNG.Tests/BlackBox/BlackBoxFile.cs
new file mode 100644
index 0000000..e2cab25
--- /dev/null
+++ b/StarterNG.Tests/BlackBox/BlackBoxFile.cs
@@ -0,0 +1,368 @@
+using System.Numerics;
+using System.Text;
+using Eu07.BlackBox;
+
+namespace StarterNG.Tests.BlackBox;
+
+///
+/// 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.
+///
+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);
+
+ /// A record of a type, its length and what the writer puts in it.
+ public BlackBoxFile Record(byte type, Action 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) =>
+ 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);
+ }
+ }
+ });
+
+ 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));
+}
diff --git a/StarterNG.Tests/BlackBox/BlackBoxJourneyTests.cs b/StarterNG.Tests/BlackBox/BlackBoxJourneyTests.cs
new file mode 100644
index 0000000..78d7f17
--- /dev/null
+++ b/StarterNG.Tests/BlackBox/BlackBoxJourneyTests.cs
@@ -0,0 +1,253 @@
+using Eu07.BlackBox;
+
+namespace StarterNG.Tests.BlackBox;
+
+///
+/// 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.
+///
+public class BlackBoxJourneyTests
+{
+ private const uint Head = 1, Car = 2, Tail = 3, Stranger = 9;
+ private const byte Coupled = 0x83;
+
+ ///
+ /// 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.
+ ///
+ 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 { 1, 2, 3 }, r.ConsistAt((int)Head, 79));
+ Assert.Equal(new HashSet { 1, 2 }, r.ConsistAt((int)Head, 81));
+ Assert.Equal(new HashSet { 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);
+ }
+}
+
+/// A train given its next timetable, one recording holding both.
+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);
+ }
+}
diff --git a/StarterNG.Tests/BlackBox/BlackBoxListingTests.cs b/StarterNG.Tests/BlackBox/BlackBoxListingTests.cs
new file mode 100644
index 0000000..7396ab9
--- /dev/null
+++ b/StarterNG.Tests/BlackBox/BlackBoxListingTests.cs
@@ -0,0 +1,96 @@
+using StarterNG.Domain.Tapes;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests.BlackBox;
+
+public class BlackBoxListingTests
+{
+ private static readonly DateTime Launched = new(2025, 12, 31, 23, 0, 0, DateTimeKind.Utc);
+
+ private static string Log(string name) => TestInstallation.At("physicslog", name);
+
+ [Fact]
+ public void Each_run_keeps_its_own_black_box_next_to_its_tapes()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(Log("ra.dat"), "t")
+ .WithFile(Log("td_2025-12-31_23-00-05.blackbox"), "b")
+ .WithFile(Log("td_2026-01-01_00-00-05.blackbox"), "b");
+ var installation = new TestInstallation(files);
+ var tapes = installation.Tapes;
+ installation.Clock.Now = Launched;
+ tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra" }, "A");
+ installation.Clock.Now = Launched.AddHours(1);
+ tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "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(), "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);
+ }
+}
diff --git a/StarterNG.Tests/BlackBox/BlackBoxReaderTests.cs b/StarterNG.Tests/BlackBox/BlackBoxReaderTests.cs
new file mode 100644
index 0000000..51c1703
--- /dev/null
+++ b/StarterNG.Tests/BlackBox/BlackBoxReaderTests.cs
@@ -0,0 +1,229 @@
+using System.Numerics;
+using Eu07.BlackBox;
+
+namespace StarterNG.Tests.BlackBox;
+
+public class BlackBoxReaderTests
+{
+ private static BlackBoxFile Session() => new BlackBoxFile()
+ .Info()
+ .Track(0, "start", new Vector3(5, 0.4f, 1100), new Vector3(5, 0.4f, 1200))
+ .Track(1, "zwr01", 1, 0, new Vector3(5, 0.4f, 1200), new Vector3(5, 0.4f, 1234))
+ .Track(1, "zwr01", 1, 1, new Vector3(5, 0.4f, 1200), new Vector3(8, 0.4f, 1234))
+ .Signal(0, "stacja1_a", 2.5, 3.0, 1300)
+ .Vehicle(1, "ep07-bb", "ROJ65113")
+ .Vehicle(2, "bdnu")
+ .Switch(0, 1, 0)
+ .Aspect(0, 0, 0, -1)
+ .Clock(0, 10 * 3600 + 34 * 60)
+ .User(0, 1, true)
+ .Vehicles(0, true, (1, 5, 1150, 0), (2, 5, 1130, 0))
+ .Cab(0.1, 1, 1150, 0)
+ .Command(1.0, 1, true, "aidriverenable")
+ .Command(1.1, 1, false, "aidriverenable")
+ .Driver(1.0, 1, 2)
+ .Aspect(2.0, 0, 40, -1)
+ .Switch(3.0, 1, 1)
+ .Passed(4.0, 1, 0, "stacja1_a_sem", 40, 38.5f, 0x01)
+ .Removed(5.0, 2)
+ .End(6.0);
+
+ [Fact]
+ public void Every_record_of_a_session_is_read_as_the_simulator_wrote_it()
+ {
+ var r = BlackBoxReader.Read(Session().Stream());
+
+ Assert.Equal(1, r.Version);
+ Assert.Equal("td.scn", r.Info!.Scenery);
+ Assert.Equal(new TimeSpan(10, 34, 0), r.Info.StartClock);
+ Assert.Equal(60f, r.Info.KeyframeInterval);
+ Assert.Equal(3, r.Tracks.Count);
+ Assert.Equal(2, r.TrackWays(1).Count);
+ Assert.Equal(TrackKind.SwitchBranch, r.TrackWays(1)[1].Kind);
+ Assert.Equal(new Vector3(8, 0.4f, 1234), r.TrackWays(1)[1].Points[1]);
+ Assert.Equal("stacja1_a", r.Signal(0)!.Name);
+ Assert.Equal(1300, r.Signal(0)!.Position.Z);
+ Assert.Equal("ROJ65113", r.Vehicle(1)!.Train);
+ Assert.Equal(5.0, r.Vehicle(2)!.RemovedAt);
+
+ var cab = Assert.Single(r.Cab);
+ Assert.Equal(1, cab.Vehicle);
+ Assert.Equal(new Position(5, 0.4, 1150), cab.Position);
+ Assert.Equal(Driver.Human, cab.Driving);
+ Assert.True(cab.AiValid);
+ Assert.Equal(3, cab.MainController);
+ Assert.Equal(5.0f, cab.BrakePipe);
+ Assert.Equal(40f, cab.Ai.TargetKmh);
+ Assert.Equal(60f, cab.Ai.TimetableVmaxKmh);
+ Assert.False(cab.Ai.HasNextLimit);
+ Assert.Equal(CabDevices.Battery | CabDevices.MainBreaker | CabDevices.PantographFront, cab.Devices);
+ Assert.True(cab.Has(CabDevices.MainBreaker));
+ Assert.False(cab.Has(CabDevices.Compressor));
+ Assert.Equal(8.5f, cab.MainReservoir);
+ Assert.Equal(3000f, cab.Voltage);
+ Assert.Equal(1100f, cab.EngineRpm);
+ Assert.Equal(Lights.HeadlightLeft | Lights.HeadlightUpper | Lights.HeadlightRight, cab.LightsHead);
+ Assert.Equal(Lights.RedMarkerLeft | Lights.RedMarkerRight, cab.LightsTail);
+
+ Assert.Equal(2, r.Commands.Count);
+ Assert.Equal("aidriverenable", r.Commands[0].Name);
+ Assert.True(r.Commands[0].Pressed);
+ Assert.Equal(2.0, r.Commands[0].Param2);
+ Assert.Equal(Driver.Ai, Assert.Single(r.DriverChanges).Driving);
+ var passed = Assert.Single(r.Passings);
+ Assert.Equal("stacja1_a_sem", passed.Event);
+ Assert.Equal(PassingFlags.Shunt, passed.Flags);
+ Assert.Equal(38.5f, passed.SpeedKmh);
+ Assert.False(passed.AtDanger);
+ Assert.True(r.Complete);
+ Assert.Equal(6.0, r.Duration);
+ Assert.Equal(0, r.Unknown);
+ Assert.Equal(0, r.Malformed);
+ }
+
+ [Fact]
+ public void The_switches_and_the_aspects_answer_for_any_moment()
+ {
+ var r = BlackBoxReader.Read(Session().Stream());
+
+ Assert.Equal(0, r.SwitchAt(1, 2.9));
+ Assert.Equal(1, r.SwitchAt(1, 3.0));
+ Assert.Equal(-1, r.SwitchAt(7, 3.0));
+ Assert.Equal(0f, r.AspectAt(0, 1.9)!.Value.Speed1);
+ Assert.Equal(40f, r.AspectAt(0, 2.0)!.Value.Speed1);
+ Assert.Null(r.AspectAt(3, 2.0));
+ }
+
+ [Fact]
+ public void A_file_still_written_is_read_on_from_where_the_last_read_stopped()
+ {
+ var bytes = Session().Bytes;
+ var whole = BlackBoxReader.Read(new MemoryStream(bytes));
+
+ // Cut at every byte: a record cut short waits for the rest, nothing is read twice.
+ for (int cut = 0; cut <= bytes.Length; cut++)
+ {
+ var reader = new BlackBoxReader();
+ reader.ReadMore(new MemoryStream(bytes, 0, cut));
+ Assert.False(reader.Recording.Complete && cut < bytes.Length);
+ reader.ReadMore(new MemoryStream(bytes));
+
+ Assert.Equal(whole.Tracks.Count, reader.Recording.Tracks.Count);
+ Assert.Equal(whole.Commands.Count, reader.Recording.Commands.Count);
+ Assert.Equal(whole.Cab.Count, reader.Recording.Cab.Count);
+ Assert.Equal(whole.Aspects.Count, reader.Recording.Aspects.Count);
+ Assert.True(reader.Recording.Complete);
+ Assert.Equal(bytes.Length, reader.Offset);
+ }
+ }
+
+ [Fact]
+ public void A_record_of_a_later_version_is_passed_over_by_its_length()
+ {
+ var file = new BlackBoxFile().Info()
+ .Record(0x7e, w => w.Write(new byte[40]))
+ .Command(1.0, 1, true, "hornhighactivate")
+ .End(2.0);
+
+ var r = BlackBoxReader.Read(file.Stream());
+
+ Assert.Equal(1, r.Unknown);
+ Assert.Equal("hornhighactivate", Assert.Single(r.Commands).Name);
+ Assert.True(r.Complete);
+ }
+
+ [Fact]
+ public void A_record_shorter_than_its_type_is_passed_over_and_counted()
+ {
+ var file = new BlackBoxFile().Record(0x20, w => w.Write(1.0)).Clock(1.0, 3600).End(2.0);
+
+ var r = BlackBoxReader.Read(file.Stream());
+
+ Assert.Empty(r.Cab);
+ Assert.Equal(1, r.Malformed);
+ Assert.Single(r.Clocks);
+ }
+
+ [Fact]
+ public void Texts_longer_than_a_byte_can_count_and_letters_beyond_ascii_read_back()
+ {
+ string name = "żółw_" + new string('x', 300);
+ var r = BlackBoxReader.Read(new BlackBoxFile().Signal(0, name, 0, 0, 0).Signal(1, "", 1, 1, 1).Stream());
+
+ Assert.Equal(name, r.Signal(0)!.Name);
+ Assert.Equal("", r.Signal(1)!.Name);
+ }
+
+ [Fact]
+ public void Something_else_than_a_black_box_or_a_version_not_known_is_refused()
+ {
+ Assert.Throws(() => BlackBoxReader.Read(new MemoryStream("Time[s] Velocity[m/s]\r\n"u8.ToArray())));
+ Assert.Throws(() => 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(() => 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));
+ }
+}
diff --git a/StarterNG.Tests/BlackBox/BlackBoxSignalsTests.cs b/StarterNG.Tests/BlackBox/BlackBoxSignalsTests.cs
new file mode 100644
index 0000000..f58aeea
--- /dev/null
+++ b/StarterNG.Tests/BlackBox/BlackBoxSignalsTests.cs
@@ -0,0 +1,125 @@
+using System.Linq;
+using Eu07.BlackBox;
+
+namespace StarterNG.Tests.BlackBox;
+
+/// The light signals at the head and the tail of the user's train, against those it was to show.
+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);
+ }
+
+ [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);
+ }
+}
+
+/// The user going over to another vehicle: the timetable of the train is the one of the train the user is in.
+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));
+ }
+}
+
+/// The departures and the platforms in the 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);
+ }
+}
diff --git a/StarterNG.Tests/BlackBox/DrivingSummaryTests.cs b/StarterNG.Tests/BlackBox/DrivingSummaryTests.cs
new file mode 100644
index 0000000..2f24ca0
--- /dev/null
+++ b/StarterNG.Tests/BlackBox/DrivingSummaryTests.cs
@@ -0,0 +1,98 @@
+using Eu07.BlackBox;
+
+namespace StarterNG.Tests.BlackBox;
+
+public class DrivingSummaryTests
+{
+ private static Recording Drive(Func more)
+ {
+ var file = new BlackBoxFile().Info();
+ return BlackBoxReader.Read(more(file).Stream());
+ }
+
+ /// Ten samples a second from t, at a speed against an AI holding target.
+ 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);
+ }
+}
diff --git a/StarterNG.Tests/BlackBox/PlatformTests.cs b/StarterNG.Tests/BlackBox/PlatformTests.cs
new file mode 100644
index 0000000..da29b7c
--- /dev/null
+++ b/StarterNG.Tests/BlackBox/PlatformTests.cs
@@ -0,0 +1,81 @@
+using System.IO;
+using Eu07.BlackBox;
+
+namespace StarterNG.Tests.BlackBox;
+
+/// The second value of a stop point as the driver reads it: all of it the metres, the last digit also the side.
+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_within_the_platform_is_along_it()
+ {
+ var stop = Stop(63.2f, 129.8f, 201);
+ Assert.True(stop.OnPlatform);
+ Assert.Equal(0, stop.OffPlatform);
+ }
+}
+
+/// The way a stop point faces and the W32, as the simulator writes them.
+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);
+ }
+}
diff --git a/StarterNG.Tests/StarterNG.Tests.csproj b/StarterNG.Tests/StarterNG.Tests.csproj
index 165de2d..88b1d5c 100644
--- a/StarterNG.Tests/StarterNG.Tests.csproj
+++ b/StarterNG.Tests/StarterNG.Tests.csproj
@@ -20,6 +20,7 @@
+
diff --git a/StarterNG.sln b/StarterNG.sln
index 262289f..67a5e51 100644
--- a/StarterNG.sln
+++ b/StarterNG.sln
@@ -1,42 +1,93 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
-VisualStudioVersion = 18.1.11312.151 d18.0
+VisualStudioVersion = 18.1.11312.151
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG", "StarterNG\StarterNG.csproj", "{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG.Tests", "StarterNG.Tests\StarterNG.Tests.csproj", "{00B068D7-2F49-4CAA-BC21-506503EBDFC8}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eu07.BlackBox", "Eu07.BlackBox\Eu07.BlackBox.csproj", "{B94F5951-3DC8-4DD8-A694-133B76395A5A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eu07.BlackBox.Views", "Eu07.BlackBox.Views\Eu07.BlackBox.Views.csproj", "{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eu07.BlackBox.Viewer", "Eu07.BlackBox.Viewer\Eu07.BlackBox.Viewer.csproj", "{0C131487-66CE-42B9-8BB6-93AAD8C47719}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x64.ActiveCfg = Debug|x64
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x64.Build.0 = Debug|x64
+ {D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x86.Build.0 = Debug|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|Any CPU.Build.0 = Release|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.ActiveCfg = Release|x64
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.Build.0 = Release|x64
+ {D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x86.ActiveCfg = Release|Any CPU
+ {D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x86.Build.0 = Release|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.ActiveCfg = Debug|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.Build.0 = Debug|Any CPU
+ {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x86.Build.0 = Debug|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.Build.0 = Release|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.ActiveCfg = Release|Any CPU
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.Build.0 = Release|Any CPU
+ {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x86.ActiveCfg = Release|Any CPU
+ {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x86.Build.0 = Release|Any CPU
+ {B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x64.Build.0 = Debug|Any CPU
+ {B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x86.Build.0 = Debug|Any CPU
+ {B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x64.ActiveCfg = Release|Any CPU
+ {B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x64.Build.0 = Release|Any CPU
+ {B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x86.ActiveCfg = Release|Any CPU
+ {B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x86.Build.0 = Release|Any CPU
+ {7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x64.Build.0 = Debug|Any CPU
+ {7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x86.Build.0 = Debug|Any CPU
+ {7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x64.ActiveCfg = Release|Any CPU
+ {7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x64.Build.0 = Release|Any CPU
+ {7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x86.ActiveCfg = Release|Any CPU
+ {7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x86.Build.0 = Release|Any CPU
+ {0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x64.Build.0 = Debug|Any CPU
+ {0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x86.Build.0 = Debug|Any CPU
+ {0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x64.ActiveCfg = Release|Any CPU
+ {0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x64.Build.0 = Release|Any CPU
+ {0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x86.ActiveCfg = Release|Any CPU
+ {0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(RiderSharedRunConfigurations) = postSolution
- File = linuix.run\StarterNG (linux64).run.xml
File = windows.run\StarterNG (win64).run.xml
EndGlobalSection
EndGlobal
diff --git a/StarterNG/Application/HaslerTapes.cs b/StarterNG/Application/HaslerTapes.cs
index 3d0b464..bc1dc7e 100644
--- a/StarterNG/Application/HaslerTapes.cs
+++ b/StarterNG/Application/HaslerTapes.cs
@@ -16,15 +16,21 @@ namespace StarterNG.Application;
public sealed record TapeFile(string Path, string Vehicle, DateTime LastWriteUtc, long Size,
TapeRole Role = TapeRole.Other);
-/// One run of the simulator and what is left of its tapes: a later run overwrites those of the vehicles it has too.
+/// The black box of a run: the whole session in one file, a new one each run.
+public sealed record BlackBoxRecording(string Path, DateTime LastWriteUtc, long Size);
+
+///
+/// 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.
+///
public sealed record TapeSession(TapeLaunch? Launch, DateTime StartedUtc, DateTime LastWrittenUtc, bool Running,
- IReadOnlyList Tapes);
+ IReadOnlyList Tapes, IReadOnlyList BlackBoxes);
/// 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.
public sealed record TapeContext(TimeSpan? StartClock, RecorderKind Recorder,
string? Scenery = null, string? Train = null);
-/// The tapes the simulator left in physicslog/ - one per driven vehicle, overwritten every run.
+/// The tapes the simulator left in physicslog/ - one per driven vehicle, overwritten every run - and the black boxes, one a run.
public sealed class HaslerTapes
{
private const int RememberedLaunches = 30;
@@ -251,32 +257,52 @@ 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();
+ 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();
foreach (var launch in launches)
{
bool last = ReferenceEquals(launch, launches[^1]);
bool running = last && _simulatorRunning();
- if (!running && !behind.ContainsKey(launch))
+ if (!running && !behind.ContainsKey(launch) && !boxesBehind.ContainsKey(launch))
continue;
var player = last ? PlayerIn(launch) : launch.Player ?? PlayerVehicles.Unknown;
sessions.Add(Session(launch, running, behind.GetValueOrDefault(launch, new List())
- .Select(t => t with { Role = launch.RoleOf(t.Vehicle, player) })));
+ .Select(t => t with { Role = launch.RoleOf(t.Vehicle, player) }),
+ boxesBehind.GetValueOrDefault(launch, new List())));
}
+ // 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();
- foreach (var tape in tapes.Where(t => !launches.Any(l => l.Covers(t.LastWriteUtc))).OrderBy(t => t.LastWriteUtc))
+ var runBoxes = new List();
+ DateTime? lastWritten = null;
+ foreach (var (written, tape, box) in loose)
{
- if (run.Count > 0 && tape.LastWriteUtc - run[^1].LastWriteUtc > QuietBetweenRuns)
+ if (lastWritten is { } before && written - before > QuietBetweenRuns)
{
- sessions.Add(Session(null, running: false, run));
+ sessions.Add(Session(null, running: false, run, runBoxes));
run = new List();
+ runBoxes = new List();
}
- run.Add(tape);
+ if (tape is not null)
+ run.Add(tape);
+ if (box is not null)
+ runBoxes.Add(box);
+ lastWritten = written;
}
- if (run.Count > 0)
- sessions.Add(Session(null, running: false, run));
+ if (lastWritten is not null)
+ sessions.Add(Session(null, running: false, run, runBoxes));
return sessions.OrderByDescending(session => session.Running)
.ThenByDescending(session => session.LastWrittenUtc)
@@ -292,13 +318,18 @@ 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));
+
/// The player's own tapes first: every manned vehicle leaves one.
- private static TapeSession Session(TapeLaunch? launch, bool running, IEnumerable tapes)
+ private static TapeSession Session(TapeLaunch? launch, bool running, IEnumerable tapes,
+ IEnumerable boxes)
{
var listed = tapes.OrderByDescending(tape => tape.Role).ThenByDescending(tape => tape.LastWriteUtc).ToList();
- var started = launch?.LaunchedUtc ?? listed.Min(tape => tape.LastWriteUtc);
- return new TapeSession(launch, started, listed.Count > 0 ? listed.Max(tape => tape.LastWriteUtc) : started,
- running, listed);
+ var recorded = boxes.OrderByDescending(box => box.LastWriteUtc).ToList();
+ var written = listed.Select(tape => tape.LastWriteUtc).Concat(recorded.Select(box => box.LastWriteUtc)).ToList();
+ var started = launch?.LaunchedUtc ?? written.Min();
+ return new TapeSession(launch, started, written.Count > 0 ? written.Max() : started, running, listed, recorded);
}
public HaslerTape? Load(TapeFile file)
diff --git a/StarterNG/StarterNG.csproj b/StarterNG/StarterNG.csproj
index c24c230..a8f2865 100644
--- a/StarterNG/StarterNG.csproj
+++ b/StarterNG/StarterNG.csproj
@@ -37,6 +37,12 @@
+
+
+
+
+
+
diff --git a/StarterNG/Views/Tapes/HaslerTapeWindow.cs b/StarterNG/Views/Tapes/HaslerTapeWindow.cs
index 03fa3ba..21c867a 100644
--- a/StarterNG/Views/Tapes/HaslerTapeWindow.cs
+++ b/StarterNG/Views/Tapes/HaslerTapeWindow.cs
@@ -11,6 +11,7 @@ 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;
@@ -57,6 +58,8 @@ public sealed class HaslerTapeWindow : Window
private readonly Control _recordingOff;
private readonly Control _logOff;
private readonly Dictionary _expanded = new();
+ private readonly Dictionary _blackBoxes = new(StringComparer.OrdinalIgnoreCase);
+ private HashSet _liveBlackBoxes = new(StringComparer.OrdinalIgnoreCase);
private TapeFile? _shown;
private string? _followed;
@@ -104,6 +107,12 @@ public sealed class HaslerTapeWindow : Window
ScrollViewer.SetHorizontalScrollBarVisibility(_list, ScrollBarVisibility.Disabled);
_list.SelectionChanged += async (_, _) =>
{
+ // A black box opens in a window of its own; the tape shown stays.
+ if (_list.SelectedItem is TreeViewItem { Tag: BlackBoxRecording box })
+ {
+ await OpenBlackBoxAsync(box);
+ 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;
@@ -291,7 +300,8 @@ public sealed class HaslerTapeWindow : Window
private static string Fingerprint(IEnumerable 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.Tapes.Select(tape => $"{tape.Vehicle}:{tape.Role}")) + " " +
+ string.Join(' ', session.BlackBoxes.Select(box => box.Path))));
/// Sessions newest first, the newest one open until the reader has said otherwise.
private void ShowSessions(IReadOnlyList sessions, bool toDriven)
@@ -304,6 +314,7 @@ 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())
_expanded[((TapeSession)node.Tag!).StartedUtc] = node.IsExpanded;
@@ -314,7 +325,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())
+ foreach (var tape in node.Items.OfType().Where(item => item.Tag is TapeFile))
{
first ??= (node, tape);
if (((TapeFile)tape.Tag!).Path == selected)
@@ -337,7 +348,7 @@ public sealed class HaslerTapeWindow : Window
_list.SelectedItem = chosen.Tape;
}
- private static TreeViewItem SessionItem(TapeSession session, bool open)
+ private 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 }
@@ -372,11 +383,65 @@ 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;
+ }
+
+ /// The black box in its window, read on while the session runs; one window a file.
+ 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);
+ }
+
+ /// The newest black box of a running session is still written; once the session ends, its last part is read and that is all.
+ private void FollowBlackBoxes(IReadOnlyList 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 };
diff --git a/StarterNG/Views/Tapes/StarterBlackBoxStrings.cs b/StarterNG/Views/Tapes/StarterBlackBoxStrings.cs
new file mode 100644
index 0000000..868aab2
--- /dev/null
+++ b/StarterNG/Views/Tapes/StarterBlackBoxStrings.cs
@@ -0,0 +1,21 @@
+using Eu07.BlackBox.Views;
+
+namespace StarterNG.Views.Tapes;
+
+///
+/// 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.
+///
+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];
+ }
+ }
+}
diff --git a/StarterNG/startercfg/lang/cz.xml b/StarterNG/startercfg/lang/cz.xml
index 82e03a5..8547393 100644
--- a/StarterNG/startercfg/lang/cz.xml
+++ b/StarterNG/startercfg/lang/cz.xml
@@ -850,6 +850,8 @@
Relace
pásek: {0}
běží
+ Černá skříňka
+ Celá relace na mapě: jízda oproti AI, povely, návěstidla, kabina
Ve skladu: {0}
Vyberte soupravu s vozidly
Úroveň jen pro tuto část grafiky
diff --git a/StarterNG/startercfg/lang/en.xml b/StarterNG/startercfg/lang/en.xml
index 187376c..9fd4ec1 100644
--- a/StarterNG/startercfg/lang/en.xml
+++ b/StarterNG/startercfg/lang/en.xml
@@ -1079,6 +1079,8 @@
Session
tapes: {0}
running
+ Black box
+ The whole session on a map: the driving against the AI, the commands, the signals, the cab
In the warehouse: {0}
Pick a consist with vehicles
Level for this part of the graphics only
diff --git a/StarterNG/startercfg/lang/hu.xml b/StarterNG/startercfg/lang/hu.xml
index 997354d..1099d59 100644
--- a/StarterNG/startercfg/lang/hu.xml
+++ b/StarterNG/startercfg/lang/hu.xml
@@ -850,6 +850,8 @@
Munkamenet
szalag: {0}
folyamatban
+ Fekete doboz
+ A teljes menet a térképen: vezetés az AI-hoz képest, parancsok, jelzők, vezetőfülke
A raktárban: {0}
Válassz járműveket tartalmazó szerelvényt
Csak a grafika ezen részének szintje
diff --git a/StarterNG/startercfg/lang/pl.xml b/StarterNG/startercfg/lang/pl.xml
index f8459d6..30cad9d 100644
--- a/StarterNG/startercfg/lang/pl.xml
+++ b/StarterNG/startercfg/lang/pl.xml
@@ -901,6 +901,8 @@
Sesja
taśm: {0}
trwa
+ Czarna skrzynka
+ Cała sesja na mapie: jazda względem AI, komendy, sygnalizatory, kabina
W magazynie: {0}
Wybierz skład z pojazdami
Poziom tylko tej części grafiki
diff --git a/StarterNG/startercfg/lang/ru.xml b/StarterNG/startercfg/lang/ru.xml
index 301cd39..372b63f 100644
--- a/StarterNG/startercfg/lang/ru.xml
+++ b/StarterNG/startercfg/lang/ru.xml
@@ -850,6 +850,8 @@
Сеанс
лент: {0}
идёт
+ Чёрный ящик
+ Вся сессия на карте: езда по сравнению с ИИ, команды, светофоры, кабина
На складе: {0}
Выберите состав с транспортом
Уровень только для этой части графики