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} Выберите состав с транспортом Уровень только для этой части графики