mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-13 01:14:29 +01:00
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:
@@ -16,15 +16,21 @@ namespace StarterNG.Application;
|
||||
public sealed record TapeFile(string Path, string Vehicle, DateTime LastWriteUtc, long Size,
|
||||
TapeRole Role = TapeRole.Other);
|
||||
|
||||
/// <summary>One run of the simulator and what is left of its tapes: a later run overwrites those of the vehicles it has too.</summary>
|
||||
/// <summary>The black box of a run: the whole session in one file, a new one each run.</summary>
|
||||
public sealed record BlackBoxRecording(string Path, DateTime LastWriteUtc, long Size);
|
||||
|
||||
/// <summary>
|
||||
/// One run of the simulator and what is left of its tapes: a later run overwrites those of the
|
||||
/// vehicles it has too. Its black box stays, newest first should there be more.
|
||||
/// </summary>
|
||||
public sealed record TapeSession(TapeLaunch? Launch, DateTime StartedUtc, DateTime LastWrittenUtc, bool Running,
|
||||
IReadOnlyList<TapeFile> Tapes);
|
||||
IReadOnlyList<TapeFile> Tapes, IReadOnlyList<BlackBoxRecording> BlackBoxes);
|
||||
|
||||
/// <summary>What is known about a tape beyond its samples: the clock it started at, the recorder it is read as, the scenario and the train.</summary>
|
||||
public sealed record TapeContext(TimeSpan? StartClock, RecorderKind Recorder,
|
||||
string? Scenery = null, string? Train = null);
|
||||
|
||||
/// <summary>The tapes the simulator left in physicslog/ - one per driven vehicle, overwritten every run.</summary>
|
||||
/// <summary>The tapes the simulator left in physicslog/ - one per driven vehicle, overwritten every run - and the black boxes, one a run.</summary>
|
||||
public sealed class HaslerTapes
|
||||
{
|
||||
private const int RememberedLaunches = 30;
|
||||
@@ -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<BlackBoxRecording>();
|
||||
var boxesBehind = boxes.GroupBy(box => launches.LastOrDefault(l => l.Covers(box.LastWriteUtc)))
|
||||
.Where(group => group.Key is not null)
|
||||
.ToDictionary(group => group.Key!, group => group.ToList());
|
||||
|
||||
var sessions = new List<TapeSession>();
|
||||
foreach (var launch in launches)
|
||||
{
|
||||
bool last = ReferenceEquals(launch, launches[^1]);
|
||||
bool running = last && _simulatorRunning();
|
||||
if (!running && !behind.ContainsKey(launch))
|
||||
if (!running && !behind.ContainsKey(launch) && !boxesBehind.ContainsKey(launch))
|
||||
continue;
|
||||
|
||||
var player = last ? PlayerIn(launch) : launch.Player ?? PlayerVehicles.Unknown;
|
||||
sessions.Add(Session(launch, running, behind.GetValueOrDefault(launch, new List<TapeFile>())
|
||||
.Select(t => t with { Role = launch.RoleOf(t.Vehicle, player) })));
|
||||
.Select(t => t with { Role = launch.RoleOf(t.Vehicle, player) }),
|
||||
boxesBehind.GetValueOrDefault(launch, new List<BlackBoxRecording>())));
|
||||
}
|
||||
|
||||
// What no launch covers goes together by when it was written: a run writes it all at its end.
|
||||
var loose = tapes.Where(t => !launches.Any(l => l.Covers(t.LastWriteUtc)))
|
||||
.Select(t => (Written: t.LastWriteUtc, Tape: (TapeFile?)t, Box: (BlackBoxRecording?)null))
|
||||
.Concat(boxes.Where(b => !launches.Any(l => l.Covers(b.LastWriteUtc)))
|
||||
.Select(b => (Written: b.LastWriteUtc, Tape: (TapeFile?)null, Box: (BlackBoxRecording?)b)))
|
||||
.OrderBy(file => file.Written);
|
||||
var run = new List<TapeFile>();
|
||||
foreach (var tape in tapes.Where(t => !launches.Any(l => l.Covers(t.LastWriteUtc))).OrderBy(t => t.LastWriteUtc))
|
||||
var runBoxes = new List<BlackBoxRecording>();
|
||||
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<TapeFile>();
|
||||
runBoxes = new List<BlackBoxRecording>();
|
||||
}
|
||||
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));
|
||||
|
||||
/// <summary>The player's own tapes first: every manned vehicle leaves one.</summary>
|
||||
private static TapeSession Session(TapeLaunch? launch, bool running, IEnumerable<TapeFile> tapes)
|
||||
private static TapeSession Session(TapeLaunch? launch, bool running, IEnumerable<TapeFile> tapes,
|
||||
IEnumerable<BlackBoxRecording> boxes)
|
||||
{
|
||||
var listed = tapes.OrderByDescending(tape => tape.Role).ThenByDescending(tape => tape.LastWriteUtc).ToList();
|
||||
var 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)
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="3.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The black box of the simulator: its reader and the window it is played back in. -->
|
||||
<ProjectReference Include="..\Eu07.BlackBox\Eu07.BlackBox.csproj" />
|
||||
<ProjectReference Include="..\Eu07.BlackBox.Views\Eu07.BlackBox.Views.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Assets\Logo\" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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<DateTime, bool> _expanded = new();
|
||||
private readonly Dictionary<string, BlackBoxWindow> _blackBoxes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private HashSet<string> _liveBlackBoxes = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private TapeFile? _shown;
|
||||
private string? _followed;
|
||||
@@ -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<TapeSession> sessions) =>
|
||||
string.Join('\n', sessions.Select(session =>
|
||||
$"{session.StartedUtc:O} {session.LastWrittenUtc:yyyyMMddHHmm} {session.Running} " +
|
||||
string.Join(' ', session.Tapes.Select(tape => $"{tape.Vehicle}:{tape.Role}"))));
|
||||
string.Join(' ', session.Tapes.Select(tape => $"{tape.Vehicle}:{tape.Role}")) + " " +
|
||||
string.Join(' ', session.BlackBoxes.Select(box => box.Path))));
|
||||
|
||||
/// <summary>Sessions newest first, the newest one open until the reader has said otherwise.</summary>
|
||||
private void ShowSessions(IReadOnlyList<TapeSession> 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<TreeViewItem>())
|
||||
_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<TreeViewItem>())
|
||||
foreach (var tape in node.Items.OfType<TreeViewItem>().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;
|
||||
}
|
||||
|
||||
/// <summary>The black box in its window, read on while the session runs; one window a file.</summary>
|
||||
private async Task OpenBlackBoxAsync(BlackBoxRecording box)
|
||||
{
|
||||
if (_blackBoxes.TryGetValue(box.Path, out var open))
|
||||
{
|
||||
open.Activate();
|
||||
return;
|
||||
}
|
||||
var window = new BlackBoxWindow(new StarterBlackBoxStrings())
|
||||
{
|
||||
CanOpenFiles = false,
|
||||
Live = _liveBlackBoxes.Contains(box.Path)
|
||||
};
|
||||
_blackBoxes[box.Path] = window;
|
||||
window.Closed += (_, _) => _blackBoxes.Remove(box.Path);
|
||||
window.Show(this);
|
||||
await window.OpenAsync(box.Path);
|
||||
}
|
||||
|
||||
/// <summary>The newest black box of a running session is still written; once the session ends, its last part is read and that is all.</summary>
|
||||
private void FollowBlackBoxes(IReadOnlyList<TapeSession> sessions)
|
||||
{
|
||||
_liveBlackBoxes = sessions.Where(session => session.Running)
|
||||
.SelectMany(session => session.BlackBoxes.Take(1))
|
||||
.Select(box => box.Path)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (path, window) in _blackBoxes)
|
||||
{
|
||||
if (!window.Live || _liveBlackBoxes.Contains(path))
|
||||
continue;
|
||||
window.ReadMore();
|
||||
window.Live = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static TreeViewItem TapeItem(TapeFile file)
|
||||
{
|
||||
var text = new StackPanel { Spacing = 1 };
|
||||
|
||||
21
StarterNG/Views/Tapes/StarterBlackBoxStrings.cs
Normal file
21
StarterNG/Views/Tapes/StarterBlackBoxStrings.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Eu07.BlackBox.Views;
|
||||
|
||||
namespace StarterNG.Views.Tapes;
|
||||
|
||||
/// <summary>
|
||||
/// The texts of the black box window in the starter's language: a translation may give any of
|
||||
/// them as BlackBox + its key; the rest are the window's own, in Polish or else in English.
|
||||
/// </summary>
|
||||
internal sealed class StarterBlackBoxStrings : IBlackBoxStrings
|
||||
{
|
||||
private readonly DefaultBlackBoxStrings _own = new(App.Loc.CurrentLangCode);
|
||||
|
||||
public string this[string key]
|
||||
{
|
||||
get
|
||||
{
|
||||
string text = App.Loc.Opt("BlackBox" + key);
|
||||
return text.Length > 0 ? text : _own[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,6 +850,8 @@
|
||||
<String key="HaslerSession">Relace</String>
|
||||
<String key="HaslerSessionTapes">pásek: {0}</String>
|
||||
<String key="HaslerSessionRunning">běží</String>
|
||||
<String key="HaslerBlackBox">Černá skříňka</String>
|
||||
<String key="HaslerBlackBoxTip">Celá relace na mapě: jízda oproti AI, povely, návěstidla, kabina</String>
|
||||
<String key="WarehouseSaved">Ve skladu: {0}</String>
|
||||
<String key="WarehouseNothingToAdd">Vyberte soupravu s vozidly</String>
|
||||
<String key="GfxGroupLevelTip">Úroveň jen pro tuto část grafiky</String>
|
||||
|
||||
@@ -1079,6 +1079,8 @@
|
||||
<String key="HaslerSession">Session</String>
|
||||
<String key="HaslerSessionTapes">tapes: {0}</String>
|
||||
<String key="HaslerSessionRunning">running</String>
|
||||
<String key="HaslerBlackBox">Black box</String>
|
||||
<String key="HaslerBlackBoxTip">The whole session on a map: the driving against the AI, the commands, the signals, the cab</String>
|
||||
<String key="WarehouseSaved">In the warehouse: {0}</String>
|
||||
<String key="WarehouseNothingToAdd">Pick a consist with vehicles</String>
|
||||
<String key="GfxGroupLevelTip">Level for this part of the graphics only</String>
|
||||
|
||||
@@ -850,6 +850,8 @@
|
||||
<String key="HaslerSession">Munkamenet</String>
|
||||
<String key="HaslerSessionTapes">szalag: {0}</String>
|
||||
<String key="HaslerSessionRunning">folyamatban</String>
|
||||
<String key="HaslerBlackBox">Fekete doboz</String>
|
||||
<String key="HaslerBlackBoxTip">A teljes menet a térképen: vezetés az AI-hoz képest, parancsok, jelzők, vezetőfülke</String>
|
||||
<String key="WarehouseSaved">A raktárban: {0}</String>
|
||||
<String key="WarehouseNothingToAdd">Válassz járműveket tartalmazó szerelvényt</String>
|
||||
<String key="GfxGroupLevelTip">Csak a grafika ezen részének szintje</String>
|
||||
|
||||
@@ -901,6 +901,8 @@
|
||||
<String key="HaslerSession">Sesja</String>
|
||||
<String key="HaslerSessionTapes">taśm: {0}</String>
|
||||
<String key="HaslerSessionRunning">trwa</String>
|
||||
<String key="HaslerBlackBox">Czarna skrzynka</String>
|
||||
<String key="HaslerBlackBoxTip">Cała sesja na mapie: jazda względem AI, komendy, sygnalizatory, kabina</String>
|
||||
<String key="WarehouseSaved">W magazynie: {0}</String>
|
||||
<String key="WarehouseNothingToAdd">Wybierz skład z pojazdami</String>
|
||||
<String key="GfxGroupLevelTip">Poziom tylko tej części grafiki</String>
|
||||
|
||||
@@ -850,6 +850,8 @@
|
||||
<String key="HaslerSession">Сеанс</String>
|
||||
<String key="HaslerSessionTapes">лент: {0}</String>
|
||||
<String key="HaslerSessionRunning">идёт</String>
|
||||
<String key="HaslerBlackBox">Чёрный ящик</String>
|
||||
<String key="HaslerBlackBoxTip">Вся сессия на карте: езда по сравнению с ИИ, команды, светофоры, кабина</String>
|
||||
<String key="WarehouseSaved">На складе: {0}</String>
|
||||
<String key="WarehouseNothingToAdd">Выберите состав с транспортом</String>
|
||||
<String key="GfxGroupLevelTip">Уровень только для этой части графики</String>
|
||||
|
||||
Reference in New Issue
Block a user