diff --git a/StarterNG.Tests/Fakes/TestInstallation.cs b/StarterNG.Tests/Fakes/TestInstallation.cs
index fbb39a3..3f4a791 100644
--- a/StarterNG.Tests/Fakes/TestInstallation.cs
+++ b/StarterNG.Tests/Fakes/TestInstallation.cs
@@ -37,6 +37,7 @@ public sealed class TestInstallation
Executables = new ExecutableLocator(Files, Paths, Environment, Log);
SettingsStore = new SettingsStore(Files, Clock, Log, SettingsPaths, new SettingsSerializer(), Executables);
+ Tapes = new HaslerTapes(Files, Paths, Log, Clock, new TimetableLocator(Files), () => SimulatorRunning);
}
public InMemoryFileSystem Files { get; }
@@ -57,6 +58,8 @@ public sealed class TestInstallation
public SettingsPaths SettingsPaths { get; }
public ExecutableLocator Executables { get; }
public SettingsStore SettingsStore { get; }
+ public HaslerTapes Tapes { get; }
+ public bool SimulatorRunning { get; set; }
/// Path of a file inside the fake installation.
public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray());
diff --git a/StarterNG.Tests/HaslerTapeTests.cs b/StarterNG.Tests/HaslerTapeTests.cs
new file mode 100644
index 0000000..6b752b9
--- /dev/null
+++ b/StarterNG.Tests/HaslerTapeTests.cs
@@ -0,0 +1,168 @@
+using System.Globalization;
+using StarterNG.Domain.Tapes;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class HaslerTapeTests
+{
+ private const string Header =
+ "Time[s] Velocity[m/s] Acceleration[m/ss] Coupler.Dist[m] Coupler.Force[N] TractionForce[kN] FrictionForce[kN] " +
+ "BrakeForce[kN] BrakePress[MPa] PipePress[MPa] MotorCurrent[A] MCP SCP BCP LBP Direction Command CVal1 CVal2 " +
+ "Security Wheelslip EngineTemp[Deg] OilTemp[Deg] WaterTemp[Deg] WaterAuxTemp[Deg]\r\n";
+
+ private static string Line(double t, double v, double brake, double current, string command = "none",
+ int security = 0) =>
+ string.Create(CultureInfo.InvariantCulture,
+ $"{t:0.0000} {v:0.0000} 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 {brake:0.0000} 0.5000 {current:0.0000} ") +
+ $"3 0 0.0000 0.0000 1 {command} 0.0000 0.0000 {security} 0 0.0000 0.0000 0.0000 0.0000\r\n";
+
+ [Fact]
+ public void A_physics_log_reads_as_speed_distance_and_the_two_hasler_marks()
+ {
+ var tape = HaslerTape.Parse(Header
+ + Line(10, 0, 0.45, 0)
+ + Line(20, 36, 0, 400)
+ + Line(30, 72, 0, 0, security: 1));
+
+ Assert.Equal(3, tape.Samples.Count);
+ Assert.Equal(72, tape.MaxSpeedKmh, 6);
+ Assert.Equal(20, tape.Duration, 6);
+ Assert.Equal(50 + 150, tape.DistanceM, 6); // trapezoids: 0->36 km/h and 36->72 km/h over 10 s each
+ Assert.True(tape.Samples[0].Braking);
+ Assert.True(tape.Samples[1].Current);
+ Assert.True(tape.Samples[2].SecurityAlarm);
+ }
+
+ [Fact]
+ public void Standing_and_running_time_split_the_recording_like_the_old_analyser()
+ {
+ var tape = HaslerTape.Parse(Header + Line(0, 0, 0.45, 0) + Line(60, 0, 0.45, 0) + Line(120, 36, 0, 400)
+ + Line(180, 36, 0, 0));
+
+ Assert.Equal(60, tape.StandingTime, 6);
+ Assert.Equal(120, tape.RunningTime, 6);
+ Assert.Equal(900 / 180.0 * 3.6, tape.AverageWithStopsKmh, 6); // 300 m ramping up to 36 km/h, 600 m at it
+ Assert.Equal(900 / 120.0 * 3.6, tape.AverageSpeedKmh, 6);
+ }
+
+ [Fact]
+ public void A_line_from_the_simulator_reads_in_kmh_and_bar_whatever_its_header_says()
+ {
+ var tape = HaslerTape.Parse(Header +
+ "107.5512 38.6099 -0.0201 -0.0002 917.2355 -0.0000 436.4420 0.0000 0.0021 4.9981 0.0000 0 0 0.0000 " +
+ "0.0000 1 none 0.0000 0.0000 0 0 50.0000 45.0000 50.0000 40.0000\r\n");
+
+ var s = Assert.Single(tape.Samples);
+ Assert.Equal(38.6099, s.SpeedKmh, 6);
+ Assert.Equal(4.9981, s.PipePressBar, 6);
+ Assert.False(s.Braking);
+ }
+
+ [Fact]
+ public void A_line_still_being_written_or_a_command_with_spaces_does_not_break_the_tape()
+ {
+ var tape = HaslerTape.Parse(Header + Line(0, 18, 0, 0, command: "Set Velocity") + Line(1, 18, 0, 0) + "2.0500 5.0");
+
+ Assert.Equal(2, tape.Samples.Count);
+ Assert.Equal(5, tape.DistanceM, 6);
+ }
+
+ [Fact]
+ public void The_sample_under_a_point_of_the_tape_is_found_by_time_and_by_distance()
+ {
+ var tape = HaslerTape.Parse(Header + Line(0, 36, 0, 0) + Line(1, 36, 0, 0) + Line(2, 36, 0, 0));
+
+ Assert.Equal(1, tape.IndexAt(1.5, byDistance: false));
+ Assert.Equal(2, tape.IndexAt(25, byDistance: true));
+ Assert.Equal(0, tape.IndexAt(-3, byDistance: false));
+ }
+
+ [Theory]
+ [InlineData(0, 90)]
+ [InlineData(90, 90)]
+ [InlineData(90.5, 100)]
+ [InlineData(120, 120)]
+ [InlineData(151, 180)]
+ [InlineData(180, 180)]
+ [InlineData(181, 200)]
+ [InlineData(205, 220)]
+ public void The_scale_tops_out_at_the_speedometers_range_then_at_the_next_twenty(double max, double top) =>
+ Assert.Equal(top, TapeFigures.TopSpeed(max));
+
+ [Theory]
+ [InlineData(1, 10)]
+ [InlineData(0.8, 10)]
+ [InlineData(0.79, 20)]
+ [InlineData(0.4, 20)]
+ [InlineData(0.16, 50)]
+ [InlineData(0.08, 100)]
+ [InlineData(0.04, 200)]
+ [InlineData(0.039, 500)]
+ public void Speed_lines_take_the_first_step_at_least_8_pixels_apart(double pxPerKmh, int step) =>
+ Assert.Equal(step, TapeFigures.SpeedStep(pxPerKmh));
+
+ [Theory]
+ [InlineData(60, 1)]
+ [InlineData(59, 2)]
+ [InlineData(30, 2)]
+ [InlineData(12, 5)]
+ [InlineData(6, 10)]
+ [InlineData(4, 15)]
+ [InlineData(2, 30)]
+ [InlineData(1, 60)]
+ [InlineData(0.99, 120)]
+ public void Clock_lines_take_the_first_step_at_least_60_pixels_apart(double pxPerMinute, double step) =>
+ Assert.Equal(step, TapeFigures.MinuteStep(pxPerMinute));
+
+ [Theory]
+ [InlineData(600, 0.1)]
+ [InlineData(500, 0.1)]
+ [InlineData(499, 0.2)]
+ [InlineData(100, 0.5)]
+ [InlineData(50, 1)]
+ [InlineData(25, 2)]
+ [InlineData(10, 5)]
+ [InlineData(5, 10)]
+ [InlineData(2.5, 20)]
+ [InlineData(2, 50)]
+ public void Distance_marks_take_the_first_step_at_least_50_pixels_apart(double pxPerKm, double step) =>
+ Assert.Equal(step, TapeFigures.KmStep(pxPerKm));
+
+ [Theory]
+ [InlineData(-5, "0:00")]
+ [InlineData(0, "0:00")]
+ [InlineData(65, "1:05")]
+ [InlineData(3599, "59:59")]
+ [InlineData(3600, "1:00:00")]
+ [InlineData(3723, "1:02:03")]
+ [InlineData(90000, "25:00:00")]
+ public void A_length_of_time_prints_hours_only_when_it_has_them(double seconds, string text) =>
+ Assert.Equal(text, TapeFigures.Clock(seconds));
+
+ [Theory]
+ [InlineData("7:52", 7, 52, 0)]
+ [InlineData("07:52", 7, 52, 0)]
+ [InlineData(" 23:05 ", 23, 5, 0)]
+ [InlineData("7:52:30", 7, 52, 30)]
+ [InlineData("24:00", 0, 0, 0)]
+ [InlineData("noon", 0, 0, 0)]
+ [InlineData("", 0, 0, 0)]
+ [InlineData(null, 0, 0, 0)]
+ public void A_clock_typed_in_is_a_time_of_the_day_or_nothing(string? text, int hours, int minutes, int seconds) =>
+ Assert.Equal(new TimeSpan(hours, minutes, seconds), TapeFigures.ParseClock(text));
+
+ [Fact]
+ public void Tapes_are_listed_from_physicslog_newest_first()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("physicslog", "EU07-424.dat"), Header + Line(0, 1, 0, 0)));
+ var tapes = installation.Tapes;
+
+ var list = tapes.List();
+
+ Assert.Single(list);
+ Assert.Equal("EU07-424", list[0].Vehicle);
+ Assert.Single(tapes.Load(list[0])!.Samples);
+ }
+}
diff --git a/StarterNG.Tests/TapeLaunchTests.cs b/StarterNG.Tests/TapeLaunchTests.cs
new file mode 100644
index 0000000..a1285fa
--- /dev/null
+++ b/StarterNG.Tests/TapeLaunchTests.cs
@@ -0,0 +1,485 @@
+using StarterNG.Application;
+using StarterNG.Domain.Tapes;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class TapeLaunchTests
+{
+ private static readonly DateTime Launched = new(2025, 12, 31, 23, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void A_launch_is_written_and_read_back_with_names_that_hold_spaces()
+ {
+ var launch = new TapeLaunch(Launched, "td.scn", new TimeSpan(7, 52, 0), new[]
+ {
+ new TapeVehicle("EP07-424-HIST", "pkp/ep07_v1", "ep07-424"),
+ new TapeVehicle("wagon 1", "pkp/111a_v2", "111a")
+ });
+
+ var back = TapeLaunch.Parse(launch.Format())!;
+
+ Assert.Equal(Launched, back.LaunchedUtc);
+ Assert.Equal("td.scn", back.Scenery);
+ Assert.Equal(new TimeSpan(7, 52, 0), back.StartClock);
+ Assert.Equal("pkp/ep07_v1", back.Find("ep07-424-hist")!.DataFolder);
+ Assert.Equal("111a", back.Find("wagon 1")!.TypeName);
+ }
+
+ [Fact]
+ public void The_driven_vehicle_and_the_consist_are_written_and_read_back()
+ {
+ var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty(),
+ "EP07 424", new[] { "EP07 424", "wagon 1" });
+
+ var back = TapeLaunch.Parse(launch.Format())!;
+
+ Assert.Equal(TapeRole.Driven, back.RoleOf("ep07 424"));
+ Assert.Equal(TapeRole.OwnConsist, back.RoleOf("WAGON 1"));
+ Assert.Equal(TapeRole.Other, back.RoleOf("et22-100"));
+ }
+
+ [Fact]
+ public void A_note_without_the_driven_vehicle_tells_nothing_of_it()
+ {
+ var back = TapeLaunch.Parse("launched\t2025-12-31T23:00:00.0000000Z\nscenery\ttd.scn\nclock\t10:30\n")!;
+
+ Assert.Null(back.Driven);
+ Assert.Equal(TapeRole.Other, back.RoleOf("ep07-424"));
+ Assert.Null(back.SceneryName);
+ Assert.Null(back.Train);
+ }
+
+ [Fact]
+ public void The_scenario_and_the_train_are_written_and_read_back()
+ {
+ var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty(),
+ SceneryName: "Tor doświadczalny", Train: "EIE 1531");
+
+ var back = TapeLaunch.Parse(launch.Format())!;
+
+ Assert.Equal("Tor doświadczalny", back.SceneryName);
+ Assert.Equal("EIE 1531", back.Train);
+ }
+
+ [Fact]
+ public void A_tape_of_the_last_launch_gets_its_scenario_and_train()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("physicslog", "EP07-424.dat"), "t"));
+ installation.Clock.Now = Launched;
+ var tapes = installation.Tapes;
+
+ tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "EP07-424", new[] { "EP07-424" },
+ "Tor doświadczalny", "EIE1531");
+ var context = tapes.ContextOf(tapes.List().Single());
+
+ Assert.Equal("Tor doświadczalny", context.Scenery);
+ Assert.Equal("EIE1531", context.Train);
+ }
+
+ [Fact]
+ public void A_tape_of_someone_elses_vehicle_does_not_get_the_train_of_the_player()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("physicslog", "ET22-100.dat"), "t"));
+ installation.Clock.Now = Launched;
+ var tapes = installation.Tapes;
+
+ tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "EP07-424", new[] { "EP07-424" },
+ "Tor doświadczalny", "EIE1531");
+ var context = tapes.ContextOf(tapes.List().Single());
+
+ Assert.Equal("Tor doświadczalny", context.Scenery);
+ Assert.Null(context.Train);
+ }
+
+ [Fact]
+ public void A_scenario_without_a_name_goes_by_its_file()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("physicslog", "EP07-424.dat"), "t"));
+ installation.Clock.Now = Launched;
+ var tapes = installation.Tapes;
+
+ tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty());
+
+ Assert.Equal("td.scn", tapes.ContextOf(tapes.List().Single()).Scenery);
+ }
+
+ [Theory]
+ [InlineData("[ Rodzaj i numer pociagu | EIE1531 \t ] \r\n[ Kategoria | IC ]\r\n", "EIE1531")]
+ [InlineData(" ____\r\n[ Rodzaj i numer pociągu | LPE 782 ]\r\n", "LPE 782")]
+ [InlineData("[ Kategoria | IC ]\r\n", null)]
+ [InlineData("", null)]
+ public void The_train_number_comes_from_the_heading_of_its_timetable(string timetable, string? number)
+ {
+ var files = new InMemoryFileSystem().WithFile(TestInstallation.At("scenery", "baltyk", "eie1531.txt"), timetable);
+
+ Assert.Equal(number, new StarterNG.Infrastructure.Sceneries.TimetableLocator(files)
+ .TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""),
+ "baltyk/eie1531"));
+ }
+
+ [Theory]
+ [InlineData("none")]
+ [InlineData("NONE")]
+ [InlineData("")]
+ [InlineData(null)]
+ public void A_trainset_without_a_timetable_has_no_train_number(string? name) =>
+ Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
+ .TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""), name));
+
+ [Fact]
+ public void A_timetable_that_is_not_there_gives_no_train_number() =>
+ Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
+ .TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""),
+ @"baltyk\mod\eie1531"));
+
+ [Fact]
+ public void The_driven_tape_and_the_consist_are_listed_ahead_of_the_rest()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("physicslog", "a-other.dat"), "t")
+ .WithFile(TestInstallation.At("physicslog", "b-wagon.dat"), "t")
+ .WithFile(TestInstallation.At("physicslog", "c-lok.dat"), "t"));
+ installation.Clock.Now = Launched;
+ var tapes = installation.Tapes;
+
+ tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "c-lok", new[] { "c-lok", "b-wagon" });
+
+ Assert.Equal("c-lok", tapes.DrivenVehicle);
+ Assert.Equal(new[] { "c-lok", "b-wagon", "a-other" }, tapes.List().Select(tape => tape.Vehicle));
+ Assert.Equal(new[] { TapeRole.StartedIn, TapeRole.OwnConsist, TapeRole.Other }, tapes.List().Select(tape => tape.Role));
+ }
+
+ [Fact]
+ public void With_no_log_the_vehicle_of_the_launch_is_only_the_one_it_started_in()
+ {
+ var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra", "rb" });
+
+ Assert.Equal(TapeRole.StartedIn, launch.RoleOf("ra", PlayerVehicles.Unknown));
+ Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("rb", PlayerVehicles.Unknown));
+ }
+
+ [Fact]
+ public void Where_the_player_has_been_is_written_and_read_back()
+ {
+ var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty(),
+ Player: PlayerVehicles.None.After("a received command: [x]\nb received command: [x]\n"));
+
+ var back = TapeLaunch.Parse(launch.Format())!;
+
+ Assert.Equal("b", back.Player!.Current);
+ Assert.True(back.Player.Used("a"));
+ }
+
+ [Fact]
+ public void A_note_of_a_single_launch_still_reads()
+ {
+ var all = TapeLaunch.ParseAll("launched\t2025-12-31T23:00:00.0000000Z\nscenery\ttd.scn\nclock\t10:30\n");
+
+ Assert.Equal("td.scn", all.Single().Scenery);
+ Assert.Null(all.Single().Player);
+ }
+
+ [Fact]
+ public void Tapes_part_between_the_launches_they_were_written_after()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("physicslog", "ra.dat"), "t")
+ .WithFile(TestInstallation.At("physicslog", "rb.dat"), "t");
+ var installation = new TestInstallation(files);
+ var tapes = installation.Tapes;
+ installation.Clock.Now = Launched;
+ tapes.RecordLaunch("a.scn", new TimeSpan(7, 0, 0), Array.Empty(), "ra", new[] { "ra" }, "A", "T1");
+ installation.Clock.Now = Launched.AddHours(1);
+ tapes.RecordLaunch("b.scn", new TimeSpan(9, 0, 0), Array.Empty(), "rb", new[] { "rb" }, "B", "T2");
+ files.WrittenAt(TestInstallation.At("physicslog", "ra.dat"), Launched.AddMinutes(30))
+ .WrittenAt(TestInstallation.At("physicslog", "rb.dat"), Launched.AddMinutes(90));
+
+ var sessions = tapes.Sessions();
+
+ Assert.Equal(new[] { "B", "A" }, sessions.Select(session => session.Launch!.SceneryName));
+ Assert.Equal("rb", sessions[0].Tapes.Single().Vehicle);
+ Assert.Equal("ra", sessions[1].Tapes.Single().Vehicle);
+ Assert.Equal(new TimeSpan(7, 0, 0), tapes.ContextOf(sessions[1].Tapes[0]).StartClock);
+ Assert.Equal("T1", tapes.ContextOf(sessions[1].Tapes[0]).Train);
+ }
+
+ [Fact]
+ public void Tapes_with_no_launch_noted_go_together_by_when_they_were_written()
+ {
+ var early = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("physicslog", "a.dat"), "t")
+ .WithFile(TestInstallation.At("physicslog", "b.dat"), "t")
+ .WithFile(TestInstallation.At("physicslog", "c.dat"), "t")
+ .WrittenAt(TestInstallation.At("physicslog", "a.dat"), early)
+ .WrittenAt(TestInstallation.At("physicslog", "b.dat"), early.AddSeconds(30))
+ .WrittenAt(TestInstallation.At("physicslog", "c.dat"), early.AddHours(1)));
+
+ var sessions = installation.Tapes.Sessions();
+
+ Assert.Equal(new[] { 1, 2 }, sessions.Select(session => session.Tapes.Count));
+ Assert.All(sessions, session => Assert.Null(session.Launch));
+ }
+
+ [Fact]
+ public void A_running_session_is_listed_with_no_tapes_yet_and_goes_with_the_simulator()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem());
+ installation.Clock.Now = Launched;
+ var tapes = installation.Tapes;
+ tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "ra");
+
+ installation.SimulatorRunning = true;
+ var running = tapes.Sessions().Single();
+ Assert.True(running.Running);
+ Assert.Empty(running.Tapes);
+
+ installation.SimulatorRunning = false;
+ Assert.Empty(tapes.Sessions());
+ }
+
+ [Fact]
+ public void A_log_of_its_own_tells_of_the_vehicles_when_the_common_one_is_stale()
+ {
+ string kept = TestInstallation.At("logs", "log (td.scn) 2026.txt");
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("log.txt"), "ra received command: [x]\n")
+ .WithFile(kept, "rb received command: [x]\n")
+ .WrittenAt(TestInstallation.At("log.txt"), Launched.AddHours(-2))
+ .WrittenAt(kept, Launched.AddMinutes(10)));
+ installation.Clock.Now = Launched;
+ var tapes = installation.Tapes;
+
+ tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra", "rb" });
+
+ Assert.Equal("rb", tapes.DrivenVehicle);
+ }
+
+ [Fact]
+ public void The_vehicles_of_a_run_stay_with_it_when_the_next_begins()
+ {
+ var (tapes, installation) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
+ var written = Launched.AddMinutes(30);
+ installation.Files.WrittenAt(TestInstallation.At("log.txt"), written)
+ .WrittenAt(TestInstallation.At("physicslog", "ra.dat"), written)
+ .WrittenAt(TestInstallation.At("physicslog", "rb.dat"), written)
+ .WrittenAt(TestInstallation.At("physicslog", "obcy.dat"), written);
+
+ installation.Clock.Now = Launched.AddHours(1);
+ tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "inny", new[] { "inny" });
+
+ var run = tapes.Sessions().Single();
+ Assert.Equal("Test", run.Launch!.SceneryName);
+ Assert.Equal(TapeRole.Driven, run.Tapes.Single(tape => tape.Vehicle == "rb").Role);
+ Assert.Equal(TapeRole.DrivenEarlier, run.Tapes.Single(tape => tape.Vehicle == "ra").Role);
+ }
+
+ [Fact]
+ public void Only_the_last_launches_are_remembered()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem());
+ var tapes = installation.Tapes;
+ for (int i = 0; i < 35; i++)
+ {
+ installation.Clock.Now = Launched.AddMinutes(i);
+ tapes.RecordLaunch($"s{i}.scn", TimeSpan.Zero, Array.Empty());
+ }
+
+ var noted = TapeLaunch.ParseAll(installation.Files.ReadAllText(
+ TestInstallation.At("physicslog", "starter-launch.txt"), System.Text.Encoding.UTF8));
+
+ Assert.Equal(30, noted.Count);
+ Assert.Equal("s5.scn", noted[0].Scenery);
+ Assert.Equal("s34.scn", noted[^1].Scenery);
+ }
+
+ [Fact]
+ public void The_vehicle_the_commands_go_to_is_the_one_the_player_is_in()
+ {
+ var player = PlayerVehicles.None.After(
+ "[ 21.764 ]\t\tTrying to enter player train, \"lok-a\"\r\n" +
+ "[ 25.609 ]\t\tlok-a received command: [aidriverdisable]\r\n" +
+ "[ 255.539 ]\t\tKey pressed: [F5]\r\n" +
+ "[ 255.569 ]\t\tlok b received command: [aidriverdisable]\r\n" +
+ "lok-c received command: [cabchangebackward]\n");
+
+ Assert.Equal("lok-c", player.Current);
+ Assert.True(player.Used("LOK-A"));
+ Assert.True(player.Used("lok b"));
+ Assert.False(player.Used("lok-d"));
+ }
+
+ [Fact]
+ public void Going_back_to_a_vehicle_makes_it_the_current_one_again()
+ {
+ var player = PlayerVehicles.None
+ .After("a received command: [x]\nb received command: [x]\n")
+ .After("A received command: [x]\n");
+
+ Assert.Equal("A", player.Current);
+ Assert.True(player.Used("b"));
+ }
+
+ [Fact]
+ public void After_a_change_of_vehicle_the_new_one_is_driven_and_the_first_was_driven_earlier()
+ {
+ var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty(),
+ "ra", new[] { "ra", "s", "rb" });
+ var player = PlayerVehicles.None.After("ra received command: [x]\nrb received command: [x]\n");
+
+ Assert.Equal(TapeRole.Driven, launch.RoleOf("RB", player));
+ Assert.Equal(TapeRole.DrivenEarlier, launch.RoleOf("ra", player));
+ Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("s", player));
+ Assert.Equal(TapeRole.Other, launch.RoleOf("obcy", player));
+ }
+
+ [Fact]
+ public void Before_the_first_command_the_vehicle_of_the_launch_is_driven()
+ {
+ var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra", "rb" });
+
+ Assert.Equal(TapeRole.Driven, launch.RoleOf("ra", PlayerVehicles.None));
+ Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("rb", PlayerVehicles.None));
+ }
+
+ private static (HaslerTapes Tapes, TestInstallation Installation) TapesOfTrain(string log)
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("physicslog", "ra.dat"), "t")
+ .WithFile(TestInstallation.At("physicslog", "rb.dat"), "t")
+ .WithFile(TestInstallation.At("physicslog", "obcy.dat"), "t")
+ .WithFile(TestInstallation.At("log.txt"), log));
+ installation.Clock.Now = Launched;
+ var tapes = installation.Tapes;
+ tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra", "rb" }, "Test", "MPJ1");
+ return (tapes, installation);
+ }
+
+ [Fact]
+ public void The_tape_of_the_vehicle_changed_to_comes_first_and_keeps_the_train()
+ {
+ var (tapes, _) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
+
+ Assert.Equal(new[] { "rb", "ra", "obcy" }, tapes.List().Select(tape => tape.Vehicle));
+ Assert.Equal(new[] { TapeRole.Driven, TapeRole.DrivenEarlier, TapeRole.Other }, tapes.List().Select(tape => tape.Role));
+ Assert.Equal("rb", tapes.DrivenVehicle);
+ Assert.Equal("MPJ1", tapes.ContextOf(tapes.List().First()).Train);
+ }
+
+ [Fact]
+ public void A_vehicle_of_another_train_changed_to_is_driven_without_the_number_of_the_first()
+ {
+ var (tapes, _) = TapesOfTrain("obcy received command: [x]\n");
+
+ var driven = tapes.List().First();
+
+ Assert.Equal("obcy", driven.Vehicle);
+ Assert.Null(tapes.ContextOf(driven).Train);
+ }
+
+ [Fact]
+ public void A_change_of_vehicle_during_the_run_is_seen_at_the_next_look()
+ {
+ const string start = "ra received command: [x]\n";
+ var (tapes, installation) = TapesOfTrain(start);
+ Assert.Equal("ra", tapes.DrivenVehicle);
+
+ installation.Files.WithFile(TestInstallation.At("log.txt"), start + "rb received com");
+ Assert.Equal("ra", tapes.DrivenVehicle);
+
+ installation.Files.WithFile(TestInstallation.At("log.txt"), start + "rb received command: [x]\n");
+ Assert.Equal("rb", tapes.DrivenVehicle);
+ }
+
+ [Fact]
+ public void A_log_begun_anew_forgets_the_vehicles_of_the_old_one()
+ {
+ var (tapes, installation) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
+ Assert.Equal("rb", tapes.DrivenVehicle);
+
+ installation.Files.WithFile(TestInstallation.At("log.txt"), "obcy received command: [x]\n");
+
+ Assert.Equal("obcy", tapes.DrivenVehicle);
+ Assert.Equal(TapeRole.OwnConsist, tapes.List().Single(tape => tape.Vehicle == "rb").Role);
+ }
+
+ [Fact]
+ public void A_log_older_than_the_launch_says_nothing_of_it()
+ {
+ var (tapes, installation) = TapesOfTrain("rb received command: [x]\n");
+ installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddMinutes(10);
+ tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra", "rb" });
+
+ Assert.Equal("ra", tapes.DrivenVehicle);
+ }
+
+ [Fact]
+ public void A_tape_older_than_the_last_launch_has_no_part_in_it()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("physicslog", "c-lok.dat"), "t"));
+ installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddDays(1);
+ var tapes = installation.Tapes;
+
+ tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "c-lok");
+
+ Assert.Equal(TapeRole.Other, tapes.List().Single().Role);
+ }
+
+ [Fact]
+ public void A_log_written_before_the_launch_is_not_taken_for_one_of_it()
+ {
+ var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty());
+
+ Assert.True(launch.Covers(Launched.AddMinutes(3)));
+ Assert.False(launch.Covers(Launched.AddHours(-1)));
+ }
+
+ [Theory]
+ [InlineData("pkp/ep07_v1", "ep07-424", RecorderKind.Hasler)]
+ [InlineData("pkp/en57al_v1", "en57al-1234", RecorderKind.Electronic)]
+ [InlineData("pkp/x4e_v1", "x4e-659", RecorderKind.Electronic)]
+ [InlineData("pkp/et22_v3", "et22-1100", RecorderKind.Hasler)]
+ public void The_recorder_follows_the_vehicle_behind_the_name(string folder, string type, RecorderKind kind) =>
+ Assert.Equal(kind, Recorders.Guess(new TapeVehicle("poc1_lok", folder, type), "poc1_lok"));
+
+ [Fact]
+ public void Without_a_launch_the_tape_name_is_all_there_is_to_go_by()
+ {
+ Assert.Equal(RecorderKind.Electronic, Recorders.Guess(null, "ED250-001"));
+ Assert.Equal(RecorderKind.Hasler, Recorders.Guess(null, "eu07-193"));
+ }
+
+ [Fact]
+ public void A_tape_of_the_last_launch_gets_its_clock_and_recorder()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("physicslog", "ED250-001.dat"), "t"));
+ installation.Clock.Now = Launched;
+ var tapes = installation.Tapes;
+
+ tapes.RecordLaunch("lk4.scn", new TimeSpan(14, 5, 0),
+ new[] { new TapeVehicle("ED250-001", "pkp/ed250_v1", "ed250-001") });
+ var context = tapes.ContextOf(tapes.List().Single());
+
+ Assert.Equal(new TimeSpan(14, 5, 0), context.StartClock);
+ Assert.Equal(RecorderKind.Electronic, context.Recorder);
+ }
+
+ [Fact]
+ public void A_tape_older_than_the_last_launch_keeps_its_hour_unknown()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("physicslog", "EU07-424.dat"), "t"));
+ installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddDays(1);
+ var tapes = installation.Tapes;
+
+ tapes.RecordLaunch("td.scn", new TimeSpan(8, 0, 0), Array.Empty());
+
+ Assert.Null(tapes.ContextOf(tapes.List().Single()).StartClock);
+ }
+}
diff --git a/StarterNG/Application/HaslerTapes.cs b/StarterNG/Application/HaslerTapes.cs
new file mode 100644
index 0000000..a68731d
--- /dev/null
+++ b/StarterNG/Application/HaslerTapes.cs
@@ -0,0 +1,308 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using StarterNG.Application.Abstractions;
+using StarterNG.Classes;
+using StarterNG.Domain.Sceneries;
+using StarterNG.Domain.Tapes;
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Infrastructure.Sceneries;
+
+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.
+public sealed record TapeSession(TapeLaunch? Launch, DateTime StartedUtc, DateTime LastWrittenUtc, bool Running,
+ IReadOnlyList Tapes);
+
+/// 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.
+public sealed class HaslerTapes
+{
+ private const int RememberedLaunches = 30;
+ private static readonly TimeSpan QuietBetweenRuns = TimeSpan.FromMinutes(2);
+
+ private readonly IFileSystem _files;
+ private readonly IGamePaths _paths;
+ private readonly IDiagnosticsLog _log;
+ private readonly IClock _clock;
+ private readonly TimetableLocator _timetables;
+ private readonly Func _simulatorRunning;
+ private readonly object _playerLock = new();
+ private IReadOnlyList? _launches;
+ private (DateTime Written, long Size) _launchesAt;
+ private PlayerVehicles _player = PlayerVehicles.None;
+ private DateTime _playerLaunch;
+ private string? _playerLog;
+ private long _playerLogRead;
+
+ public HaslerTapes(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, IClock clock,
+ TimetableLocator timetables, Func simulatorRunning)
+ {
+ _files = files;
+ _paths = paths;
+ _log = log;
+ _clock = clock;
+ _timetables = timetables;
+ _simulatorRunning = simulatorRunning;
+ }
+
+ public string Folder => _paths.FromRoot("physicslog");
+
+ /// Not a .dat, so the simulator's own logs are never mistaken for it, nor it for one.
+ private string LaunchFile => Path.Combine(Folder, "starter-launch.txt");
+
+ ///
+ /// The simulator logs every driven vehicle under its scenery name, with the
+ /// time counted from the start; the clock it starts at and what each name is
+ /// are only known here. So is whose tape is the player's: a scenery with no
+ /// consist picked starts with the free camera, in no vehicle at all.
+ ///
+ public void RecordLaunch(Scenery scenery, Trainset? trainset, string? driven)
+ {
+ var clock = TimeSpan.TryParseExact(scenery.Weather.ScenarioTimeOverride, @"h\:mm",
+ CultureInfo.InvariantCulture, out var over)
+ ? over
+ : TimeSpan.TryParseExact(scenery.Weather.Time, @"h\:mm", CultureInfo.InvariantCulture, out var time)
+ ? time
+ : TimeSpan.Zero;
+
+ var own = trainset?.Vehicles ?? new List();
+ var vehicles = scenery.Trainsets.SelectMany(t => t.Vehicles).Concat(own)
+ .Select(v => new TapeVehicle(v.Name, v.DataFolder ?? "", v.MmdFile ?? ""));
+ RecordLaunch(Path.GetFileName(scenery.Path), clock, vehicles, driven,
+ driven is null ? null : own.Select(v => v.Name), scenery.Name,
+ driven is null ? null : _timetables.TrainNumber(scenery, trainset?.Name));
+ }
+
+ /// Notes down, next to the logs the simulator is about to write, what only the starter knows.
+ public void RecordLaunch(string scenery, TimeSpan startClock, IEnumerable vehicles,
+ string? driven = null, IEnumerable? consist = null,
+ string? sceneryName = null, string? train = null)
+ {
+ var launch = new TapeLaunch(_clock.UtcNow, scenery, startClock,
+ vehicles.Where(v => !string.IsNullOrEmpty(v.Name))
+ .DistinctBy(v => v.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList(),
+ driven, consist?.ToList(), sceneryName, train);
+ try
+ {
+ var noted = Launches().ToList();
+ // The log is about to begin anew: what it says of the run before stays with that run.
+ if (noted.Count > 0)
+ noted[^1] = noted[^1] with { Player = PlayerIn(noted[^1]) };
+ noted.Add(launch);
+
+ _files.CreateDirectory(Folder);
+ _files.WriteAllText(LaunchFile, string.Concat(noted.TakeLast(RememberedLaunches).Select(l => l.Format())),
+ Encoding.UTF8);
+ lock (_playerLock)
+ _launches = null;
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"writing {LaunchFile}", ex);
+ }
+ }
+
+ ///
+ /// The launches noted so far, oldest first. Read again only when the file has
+ /// changed: this runs every few seconds next to a running simulator.
+ ///
+ private IReadOnlyList Launches()
+ {
+ lock (_playerLock)
+ {
+ try
+ {
+ if (!_files.FileExists(LaunchFile))
+ return Array.Empty();
+
+ var at = (_files.GetLastWriteTimeUtc(LaunchFile), _files.GetFileSize(LaunchFile));
+ if (_launches is null || at != _launchesAt)
+ {
+ _launches = TapeLaunch.ParseAll(_files.ReadAllText(LaunchFile, Encoding.UTF8));
+ _launchesAt = at;
+ }
+ return _launches;
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"reading {LaunchFile}", ex);
+ return Array.Empty();
+ }
+ }
+ }
+
+ /// The simulator's log of this launch: log.txt or, with logs kept, one of its own in logs/.
+ private string? LogOf(TapeLaunch launch)
+ {
+ var logs = new List { _paths.FromRoot("log.txt") };
+ string kept = _paths.FromRoot("logs");
+ if (_files.DirectoryExists(kept))
+ logs.AddRange(_files.GetFiles(kept, "log (*.txt"));
+
+ return logs.Where(_files.FileExists)
+ .Where(log => launch.Covers(_files.GetLastWriteTimeUtc(log)))
+ .OrderByDescending(_files.GetLastWriteTimeUtc)
+ .FirstOrDefault();
+ }
+
+ ///
+ /// Where the player has been in this launch. Only what the log has gained since
+ /// the last look is read: this runs every few seconds next to a running simulator.
+ ///
+ private PlayerVehicles PlayerIn(TapeLaunch launch)
+ {
+ lock (_playerLock)
+ {
+ string? path = null;
+ try
+ {
+ path = LogOf(launch);
+ if (path is null)
+ return PlayerVehicles.Unknown;
+
+ long size = _files.GetFileSize(path);
+ if (launch.LaunchedUtc != _playerLaunch || path != _playerLog || size < _playerLogRead)
+ {
+ _player = PlayerVehicles.None;
+ _playerLaunch = launch.LaunchedUtc;
+ _playerLog = path;
+ _playerLogRead = 0;
+ }
+ if (size == _playerLogRead)
+ return _player;
+
+ using var stream = _files.OpenRead(path);
+ stream.Seek(_playerLogRead, SeekOrigin.Begin);
+ using var added = new MemoryStream();
+ stream.CopyTo(added);
+ byte[] bytes = added.ToArray();
+
+ // A line still being written waits for the next look.
+ int end = Array.LastIndexOf(bytes, (byte)'\n') + 1;
+ if (end > 0)
+ {
+ _player = _player.After(LegacyText.Decode(bytes[..end]));
+ _playerLogRead += end;
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"reading {path}", ex);
+ }
+ return _player;
+ }
+ }
+
+ /// The vehicle the player is in, or went off in while the log has not said otherwise, if the starter launched one.
+ public string? DrivenVehicle =>
+ Launches().LastOrDefault() is { } launch ? PlayerIn(launch).Current ?? launch.Driven : null;
+
+ public TapeContext ContextOf(TapeFile file)
+ {
+ var launch = Launches().LastOrDefault(l => l.Covers(file.LastWriteUtc));
+ if (launch is null)
+ return new TapeContext(null, Recorders.Guess(null, file.Vehicle));
+
+ var vehicle = launch.Find(file.Vehicle);
+ string scenery = string.IsNullOrWhiteSpace(launch.SceneryName) ? launch.Scenery : launch.SceneryName;
+ return new TapeContext(launch.StartClock, Recorders.Guess(vehicle, file.Vehicle),
+ string.IsNullOrWhiteSpace(scenery) ? null : scenery,
+ launch.RoleOf(file.Vehicle) == TapeRole.Other ? null : launch.Train);
+ }
+
+ /// Every tape, session by session.
+ public IReadOnlyList List() => Sessions().SelectMany(session => session.Tapes).ToList();
+
+ ///
+ /// The runs the tapes came from, the running or latest one first. A tape belongs
+ /// to the last launch before it was written; one written before any known launch
+ /// goes with those written about the same time, as a run writes them all at its end.
+ ///
+ public IReadOnlyList Sessions()
+ {
+ try
+ {
+ var launches = Launches();
+ var tapes = _files.DirectoryExists(Folder)
+ ? _files.GetFiles(Folder, "*.dat").Select(ReadTape).ToList()
+ : new List();
+ 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 sessions = new List();
+ foreach (var launch in launches)
+ {
+ bool last = ReferenceEquals(launch, launches[^1]);
+ bool running = last && _simulatorRunning();
+ if (!running && !behind.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) })));
+ }
+
+ var run = new List();
+ foreach (var tape in tapes.Where(t => !launches.Any(l => l.Covers(t.LastWriteUtc))).OrderBy(t => t.LastWriteUtc))
+ {
+ if (run.Count > 0 && tape.LastWriteUtc - run[^1].LastWriteUtc > QuietBetweenRuns)
+ {
+ sessions.Add(Session(null, running: false, run));
+ run = new List();
+ }
+ run.Add(tape);
+ }
+ if (run.Count > 0)
+ sessions.Add(Session(null, running: false, run));
+
+ return sessions.OrderByDescending(session => session.Running)
+ .ThenByDescending(session => session.LastWrittenUtc)
+ .ToList();
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"listing {Folder}", ex);
+ return Array.Empty();
+ }
+ }
+
+ private TapeFile ReadTape(string path) =>
+ new(path, Path.GetFileNameWithoutExtension(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)
+ {
+ 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);
+ }
+
+ public HaslerTape? Load(TapeFile file)
+ {
+ try
+ {
+ // Through OpenRead, which does not mind the simulator still writing the file.
+ using var stream = _files.OpenRead(file.Path);
+ using var reader = new StreamReader(stream, Encoding.ASCII);
+ return HaslerTape.Parse(reader.ReadToEnd());
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"reading {file.Path}", ex);
+ return null;
+ }
+ }
+}
diff --git a/StarterNG/Domain/Tapes/HaslerTape.cs b/StarterNG/Domain/Tapes/HaslerTape.cs
new file mode 100644
index 0000000..ec11775
--- /dev/null
+++ b/StarterNG/Domain/Tapes/HaslerTape.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace StarterNG.Domain.Tapes;
+
+/// One line of the simulator's physics log, in the units a speed recorder shows.
+public readonly record struct TapeSample(
+ double Time,
+ double SpeedKmh,
+ double DistanceM,
+ double BrakePressBar,
+ double PipePressBar,
+ double MotorCurrentA,
+ bool SecurityAlarm)
+{
+ /// Above this cylinder pressure the sample counts as braking.
+ public const double BrakeMarkPressBar = 0.4;
+
+ public bool Braking => BrakePressBar > BrakeMarkPressBar;
+
+ public bool Current => MotorCurrentA != 0.0;
+}
+
+///
+/// A trip recorded by the simulator with "physicslog yes": physicslog/<vehicle>.dat,
+/// a line every 0.05 s of simulated time, read as a Hasler speed recorder tape.
+///
+public sealed class HaslerTape
+{
+ // Columns counted from the start; the security flag is counted from the end, past
+ // the command, which is free text. The header says m/s and MPa, the values are km/h and bar.
+ private const int TimeColumn = 0;
+ private const int VelocityColumn = 1;
+ private const int BrakePressColumn = 8;
+ private const int PipePressColumn = 9;
+ private const int MotorCurrentColumn = 10;
+ private const int SecurityFromEnd = 6;
+ private const int MinimumColumns = 17;
+
+ public HaslerTape(IReadOnlyList samples)
+ {
+ Samples = samples;
+ foreach (var s in samples)
+ MaxSpeedKmh = Math.Max(MaxSpeedKmh, s.SpeedKmh);
+ for (int i = 1; i < samples.Count; i++)
+ if (samples[i].SpeedKmh > 0.5)
+ RunningTime += samples[i].Time - samples[i - 1].Time;
+ }
+
+ public IReadOnlyList Samples { get; }
+
+ public bool IsEmpty => Samples.Count == 0;
+
+ public double StartTime => IsEmpty ? 0 : Samples[0].Time;
+
+ public double Duration => IsEmpty ? 0 : Samples[^1].Time - Samples[0].Time;
+
+ public double DistanceM => IsEmpty ? 0 : Samples[^1].DistanceM;
+
+ public double MaxSpeedKmh { get; }
+
+ /// Seconds on the move; the rest of was spent standing.
+ public double RunningTime { get; }
+
+ public double StandingTime => Duration - RunningTime;
+
+ /// Mean speed while moving.
+ public double AverageSpeedKmh => RunningTime > 0 ? DistanceM / RunningTime * 3.6 : 0;
+
+ /// Mean speed over the whole recording, stops included - the commercial speed.
+ public double AverageWithStopsKmh => Duration > 0 ? DistanceM / Duration * 3.6 : 0;
+
+ public static HaslerTape Parse(string text)
+ {
+ var samples = new List();
+ double distance = 0;
+ TapeSample? previous = null;
+
+ foreach (var raw in text.Split('\n'))
+ {
+ var columns = raw.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+ if (columns.Length < MinimumColumns
+ || !Number(columns[TimeColumn], out double time)
+ || !Number(columns[VelocityColumn], out double velocity))
+ continue; // the header, or a line cut short by a simulator still writing
+
+ Number(columns[BrakePressColumn], out double brake);
+ Number(columns[PipePressColumn], out double pipe);
+ Number(columns[MotorCurrentColumn], out double current);
+ bool security = columns[^SecurityFromEnd] == "1";
+
+ double speed = Math.Abs(velocity);
+ if (previous is { } p)
+ {
+ if (time < p.Time)
+ continue;
+ distance += (p.SpeedKmh + speed) / 2 / 3.6 * (time - p.Time);
+ }
+
+ var sample = new TapeSample(time, speed, distance, brake, pipe, current, security);
+ samples.Add(sample);
+ previous = sample;
+ }
+
+ return new HaslerTape(samples);
+ }
+
+ /// Index of the last sample at or before the given time or distance.
+ public int IndexAt(double position, bool byDistance)
+ {
+ int lo = 0, hi = Samples.Count - 1;
+ if (hi < 0)
+ return -1;
+ while (lo < hi)
+ {
+ int mid = (lo + hi + 1) / 2;
+ double value = byDistance ? Samples[mid].DistanceM : Samples[mid].Time;
+ if (value <= position)
+ lo = mid;
+ else
+ hi = mid - 1;
+ }
+ return lo;
+ }
+
+ private static bool Number(string text, out double value) =>
+ double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
+}
diff --git a/StarterNG/Domain/Tapes/PlayerVehicles.cs b/StarterNG/Domain/Tapes/PlayerVehicles.cs
new file mode 100644
index 0000000..9459f00
--- /dev/null
+++ b/StarterNG/Domain/Tapes/PlayerVehicles.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace StarterNG.Domain.Tapes;
+
+/// The vehicles the player's commands went to during a run, the one used last at the end.
+public sealed class PlayerVehicles
+{
+ private const string CommandMark = " received command: [";
+
+ /// No log of the run to go by, so a change of vehicle cannot be seen.
+ public static readonly PlayerVehicles Unknown = new(Array.Empty(), known: false);
+
+ /// A log with no command in it yet.
+ public static readonly PlayerVehicles None = new(Array.Empty(), known: true);
+
+ private readonly IReadOnlyList _vehicles;
+
+ private PlayerVehicles(IReadOnlyList vehicles, bool known)
+ {
+ _vehicles = vehicles;
+ Known = known;
+ }
+
+ public bool Known { get; }
+
+ public IReadOnlyList Names => _vehicles;
+
+ public string? Current => _vehicles.Count > 0 ? _vehicles[^1] : null;
+
+ public static PlayerVehicles Of(IEnumerable names) => new(names.ToList(), known: true);
+
+ public bool Used(string vehicle) => _vehicles.Contains(vehicle, StringComparer.OrdinalIgnoreCase);
+
+ /// What a further part of the simulator's log, in whole lines, adds to this.
+ public PlayerVehicles After(string log)
+ {
+ var vehicles = _vehicles.ToList();
+ foreach (string line in log.Split('\n'))
+ {
+ int mark = line.IndexOf(CommandMark, StringComparison.Ordinal);
+ if (mark < 0)
+ continue;
+
+ int stamp = line.IndexOf(']');
+ string vehicle = line[(stamp < mark ? stamp + 1 : 0)..mark].Trim();
+ if (vehicle.Length == 0 || string.Equals(vehicles.LastOrDefault(), vehicle, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ vehicles.RemoveAll(v => string.Equals(v, vehicle, StringComparison.OrdinalIgnoreCase));
+ vehicles.Add(vehicle);
+ }
+ return new PlayerVehicles(vehicles, known: true);
+ }
+}
diff --git a/StarterNG/Domain/Tapes/TapeFigures.cs b/StarterNG/Domain/Tapes/TapeFigures.cs
new file mode 100644
index 0000000..c20d4b4
--- /dev/null
+++ b/StarterNG/Domain/Tapes/TapeFigures.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Globalization;
+
+namespace StarterNG.Domain.Tapes;
+
+/// The figures a tape is printed and read with, which no drawing is needed to work out.
+public static class TapeFigures
+{
+ private static readonly double[] SpeedRanges = { 90, 100, 120, 150, 180 };
+ private static readonly double[] SpeedSteps = { 10, 20, 50, 100, 200 };
+ private static readonly double[] MinuteSteps = { 1, 2, 5, 10, 15, 30, 60 };
+ private static readonly double[] KmSteps = { 0.1, 0.2, 0.5, 1, 2, 5, 10, 20 };
+
+ /// The scale is printed for the speedometer's range: 90, 100, 120, 150 or 180 km/h.
+ public static double TopSpeed(double maxKmh)
+ {
+ foreach (double range in SpeedRanges)
+ if (maxKmh <= range)
+ return range;
+ return Math.Ceiling(maxKmh / 20) * 20;
+ }
+
+ /// A round number of km/h between value lines, far enough apart for their figures to stay readable.
+ public static int SpeedStep(double pxPerKmh) => (int)FirstThatFits(SpeedSteps, pxPerKmh, 8, 500);
+
+ /// A round number of minutes between clock lines, about every 60 pixels or more.
+ public static double MinuteStep(double pxPerMinute) => FirstThatFits(MinuteSteps, pxPerMinute, 60, 120);
+
+ /// A round number of kilometres between distance marks, 50 pixels apart or more.
+ public static double KmStep(double pxPerKm) => FirstThatFits(KmSteps, pxPerKm, 50, 50);
+
+ private static double FirstThatFits(double[] steps, double pxPerUnit, double minPx, double otherwise)
+ {
+ foreach (double step in steps)
+ if (step * pxPerUnit >= minPx)
+ return step;
+ return otherwise;
+ }
+
+ /// Hours only when there are any: 7:05, 1:02:03. Below zero counts as zero.
+ public static string Clock(double seconds)
+ {
+ var t = TimeSpan.FromSeconds(Math.Max(0, seconds));
+ return t.TotalHours >= 1 ? $"{(int)t.TotalHours}:{t.Minutes:00}:{t.Seconds:00}" : $"{t.Minutes}:{t.Seconds:00}";
+ }
+
+ /// A time of day as typed; anything that is not one is midnight.
+ public static TimeSpan ParseClock(string? text) =>
+ TimeSpan.TryParseExact(text?.Trim(), new[] { @"h\:mm", @"hh\:mm", @"h\:mm\:ss" }, CultureInfo.InvariantCulture,
+ out var clock) && clock < TimeSpan.FromDays(1)
+ ? clock
+ : TimeSpan.Zero;
+}
diff --git a/StarterNG/Domain/Tapes/TapeLaunch.cs b/StarterNG/Domain/Tapes/TapeLaunch.cs
new file mode 100644
index 0000000..b29dcc0
--- /dev/null
+++ b/StarterNG/Domain/Tapes/TapeLaunch.cs
@@ -0,0 +1,178 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace StarterNG.Domain.Tapes;
+
+/// How a vehicle records its runs: a Hasler-type paper tape, or an electronic recorder read out on a screen.
+public enum RecorderKind
+{
+ Hasler,
+ Electronic
+}
+
+/// A vehicle of a launched scenery, as the simulator names it in physicslog/.
+public sealed record TapeVehicle(string Name, string DataFolder, string TypeName);
+
+/// What a tape is to the one who started the simulator; in the order they are listed by.
+public enum TapeRole
+{
+ Other,
+ OwnConsist,
+ DrivenEarlier,
+ StartedIn,
+ Driven
+}
+
+///
+/// What the starter knew when it launched the simulator and the log does not
+/// say: the time of day the scenery starts at - the log only counts seconds from
+/// there - which vehicle stands behind each name the logs are written under,
+/// which of them the player went off in: every manned vehicle leaves a log - the
+/// scenario by its name and the number of the train the timetable gives. Once
+/// the next launch is noted, also where the player has been.
+///
+public sealed record TapeLaunch(DateTime LaunchedUtc, string Scenery, TimeSpan StartClock,
+ IReadOnlyList Vehicles, string? Driven = null,
+ IReadOnlyList? Consist = null, string? SceneryName = null,
+ string? Train = null, PlayerVehicles? Player = null)
+{
+ private static readonly Regex NextLaunch = new(@"(?m)^(?=launched\t)", RegexOptions.Compiled);
+
+ public TapeVehicle? Find(string vehicleName) =>
+ Vehicles.FirstOrDefault(v => string.Equals(v.Name, vehicleName, StringComparison.OrdinalIgnoreCase));
+
+ public TapeRole RoleOf(string vehicleName) =>
+ string.Equals(Driven, vehicleName, StringComparison.OrdinalIgnoreCase)
+ ? TapeRole.Driven
+ : Consist?.Contains(vehicleName, StringComparer.OrdinalIgnoreCase) == true
+ ? TapeRole.OwnConsist
+ : TapeRole.Other;
+
+ /// The role as the run went: the vehicle the player used last is the driven one, whichever they set off in.
+ public TapeRole RoleOf(string vehicleName, PlayerVehicles player)
+ {
+ var role = RoleOf(vehicleName);
+ if (!player.Known)
+ return role == TapeRole.Driven ? TapeRole.StartedIn : role;
+ if (player.Current is not { } current)
+ return role;
+ if (string.Equals(current, vehicleName, StringComparison.OrdinalIgnoreCase))
+ return TapeRole.Driven;
+ return role == TapeRole.Driven || player.Used(vehicleName) ? TapeRole.DrivenEarlier : role;
+ }
+
+ ///
+ /// A log belongs to this launch if the simulator wrote it after the starter
+ /// started it; the simulator truncates the log of a vehicle on every run.
+ ///
+ public bool Covers(DateTime logWrittenUtc) => logWrittenUtc >= LaunchedUtc - TimeSpan.FromSeconds(5);
+
+ // One fact a line, tab separated: names and folders may hold spaces.
+ public string Format()
+ {
+ var text = new StringBuilder();
+ text.Append("launched\t").Append(LaunchedUtc.ToString("O", CultureInfo.InvariantCulture)).Append('\n');
+ text.Append("scenery\t").Append(Scenery).Append('\n');
+ text.Append("clock\t").Append(StartClock.ToString(@"hh\:mm", CultureInfo.InvariantCulture)).Append('\n');
+ if (!string.IsNullOrWhiteSpace(SceneryName))
+ text.Append("name\t").Append(SceneryName).Append('\n');
+ if (!string.IsNullOrWhiteSpace(Train))
+ text.Append("train\t").Append(Train).Append('\n');
+ foreach (var v in Vehicles)
+ text.Append("vehicle\t").Append(v.Name).Append('\t').Append(v.DataFolder).Append('\t').Append(v.TypeName)
+ .Append('\n');
+ if (Driven is not null)
+ text.Append("driven\t").Append(Driven).Append('\n');
+ foreach (string name in Consist ?? Array.Empty())
+ text.Append("consist\t").Append(name).Append('\n');
+ if (Player is { Known: true } player)
+ text.AppendJoin('\t', player.Names.Prepend("played")).Append('\n');
+ return text.ToString();
+ }
+
+ /// Every launch a file holds, oldest first.
+ public static IReadOnlyList ParseAll(string text) =>
+ NextLaunch.Split(text).Select(Parse).OfType().OrderBy(launch => launch.LaunchedUtc).ToList();
+
+ public static TapeLaunch? Parse(string text)
+ {
+ DateTime? launched = null;
+ string scenery = "";
+ var clock = TimeSpan.Zero;
+ var vehicles = new List();
+ string? driven = null;
+ var consist = new List();
+ string? name = null;
+ string? train = null;
+ PlayerVehicles? player = null;
+ foreach (var line in text.Split('\n'))
+ {
+ var parts = line.TrimEnd('\r').Split('\t');
+ switch (parts[0])
+ {
+ case "launched" when parts.Length > 1 &&
+ DateTime.TryParse(parts[1], CultureInfo.InvariantCulture,
+ DateTimeStyles.RoundtripKind, out var at):
+ launched = at.ToUniversalTime();
+ break;
+ case "scenery" when parts.Length > 1:
+ scenery = parts[1];
+ break;
+ case "clock" when parts.Length > 1:
+ TimeSpan.TryParseExact(parts[1], @"h\:mm", CultureInfo.InvariantCulture, out clock);
+ break;
+ case "vehicle" when parts.Length > 3:
+ vehicles.Add(new TapeVehicle(parts[1], parts[2], parts[3]));
+ break;
+ case "driven" when parts.Length > 1:
+ driven = parts[1];
+ break;
+ case "consist" when parts.Length > 1:
+ consist.Add(parts[1]);
+ break;
+ case "name" when parts.Length > 1:
+ name = parts[1];
+ break;
+ case "train" when parts.Length > 1:
+ train = parts[1];
+ break;
+ case "played":
+ player = PlayerVehicles.Of(parts.Skip(1));
+ break;
+ }
+ }
+ return launched is { } when
+ ? new TapeLaunch(when, scenery, clock, vehicles, driven, consist, name, train, player)
+ : null;
+ }
+}
+
+public static class Recorders
+{
+ ///
+ /// Vehicles with an electronic recorder in place of a Hasler, matched on the vehicle
+ /// folder and type, or failing that on the name the log is written under.
+ ///
+ private static readonly Regex Electronic = new(
+ @"(?ix) (^|[^a-z0-9])
+ ( en57a[lk][a-z]* | en71ak[a-z]* | en76 | en96 | en97 | ed74 | ed160 | ed161 | ed250 | ed78 | ed72a
+ | ep07p | eu07a | e6act | e6acti | e4dcu | e4mcu | e4msu | e6mcu | 111e
+ | x4e | traxx | vectron | griffin | dragon | husarz | elf | impuls | flirt
+ | pendolino | sa1[0-9][0-9] | 22we | 27we | 31we | 34we | 35we | 36we | 45we )
+ ($|[^a-z0-9])",
+ RegexOptions.Compiled);
+
+ public static RecorderKind Guess(TapeVehicle? vehicle, string tapeName)
+ {
+ if (vehicle is not null)
+ return Electronic.IsMatch(vehicle.DataFolder.Replace('\\', '/').Split('/').Last())
+ || Electronic.IsMatch(vehicle.TypeName)
+ ? RecorderKind.Electronic
+ : RecorderKind.Hasler;
+ return Electronic.IsMatch(tapeName) ? RecorderKind.Electronic : RecorderKind.Hasler;
+ }
+}
diff --git a/StarterNG/Views/Tapes/ElectronicTapeView.cs b/StarterNG/Views/Tapes/ElectronicTapeView.cs
new file mode 100644
index 0000000..f5bd4e2
--- /dev/null
+++ b/StarterNG/Views/Tapes/ElectronicTapeView.cs
@@ -0,0 +1,200 @@
+using System;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Media;
+using StarterNG.Domain.Tapes;
+
+namespace StarterNG.Views.Tapes;
+
+///
+/// A trip on the screen of an electronic recorder - no paper, no stylus, no
+/// clockwork: the speed over a labelled km/h axis, the brake pipe and cylinder
+/// pressures on a chart of their own, and the binary
+/// channels - vigilance alarm, motor current, braking - as bars in rows below,
+/// with the clock and the kilometres along the axes.
+///
+public sealed class ElectronicTapeView : TapeView
+{
+ private const double AxisRow = 22;
+ private const double SpeedChart = 220;
+ private const double ChartGap = 14;
+ private const double PressureChart = 84;
+ private const double PressureFullScaleBar = 6;
+ private const double ChannelRow = 18;
+ private const int Channels = 3;
+ private const double LabelRepeat = 900;
+
+ private const double SpeedTop = AxisRow;
+ private const double SpeedBottom = SpeedTop + SpeedChart;
+ private const double PressureTop = SpeedBottom + ChartGap;
+ private const double PressureBottom = PressureTop + PressureChart;
+ private const double ChannelsTop = PressureBottom + ChartGap;
+ private const double ChannelsBottom = ChannelsTop + Channels * ChannelRow;
+
+ private static readonly IBrush Background = new SolidColorBrush(Color.Parse("#F4F6F8"));
+ private static readonly IBrush PlotBrush = Brushes.White;
+ private static readonly IBrush TextBrush = new SolidColorBrush(Color.Parse("#3A4450"));
+ private static readonly IBrush DimBrush = new SolidColorBrush(Color.Parse("#7B8794"));
+ private static readonly IPen FramePen = new Pen(new SolidColorBrush(Color.Parse("#B9C2CC")), 1);
+ private static readonly IPen GridPen = new Pen(new SolidColorBrush(Color.Parse("#E4E8ED")), 1);
+ private static readonly IPen GridBoldPen = new Pen(new SolidColorBrush(Color.Parse("#CED5DD")), 1);
+ private static readonly IPen SpeedPen = new Pen(new SolidColorBrush(Color.Parse("#1565C0")), 1.6,
+ lineJoin: PenLineJoin.Round);
+ private static readonly IPen PipePen = new Pen(new SolidColorBrush(Color.Parse("#2E7D32")), 1.3,
+ lineJoin: PenLineJoin.Round);
+ private static readonly IPen CylinderPen = new Pen(new SolidColorBrush(Color.Parse("#C62828")), 1.3,
+ lineJoin: PenLineJoin.Round);
+ private static readonly IBrush ShpBrush = new SolidColorBrush(Color.Parse("#F9A825"));
+ private static readonly IBrush CurrentBrush = new SolidColorBrush(Color.Parse("#5C6BC0"));
+ private static readonly IBrush BrakeBrush = new SolidColorBrush(Color.Parse("#E53935"));
+ private static readonly Typeface Face = new(FontFamily.Default);
+
+ protected override double LeftMargin => 74;
+
+ protected override double TapeHeight => ChannelsBottom + AxisRow + 4;
+
+ private double SpeedY(double speed) => SpeedBottom - SpeedChart * Math.Min(speed, TopSpeed) / TopSpeed;
+
+ private static double PressureY(double bar) =>
+ PressureBottom - PressureChart * Math.Clamp(bar / PressureFullScaleBar, 0, 1);
+
+ protected override void DrawBlank(DrawingContext context) =>
+ context.DrawRectangle(Background, null, new Rect(0, 0, Bounds.Width, TapeHeight));
+
+ protected override void DrawTape(DrawingContext context, HaslerTape tape)
+ {
+ double left = LeftMargin, right = TapeRight;
+ foreach (var (top, bottom) in new[] { (SpeedTop, SpeedBottom), (PressureTop, PressureBottom),
+ (ChannelsTop, ChannelsBottom) })
+ context.DrawRectangle(PlotBrush, FramePen, new Rect(left, top, right - left, bottom - top));
+
+ DrawValueGrid(context, left, right);
+ DrawTimeAxis(context, tape);
+ DrawDistanceAxis(context, tape);
+
+ context.DrawGeometry(null, PipePen, Trace(tape, s => PressureY(s.PipePressBar)));
+ context.DrawGeometry(null, CylinderPen, Trace(tape, s => PressureY(s.BrakePressBar)));
+ context.DrawGeometry(null, SpeedPen, Trace(tape, s => SpeedY(s.SpeedKmh)));
+
+ DrawChannel(context, tape, 0, s => s.SecurityAlarm, ShpBrush);
+ DrawChannel(context, tape, 1, s => s.Current, CurrentBrush);
+ DrawChannel(context, tape, 2, s => s.Braking, BrakeBrush);
+
+ DrawLegend(context);
+ }
+
+ /// The value lines and their figures, repeated along a long trip so a scrolled view still reads.
+ private void DrawValueGrid(DrawingContext context, double left, double right)
+ {
+ int step = TapeFigures.SpeedStep(SpeedChart / TopSpeed);
+ for (int v = 0; v <= TopSpeed; v += step)
+ {
+ double y = SpeedY(v);
+ context.DrawLine(v % (5 * step) == 0 ? GridBoldPen : GridPen, new Point(left, y), new Point(right, y));
+ if (v % (2 * step) == 0)
+ for (double x = left; x < right; x += LabelRepeat)
+ Print(context, v.ToString(CultureInfo.InvariantCulture), x == left ? 30 : x + 4, y - 7, 10,
+ x == left ? TextBrush : DimBrush);
+ }
+
+ for (int bar = 0; bar <= PressureFullScaleBar; bar++)
+ {
+ double y = PressureY(bar);
+ context.DrawLine(bar == 5 ? GridBoldPen : GridPen, new Point(left, y), new Point(right, y));
+ if (bar % 2 == 0)
+ Print(context, bar.ToString(CultureInfo.InvariantCulture), 52, y - 7, 10, TextBrush);
+ }
+
+ for (int i = 1; i < Channels; i++)
+ context.DrawLine(GridPen, new Point(left, ChannelsTop + i * ChannelRow),
+ new Point(right, ChannelsTop + i * ChannelRow));
+ }
+
+ /// The clock along the top edge: a line every minute of uniform time, or ticks where minutes pass on a distance axis.
+ private void DrawTimeAxis(DrawingContext context, HaslerTape tape)
+ {
+ double pxPerMinute = ByDistance
+ ? Scale / Math.Max(tape.Duration / 60.0 / Math.Max(Length, 0.001), 0.001)
+ : Scale;
+ double step = TapeFigures.MinuteStep(pxPerMinute);
+ double next = Math.Ceiling(ClockAt(tape.Samples[0]) / 60 / step) * step * 60;
+ double lastLabel = double.NegativeInfinity;
+ foreach (var s in tape.Samples)
+ {
+ double clock = ClockAt(s);
+ if (clock < next)
+ continue;
+ double x = XOf(s);
+ context.DrawLine(GridPen, new Point(x, SpeedTop), new Point(x, SpeedBottom));
+ context.DrawLine(FramePen, new Point(x, SpeedTop - 4), new Point(x, SpeedTop));
+ if (x - lastLabel > 46)
+ {
+ var t = TimeSpan.FromSeconds(next % 86400);
+ Print(context, $"{(int)t.TotalHours:00}:{t.Minutes:00}", x - 14, 3, 10, TextBrush);
+ lastLabel = x;
+ }
+ next = (Math.Floor(clock / 60 / step) + 1) * step * 60;
+ }
+ Print(context, "h:min", 4, 3, 10, DimBrush);
+ }
+
+ /// Kilometres along the bottom edge.
+ private void DrawDistanceAxis(DrawingContext context, HaslerTape tape)
+ {
+ double stepKm = TapeFigures.KmStep(ByDistance ? Scale : Scale * tape.Duration / 60 / Math.Max(tape.DistanceM / 1000, 0.001));
+ double next = 0;
+ double y = ChannelsBottom;
+ double lastLabel = double.NegativeInfinity;
+ foreach (var s in tape.Samples)
+ {
+ if (s.DistanceM < next * 1000)
+ continue;
+ double x = XOf(s);
+ context.DrawLine(FramePen, new Point(x, y), new Point(x, y + 4));
+ if (ByDistance)
+ context.DrawLine(GridPen, new Point(x, PressureTop), new Point(x, PressureBottom));
+ if (x - lastLabel > 40)
+ {
+ Print(context, next.ToString("0.#", CultureInfo.InvariantCulture), x - 6, y + 5, 10, TextBrush);
+ lastLabel = x;
+ }
+ next = (Math.Floor(s.DistanceM / 1000 / stepKm) + 1) * stepKm;
+ }
+ Print(context, "km", 4, y + 5, 10, DimBrush);
+ }
+
+ private void DrawChannel(DrawingContext context, HaslerTape tape, int row, Func on, IBrush brush)
+ {
+ double top = ChannelsTop + row * ChannelRow + 4, height = ChannelRow - 8;
+ double? start = null, last = LeftMargin;
+ foreach (var s in tape.Samples)
+ {
+ double x = XOf(s);
+ if (on(s))
+ start ??= x;
+ else if (start is { } from)
+ {
+ context.DrawRectangle(brush, null, new Rect(from, top, Math.Max(1, x - from), height));
+ start = null;
+ }
+ last = x;
+ }
+ if (start is { } open)
+ context.DrawRectangle(brush, null, new Rect(open, top, Math.Max(1, last.Value - open), height));
+ }
+
+ private void DrawLegend(DrawingContext context)
+ {
+ Print(context, "km/h", 4, SpeedTop + 2, 10, DimBrush);
+ Print(context, "bar", 4, PressureTop + 2, 10, DimBrush);
+ Print(context, App.Loc["HaslerTrackPipe"], 4, PressureTop + 22, 10, PipePen.Brush!);
+ Print(context, App.Loc["HaslerTrackCylinders"], 4, PressureTop + 38, 10, CylinderPen.Brush!);
+ Print(context, App.Loc["HaslerTrackShp"], 4, ChannelsTop + 2, 10, TextBrush);
+ Print(context, App.Loc["HaslerTrackCurrent"], 4, ChannelsTop + ChannelRow + 2, 10, TextBrush);
+ Print(context, App.Loc["HaslerTrackBrake"], 4, ChannelsTop + 2 * ChannelRow + 2, 10, TextBrush);
+ }
+
+ private static void Print(DrawingContext context, string text, double x, double y, double size, IBrush brush) =>
+ context.DrawText(new FormattedText(text, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, Face, size,
+ brush), new Point(x, y));
+}
diff --git a/StarterNG/Views/Tapes/HaslerTapeView.cs b/StarterNG/Views/Tapes/HaslerTapeView.cs
new file mode 100644
index 0000000..3bf778e
--- /dev/null
+++ b/StarterNG/Views/Tapes/HaslerTapeView.cs
@@ -0,0 +1,359 @@
+using System;
+using System.Globalization;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using StarterNG.Domain.Tapes;
+
+namespace StarterNG.Views.Tapes;
+
+///
+/// A trip drawn as a Hasler tape: blue-grey waxed paper, on which every record is
+/// a light, slightly fuzzy line.
+///
+/// - Top margin: a hole every 0.5 km, with the full hours punched in dots between them.
+/// - One field ruled every 10 km/h. The speed curve runs in the lower part and its
+/// figures sit on their lines across the paper. The minute stylus zig-zags in the
+/// upper part on a 10 minute cycle, peaking on every tenth minute. While the train
+/// stands it keeps going, so a stop on a distance-fed tape shows as a brush of strokes.
+/// - Two red lines. Every vigilance (SHP) acknowledgement is a stroke up through the upper one.
+/// - Motor current: a step of its own stylus.
+/// - Brake cylinder pressure: spikes rising from a baseline near the bottom edge.
+///
+/// The paper is fed by distance or by time.
+///
+public sealed class HaslerTapeView : TapeView
+{
+ public HaslerTapeView() => RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.LowQuality);
+
+ private const double Pitch = 16; // px between printed lines, one per 10 km/h
+ private const int MinuteLines = 6; // the minute stylus swings over this many lines
+
+ private const double TopEdge = 26;
+ private const double FieldTop = TopEdge + 10;
+ private const double ShpGap = 14;
+ private const double ShpReach = 9;
+ private const double RedGap = 22;
+ private const double CurrentBand = 16;
+ private const double BrakeBand = 46;
+ private const double BottomEdge = 20;
+
+ private const double MinuteCycle = 600; // s, from one peak to the next
+ private const double PerforationStepM = 500;
+ private const double BrakeFullScaleBar = 5;
+ private const double LabelSpacing = 170;
+
+ private const int GrainSize = 512;
+ private const int GrainPatch = 256; // px laid at a time, each from its own place in the grain
+ private const int MottlingCell = 8; // px of paper to a pixel of the mottling
+
+ private static readonly IBrush PaperBrush = new SolidColorBrush(Color.Parse("#7A94A5"));
+ private static readonly Bitmap Grain = GrainTexture();
+ private static Bitmap? _mottling;
+ private static readonly IBrush EdgeBrush = new SolidColorBrush(Color.Parse("#A8B9C3"));
+ private static readonly IBrush PrintBrush = new SolidColorBrush(Color.Parse("#2A2B33"));
+ private static readonly IBrush HoleBrush = new SolidColorBrush(Color.Parse("#EEF3F5"));
+ private static readonly IPen PrintPen = new Pen(PrintBrush, 1);
+ private static readonly IPen EdgePen = new Pen(PrintBrush, 1.2);
+ private static readonly IPen RedPen = new Pen(new SolidColorBrush(Color.Parse("#9C4E63")), 4);
+ private static readonly IPen StylusPen = new Pen(new SolidColorBrush(Color.Parse("#E4EDF2"), 0.9), 1.5,
+ lineJoin: PenLineJoin.Round, lineCap: PenLineCap.Round);
+ private static readonly IPen StylusHalo = new Pen(new SolidColorBrush(Color.Parse("#E4EDF2"), 0.22), 3.6,
+ lineJoin: PenLineJoin.Round, lineCap: PenLineCap.Round);
+ private static readonly Typeface PrintFace = new(FontFamily.Default, FontStyle.Normal, FontWeight.SemiBold);
+
+ // 5x7 dot figures for the hours punched into the top edge.
+ private static readonly string[] Digits =
+ {
+ "01110100011001110101110011000101110", "00100011000010000100001000010001110",
+ "01110100010000100010001000100011111", "11111000100010000010000011000101110",
+ "00010001100101010010111110001000010", "11111100001111000001000011000101110",
+ "00110010001000011110100011000101110", "11111000010001000100010000100001000",
+ "01110100011000101110100011000101110", "01110100011000101111000010001001100"
+ };
+
+ protected override double LeftMargin => 8;
+
+ private int SpeedLines => (int)(TopSpeed / 10);
+
+ private double FieldBottom => FieldTop + (SpeedLines + MinuteLines) * Pitch;
+
+ private double RedTop => FieldBottom + ShpGap;
+
+ private double CurrentTop => RedTop + RedGap + 4;
+
+ private double BrakeBase => CurrentTop + CurrentBand + BrakeBand;
+
+ protected override double TapeHeight => BrakeBase + 6 + BottomEdge;
+
+ private double SpeedY(double speed) => FieldBottom - Math.Min(speed, TopSpeed) / 10 * Pitch;
+
+ /// The minute stylus: at the top on every tenth minute, at the bottom five minutes later.
+ private static double MinuteY(double clock)
+ {
+ double phase = clock % MinuteCycle / MinuteCycle;
+ return FieldTop + MinuteLines * Pitch * (1 - Math.Abs(1 - 2 * phase));
+ }
+
+ private double BrakeY(double press) => BrakeBase - BrakeBand * Math.Clamp(press / BrakeFullScaleBar, 0, 1);
+
+ protected override void DrawBlank(DrawingContext context)
+ {
+ double width = Bounds.Width;
+ double height = TapeHeight;
+ DrawPaper(context, width, height);
+ context.DrawRectangle(EdgeBrush, null, new Rect(0, 0, width, TopEdge));
+ context.DrawRectangle(EdgeBrush, null, new Rect(0, height - BottomEdge, width, BottomEdge));
+ context.DrawLine(EdgePen, new Point(0, height - BottomEdge), new Point(width, height - BottomEdge));
+ }
+
+ protected override void DrawTape(DrawingContext context, HaslerTape tape)
+ {
+ DrawPrint(context, Bounds.Width);
+ DrawEdges(context, tape, TapeHeight);
+ DrawStylus(context, tape, s => MinuteY(ClockAt(s)));
+ DrawStylus(context, tape, s => SpeedY(s.SpeedKmh));
+ DrawShp(context, tape);
+ DrawStylus(context, tape, s => s.Current ? CurrentTop + CurrentBand - 4 : CurrentTop + 3);
+ DrawStylus(context, tape, s => BrakeY(s.BrakePressBar));
+ }
+
+ ///
+ /// What the paper comes printed with: a line every 10 km/h across the whole
+ /// field, the speed figures climbing across it - each on its own line, which
+ /// breaks around it - and the two red lines.
+ ///
+ private void DrawPrint(DrawingContext context, double width)
+ {
+ int lines = SpeedLines + MinuteLines;
+ var gaps = new System.Collections.Generic.List<(int Line, double From, double To, FormattedText Text)>();
+ double block = SpeedLines * LabelSpacing;
+ for (double start = LeftMargin + LabelSpacing / 2; start < width; start += block)
+ for (int v = 1; v <= SpeedLines; v++)
+ {
+ double x = start + (v - 1) * LabelSpacing;
+ if (x >= width)
+ break;
+ var text = new FormattedText((v * 10).ToString(CultureInfo.InvariantCulture),
+ CultureInfo.InvariantCulture, FlowDirection.LeftToRight, PrintFace, 15,
+ PrintBrush);
+ gaps.Add((v, x - 5, x + text.Width + 5, text));
+ }
+
+ for (int line = 0; line <= lines; line++)
+ {
+ double y = FieldBottom - line * Pitch;
+ double from = 0;
+ foreach (var gap in gaps)
+ if (gap.Line == line)
+ {
+ context.DrawLine(PrintPen, new Point(from, y), new Point(gap.From, y));
+ from = gap.To;
+ }
+ context.DrawLine(PrintPen, new Point(from, y), new Point(width, y));
+ }
+
+ foreach (var gap in gaps)
+ context.DrawText(gap.Text, new Point(gap.From + 5, SpeedY(gap.Line * 10) - gap.Text.Height / 2));
+
+ context.DrawLine(RedPen, new Point(0, RedTop), new Point(width, RedTop));
+ context.DrawLine(RedPen, new Point(0, RedTop + RedGap), new Point(width, RedTop + RedGap));
+ }
+
+ ///
+ /// The transport holes along both edges, one per 0.5 km, and the hour punched
+ /// as a figure of dots into the top edge where the clock passes a full hour -
+ /// midnight as 24.
+ ///
+ private void DrawEdges(DrawingContext context, HaslerTape tape, double height)
+ {
+ var figures = new System.Collections.Generic.List<(double From, double To)>();
+ double nextHour = Math.Ceiling(ClockAt(tape.Samples[0]) / 3600) * 3600;
+ foreach (var s in tape.Samples)
+ {
+ double clock = ClockAt(s);
+ if (clock >= nextHour)
+ {
+ int hour = (int)(clock / 3600) % 24;
+ figures.Add(PunchHour(context, hour == 0 ? 24 : hour, XOf(s)));
+ nextHour = (Math.Floor(clock / 3600) + 1) * 3600;
+ }
+ }
+
+ double nextHole = 0;
+ foreach (var s in tape.Samples)
+ if (s.DistanceM >= nextHole)
+ {
+ double x = XOf(s);
+ if (!figures.Exists(f => x > f.From - 3 && x < f.To + 3))
+ Hole(context, x, TopEdge / 2);
+ Hole(context, x, height - BottomEdge / 2);
+ nextHole = (Math.Floor(s.DistanceM / PerforationStepM) + 1) * PerforationStepM;
+ }
+ }
+
+ private static (double From, double To) PunchHour(DrawingContext context, int hour, double x)
+ {
+ const double dot = 3;
+ string figures = hour.ToString(CultureInfo.InvariantCulture);
+ double width = figures.Length * 6 * dot;
+ double left = x - width / 2;
+ double top = (TopEdge - 7 * dot) / 2 + dot / 2;
+ for (int f = 0; f < figures.Length; f++)
+ {
+ string pattern = Digits[figures[f] - '0'];
+ for (int i = 0; i < pattern.Length; i++)
+ if (pattern[i] == '1')
+ context.DrawEllipse(HoleBrush, null,
+ new Point(left + f * 6 * dot + i % 5 * dot, top + i / 5 * dot), 1.2, 1.2);
+ }
+ return (left, left + width);
+ }
+
+ private static void Hole(DrawingContext context, double x, double y)
+ {
+ context.DrawEllipse(HoleBrush, null, new Point(x, y), 2.4, 2.4);
+ context.DrawEllipse(HoleBrush, null, new Point(x - 2.8, y), 0.9, 0.9);
+ context.DrawEllipse(HoleBrush, null, new Point(x + 2.8, y), 0.9, 0.9);
+ }
+
+ private void DrawStylus(DrawingContext context, HaslerTape tape, Func y)
+ {
+ var geometry = Trace(tape, y);
+ context.DrawGeometry(null, StylusHalo, geometry);
+ context.DrawGeometry(null, StylusPen, geometry);
+ }
+
+ /// The vigilance stylus rests under the upper red line and strokes up through it on every acknowledgement.
+ private void DrawShp(DrawingContext context, HaslerTape tape)
+ {
+ double rest = RedTop + 6;
+ double left = LeftMargin, right = TapeRight;
+ context.DrawLine(StylusHalo, new Point(left, rest), new Point(right, rest));
+ context.DrawLine(StylusPen, new Point(left, rest), new Point(right, rest));
+ bool alarm = false;
+ foreach (var s in tape.Samples)
+ {
+ if (alarm && !s.SecurityAlarm)
+ {
+ double x = XOf(s);
+ context.DrawLine(StylusHalo, new Point(x, rest), new Point(x, RedTop - ShpReach));
+ context.DrawLine(StylusPen, new Point(x, rest), new Point(x, RedTop - ShpReach));
+ }
+ alarm = s.SecurityAlarm;
+ }
+ }
+
+ ///
+ /// The paper, which repeats nowhere: the mottling is one bitmap stretched over the
+ /// whole tape, and the grain, having no shapes to join up, is laid in patches from
+ /// scattered places of its texture. A tiled ImageBrush loses tiles on screen, leaving
+ /// holes in the paper; plain DrawImage calls do not, and the solid colour underneath
+ /// covers anything the bitmaps do not.
+ ///
+ private static void DrawPaper(DrawingContext context, double width, double height)
+ {
+ context.DrawRectangle(PaperBrush, null, new Rect(0, 0, width, height));
+ context.DrawImage(MottlingFor(width, height), new Rect(0, 0, width / MottlingCell, height / MottlingCell),
+ new Rect(0, 0, width, height));
+
+ for (int row = 0; row * GrainPatch < height; row++)
+ for (int column = 0; column * GrainPatch < width; column++)
+ {
+ uint place = Hash(column, row);
+ // The last row and column are cut to the tape, not left hanging over its edge.
+ var size = new Size(Math.Min(GrainPatch, width - column * GrainPatch),
+ Math.Min(GrainPatch, height - row * GrainPatch));
+ context.DrawImage(Grain, new Rect(new Point(place % GrainPatch, (place >> 16) % GrainPatch), size),
+ new Rect(new Point(column * GrainPatch, row * GrainPatch), size));
+ }
+ }
+
+ ///
+ /// The light and dark clouds of the coating. Worked out from the place on the paper
+ /// alone, so one bitmap serves every tape it is big enough for and only ever grows.
+ ///
+ private static Bitmap MottlingFor(double width, double height)
+ {
+ int columns = (int)Math.Ceiling(width / MottlingCell) + 1;
+ int rows = (int)Math.Ceiling(height / MottlingCell) + 1;
+ if (_mottling is not null && _mottling.PixelSize.Width >= columns && _mottling.PixelSize.Height >= rows)
+ return _mottling;
+
+ columns = Math.Max(columns + columns / 4, _mottling?.PixelSize.Width ?? 0);
+ rows = Math.Max(rows, _mottling?.PixelSize.Height ?? 0);
+ var pixels = new byte[columns * rows * 4];
+ for (int y = 0; y < rows; y++)
+ for (int x = 0; x < columns; x++)
+ {
+ double shade = Noise(x, y, 5) * 18 + Noise(x, y, 23) * 12;
+ int o = (y * columns + x) * 4;
+ pixels[o] = Channel(165 + shade); // B
+ pixels[o + 1] = Channel(148 + shade); // G
+ pixels[o + 2] = Channel(122 + shade); // R
+ pixels[o + 3] = 255;
+ }
+
+ return _mottling = ToBitmap(pixels, columns, rows, AlphaFormat.Opaque);
+ }
+
+ /// The fine grain of the coating and flecks of it already gone, laid over the mottling.
+ private static Bitmap GrainTexture()
+ {
+ var random = new Random(2020);
+ var pixels = new byte[GrainSize * GrainSize * 4];
+ for (int o = 0; o < pixels.Length; o += 4)
+ {
+ double shade = (random.NextDouble() - 0.5) * 20;
+ if (random.NextDouble() < 0.004)
+ shade += 64; // a fleck of coating already gone
+
+ // Premultiplied: white to lighten, black to darken.
+ byte alpha = Channel(Math.Abs(shade));
+ pixels[o] = pixels[o + 1] = pixels[o + 2] = shade > 0 ? alpha : (byte)0;
+ pixels[o + 3] = alpha;
+ }
+
+ return ToBitmap(pixels, GrainSize, GrainSize, AlphaFormat.Premul);
+ }
+
+ /// Smooth noise over cells of the given size, -0.5 to 0.5; a place always gives the same value.
+ private static double Noise(int x, int y, int cell)
+ {
+ int left = x / cell, top = y / cell;
+ double across = Fade((double)(x % cell) / cell), down = Fade((double)(y % cell) / cell);
+ return Lerp(Lerp(Corner(left, top), Corner(left + 1, top), across),
+ Lerp(Corner(left, top + 1), Corner(left + 1, top + 1), across), down);
+
+ double Corner(int i, int j) => Hash(i, j + cell * 7919) / (double)uint.MaxValue - 0.5;
+ static double Fade(double t) => t * t * (3 - 2 * t);
+ static double Lerp(double a, double b, double t) => a + (b - a) * t;
+ }
+
+ private static uint Hash(int x, int y)
+ {
+ unchecked
+ {
+ uint h = (uint)x * 73856093u ^ (uint)y * 19349663u;
+ h ^= h >> 15;
+ h *= 2246822519u;
+ h ^= h >> 13;
+ h *= 3266489917u;
+ return h ^ h >> 16;
+ }
+ }
+
+ private static byte Channel(double v) => (byte)Math.Clamp(v, 0, 255);
+
+ private static Bitmap ToBitmap(byte[] pixels, int width, int height, AlphaFormat alpha)
+ {
+ var bitmap = new WriteableBitmap(new PixelSize(width, height), new Vector(96, 96), PixelFormat.Bgra8888, alpha);
+ using (var buffer = bitmap.Lock())
+ for (int y = 0; y < height; y++)
+ Marshal.Copy(pixels, y * width * 4, buffer.Address + y * buffer.RowBytes, width * 4);
+
+ return bitmap;
+ }
+}
diff --git a/StarterNG/Views/Tapes/HaslerTapeWindow.cs b/StarterNG/Views/Tapes/HaslerTapeWindow.cs
new file mode 100644
index 0000000..03fa3ba
--- /dev/null
+++ b/StarterNG/Views/Tapes/HaslerTapeWindow.cs
@@ -0,0 +1,566 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+using StarterNG.Application;
+using StarterNG.Domain.Settings;
+using StarterNG.Domain.Tapes;
+
+namespace StarterNG.Views.Tapes;
+
+///
+/// Reads the speed recorder tapes the simulator writes to physicslog/ - one per
+/// driven vehicle - grouped by the run they came from, and lays them out as Hasler
+/// paper, fed by distance, or as the screen of an electronic recorder for vehicles
+/// that have one, by time or by distance. The clock and the recorder come from
+/// what the starter noted at the launch; both can be set by hand. While a session
+/// runs "Live" goes to the tape of the vehicle the player is in and keeps
+/// re-reading it.
+///
+public sealed class HaslerTapeWindow : Window
+{
+ private const double DefaultKmScale = 40;
+ private const double DefaultMinuteScale = 30;
+ private const double MaxExportWidth = 32000;
+
+ private static readonly IBrush RunningBrush = new SolidColorBrush(Color.Parse("#41c400"));
+
+ private readonly HaslerTapes _tapes = AppServices.Current.Tapes;
+ private readonly TreeView _list = new() { Width = 260 };
+ private readonly ComboBox _feed = new() { Width = 120, VerticalAlignment = VerticalAlignment.Center };
+ private readonly Slider _scale = new() { Width = 140, Minimum = 4, Maximum = 600, VerticalAlignment = VerticalAlignment.Center };
+ private readonly ComboBox _recorder = new() { Width = 150, VerticalAlignment = VerticalAlignment.Center };
+ private readonly CheckBox _live = new() { VerticalAlignment = VerticalAlignment.Center };
+ private readonly TextBox _startClock = new() { Width = 64, PlaceholderText = "hh:mm", VerticalAlignment = VerticalAlignment.Center };
+ private readonly TextBlock _summary = new() { Margin = new Thickness(8, 4), TextWrapping = TextWrapping.Wrap };
+ private readonly TextBlock _cursor = new() { Margin = new Thickness(8, 4), FontSize = 12, Opacity = 0.85 };
+ private readonly TextBlock _empty = new()
+ {
+ Margin = new Thickness(24),
+ TextWrapping = TextWrapping.Wrap,
+ Opacity = 0.75,
+ IsVisible = false
+ };
+ private readonly ScrollViewer _scroll;
+ private TapeView _view;
+ private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromSeconds(2) };
+ private TapeContext? _context;
+ private readonly Control _recordingOff;
+ private readonly Control _logOff;
+ private readonly Dictionary _expanded = new();
+
+ private TapeFile? _shown;
+ private string? _followed;
+ private string _listed = "";
+ private bool _running;
+ private bool _loading;
+ private bool _looking;
+
+ /// A setting went on from here; the settings page's boxes have to follow it.
+ public event Action? LogEnabled;
+
+ public HaslerTapeWindow()
+ {
+ Title = App.Loc["HaslerTapes"];
+ Width = 1100;
+ Height = 560;
+ MinWidth = 700;
+ MinHeight = 420;
+ WindowStartupLocation = WindowStartupLocation.CenterOwner;
+
+ _feed.ItemsSource = new[] { App.Loc["HaslerByDistance"], App.Loc["HaslerByTime"] };
+ _feed.SelectedIndex = 0;
+ _feed.IsEnabled = false;
+ _feed.SelectionChanged += (_, _) => FeedChanged();
+ _scale.Value = DefaultKmScale;
+ _scale.PropertyChanged += (_, e) =>
+ {
+ if (e.Property == RangeBase.ValueProperty)
+ _view.Scale = _scale.Value;
+ };
+ _live.Content = App.Loc["HaslerLive"];
+ ToolTip.SetShowOnDisabled(_live, true);
+ _live.IsCheckedChanged += async (_, _) =>
+ {
+ if (_live.IsChecked == true)
+ await RefreshListAsync(toDriven: true);
+ };
+ _timer.Tick += async (_, _) => await LookAtSessionsAsync();
+ _startClock.TextChanged += (_, _) => _view.StartClock = TapeFigures.ParseClock(_startClock.Text);
+ _recorder.ItemsSource = new[] { App.Loc["HaslerRecorderHasler"], App.Loc["HaslerRecorderElectronic"] };
+ _recorder.SelectedIndex = 0;
+ _recorder.SelectionChanged += (_, _) => RecorderChanged();
+ ToolTip.SetTip(_startClock, App.Loc["HaslerStartClockTip"]);
+
+ ScrollViewer.SetHorizontalScrollBarVisibility(_list, ScrollBarVisibility.Disabled);
+ _list.SelectionChanged += async (_, _) =>
+ {
+ // 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;
+ var file = _list.SelectedItem switch
+ {
+ TreeViewItem { Tag: TapeFile tape } => tape,
+ TreeViewItem { Tag: TapeSession { Tapes.Count: > 0 } run } => run.Tapes[0],
+ _ => null
+ };
+ if (file is null || file == _shown)
+ return;
+
+ ApplyContext(_tapes.ContextOf(file));
+ await ShowAsync(file, scrollToEnd: false);
+ };
+
+ _view = NewView(RecorderKind.Hasler);
+ _view.Scale = DefaultKmScale;
+ _view.Hovered += ShowCursor;
+ _scroll = new ScrollViewer
+ {
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
+ Content = _view
+ };
+
+ _recordingOff = Strip("HaslerRecordingOff", "HaslerRecordingEnable", settings => settings.PhysicsLog = true);
+ _logOff = Strip("HaslerLogOff", "HaslerLogEnable", settings => settings.DebugLog = true);
+
+ var folder = ToolButton(App.Loc["HaslerOpenFolder"]);
+ folder.Click += (_, _) =>
+ {
+ AppServices.Current.Files.CreateDirectory(_tapes.Folder);
+ AppServices.Current.Processes.OpenInShell(_tapes.Folder);
+ };
+
+ var export = ToolButton(App.Loc["HaslerExportPng"]);
+ export.Click += async (_, _) => await ExportPngAsync();
+
+ var toolbar = new WrapPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(4, 2) };
+ foreach (var control in new Control[]
+ {
+ Label(App.Loc["HaslerRecorder"]), _recorder, Label(App.Loc["HaslerFeed"]), _feed,
+ Label(App.Loc["HaslerScale"]), _scale,
+ Label(App.Loc["HaslerStartClock"]), _startClock, _live, folder, export
+ })
+ {
+ control.Margin = new Thickness(4);
+ toolbar.Children.Add(control);
+ }
+
+ var tapeArea = new DockPanel();
+ DockPanel.SetDock(_recordingOff, Dock.Top);
+ DockPanel.SetDock(_logOff, Dock.Top);
+ DockPanel.SetDock(toolbar, Dock.Top);
+ DockPanel.SetDock(_summary, Dock.Top);
+ DockPanel.SetDock(_cursor, Dock.Bottom);
+ tapeArea.Children.Add(_recordingOff);
+ tapeArea.Children.Add(_logOff);
+ tapeArea.Children.Add(toolbar);
+ tapeArea.Children.Add(_summary);
+ tapeArea.Children.Add(_cursor);
+ tapeArea.Children.Add(new Panel { Children = { _scroll, _empty } });
+
+ var root = new DockPanel();
+ var listBorder = new Border
+ {
+ BorderBrush = new SolidColorBrush(Color.Parse("#3A424A")),
+ BorderThickness = new Thickness(0, 0, 1, 0),
+ Child = _list
+ };
+ DockPanel.SetDock(listBorder, Dock.Left);
+ root.Children.Add(listBorder);
+ root.Children.Add(tapeArea);
+ Content = root;
+
+ Opened += async (_, _) =>
+ {
+ _timer.Start();
+ await RefreshListAsync();
+ };
+ Activated += (_, _) => ShowWhatIsKnown();
+ Closed += (_, _) => _timer.Stop();
+ }
+
+ ///
+ /// What gets recorded, and whether there is a trip for "live" to follow: a session
+ /// that runs, and the player in a vehicle.
+ ///
+ private void ShowWhatIsKnown()
+ {
+ var settings = AppServices.Current.Settings;
+ _recordingOff.IsVisible = !settings.PhysicsLog;
+ _logOff.IsVisible = settings.PhysicsLog && !settings.DebugLog;
+
+ bool canFollow = _running && _tapes.DrivenVehicle is not null;
+ _live.IsEnabled = canFollow;
+ if (!canFollow)
+ _live.IsChecked = false;
+ ToolTip.SetTip(_live, App.Loc[canFollow ? "HaslerLiveTip" : "HaslerLiveIdle"]);
+ }
+
+ private DockPanel Strip(string textKey, string buttonKey, Action enable)
+ {
+ var button = ToolButton(App.Loc[buttonKey]);
+ button.Classes.Add("Accent");
+ button.Margin = new Thickness(8, 0, 0, 0);
+ button.Click += (_, _) => Enable(enable);
+ DockPanel.SetDock(button, Dock.Right);
+ return new DockPanel
+ {
+ Margin = new Thickness(8, 4),
+ IsVisible = false,
+ Children =
+ {
+ button,
+ new TextBlock
+ {
+ Text = App.Loc[textKey],
+ TextWrapping = TextWrapping.Wrap,
+ VerticalAlignment = VerticalAlignment.Center
+ }
+ }
+ };
+ }
+
+ /// Only this one setting: other unsaved edits on the settings page are not this button's to save.
+ private void Enable(Action enable)
+ {
+ var store = AppServices.Current.SettingsStore;
+ enable(store.Settings);
+ store.Save();
+ LogEnabled?.Invoke();
+ ShowWhatIsKnown();
+ if (store.LastSaveFailed)
+ _summary.Text = App.Loc["SettingsNotSaved"];
+ }
+
+ private static Button ToolButton(string text) => new()
+ {
+ Content = text,
+ Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
+ };
+
+ private static TextBlock Label(string text) => new() { Text = text, VerticalAlignment = VerticalAlignment.Center };
+
+ private RecorderKind Recorder => _recorder.SelectedIndex == 1 ? RecorderKind.Electronic : RecorderKind.Hasler;
+
+ private static TapeView NewView(RecorderKind kind) => kind == RecorderKind.Electronic
+ ? new ElectronicTapeView { HorizontalAlignment = HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Top }
+ : new HaslerTapeView { HorizontalAlignment = HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Top };
+
+ ///
+ /// The same trip drawn the other way. Hasler paper is fed by distance and
+ /// nothing else; the screen of an electronic recorder runs on the clock, and
+ /// may be switched to distance.
+ ///
+ private void RecorderChanged()
+ {
+ bool electronic = Recorder == RecorderKind.Electronic;
+ _feed.IsEnabled = electronic;
+ _feed.SelectedIndex = electronic ? 1 : 0;
+ if ((_view is ElectronicTapeView) == electronic)
+ return;
+
+ var old = _view;
+ old.Hovered -= ShowCursor;
+ _view = NewView(Recorder);
+ _view.CopySettingsFrom(old);
+ _view.Hovered += ShowCursor;
+ _scroll.Content = _view;
+ // The other view is not as tall; an offset kept from it would hide its top rows.
+ _scroll.Offset = new Vector(_scroll.Offset.X, 0);
+ _cursor.Text = "";
+ }
+
+ private async Task RefreshListAsync(bool toDriven = false) =>
+ ShowSessions(await Task.Run(_tapes.Sessions), toDriven);
+
+ /// The tape of the vehicle the player is in, in the session that runs.
+ private static TapeFile? DrivenTape(IEnumerable sessions) =>
+ sessions.FirstOrDefault(session => session.Running)?.Tapes
+ .FirstOrDefault(tape => tape.Role is TapeRole.Driven or TapeRole.StartedIn);
+
+ 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}"))));
+
+ /// Sessions newest first, the newest one open until the reader has said otherwise.
+ private void ShowSessions(IReadOnlyList sessions, bool toDriven)
+ {
+ _running = sessions.Any(session => session.Running);
+ _listed = Fingerprint(sessions);
+ ShowWhatIsKnown();
+
+ string? driven = DrivenTape(sessions)?.Path;
+ if (toDriven)
+ _followed = driven;
+ string? selected = toDriven && driven is not null ? driven : _shown?.Path;
+
+ foreach (var node in _list.Items.OfType())
+ _expanded[((TapeSession)node.Tag!).StartedUtc] = node.IsExpanded;
+ _list.Items.Clear();
+
+ (TreeViewItem Node, TreeViewItem Tape)? first = null, pick = null;
+ for (int i = 0; i < sessions.Count; i++)
+ {
+ var node = SessionItem(sessions[i], _expanded.GetValueOrDefault(sessions[i].StartedUtc, i == 0));
+ _list.Items.Add(node);
+ foreach (var tape in node.Items.OfType())
+ {
+ first ??= (node, tape);
+ if (((TapeFile)tape.Tag!).Path == selected)
+ pick = (node, tape);
+ }
+ }
+
+ _empty.Text = string.Format(App.Loc["HaslerNoTapes"], _tapes.Folder);
+ _empty.IsVisible = first is null;
+ _scroll.IsVisible = first is not null;
+ if ((pick ?? first) is not { } chosen)
+ {
+ _shown = null;
+ _view.Tape = null;
+ _summary.Text = "";
+ return;
+ }
+
+ chosen.Node.IsExpanded = true;
+ _list.SelectedItem = chosen.Tape;
+ }
+
+ private static 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 }
+ .Where(part => !string.IsNullOrWhiteSpace(part)))
+ : App.Loc["HaslerSession"];
+
+ var started = session.StartedUtc.ToLocalTime();
+ var written = session.LastWrittenUtc.ToLocalTime();
+ string when = $"{started:dd.MM HH:mm}";
+ if (!session.Running && written - started >= TimeSpan.FromMinutes(1))
+ when += $"–{written:HH:mm}";
+ if (session.Tapes.Count > 0)
+ when += " · " + string.Format(App.Loc["HaslerSessionTapes"], session.Tapes.Count);
+
+ var details = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 };
+ details.Children.Add(new TextBlock { Text = when, FontSize = 11, Opacity = 0.7 });
+ if (session.Running)
+ details.Children.Add(new TextBlock
+ {
+ Text = App.Loc["HaslerSessionRunning"],
+ FontSize = 11,
+ FontWeight = FontWeight.SemiBold,
+ Foreground = RunningBrush
+ });
+
+ var name = new TextBlock { Text = title, FontWeight = FontWeight.SemiBold, TextTrimming = TextTrimming.CharacterEllipsis };
+ ToolTip.SetTip(name, title);
+
+ var item = new TreeViewItem
+ {
+ Header = new StackPanel { Spacing = 1, Children = { name, details } },
+ Tag = session,
+ IsExpanded = open
+ };
+ foreach (var tape in session.Tapes)
+ item.Items.Add(TapeItem(tape));
+ return item;
+ }
+
+ private static TreeViewItem TapeItem(TapeFile file)
+ {
+ var text = new StackPanel { Spacing = 1 };
+ text.Children.Add(new TextBlock
+ {
+ Text = file.Vehicle,
+ FontWeight = file.Role == TapeRole.Other ? FontWeight.Normal : FontWeight.SemiBold,
+ TextTrimming = TextTrimming.CharacterEllipsis
+ });
+ string? role = file.Role switch
+ {
+ TapeRole.Driven => App.Loc["HaslerRoleDriven"],
+ TapeRole.StartedIn => App.Loc["HaslerRoleStartedIn"],
+ TapeRole.DrivenEarlier => App.Loc["HaslerRoleDrivenEarlier"],
+ TapeRole.OwnConsist => App.Loc["HaslerRoleConsist"],
+ _ => null
+ };
+ if (role is not null)
+ text.Children.Add(new TextBlock { Text = role, FontSize = 11, Opacity = 0.7 });
+ return new TreeViewItem { Header = text, Tag = file };
+ }
+
+ ///
+ /// What the starter noted at the launch the tape came from: the clock the
+ /// scenery started at and the recorder its vehicle has. A tape from before
+ /// the starter noted anything keeps the hour unknown and the recorder guessed.
+ ///
+ private void ApplyContext(TapeContext context)
+ {
+ _context = context;
+ _startClock.Text = context.StartClock is { } clock
+ ? clock.ToString(@"hh\:mm", CultureInfo.InvariantCulture)
+ : "";
+ _recorder.SelectedIndex = context.Recorder == RecorderKind.Electronic ? 1 : 0;
+ }
+
+ private async Task ShowAsync(TapeFile file, bool scrollToEnd)
+ {
+ if (_loading)
+ return;
+ _loading = true;
+ try
+ {
+ var tape = await Task.Run(() => _tapes.Load(file));
+ _shown = file;
+ if (tape is null)
+ {
+ _view.Tape = null;
+ _summary.Text = string.Format(App.Loc["HaslerUnreadable"], file.Path);
+ return;
+ }
+
+ _view.Tape = tape;
+ _summary.Text = string.Format(App.Loc["HaslerSummary"], file.Vehicle, TapeFigures.Clock(tape.Duration),
+ TapeFigures.Clock(tape.RunningTime), TapeFigures.Clock(tape.StandingTime),
+ tape.DistanceM / 1000.0, tape.MaxSpeedKmh, tape.AverageSpeedKmh,
+ tape.AverageWithStopsKmh);
+ if (_context?.Scenery is { } scenery)
+ _summary.Text += " · " + string.Format(App.Loc["HaslerScenario"], scenery);
+ if (_context?.Train is { } train)
+ _summary.Text += " · " + string.Format(App.Loc["HaslerTrain"], train);
+ // The log counts seconds from the start: an hour comes only from what the starter noted.
+ if (string.IsNullOrWhiteSpace(_startClock.Text))
+ _summary.Text += " · " + App.Loc["HaslerClockUnknown"];
+ if (scrollToEnd)
+ Dispatcher.UIThread.Post(() => _scroll.Offset = new Vector(double.MaxValue, _scroll.Offset.Y),
+ DispatcherPriority.Background);
+ }
+ finally
+ {
+ _loading = false;
+ }
+ }
+
+ ///
+ /// Keeps the list up with the sessions, and for a trip followed live re-reads the
+ /// tape when the simulator has added to it. A change of vehicle or a new launch's
+ /// own tape takes over; one picked by hand stays until then.
+ ///
+ private async Task LookAtSessionsAsync()
+ {
+ if (_loading || _looking)
+ return;
+
+ _looking = true;
+ try
+ {
+ var sessions = await Task.Run(_tapes.Sessions);
+ bool live = _live.IsChecked == true;
+ bool newDriven = live && DrivenTape(sessions) is { } driven && driven.Path != _followed;
+ if (newDriven || Fingerprint(sessions) != _listed)
+ {
+ ShowSessions(sessions, toDriven: newDriven);
+ return;
+ }
+
+ if (!live || _shown is not { } shown)
+ return;
+ var now = sessions.SelectMany(session => session.Tapes).FirstOrDefault(tape => tape.Path == shown.Path);
+ if (now is null || (now.Size == shown.Size && now.LastWriteUtc == shown.LastWriteUtc))
+ return;
+
+ // Following the end only when the reader is already there, so scrolling back is not undone.
+ bool atEnd = _scroll.Offset.X + _scroll.Viewport.Width >= _scroll.Extent.Width - 4;
+ await ShowAsync(now, scrollToEnd: atEnd);
+ }
+ finally
+ {
+ _looking = false;
+ }
+ }
+
+ private void FeedChanged()
+ {
+ bool byDistance = _feed.SelectedIndex != 1;
+ if (_view.ByDistance == byDistance)
+ return;
+ _view.ByDistance = byDistance;
+ _scale.Value = byDistance ? DefaultKmScale : DefaultMinuteScale;
+ _scale.Maximum = byDistance ? 600 : 300;
+ }
+
+ /// The whole tape, not just what is scrolled into view, drawn afresh at the chosen scale.
+ private async Task ExportPngAsync()
+ {
+ if (_view.Tape is not { IsEmpty: false } tape || _shown is null)
+ return;
+
+ var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = App.Loc["HaslerExportPng"],
+ SuggestedFileName = _shown.Vehicle + ".png",
+ DefaultExtension = "png",
+ FileTypeChoices = new[] { new FilePickerFileType("PNG") { Patterns = new[] { "*.png" } } }
+ });
+ if (file is null)
+ return;
+
+ var copy = NewView(Recorder);
+ copy.CopySettingsFrom(_view);
+ // The trip it was asked for: a reload may have come in behind the file picker.
+ copy.Tape = tape;
+ copy.Measure(Size.Infinity);
+ if (copy.DesiredSize.Width > MaxExportWidth)
+ {
+ // A bitmap that wide is past what the renderer takes; the paper is squeezed to fit instead.
+ copy.Scale *= MaxExportWidth / copy.DesiredSize.Width;
+ copy.Measure(Size.Infinity);
+ }
+ copy.Arrange(new Rect(copy.DesiredSize));
+
+ try
+ {
+ using var bitmap = new RenderTargetBitmap(new PixelSize((int)Math.Ceiling(copy.DesiredSize.Width),
+ (int)Math.Ceiling(copy.DesiredSize.Height)));
+ bitmap.Render(copy);
+ await using var stream = await file.OpenWriteAsync();
+ bitmap.Save(stream);
+ }
+ catch (Exception ex)
+ {
+ AppServices.Current.Log.Log($"exporting {_shown.Path} to {file.Name}", ex);
+ _summary.Text = string.Format(App.Loc["HaslerExportFailed"], file.Name);
+ }
+ }
+
+ private void ShowCursor(TapeSample? sample)
+ {
+ if (sample is not { } s || _view.Tape is null)
+ {
+ _cursor.Text = "";
+ return;
+ }
+ // Only what the record in view shows: a Hasler tape has no brake pipe and
+ // marks current as on or off, and the electronic screen draws the current
+ // and the vigilance alarm as channels, not values.
+ var clock = TimeSpan.FromSeconds(_view.ClockAt(s) % 86400);
+ string time = clock.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture);
+ _cursor.Text = Recorder == RecorderKind.Electronic
+ ? string.Format(App.Loc["ElectronicCursor"], time, s.DistanceM / 1000.0, s.SpeedKmh, s.PipePressBar,
+ s.BrakePressBar, YesNo(s.Current), YesNo(s.SecurityAlarm))
+ : string.Format(App.Loc["HaslerCursor"], time, s.DistanceM / 1000.0, s.SpeedKmh, s.BrakePressBar,
+ YesNo(s.Current));
+ }
+
+ private static string YesNo(bool on) => on ? App.Loc["HaslerYes"] : App.Loc["HaslerNo"];
+}
diff --git a/StarterNG/Views/Tapes/TapeView.cs b/StarterNG/Views/Tapes/TapeView.cs
new file mode 100644
index 0000000..63ab02f
--- /dev/null
+++ b/StarterNG/Views/Tapes/TapeView.cs
@@ -0,0 +1,202 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using StarterNG.Domain.Tapes;
+
+namespace StarterNG.Views.Tapes;
+
+///
+/// What every way of drawing a recorded trip shares: the trip, how it is laid
+/// along the x axis - by distance or by time, at a scale - the clock it started
+/// at, and the pointer read-out.
+///
+public abstract class TapeView : Control
+{
+ public static readonly StyledProperty TapeProperty =
+ AvaloniaProperty.Register(nameof(Tape));
+
+ public static readonly StyledProperty ByDistanceProperty =
+ AvaloniaProperty.Register(nameof(ByDistance), true);
+
+ /// Pixels per kilometre when fed by distance, per minute when fed by time.
+ public static readonly StyledProperty ScaleProperty =
+ AvaloniaProperty.Register(nameof(Scale), 40);
+
+ ///
+ /// The time of day the recording started at. The log only counts seconds, so
+ /// without it the clock counts from the start of the trip.
+ ///
+ public static readonly StyledProperty StartClockProperty =
+ AvaloniaProperty.Register(nameof(StartClock));
+
+ private static readonly IPen CursorPen = new Pen(new SolidColorBrush(Color.Parse("#FFB000")), 1);
+
+ static TapeView()
+ {
+ AffectsMeasure(TapeProperty, ByDistanceProperty, ScaleProperty);
+ AffectsRender(TapeProperty, ByDistanceProperty, ScaleProperty, StartClockProperty);
+ }
+
+ public HaslerTape? Tape
+ {
+ get => GetValue(TapeProperty);
+ set => SetValue(TapeProperty, value);
+ }
+
+ public bool ByDistance
+ {
+ get => GetValue(ByDistanceProperty);
+ set => SetValue(ByDistanceProperty, value);
+ }
+
+ public double Scale
+ {
+ get => GetValue(ScaleProperty);
+ set => SetValue(ScaleProperty, value);
+ }
+
+ public TimeSpan StartClock
+ {
+ get => GetValue(StartClockProperty);
+ set => SetValue(StartClockProperty, value);
+ }
+
+ /// The sample under the pointer, or null once it leaves the tape.
+ public event Action? Hovered;
+
+ protected abstract double LeftMargin { get; }
+
+ private const double RightMargin = 24;
+
+ protected abstract double TapeHeight { get; }
+
+ protected double? CursorX { get; private set; }
+
+ protected double TopSpeed => TapeFigures.TopSpeed(Tape?.MaxSpeedKmh ?? 0);
+
+ protected double Length => Tape is null ? 0 : ByDistance ? Tape.DistanceM / 1000.0 : Tape.Duration / 60.0;
+
+ protected double TapeRight => LeftMargin + Length * Scale;
+
+ protected double XOf(TapeSample s) =>
+ LeftMargin + (ByDistance ? s.DistanceM / 1000.0 : (s.Time - (Tape?.StartTime ?? 0)) / 60.0) * Scale;
+
+ private double PositionOf(double x) =>
+ ByDistance ? (x - LeftMargin) / Scale * 1000.0 : (x - LeftMargin) / Scale * 60.0 + (Tape?.StartTime ?? 0);
+
+ /// Seconds since midnight at the sample, by the clock the trip started at.
+ public double ClockAt(TapeSample s) => StartClock.TotalSeconds + s.Time - (Tape?.StartTime ?? 0);
+
+ /// What the view shows, for another one to show the same.
+ public void CopySettingsFrom(TapeView other)
+ {
+ Tape = other.Tape;
+ ByDistance = other.ByDistance;
+ Scale = other.Scale;
+ StartClock = other.StartClock;
+ }
+
+ protected override Size MeasureOverride(Size availableSize) =>
+ new(TapeRight + RightMargin, TapeHeight);
+
+ public sealed override void Render(DrawingContext context)
+ {
+ DrawBlank(context);
+
+ var tape = Tape;
+ if (tape is null || tape.IsEmpty)
+ return;
+
+ DrawTape(context, tape);
+ DrawCursor(context);
+ }
+
+ /// What is there with no trip on it.
+ protected abstract void DrawBlank(DrawingContext context);
+
+ /// A trip with something in it, over the blank.
+ protected abstract void DrawTape(DrawingContext context, HaslerTape tape);
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ base.OnPointerMoved(e);
+ var tape = Tape;
+ if (tape is null || tape.IsEmpty)
+ return;
+
+ double x = Math.Clamp(e.GetPosition(this).X, LeftMargin, TapeRight);
+ int index = tape.IndexAt(PositionOf(x), ByDistance);
+ CursorX = x;
+ InvalidateVisual();
+ Hovered?.Invoke(index < 0 ? null : tape.Samples[index]);
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ CursorX = null;
+ InvalidateVisual();
+ Hovered?.Invoke(null);
+ }
+
+ private void DrawCursor(DrawingContext context)
+ {
+ if (CursorX is { } x)
+ context.DrawLine(CursorPen, new Point(x, 0), new Point(x, TapeHeight));
+ }
+
+ ///
+ /// A record as one line, one column of pixels at a time: per column the
+ /// lowest and highest point in it, so a long trip draws as fast as a short
+ /// one and what happens during a stop on a distance-fed tape still shows as
+ /// one stroke.
+ ///
+ protected StreamGeometry Trace(HaslerTape tape, Func y)
+ {
+ var geometry = new StreamGeometry();
+ using var g = geometry.Open();
+ bool begun = false;
+ int column = int.MinValue;
+ double top = 0, bottom = 0, first = 0, last = 0;
+
+ void Flush()
+ {
+ if (column == int.MinValue)
+ return;
+ // Enter where the column started, leave where it ended.
+ var (a, b) = first <= last ? (top, bottom) : (bottom, top);
+ foreach (double v in new[] { first, a, b, last })
+ {
+ var p = new Point(column, v);
+ if (!begun)
+ {
+ g.BeginFigure(p, false);
+ begun = true;
+ }
+ else
+ g.LineTo(p);
+ }
+ }
+
+ foreach (var s in tape.Samples)
+ {
+ int c = (int)Math.Round(XOf(s));
+ double value = y(s);
+ if (c != column)
+ {
+ Flush();
+ column = c;
+ top = bottom = first = value;
+ }
+ top = Math.Min(top, value);
+ bottom = Math.Max(bottom, value);
+ last = value;
+ }
+ Flush();
+ if (begun)
+ g.EndFigure(false);
+ return geometry;
+ }
+}