Play back the black box of the simulator: map, cab, timeline, timetable and a summary

The simulator writes a black box of each run to physicslog/*.blackbox
(blackbox_doc.md in the maszyna repository). Three new projects read and
show it:

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

The tapes window of the starter lists the black boxes of each session
beside the Hasler tapes and opens them in the player.
This commit is contained in:
maj00r
2026-11-11 00:39:35 +01:00
parent e7d8596652
commit e530b95352
40 changed files with 6953 additions and 20 deletions

View File

@@ -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;
/// <summary>One line of the list of events.</summary>
public sealed record BlackBoxEvent(double Time, string Text, IBrush Brush, bool Important);
/// <summary>
/// What happened to the user's train, by the clock: the commands, the autopilot, the
/// signals passed, the stations, the horn points, the couplings, the train protection,
/// the cab and the camera of the user. A click goes to its moment.
/// </summary>
public sealed class EventList : ListBox
{
private Recording? _recording;
private int _shownRevision = -1;
private List<BlackBoxEvent> _events = new();
private bool _picking;
public event Action<double>? Seek;
// The look of a list box: a control of a type of its own has no theme otherwise, and shows nothing.
protected override Type StyleKeyOverride => typeof(ListBox);
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
public EventList()
{
SelectionMode = SelectionMode.Single;
Padding = new Thickness(4, 0, 4, 6);
// The picked line in the starter's green.
Resources["ListBoxItemBackgroundSelected"] = Palette.AccentFill;
Resources["ListBoxItemBackgroundSelectedPointerOver"] = Palette.AccentFillHover;
Resources["ListBoxItemBackgroundSelectedPressed"] = Palette.AccentFill;
Resources["ListBoxItemBackgroundPointerOver"] = Palette.CardHover;
SelectionChanged += (_, _) =>
{
if (!_picking && SelectedIndex >= 0 && SelectedIndex < _events.Count)
Seek?.Invoke(_events[SelectedIndex].Time);
};
}
public Recording? Recording
{
get => _recording;
set
{
_recording = value;
_shownRevision = -1;
Refresh();
}
}
public IReadOnlyList<BlackBoxEvent> Events => _events;
/// <summary>Takes in what the recording gained; the list grows at its end, so what is scrolled to stays.</summary>
public void Refresh()
{
if (_recording is not { } recording)
{
_events.Clear();
Items.Clear();
return;
}
if (recording.Revision == _shownRevision)
return;
_shownRevision = recording.Revision;
var events = Collect(recording, Strings);
bool grown = events.Count >= _events.Count &&
_events.Select((e, i) => e == events[i]).All(same => same);
if (!grown)
Items.Clear();
for (int i = grown ? _events.Count : 0; i < events.Count; i++)
Items.Add(new ListBoxItem
{
Content = Line(recording, events[i]),
Padding = new Thickness(8, 4),
MinHeight = 0,
CornerRadius = new CornerRadius(6),
Margin = new Thickness(0, 1)
});
_events = events;
}
/// <summary>The event at or just before t picked, without going to it.</summary>
public void ShowTime(double t)
{
int index = -1;
for (int i = 0; i < _events.Count && _events[i].Time <= t + 0.05; i++)
index = i;
if (index == SelectedIndex)
return;
_picking = true;
try
{
SelectedIndex = index;
if (index >= 0)
ScrollIntoView(index);
}
finally
{
_picking = false;
}
}
private static Control Line(Recording recording, BlackBoxEvent e)
{
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("58,14,*") };
var time = new TextBlock
{
Text = recording.ClockAt(e.Time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture),
Foreground = Palette.FaintText,
FontSize = 11.5,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 1, 0, 0)
};
var dot = new Border
{
Width = e.Important ? 8 : 6,
Height = e.Important ? 8 : 6,
CornerRadius = new CornerRadius(4),
Background = e.Brush,
VerticalAlignment = VerticalAlignment.Top,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, e.Important ? 5 : 6, 0, 0)
};
var text = new TextBlock
{
Text = e.Text,
Foreground = e.Important ? Palette.Text : Palette.DimText,
FontSize = 12.5,
FontWeight = e.Important ? FontWeight.SemiBold : FontWeight.Normal,
TextWrapping = TextWrapping.Wrap,
VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(dot, 1);
Grid.SetColumn(text, 2);
grid.Children.Add(time);
grid.Children.Add(dot);
grid.Children.Add(text);
return grid;
}
/// <summary>The events of the user's train, in the order they happened.</summary>
public static List<BlackBoxEvent> Collect(Recording recording, IBlackBoxStrings strings)
{
var culture = CultureInfo.CurrentCulture;
var events = new List<BlackBoxEvent>();
foreach (var c in recording.UserCommands())
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCommand"], c.Name), Palette.Command, false));
foreach (var d in recording.DriverChanges)
{
if (recording.CabAt(d.Time + 0.5) is not { } s || s.Vehicle != d.Vehicle)
continue;
events.Add(new BlackBoxEvent(d.Time, strings[d.Driving == Driver.Ai ? "EventAutopilotOn" : "EventAutopilotOff"],
Palette.Autopilot, true));
}
foreach (var p in recording.UserPassings())
{
string name = recording.Signal(p.Signal)?.Name ?? p.Event;
events.Add(p.AtDanger
? new BlackBoxEvent(p.Time, string.Format(culture, strings["EventDanger"], name, Math.Abs(p.SpeedKmh), p.Overrun), Palette.Over, true)
: new BlackBoxEvent(p.Time, string.Format(culture, strings["EventPassed"], name, MapView.AspectText(strings, p.Speed1, p.Speed2),
Math.Abs(p.SpeedKmh)), Palette.Of(new AspectChange(p.Time, p.Signal, p.Speed1, p.Speed2, "")), false));
}
AddUserVehicles(recording, strings, events, culture);
AddStations(recording, strings, events, culture);
foreach (var horn in recording.UserHornChecks())
events.Add(new BlackBoxEvent(horn.Request.Time, strings[horn.Sounded ? "EventHornSounded" : "EventHornMissed"],
horn.Sounded ? Palette.HornPoint : Palette.Bad, !horn.Sounded));
foreach (var span in recording.SecurityBrakings())
{
// Which of them was not acknowledged: the lamps blinking when it braked.
string who = recording.CabAt(span.Start) is { } s
? s.Has(SecurityFlags.CabSignal) && s.Has(SecurityFlags.Vigilance) ? "CA/SHP" : s.Has(SecurityFlags.CabSignal) ? "SHP" : "CA"
: "CA";
events.Add(new BlackBoxEvent(span.Start, string.Format(culture, strings["EventSecurityBraking"], who), Palette.Bad, true));
}
AddCameras(recording, strings, events, culture);
AddCouplings(recording, strings, events, culture);
foreach (var t in recording.Timetables)
events.Add(new BlackBoxEvent(t.Time, string.Format(culture, strings["EventTimetable"], $"{t.Category} {t.Train}".Trim(),
string.Format(culture, strings["Relation"], t.From, t.To)), Palette.Station, false));
if (recording.EndTime is { } end)
events.Add(new BlackBoxEvent(end, strings["EventEnd"], Palette.FaintText, false));
// Stable: what happened at the same moment stays in the order of its kind.
return events.OrderBy(e => e.Time).ToList();
}
private static void AddUserVehicles(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
{
UserVehicleChange? before = null;
foreach (var u in recording.UserVehicles)
{
string vehicle = recording.Vehicle(u.Vehicle)?.Name ?? "";
// Only the cab changed: told as such.
if (before is { } b && b.Vehicle == u.Vehicle && b.InCab == u.InCab && b.CabEnd != u.CabEnd && u.CabEnd != 0)
events.Add(new BlackBoxEvent(u.Time, string.Format(culture, strings["EventCab"], strings[u.CabEnd > 0 ? "CabEndFront" : "CabEndRear"], vehicle),
Palette.UserTrain, true));
else
{
string text = u.Vehicle == 0
? strings["EventNoVehicle"]
: string.Format(culture, strings[u.InCab ? "EventInVehicle" : "EventOutside"], vehicle);
events.Add(new BlackBoxEvent(u.Time, text, u.InCab ? Palette.UserTrain : Palette.DimText, true));
}
before = u;
}
}
private static void AddStations(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
{
foreach (var st in recording.UserStations())
{
switch (st.Kind)
{
case StationKind.Stopped:
{
float error = st.StopError;
string where = Math.Abs(error) < 1
? strings["TtStopExact"]
: string.Format(culture, strings[error > 0 ? "TtStopShort" : "TtStopPast"], Math.Abs(error));
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStopped"], StationNames.Display(st.Station), where), Palette.Station, true));
break;
}
case StationKind.Passed:
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStationPassed"], StationNames.Display(st.Station)), Palette.Station, false));
break;
case StationKind.Missed:
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStationMissed"], StationNames.Display(st.Station)), Palette.Bad, true));
break;
case StationKind.Ready:
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventReady"], StationNames.Display(st.Station)), Palette.Station, false));
break;
}
}
}
/// <summary>The camera of the user going to another mode: the cab, outside, free.</summary>
private static void AddCameras(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
{
CameraMode? mode = null;
foreach (var c in recording.Cameras)
{
if (c.Mode == mode)
continue;
if (mode is not null)
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCamera"], strings[CameraKey(c.Mode)]), Palette.DimText, false));
mode = c.Mode;
}
}
public static string CameraKey(CameraMode mode) => mode switch
{
CameraMode.Cab => "CameraCab",
CameraMode.External => "CameraExternal",
CameraMode.Debug => "CameraDebug",
_ => "CameraFree"
};
private static void AddCouplings(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
{
string Name(int id) => recording.Vehicle(id)?.Name ?? id.ToString(culture);
foreach (var c in recording.UserCouplings())
{
if (c.Coupled)
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCoupled"], Name(c.Vehicle), Name(c.Other)), Palette.Good, true));
else if (c.Uncoupled)
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventUncoupled"], Name(c.Vehicle), Name(c.PreviousOther)), Palette.Warn, true));
else
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCouplingChanged"], Name(c.Vehicle), Name(c.Other),
CouplingText(strings, c.Flags)), Palette.DimText, false));
}
}
/// <summary>What a coupling joins, in words.</summary>
public static string CouplingText(IBlackBoxStrings strings, CouplingFlags flags)
{
var parts = new List<string>();
foreach (var (flag, key) in new[]
{
(CouplingFlags.Coupler, "CouplingCoupler"), (CouplingFlags.BrakePipe, "CouplingBrakePipe"),
(CouplingFlags.MainPipe, "CouplingMainPipe"), (CouplingFlags.Control, "CouplingControl"),
(CouplingFlags.Power, "CouplingPower"), (CouplingFlags.Heating, "CouplingHeating"),
(CouplingFlags.Gangway, "CouplingGangway")
})
if ((flags & flag) != 0)
parts.Add(strings[key]);
return parts.Count == 0 ? strings["CouplingNothing"] : string.Join(", ", parts);
}
}