Files
StarterNG/Eu07.BlackBox/BlackBoxReader.cs
maj00r e530b95352 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.
2026-11-11 00:39:35 +01:00

321 lines
13 KiB
C#

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using System.Text;
namespace Eu07.BlackBox;
/// <summary>
/// Reads a black box into a <see cref="Recording"/>, as much of it as is there. A file
/// the simulator still writes is read again from where the last read stopped: a record
/// cut short waits for the next read, so a running session can be followed every few
/// seconds without reading it from the start.
/// </summary>
public sealed class BlackBoxReader
{
public static ReadOnlySpan<byte> Magic => "EU07BBX\0"u8;
public const int FormatVersion = 1;
public const int HeaderSize = 12;
public const int RecordHeaderSize = 5;
private long _offset;
public Recording Recording { get; } = new();
/// <summary>Bytes taken so far: the header and the whole records.</summary>
public long Offset => _offset;
/// <summary>Opens the file shared, as the simulator may be writing it, and reads what is new.</summary>
public int ReadMore(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return ReadMore(stream);
}
/// <summary>Reads the whole records added since the last read. Returns how many were read.</summary>
/// <exception cref="InvalidDataException">Not a black box, or of a version this reader doesn't know.</exception>
public int ReadMore(Stream stream)
{
if (stream.CanSeek)
{
if (stream.Length < _offset)
throw new InvalidDataException("The black box got shorter than what was read of it: it was written anew.");
stream.Seek(_offset, SeekOrigin.Begin);
}
using var added = new MemoryStream();
stream.CopyTo(added);
var bytes = added.GetBuffer().AsSpan(0, (int)added.Length);
int at = 0;
if (_offset == 0)
{
if (bytes.Length < HeaderSize)
return 0;
ReadHeader(bytes[..HeaderSize]);
at = HeaderSize;
}
int read = 0;
while (bytes.Length - at >= RecordHeaderSize)
{
var type = (RecordType)bytes[at];
uint length = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(at + 1, 4));
if (length > int.MaxValue || bytes.Length - at - RecordHeaderSize < length)
break;
var payload = bytes.Slice(at + RecordHeaderSize, (int)length);
try
{
Take(type, new PayloadReader(payload));
}
catch (EndOfStreamException)
{
// Shorter than its type says: passed over, as one of an unknown type is.
Recording.Malformed++;
}
at += RecordHeaderSize + (int)length;
++read;
}
_offset += at;
Recording.Changed();
return read;
}
/// <summary>Reads a whole file at once.</summary>
public static Recording Read(string path)
{
var reader = new BlackBoxReader();
reader.ReadMore(path);
return reader.Recording;
}
public static Recording Read(Stream stream)
{
var reader = new BlackBoxReader();
reader.ReadMore(stream);
return reader.Recording;
}
/// <summary>Whether the bytes start as a black box does.</summary>
public static bool LooksLikeBlackBox(ReadOnlySpan<byte> start) => start.Length >= Magic.Length && start[..Magic.Length].SequenceEqual(Magic);
private void ReadHeader(ReadOnlySpan<byte> header)
{
if (!LooksLikeBlackBox(header))
throw new InvalidDataException("Not a black box of the simulator.");
int version = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(8, 2));
if (version != FormatVersion)
throw new InvalidDataException($"A black box of version {version}, this reader knows version {FormatVersion}.");
Recording.Version = version;
}
private void Take(RecordType type, PayloadReader r)
{
var rec = Recording;
switch (type)
{
case RecordType.Info:
rec.Info = new RecordingInfo(r.Text(), r.Text(), r.U16(), r.U16(), r.F64(), r.Text(), r.Text(),
r.F32(), r.F32(), r.F32());
break;
case RecordType.Track:
{
int index = (int)r.U32();
string name = r.Text();
var kind = (TrackKind)r.U8();
int branch = r.U8();
int category = r.U8();
uint count = r.U32();
if (count > r.Remaining / 12)
throw new EndOfStreamException();
var points = new Vector3[count];
for (int i = 0; i < points.Length; i++)
points[i] = r.Vec3();
rec.AddTrack(new MapTrack(index, name, kind, branch, category, points));
break;
}
case RecordType.Signal:
rec.AddSignal(new MapSignal((int)r.U32(), r.Text(), r.DVec3()));
break;
case RecordType.Vehicle:
rec.AddVehicle(new VehicleInfo((int)r.U32(), r.Text(), r.Text(), r.Text(), r.F32(), r.F32(), r.U8()));
break;
case RecordType.VehicleRemoved:
rec.RemoveVehicle(r.F64(), (int)r.U32());
break;
case RecordType.Cab:
{
var sample = new CabSample(r.F64(), (int)r.U32(), r.DVec3(), r.F32(), r.F32(), r.F32(), (Driver)r.U8(),
(CabFlags)r.U8(), r.I8(), r.I8(), r.I8(), r.F32(), r.F32(), r.F32(), r.F32(),
r.F32(),
new AiReference(r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32()),
(CabDevices)r.U32(), r.F32(), r.F32(), r.F32(), (Lights)r.U16(), (Lights)r.U16());
// The cab, the train protection and the lateral acceleration came later: a recording
// made before them ends here.
if (r.Remaining >= 6)
sample = sample with { CabEnd = r.I8(), Security = (SecurityFlags)r.U8(), LateralAcceleration = r.F32() };
// What the driver went by and the lights asked for came later still.
if (r.Remaining >= 5)
sample = sample with { Movement = (Movement)r.U8(), LightHintHead = (short)r.U16(), LightHintTail = (short)r.U16() };
rec.AddCab(sample);
break;
}
case RecordType.StopPoint:
{
var point = new MapStopPoint((int)r.U32(), r.Text(), r.DVec3(), r.F32(), r.F32());
// The heading came later: a recording made before it ends here.
if (r.Remaining >= 4)
point = point with { Heading = r.F32() };
rec.AddStopPoint(point);
break;
}
case RecordType.HornPoint:
rec.AddHornPoint(new MapHornPoint((int)r.U32(), r.Text(), r.DVec3(), r.F32(), r.F32()));
break;
case RecordType.Timetable:
{
double time = r.F64();
int vehicle = (int)r.U32();
string train = r.Text(), category = r.Text(), from = r.Text(), to = r.Text();
float vmax = r.F32();
uint count = r.U32();
// A row holds at least its empty name, four numbers and a flag.
if (count > r.Remaining / 18)
throw new EndOfStreamException();
var rows = new TimetableRow[count];
for (int i = 0; i < rows.Length; i++)
rows[i] = new TimetableRow(r.Text(), r.F32(), r.F32(), r.F32(), r.F32(), r.U8() != 0);
rec.AddTimetable(new Timetable(time, vehicle, train, category, from, to, vmax, rows));
break;
}
case RecordType.Station:
rec.AddStation(new StationEvent(r.F64(), (int)r.U32(), (StationKind)r.U8(), r.U16(), r.Text(), r.DVec3(),
r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32()));
break;
case RecordType.Camera:
rec.AddCamera(new CameraState(r.F64(), (CameraMode)r.U8(), r.U8(), (int)r.U32(), r.DVec3(), r.F32(), r.F32()));
break;
case RecordType.Coupling:
rec.AddCoupling(new CouplingChange(r.F64(), (int)r.U32(), r.U8(), (int)r.U32(), r.U8(), (CouplingFlags)r.U8(),
(CouplingFlags)r.U8(), (int)r.U32(), r.DVec3()));
break;
case RecordType.HornRequest:
rec.AddHornRequest(new HornRequest(r.F64(), (int)r.U32(), r.F32(), r.F32(), r.DVec3()));
break;
case RecordType.Vehicles:
{
double time = r.F64();
bool keyframe = r.U8() != 0;
uint count = r.U32();
if (count > r.Remaining / 25)
throw new EndOfStreamException();
var states = new VehicleState[count];
for (int i = 0; i < states.Length; i++)
states[i] = new VehicleState((int)r.U32(), r.Vec3(), r.F32(), r.F32(), (Driver)r.U8());
rec.AddLook(new VehicleLook(time, keyframe, states));
break;
}
case RecordType.SwitchState:
rec.AddSwitch(new SwitchChange(r.F64(), (int)r.U32(), r.U8()));
break;
case RecordType.SignalAspect:
rec.AddAspect(new AspectChange(r.F64(), (int)r.U32(), r.F32(), r.F32(), r.Text()));
break;
case RecordType.Command:
{
double time = r.F64();
int vehicle = (int)r.U32();
bool pressed = r.U8() != 0;
double param1 = r.F64(), param2 = r.F64();
rec.AddCommand(new CommandEntry(time, vehicle, pressed, param1, param2, r.Text()));
break;
}
case RecordType.DriverChange:
rec.AddDriverChange(new DriverChange(r.F64(), (int)r.U32(), (Driver)r.U8()));
break;
case RecordType.SignalPassed:
{
double time = r.F64();
int vehicle = (int)r.U32();
int signal = unchecked((int)r.U32());
rec.AddPassing(new SignalPassing(time, vehicle, signal, r.Text(), r.DVec3(), r.F32(), r.F32(), r.F32(),
r.F32(), (PassingFlags)r.U8()));
break;
}
case RecordType.UserVehicle:
{
var change = new UserVehicleChange(r.F64(), (int)r.U32(), r.U8() != 0);
if (r.Remaining >= 1)
change = change with { CabEnd = r.I8() };
rec.AddUserVehicle(change);
break;
}
case RecordType.Clock:
rec.AddClock(new ClockEntry(r.F64(), r.U16(), r.U16(), r.F64()));
break;
case RecordType.End:
rec.EndTime = r.F64();
break;
default:
// A type of a later version: its length lets it be passed over.
rec.Unknown++;
break;
}
}
/// <summary>The fields of a record in the order they come, little-endian. A field past the end throws.</summary>
private ref struct PayloadReader
{
private readonly ReadOnlySpan<byte> _bytes;
private int _at;
public PayloadReader(ReadOnlySpan<byte> bytes)
{
_bytes = bytes;
_at = 0;
}
public int Remaining => _bytes.Length - _at;
private ReadOnlySpan<byte> Take(int count)
{
if (Remaining < count)
throw new EndOfStreamException();
var span = _bytes.Slice(_at, count);
_at += count;
return span;
}
public byte U8() => Take(1)[0];
public sbyte I8() => unchecked((sbyte)Take(1)[0]);
public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
public float F32() => BinaryPrimitives.ReadSingleLittleEndian(Take(4));
public double F64() => BinaryPrimitives.ReadDoubleLittleEndian(Take(8));
public Vector3 Vec3() => new(F32(), F32(), F32());
public Position DVec3() => new(F64(), F64(), F64());
/// <summary>UTF-8 after its length in 7 bit groups, low first, as BinaryWriter writes a string.</summary>
public string Text()
{
int length = 0;
for (int shift = 0; ; shift += 7)
{
if (shift > 28)
throw new EndOfStreamException();
byte b = U8();
length |= (b & 0x7f) << shift;
if (b < 0x80)
break;
}
if (length < 0)
throw new EndOfStreamException();
return length == 0 ? "" : Encoding.UTF8.GetString(Take(length));
}
}
}