mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-01 20:34:57 +01:00
Start the scenery editor from its own button and note launches
With no scenery selected the button starts a new one in the editor. Each launch notes the scenery, the timetable number and the vehicles for the tapes. Tools work on the selected scenery, and an emptied consist stays listed.
This commit is contained in:
@@ -32,8 +32,39 @@ public class SimulatorAftermathTests
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("log.txt"), "first\r\nsecond\r\n\r\nthird\r\n"));
|
||||
|
||||
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log).SimulatorLogTail(2);
|
||||
var started = installation.Files.LastWriteTimeUtc.AddMinutes(-1);
|
||||
|
||||
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log)
|
||||
.SimulatorLogTail(2, started);
|
||||
|
||||
Assert.Equal(new[] { "second", "third" }, tail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_not_written_since_the_start_says_nothing_of_this_run()
|
||||
{
|
||||
// Dead before it opened its log: what is there is the session before.
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("log.txt"), "yesterday's error\r\n"));
|
||||
var started = installation.Files.LastWriteTimeUtc.AddMinutes(10);
|
||||
|
||||
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log)
|
||||
.SimulatorLogTail(2, started);
|
||||
|
||||
Assert.Empty(tail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_written_a_moment_before_the_start_was_taken_counts()
|
||||
{
|
||||
// The start is taken just after the launch, as for the crash dumps.
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("log.txt"), "opening\r\n"));
|
||||
var started = installation.Files.LastWriteTimeUtc.AddSeconds(3);
|
||||
|
||||
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log)
|
||||
.SimulatorLogTail(2, started);
|
||||
|
||||
Assert.Equal(new[] { "opening" }, tail);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +55,28 @@ public class SimulatorExitTests
|
||||
? ("cmd", new[] { "/c", "echo boom 1>&2 & exit 3" })
|
||||
: ("/bin/sh", new[] { "-c", "echo boom >&2; exit 3" });
|
||||
|
||||
var process = launcher.Start(shell, script, null, captureErrors: true, out string? error);
|
||||
var process = launcher.Start(shell, script, null, new Dictionary<string, string>(), captureErrors: true,
|
||||
out string? error);
|
||||
Assert.Null(error);
|
||||
await process!.WaitForExitAsync();
|
||||
|
||||
Assert.Equal(3, process.ExitCode);
|
||||
Assert.Equal("boom", Assert.Single(process.ErrorTail).Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_real_process_gets_the_environment_it_was_given()
|
||||
{
|
||||
var launcher = new SystemProcessLauncher(new TestInstallation().Log);
|
||||
var (shell, script) = OperatingSystem.IsWindows()
|
||||
? ("cmd", new[] { "/c", "echo %__NV_PRIME_RENDER_OFFLOAD% 1>&2" })
|
||||
: ("/bin/sh", new[] { "-c", "echo $__NV_PRIME_RENDER_OFFLOAD >&2" });
|
||||
|
||||
var process = launcher.Start(shell, script, null,
|
||||
new Dictionary<string, string> { ["__NV_PRIME_RENDER_OFFLOAD"] = "1" },
|
||||
captureErrors: true, out _);
|
||||
await process!.WaitForExitAsync();
|
||||
|
||||
Assert.Equal("1", Assert.Single(process.ErrorTail).Trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
@@ -23,6 +25,58 @@ public class StartSimulationTests
|
||||
Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_the_physics_log_on_the_launch_is_noted_for_the_tapes()
|
||||
{
|
||||
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build), sceneryClock: "07:52");
|
||||
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||
|
||||
start.Execute(freeFly: false, saveSettings: false);
|
||||
|
||||
// Under the name the simulator is given, which is the name it logs under.
|
||||
string driven = launcher.Arguments![launcher.Arguments.ToList().IndexOf("-v") + 1];
|
||||
string noted = installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt"));
|
||||
Assert.Contains("clock\t07:52", noted);
|
||||
Assert.Contains($"vehicle\t{driven}\tPKP/EP07\tep07.mmd", noted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_the_physics_log_nothing_is_noted()
|
||||
{
|
||||
var (start, _, installation) = Rig(files => files.WithExecutable(Build));
|
||||
|
||||
start.Execute(freeFly: false, saveSettings: false);
|
||||
|
||||
Assert.False(installation.Files.FileExists(TestInstallation.At("physicslog", "starter-launch.txt")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("v2")]
|
||||
[InlineData("V2")]
|
||||
[InlineData("gone")]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void A_crew_is_there_to_start_with_when_the_pick_has_one_or_names_nothing(string? preferred) =>
|
||||
Assert.True(StartSimulation.HasCrewToStart(WagonAndLocomotive, preferred));
|
||||
|
||||
[Fact]
|
||||
public void A_pick_with_nobody_in_it_says_no_crew() =>
|
||||
Assert.False(StartSimulation.HasCrewToStart(WagonAndLocomotive, "v1"));
|
||||
|
||||
[Fact]
|
||||
public void A_consist_nobody_sits_in_has_no_crew_to_start()
|
||||
{
|
||||
var empty = new Trainset("trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset");
|
||||
|
||||
Assert.False(StartSimulation.HasCrewToStart(empty, null));
|
||||
}
|
||||
|
||||
private static Trainset WagonAndLocomotive => new(
|
||||
"trainset a tor 0 0\n" +
|
||||
"node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\n" +
|
||||
"node -1 0 v2 dynamic d s m 0 headdriver 3 0 enddynamic\n" +
|
||||
"endtrainset");
|
||||
|
||||
[Fact]
|
||||
public void A_build_for_the_other_system_is_still_launched()
|
||||
{
|
||||
@@ -54,6 +108,49 @@ public class StartSimulationTests
|
||||
Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wine_is_pointed_at_the_settings_the_starter_saves()
|
||||
{
|
||||
string wine = Path.Combine("/usr/bin", "wine");
|
||||
string exe = Path.Combine(Path.GetTempPath(), "maszyna", "eu07.exe");
|
||||
const string roaming = "/home/kolejarz/.wine/drive_c/users/kolejarz/AppData/Roaming";
|
||||
var (start, _, installation) = Rig(files => files
|
||||
.WithExecutable(wine)
|
||||
.WithFile(exe, "MZ\u0090\u0000")
|
||||
.WithDirectory(roaming));
|
||||
installation.Environment.With("PATH", "/usr/bin").With("HOME", "/home/kolejarz").With("USER", "kolejarz");
|
||||
installation.SettingsStore.Settings.ExecutablePath = exe;
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine("/home/kolejarz", ".config", "MaSzyna")),
|
||||
installation.Files.LinkTarget(Path.Combine(roaming, "MaSzyna")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_native_build_leaves_the_wine_prefix_alone()
|
||||
{
|
||||
const string roaming = "/home/kolejarz/.wine/drive_c/users/kolejarz/AppData/Roaming";
|
||||
var (start, _, installation) = Rig(files => files.WithExecutable(Build).WithDirectory(roaming));
|
||||
installation.Environment.With("HOME", "/home/kolejarz").With("USER", "kolejarz");
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
Assert.Null(installation.Files.LinkTarget(roaming + "/MaSzyna"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(3)]
|
||||
public void Any_renderer_is_left_for_the_simulator_to_take(int renderer)
|
||||
{
|
||||
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build, "\u007fELF binary"));
|
||||
installation.SettingsStore.Settings.RenderEngine = renderer;
|
||||
|
||||
Assert.Equal(SimulationStartOutcome.Started, start.Execute(freeFly: true, saveSettings: false).Outcome);
|
||||
Assert.NotNull(launcher.Executable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_error_stream_is_kept_only_while_the_starter_stays_to_read_it()
|
||||
{
|
||||
@@ -80,6 +177,28 @@ public class StartSimulationTests
|
||||
Assert.False(installation.Files.FileExists(TestInstallation.At("scenery", "$td.scn")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_scenery_opens_at_the_wizard_even_with_a_scenery_selected()
|
||||
{
|
||||
var (start, launcher, _) = Rig(files => files.WithExecutable(Build));
|
||||
|
||||
var result = start.ExecuteNewSceneryEditor();
|
||||
|
||||
Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
|
||||
Assert.Equal(new[] { "-edit" }, launcher.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_no_scenery_selected_the_editor_opens_at_its_wizard()
|
||||
{
|
||||
var (start, launcher, _) = Rig(files => files.WithExecutable(Build), state => state.CurrentScenery = null);
|
||||
|
||||
var result = start.ExecuteEditor();
|
||||
|
||||
Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
|
||||
Assert.Equal(new[] { "-edit" }, launcher.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_that_cannot_be_run_is_not()
|
||||
{
|
||||
@@ -92,8 +211,134 @@ public class StartSimulationTests
|
||||
Assert.Null(launcher.Executable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scenery_with_no_consist_picked_starts_with_the_free_camera()
|
||||
{
|
||||
var (start, launcher, _) = Rig(files => files.WithExecutable(Build), state => state.CurrentTrainset = null);
|
||||
|
||||
var result = start.Execute(freeFly: false, saveSettings: false);
|
||||
|
||||
Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
|
||||
Assert.Equal(new[] { "-s", "$td.scn" }, launcher.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_copy_for_a_start_is_named_in_lower_case_as_the_simulator_looks_for_it()
|
||||
{
|
||||
var (start, launcher, _) = Rig(files => files.WithExecutable(Build), sceneryFile: "Glinojeck.scn");
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
Assert.Equal(new[] { "-s", "$glinojeck.scn" }, launcher.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_free_camera_start_without_a_consist_notes_no_driven_vehicle_for_the_tapes()
|
||||
{
|
||||
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build),
|
||||
state => state.CurrentTrainset = null);
|
||||
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
Assert.NotNull(launcher.Executable);
|
||||
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
|
||||
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
|
||||
Assert.Null(note.Driven);
|
||||
Assert.Empty(note.Consist!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_start_in_a_vehicle_notes_it_and_its_consist_for_the_tapes()
|
||||
{
|
||||
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build));
|
||||
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||
|
||||
start.Execute(freeFly: false, saveSettings: false);
|
||||
|
||||
// Under the name the simulator is given, which is the name it logs under.
|
||||
string given = launcher.Arguments![launcher.Arguments.ToList().IndexOf("-v") + 1];
|
||||
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
|
||||
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
|
||||
Assert.Equal(given, note.Driven);
|
||||
Assert.Equal(new[] { given }, note.Consist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_start_notes_the_scenario_and_the_train_of_the_timetable_for_the_tapes()
|
||||
{
|
||||
var (start, _, installation) = Rig(files =>
|
||||
{
|
||||
files.WithExecutable(Build);
|
||||
files.WithFile(TestInstallation.At("scenery", "pociag.txt"),
|
||||
"[ Rodzaj i numer pociagu | MPJ15120 ]\r\n");
|
||||
});
|
||||
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||
|
||||
start.Execute(freeFly: false, saveSettings: false);
|
||||
|
||||
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
|
||||
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
|
||||
Assert.Equal("Test", note.SceneryName);
|
||||
Assert.Equal("MPJ15120", note.Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_free_camera_start_notes_no_train_for_the_tapes()
|
||||
{
|
||||
var (start, _, installation) = Rig(files => files.WithExecutable(Build));
|
||||
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
|
||||
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
|
||||
Assert.Null(note.Train);
|
||||
}
|
||||
|
||||
private static void HybridLaptop(InMemoryFileSystem files)
|
||||
{
|
||||
files.WithFile("/sys/class/drm/card0/device/uevent",
|
||||
"DRIVER=nvidia\nPCI_ID=10DE:2D19\nPCI_SLOT_NAME=0000:01:00.0\n")
|
||||
.WithFile("/sys/class/drm/card0/device/boot_vga", "0\n")
|
||||
.WithFile("/sys/class/drm/card1/device/uevent",
|
||||
"DRIVER=amdgpu\nPCI_ID=1002:1900\nPCI_SLOT_NAME=0000:65:00.0\n")
|
||||
.WithFile("/sys/class/drm/card1/device/boot_vga", "1\n")
|
||||
.WithFile("/sys/class/drm/card1-eDP-1/status", "connected\n");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_a_laptop_with_two_cards_the_simulator_goes_to_the_discrete_one()
|
||||
{
|
||||
var (start, launcher, _) = Rig(files =>
|
||||
{
|
||||
files.WithExecutable(Build);
|
||||
HybridLaptop(files);
|
||||
});
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
Assert.Equal("1", launcher.Environment!["__NV_PRIME_RENDER_OFFLOAD"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Left_to_the_system_the_simulator_gets_no_card_of_the_starters_choosing()
|
||||
{
|
||||
var (start, launcher, installation) = Rig(files =>
|
||||
{
|
||||
files.WithExecutable(Build);
|
||||
HybridLaptop(files);
|
||||
});
|
||||
installation.SettingsStore.Settings.Gpu = StarterNG.Domain.Settings.GpuPreference.System;
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
Assert.Empty(launcher.Environment!);
|
||||
}
|
||||
|
||||
private static (StartSimulation Start, RecordingLauncher Launcher, TestInstallation Installation) Rig(
|
||||
Action<InMemoryFileSystem> addSimulator)
|
||||
Action<InMemoryFileSystem> addSimulator, Action<AppState>? arrange = null, string sceneryFile = "td.scn",
|
||||
string sceneryClock = "10:30")
|
||||
{
|
||||
var installation = new TestInstallation();
|
||||
addSimulator(installation.Files);
|
||||
@@ -103,7 +348,7 @@ public class StartSimulationTests
|
||||
settings.ExecutablePath = Build;
|
||||
|
||||
var scenery = new SceneryParser(installation.Clock, installation.Random).Parse(
|
||||
TestInstallation.At("scenery", "td.scn"),
|
||||
TestInstallation.At("scenery", sceneryFile),
|
||||
"""
|
||||
//$n Test
|
||||
trainset pociag tor_1 10 0
|
||||
@@ -111,12 +356,20 @@ public class StartSimulationTests
|
||||
endtrainset
|
||||
FirstInit
|
||||
""");
|
||||
scenery.Weather.ScenarioTimeOverride = sceneryClock;
|
||||
var state = new AppState { CurrentScenery = scenery, CurrentTrainset = scenery.Trainsets[0] };
|
||||
arrange?.Invoke(state);
|
||||
|
||||
var launcher = new RecordingLauncher();
|
||||
var start = new StartSimulation(state, installation.SettingsStore, installation.Library.Vehicles,
|
||||
installation.Executables, installation.Files, installation.Paths, launcher,
|
||||
installation.Random, installation.Log);
|
||||
installation.Executables,
|
||||
installation.Files, installation.Paths, launcher,
|
||||
installation.Random, installation.Log,
|
||||
new LinuxGpus(installation.Files, installation.Environment),
|
||||
installation.Environment,
|
||||
new WineConfigLink(installation.Files, installation.Environment,
|
||||
installation.SettingsPaths, installation.Log),
|
||||
installation.Tapes);
|
||||
return (start, launcher, installation);
|
||||
}
|
||||
|
||||
@@ -130,9 +383,13 @@ public class StartSimulationTests
|
||||
|
||||
public bool? CapturedErrors { get; private set; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Environment { get; private set; }
|
||||
|
||||
public IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
|
||||
bool captureErrors, out string? error)
|
||||
IReadOnlyDictionary<string, string> environment, bool captureErrors,
|
||||
out string? error)
|
||||
{
|
||||
Environment = environment;
|
||||
Executable = executablePath;
|
||||
CapturedErrors = captureErrors;
|
||||
Arguments = arguments;
|
||||
@@ -144,6 +401,8 @@ public class StartSimulationTests
|
||||
public bool OpenInShell(string pathOrUrl) => false;
|
||||
|
||||
public IProcessHandle? FindRunning(string processName) => null;
|
||||
|
||||
public bool IsRunning(string processName) => false;
|
||||
}
|
||||
|
||||
private sealed class RunningProcess : IProcessHandle
|
||||
|
||||
@@ -20,14 +20,17 @@ public interface IProcessHandle
|
||||
public interface IProcessLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts a process. With captureErrors its error stream is kept for the
|
||||
/// handle - only for a process the starter outlives: a write to a pipe nobody
|
||||
/// reads any more can kill the writer.
|
||||
/// Starts a process, with <paramref name="environment"/> added to the starter's
|
||||
/// own. With captureErrors its error stream is kept for the handle - only for a
|
||||
/// process the starter outlives: a write to a pipe nobody reads any more can
|
||||
/// kill the writer.
|
||||
/// </summary>
|
||||
IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
|
||||
bool captureErrors, out string? error);
|
||||
IReadOnlyDictionary<string, string> environment, bool captureErrors, out string? error);
|
||||
|
||||
bool OpenInShell(string pathOrUrl);
|
||||
|
||||
IProcessHandle? FindRunning(string processName);
|
||||
|
||||
bool IsRunning(string processName);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ namespace StarterNG.Application;
|
||||
/// <summary>What a simulator that went down left behind: its log and its crash dumps.</summary>
|
||||
public sealed class SimulatorAftermath
|
||||
{
|
||||
// A little slack: the start is taken just after the launch, not before it.
|
||||
private static readonly TimeSpan StartSlack = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IGamePaths _paths;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
@@ -42,8 +45,7 @@ public sealed class SimulatorAftermath
|
||||
if (_files.DirectoryExists(crashpad))
|
||||
dumps.AddRange(_files.GetFilesRecursive(crashpad, "*.dmp"));
|
||||
|
||||
// A little slack: the start is taken just after the launch, not before it.
|
||||
var since = startedUtc - TimeSpan.FromSeconds(5);
|
||||
var since = startedUtc - StartSlack;
|
||||
return dumps.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Where(dump => _files.GetLastWriteTimeUtc(dump) >= since)
|
||||
.OrderByDescending(_files.GetLastWriteTimeUtc)
|
||||
@@ -56,13 +58,17 @@ public sealed class SimulatorAftermath
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The end of the simulator's own log, for when it died without a word on its error stream.</summary>
|
||||
public IReadOnlyList<string> SimulatorLogTail(int count)
|
||||
/// <summary>
|
||||
/// The end of the simulator's own log, for when it died without a word on its
|
||||
/// error stream. A log not written since the start is the session before's, the
|
||||
/// simulator having died before opening its own: it says nothing about this one.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SimulatorLogTail(int count, DateTime startedUtc)
|
||||
{
|
||||
string path = _paths.FromRoot("log.txt");
|
||||
try
|
||||
{
|
||||
if (!_files.FileExists(path))
|
||||
if (!_files.FileExists(path) || _files.GetLastWriteTimeUtc(path) < startedUtc - StartSlack)
|
||||
return Array.Empty<string>();
|
||||
|
||||
var lines = LegacyText.Decode(_files.ReadAllBytes(path))
|
||||
|
||||
@@ -2,12 +2,12 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Settings;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Infrastructure.Settings;
|
||||
|
||||
@@ -47,11 +47,21 @@ public sealed class StartSimulation
|
||||
private readonly IProcessLauncher _processes;
|
||||
private readonly IRandomSource _random;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
private readonly LinuxGpus _gpus;
|
||||
private readonly IEnvironment _environment;
|
||||
private readonly WineConfigLink _wineConfig;
|
||||
private readonly HaslerTapes _tapes;
|
||||
|
||||
public StartSimulation(AppState state, SettingsStore settings, VehicleCatalog vehicles,
|
||||
ExecutableLocator executables, IFileSystem files, IGamePaths paths,
|
||||
IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log)
|
||||
IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log,
|
||||
LinuxGpus gpus, IEnvironment environment, WineConfigLink wineConfig,
|
||||
HaslerTapes tapes)
|
||||
{
|
||||
_gpus = gpus;
|
||||
_wineConfig = wineConfig;
|
||||
_environment = environment;
|
||||
_tapes = tapes;
|
||||
_state = state;
|
||||
_settings = settings;
|
||||
_vehicles = vehicles;
|
||||
@@ -63,11 +73,12 @@ public sealed class StartSimulation
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public static string? StartableVehicle(Trainset? trainset, string? preferred)
|
||||
/// <summary>
|
||||
/// What the start button says of the consist. The vehicle picked answers for it
|
||||
/// even with nobody in it, where the launch itself goes on to the first crew.
|
||||
/// </summary>
|
||||
public static bool HasCrewToStart(Trainset trainset, string? preferred)
|
||||
{
|
||||
if (trainset is null || trainset.Vehicles.Count == 0)
|
||||
return null;
|
||||
|
||||
static bool CanStart(Dynamic vehicle) =>
|
||||
vehicle.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
|
||||
|
||||
@@ -75,39 +86,49 @@ public sealed class StartSimulation
|
||||
{
|
||||
var picked = trainset.Vehicles.FirstOrDefault(vehicle =>
|
||||
string.Equals(vehicle.Name, preferred, StringComparison.OrdinalIgnoreCase));
|
||||
if (picked is not null && CanStart(picked))
|
||||
return picked.Name;
|
||||
if (picked is not null)
|
||||
return CanStart(picked);
|
||||
}
|
||||
|
||||
return trainset.Vehicles.FirstOrDefault(CanStart)?.Name;
|
||||
return trainset.Vehicles.Any(CanStart);
|
||||
}
|
||||
|
||||
public SimulationStartResult Execute(bool freeFly, bool saveSettings)
|
||||
{
|
||||
var scenery = _state.CurrentScenery;
|
||||
var trainset = _state.CurrentTrainset;
|
||||
if (scenery is null || trainset is null)
|
||||
if (scenery is null)
|
||||
return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
|
||||
|
||||
// The depot checkbox is the consist's own state; this setting is a blanket
|
||||
// override for people who always want the same thing, and it wins.
|
||||
switch (_settings.Settings.ReadyOverride)
|
||||
// A scenery with no consist picked starts with the free camera.
|
||||
string? vehicle = null;
|
||||
if (trainset is null)
|
||||
{
|
||||
case ConsistReadyOverride.AlwaysCold:
|
||||
trainset.ReadyToGo = false;
|
||||
break;
|
||||
case ConsistReadyOverride.AlwaysReady:
|
||||
trainset.ReadyToGo = true;
|
||||
break;
|
||||
freeFly = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// The depot checkbox is the consist's own state; this setting is a blanket
|
||||
// override for people who always want the same thing, and it wins.
|
||||
switch (_settings.Settings.ReadyOverride)
|
||||
{
|
||||
case ConsistReadyOverride.AlwaysCold:
|
||||
trainset.ReadyToGo = false;
|
||||
break;
|
||||
case ConsistReadyOverride.AlwaysReady:
|
||||
trainset.ReadyToGo = true;
|
||||
break;
|
||||
}
|
||||
|
||||
string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles)
|
||||
?? StartableVehicle(trainset, _state.StartingVehicleName);
|
||||
_state.StartingVehicleName = vehicle;
|
||||
vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles);
|
||||
_state.StartingVehicleName = vehicle;
|
||||
}
|
||||
|
||||
scenery.Weather.Dirty = true;
|
||||
|
||||
string exportName = ExportPrefix + Path.GetFileName(scenery.Path);
|
||||
// In lower case: that is the name the simulator looks for, and on Linux
|
||||
// "$Glinojeck.scn" is not "$glinojeck.scn".
|
||||
string exportName = ExportPrefix + SimulatorNames.Lower(Path.GetFileName(scenery.Path));
|
||||
string exportPath = Path.Combine(Path.GetDirectoryName(scenery.Path) ?? "scenery", exportName);
|
||||
try
|
||||
{
|
||||
@@ -125,6 +146,9 @@ public sealed class StartSimulation
|
||||
if (saveSettings)
|
||||
_settings.CaptureAndSave();
|
||||
|
||||
if (_settings.Settings.PhysicsLog)
|
||||
_tapes.RecordLaunch(scenery, trainset, freeFly ? null : vehicle);
|
||||
|
||||
string[] arguments = freeFly || string.IsNullOrEmpty(vehicle)
|
||||
? new[] { "-s", exportName }
|
||||
: new[] { "-s", exportName, "-v", vehicle };
|
||||
@@ -134,15 +158,16 @@ public sealed class StartSimulation
|
||||
|
||||
/// <summary>
|
||||
/// Opens the selected scenery in the simulator's editor. The scenery file itself,
|
||||
/// not an export: what the editor saves has to land in the scenery.
|
||||
/// not an export: what the editor saves has to land in the scenery. With none
|
||||
/// selected the simulator opens its wizard of a new scenery.
|
||||
/// </summary>
|
||||
public SimulationStartResult ExecuteEditor()
|
||||
{
|
||||
if (_state.CurrentScenery is not { } scenery)
|
||||
return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
|
||||
public SimulationStartResult ExecuteEditor() =>
|
||||
Launch(_state.CurrentScenery is { } scenery
|
||||
? new[] { "-edit", Path.GetFileName(scenery.Path) }
|
||||
: new[] { "-edit" });
|
||||
|
||||
return Launch(new[] { "-edit", Path.GetFileName(scenery.Path) });
|
||||
}
|
||||
/// <summary>The simulator's wizard of a new scenery, whichever one is selected.</summary>
|
||||
public SimulationStartResult ExecuteNewSceneryEditor() => Launch(new[] { "-edit" });
|
||||
|
||||
private SimulationStartResult Launch(IReadOnlyList<string> arguments)
|
||||
{
|
||||
@@ -157,10 +182,14 @@ public sealed class StartSimulation
|
||||
// The installation, not the folder of the binary: a simulator picked from a
|
||||
// build tree lives beside the data, and the data is what it opens.
|
||||
var (program, programArguments) = _executables.LaunchCommand(executable, arguments);
|
||||
// Under Wine eu07.exe would read the prefix's own eu07.ini, not the one just saved.
|
||||
if (_executables.RunsThroughWine(executable))
|
||||
_wineConfig.Ensure();
|
||||
// Its error stream is kept for a report on a crash - unless the starter closes
|
||||
// now, leaving nobody to read it or to report.
|
||||
bool watched = !_settings.Settings.AutoCloseStarter;
|
||||
var process = _processes.Start(program, programArguments, _paths.Root, watched, out string? error);
|
||||
var process = _processes.Start(program, programArguments, _paths.Root, GraphicsCard(), watched,
|
||||
out string? error);
|
||||
if (process is null)
|
||||
return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable,
|
||||
Detail: error);
|
||||
@@ -168,4 +197,30 @@ public sealed class StartSimulation
|
||||
return new SimulationStartResult(SimulationStartOutcome.Started, process, executable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What puts the simulator on the card the settings ask for, said in the log
|
||||
/// either way: a slow simulator on a laptop is the first thing to ask about.
|
||||
/// </summary>
|
||||
private IReadOnlyDictionary<string, string> GraphicsCard()
|
||||
{
|
||||
var gpus = _gpus.List();
|
||||
if (gpus.Count < 2)
|
||||
return new Dictionary<string, string>();
|
||||
|
||||
var preference = _settings.Settings.Gpu;
|
||||
string cards = string.Join(", ", gpus);
|
||||
if (GpuOffload.Target(gpus, preference, _environment.GetVariable) is not { } target)
|
||||
{
|
||||
_log.Log($"GPU: {cards}; left to the system ({(preference == GpuPreference.System
|
||||
? "chosen in the settings"
|
||||
: "set by the player, or no card to move to")})");
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var environment = GpuOffload.EnvironmentFor(target);
|
||||
_log.Log($"GPU: {cards}; simulator on {target} with " +
|
||||
string.Join(' ', environment.Select(pair => $"{pair.Key}={pair.Value}")));
|
||||
return environment;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ public sealed class SystemProcessLauncher : IProcessLauncher
|
||||
}
|
||||
|
||||
public IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
|
||||
bool captureErrors, out string? error)
|
||||
IReadOnlyDictionary<string, string> environment, bool captureErrors,
|
||||
out string? error)
|
||||
{
|
||||
var info = new ProcessStartInfo
|
||||
{
|
||||
@@ -29,6 +30,8 @@ public sealed class SystemProcessLauncher : IProcessLauncher
|
||||
};
|
||||
foreach (string argument in arguments)
|
||||
info.ArgumentList.Add(argument);
|
||||
foreach (var (name, value) in environment)
|
||||
info.Environment[name] = value;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -87,6 +90,22 @@ public sealed class SystemProcessLauncher : IProcessLauncher
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsRunning(string processName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var found = Process.GetProcessesByName(processName);
|
||||
foreach (var process in found)
|
||||
process.Dispose();
|
||||
return found.Length > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"lookup {processName}", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ProcessHandle : IProcessHandle
|
||||
{
|
||||
private const int KeptErrorLines = 20;
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:starterNg="clr-namespace:StarterNG"
|
||||
xmlns:infra="clr-namespace:StarterNG.Infrastructure"
|
||||
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="StarterNG.Views.Scenarios"
|
||||
x:CompileBindings="True">
|
||||
<UserControl.Resources>
|
||||
<SolidColorBrush x:Key="SyntaxFaultBrush" Color="#E0A030" />
|
||||
</UserControl.Resources>
|
||||
<Grid Name="scenariosGrid" Margin="0" ColumnSpacing="0" RowSpacing="3">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" MinWidth="200" MaxWidth="460" />
|
||||
@@ -72,7 +76,7 @@
|
||||
<GridSplitter Grid.Row="1" Height="4" HorizontalAlignment="Stretch"
|
||||
Background="Transparent" ResizeDirection="Rows" />
|
||||
|
||||
<TabControl Grid.Row="2">
|
||||
<TabControl Grid.Row="2" Name="infoTabs">
|
||||
<TabItem Header="{Binding [TabDescription], Source={x:Static starterNg:App.Loc}}">
|
||||
<Grid RowDefinitions="2*,6,*" Margin="4">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,3,225">
|
||||
@@ -98,7 +102,7 @@
|
||||
<TabItem Header="{Binding [Weather], Source={x:Static starterNg:App.Loc}}">
|
||||
<StackPanel Name="weatherForm" Margin="8,6" Spacing="8" IsEnabled="False">
|
||||
<Grid ColumnDefinitions="Auto,12,380,8,70"
|
||||
RowDefinitions="Auto,6,Auto,14,Auto,Auto,8,Auto,Auto,14,Auto,14,Auto">
|
||||
RowDefinitions="Auto,6,Auto,14,Auto,Auto,8,Auto,Auto,10,Auto,10,Auto">
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource CarbonFgDim}"
|
||||
@@ -108,7 +112,7 @@
|
||||
YearVisible="False" MinWidth="0"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Stretch"
|
||||
SelectedDateChanged="WeatherDate_OnChanged" />
|
||||
<ComboBox Grid.Column="2" Name="weatherSeason"
|
||||
<ComboBox Grid.Column="2" Name="weatherSeason" Classes="Localized"
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Center"
|
||||
SelectionChanged="WeatherSeason_OnChanged">
|
||||
<ComboBoxItem Tag="0" Content="{Binding [SeasonNow], Source={x:Static starterNg:App.Loc}}" />
|
||||
@@ -176,7 +180,7 @@
|
||||
<TextBlock Grid.Row="10" Grid.Column="0" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource CarbonFgDim}"
|
||||
Text="{Binding [Overcast], Source={x:Static starterNg:App.Loc}}" />
|
||||
<ComboBox Grid.Row="10" Grid.Column="2" Name="weatherOvercast"
|
||||
<ComboBox Grid.Row="10" Grid.Column="2" Name="weatherOvercast" Classes="Localized"
|
||||
HorizontalAlignment="Stretch"
|
||||
SelectionChanged="Weather_OnSelectionChanged">
|
||||
<ComboBoxItem Tag="random" Content="{Binding [OvercastRandom], Source={x:Static starterNg:App.Loc}}" />
|
||||
@@ -217,6 +221,26 @@
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
<!-- Only for a scenery whose files hold mistakes - see PopulateFaults. -->
|
||||
<TabItem Name="faultsTab" IsVisible="False">
|
||||
<TabItem.Header>
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<materialIcons:MaterialIcon Kind="AlertOutline" Width="15" Height="15"
|
||||
Foreground="{StaticResource SyntaxFaultBrush}" />
|
||||
<TextBlock Name="faultsTabHeader" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
<DockPanel LastChildFill="True" Margin="4">
|
||||
<TextBlock DockPanel.Dock="Top" TextWrapping="Wrap" Margin="2,0,0,6"
|
||||
Foreground="{StaticResource CarbonFgDim}"
|
||||
Text="{Binding [SyntaxIntro], Source={x:Static starterNg:App.Loc}}" />
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
|
||||
<SelectableTextBlock Name="faultsText"
|
||||
FontFamily="{x:Static infra:MonoFont.Family}"
|
||||
FontSize="12" TextWrapping="NoWrap" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
@@ -14,7 +13,6 @@ using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.VisualTree;
|
||||
using StarterNG.Classes;
|
||||
@@ -30,6 +28,8 @@ public partial class Scenarios : UserControl
|
||||
{
|
||||
public List<Scenery> Sceneries;
|
||||
|
||||
private readonly VehicleInfo _info = new(AppServices.Current.Library.Vehicles, AppServices.Current.Physics);
|
||||
|
||||
private Scenery? _currentScenery;
|
||||
|
||||
private bool _loadingWeather;
|
||||
@@ -62,10 +62,27 @@ public partial class Scenarios : UserControl
|
||||
|
||||
private void OnReentered()
|
||||
{
|
||||
RefreshVehicleLabels();
|
||||
RefreshVehicleList();
|
||||
RefreshSelectedConsist();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The list again, as the depot page may have picked another consist or filled
|
||||
/// one. Selecting its row counts as no pick here: the vehicle to start in stays.
|
||||
/// </summary>
|
||||
private void RefreshVehicleList()
|
||||
{
|
||||
if (SelectedScenery is not { } scenery)
|
||||
return;
|
||||
|
||||
var state = AppServices.Current.State;
|
||||
var trainset = state.CurrentTrainset;
|
||||
string? vehicle = state.StartingVehicleName;
|
||||
PopulateVehicleList(scenery);
|
||||
if (ReferenceEquals(state.CurrentTrainset, trainset))
|
||||
state.StartingVehicleName = vehicle;
|
||||
}
|
||||
|
||||
private void BuildSceneryTree()
|
||||
{
|
||||
sceneryList.Items.Clear();
|
||||
@@ -81,11 +98,11 @@ public partial class Scenarios : UserControl
|
||||
sceneryList.Items.Add(ToTreeItem(node, expandGroups));
|
||||
}
|
||||
|
||||
private static TreeViewItem ToTreeItem(SceneryTreeNode node, bool expandGroups)
|
||||
private TreeViewItem ToTreeItem(SceneryTreeNode node, bool expandGroups)
|
||||
{
|
||||
var item = new TreeViewItem
|
||||
{
|
||||
Header = node.Label,
|
||||
Header = node.IsGroup ? node.Label : SceneryHeader(node.Label, Sceneries[node.SceneryIndex]),
|
||||
Tag = node.IsGroup ? null : node.SceneryIndex,
|
||||
IsExpanded = node.IsGroup && expandGroups
|
||||
};
|
||||
@@ -96,6 +113,27 @@ public partial class Scenarios : UserControl
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>The scenery's name, marked when its files hold mistakes.</summary>
|
||||
private object SceneryHeader(string label, Scenery scenery)
|
||||
{
|
||||
if (scenery.Faults.Count == 0)
|
||||
return label;
|
||||
|
||||
var mark = new Material.Icons.Avalonia.MaterialIcon
|
||||
{
|
||||
Kind = Material.Icons.MaterialIconKind.AlertOutline,
|
||||
Width = 13,
|
||||
Height = 13,
|
||||
Margin = new Thickness(5, 0, 0, 0),
|
||||
Foreground = (Avalonia.Media.IBrush)Resources["SyntaxFaultBrush"]!
|
||||
};
|
||||
var header = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
header.Children.Add(new TextBlock { Text = label, VerticalAlignment = VerticalAlignment.Center });
|
||||
header.Children.Add(mark);
|
||||
ToolTip.SetTip(mark, string.Format(App.Loc["SyntaxTreeTip"], scenery.Faults.Count));
|
||||
return header;
|
||||
}
|
||||
|
||||
private Scenery? SelectedScenery =>
|
||||
sceneryList.SelectedItem is TreeViewItem { Tag: int index } && index < Sceneries.Count
|
||||
? Sceneries[index]
|
||||
@@ -215,14 +253,11 @@ public partial class Scenarios : UserControl
|
||||
AppServices.Current.SettingsStore.Save();
|
||||
}
|
||||
|
||||
private static string TrainsetListLabel(Trainset trainset) =>
|
||||
TrainsetDisplay.CompositionText(trainset)
|
||||
?? string.Format(App.Loc["EmptyTrainset"], trainset.Track ?? "");
|
||||
|
||||
private static Control TrainsetListContent(Trainset trainset) =>
|
||||
/// <summary>A consist's label in a list of them, here and on the depot page.</summary>
|
||||
internal static TextBlock TrainsetListContent(Trainset trainset) =>
|
||||
new Avalonia.Controls.TextBlock
|
||||
{
|
||||
Text = TrainsetListLabel(trainset),
|
||||
Text = TrainsetDisplay.ListLabel(trainset),
|
||||
TextTrimming = Avalonia.Media.TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = Avalonia.Media.TextWrapping.NoWrap
|
||||
};
|
||||
@@ -233,13 +268,14 @@ public partial class Scenarios : UserControl
|
||||
|
||||
bool showAi = AppServices.Current.Settings.ShowAiVehicles;
|
||||
bool drivableOnly = AppServices.Current.Settings.DrivableOnly;
|
||||
var current = AppServices.Current.State.CurrentTrainset;
|
||||
ListBoxItem? currentRow = null;
|
||||
|
||||
for (int i = 0; i < scn.Trainsets.Count; i++)
|
||||
{
|
||||
var trainset = scn.Trainsets[i];
|
||||
|
||||
if (IsAiTrainset(trainset) && !showAi) continue;
|
||||
if (drivableOnly && !UData.StaffedTrain(trainset)) continue;
|
||||
if (!TrainsetDisplay.ListedIn(trainset, showAi, drivableOnly, current)) continue;
|
||||
|
||||
var listItem = new ListBoxItem
|
||||
{
|
||||
@@ -250,17 +286,27 @@ public partial class Scenarios : UserControl
|
||||
listItem.ContextMenu = ConsistContextMenu.Build(
|
||||
listItem,
|
||||
() => trainset,
|
||||
vehicles => ApplyConsist(listItem, trainset, vehicles));
|
||||
vehicles => ApplyConsist(trainset, vehicles, listItem),
|
||||
removeAll: () => ApplyConsist(trainset, new List<Dynamic>(), listItem));
|
||||
vehicleList.Items.Add(listItem);
|
||||
|
||||
if (ReferenceEquals(trainset, current))
|
||||
currentRow = listItem;
|
||||
}
|
||||
|
||||
if (vehicleList.Items.Count > 0)
|
||||
vehicleList.SelectedIndex = 0;
|
||||
vehicleList.SelectedItem = currentRow ?? vehicleList.Items[0];
|
||||
else
|
||||
ClearConsistStrip();
|
||||
}
|
||||
|
||||
private static bool IsAiTrainset(Trainset trainset) =>
|
||||
!string.IsNullOrEmpty(trainset.Description) &&
|
||||
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
|
||||
/// <summary>No consist to show: what the scenery before had listed is not left standing.</summary>
|
||||
private void ClearConsistStrip()
|
||||
{
|
||||
consistStack.Children.Clear();
|
||||
_stripTrainset = null;
|
||||
trainStats.Children.Clear();
|
||||
}
|
||||
|
||||
private void ArchivalSwitch_OnChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
@@ -273,20 +319,39 @@ public partial class Scenarios : UserControl
|
||||
RestoreLastScenery();
|
||||
}
|
||||
|
||||
private void ApplyConsist(ListBoxItem item, Trainset trainset, List<Dynamic> vehicles)
|
||||
/// <param name="row">
|
||||
/// The list row it was chosen on, which may be of a consist not the picked one.
|
||||
/// Without one it comes from the strip, which redraws whichever consist it shows.
|
||||
/// </param>
|
||||
private void ApplyConsist(Trainset trainset, List<Dynamic> vehicles, ListBoxItem? row = null)
|
||||
{
|
||||
trainset.Vehicles = vehicles;
|
||||
item.Content = TrainsetListContent(trainset);
|
||||
if (ReferenceEquals(AppServices.Current.State.CurrentTrainset, trainset))
|
||||
if (row is null)
|
||||
{
|
||||
RedrawConsist(trainset);
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Content = TrainsetListContent(trainset);
|
||||
if (!ReferenceEquals(AppServices.Current.State.CurrentTrainset, trainset))
|
||||
return;
|
||||
|
||||
ShowConsist(trainset);
|
||||
}
|
||||
AppServices.Current.State.NotifyChanged();
|
||||
}
|
||||
|
||||
private void RedrawConsist(Trainset trainset)
|
||||
{
|
||||
RefreshVehicleLabels();
|
||||
ShowConsist(trainset);
|
||||
}
|
||||
|
||||
private void RefreshVehicleLabels()
|
||||
{
|
||||
if (sceneryList.SelectedItem is not TreeViewItem { Tag: int tag } || tag >= Sceneries.Count)
|
||||
if (SelectedScenery is not { } scn)
|
||||
return;
|
||||
|
||||
var scn = Sceneries[tag];
|
||||
foreach (var obj in vehicleList.Items)
|
||||
{
|
||||
if (obj is ListBoxItem { Tag: int vi } lbi && vi < scn.Trainsets.Count)
|
||||
@@ -313,6 +378,17 @@ public partial class Scenarios : UserControl
|
||||
}
|
||||
Scenery selectedScn = Sceneries[tag];
|
||||
|
||||
// The scenery is the one started from now on, whether or not it lists a
|
||||
// consist: left as it was, a scenery with none - or none the filters show -
|
||||
// started the one picked before.
|
||||
var state = AppServices.Current.State;
|
||||
if (!ReferenceEquals(state.CurrentScenery, selectedScn))
|
||||
{
|
||||
state.CurrentScenery = selectedScn;
|
||||
state.CurrentTrainset = null;
|
||||
state.StartingVehicleName = null;
|
||||
}
|
||||
|
||||
ShowSceneryInfo(selectedScn);
|
||||
ShowWeather(selectedScn);
|
||||
PersistLastScenery(selectedScn);
|
||||
@@ -320,7 +396,7 @@ public partial class Scenarios : UserControl
|
||||
missionDescription.Text = "";
|
||||
timetableContent.Text = App.Loc["NoTimetable"];
|
||||
|
||||
PopulateVehicleList(selectedScn);
|
||||
RefreshVehicleList();
|
||||
}
|
||||
|
||||
private void ShowSceneryInfo(Scenery scenery)
|
||||
@@ -348,18 +424,36 @@ public partial class Scenarios : UserControl
|
||||
|
||||
PopulateAttachments(scenery);
|
||||
PopulateFaults(scenery);
|
||||
LogUnknownTextures(scenery);
|
||||
}
|
||||
|
||||
/// <summary>Most a scenery's faults the tab lists; the rest are only counted.</summary>
|
||||
private const int FaultsShown = 500;
|
||||
|
||||
private void PopulateFaults(Scenery scenery)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
lines.Add(StarterNG.Domain.Sceneries.SceneryFaultText.Format(scenery.Faults, 500, key => App.Loc[key]));
|
||||
var faults = scenery.Faults;
|
||||
faultsTab.IsVisible = faults.Count > 0;
|
||||
if (faults.Count == 0)
|
||||
{
|
||||
if (infoTabs.SelectedItem == faultsTab)
|
||||
infoTabs.SelectedIndex = 0;
|
||||
faultsText.Text = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
faultsTabHeader.Text = string.Format(App.Loc["SyntaxTab"], faults.Count);
|
||||
faultsText.Text = SceneryFaultText.Format(faults, FaultsShown, key => App.Loc[key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Textures the vehicle library does not know are no fault of the text, only noted.
|
||||
private static void LogUnknownTextures(Scenery scenery)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var db = AppServices.Current.Library.Vehicles;
|
||||
foreach (var ts in scenery.Trainsets)
|
||||
{
|
||||
if (!ts.Parsed)
|
||||
lines.Add($"# unparsed trainset: {ts.Name}");
|
||||
foreach (var v in ts.Vehicles)
|
||||
{
|
||||
if (string.IsNullOrEmpty(v.SkinFile)) continue;
|
||||
@@ -393,6 +487,9 @@ public partial class Scenarios : UserControl
|
||||
};
|
||||
btn.Classes.Add("Flat");
|
||||
ToolTip.SetTip(btn, att.FilePath);
|
||||
// A button that does nothing is no way to say the file is not there.
|
||||
btn.IsEnabled = File.Exists(Path.GetFullPath(att.FilePath));
|
||||
ToolTip.SetShowOnDisabled(btn, true);
|
||||
btn.Click += (_, _) => OpenAttachment(att.FilePath);
|
||||
attachmentsPanel.Children.Add(btn);
|
||||
}
|
||||
@@ -421,8 +518,7 @@ public partial class Scenarios : UserControl
|
||||
BuildSceneryTree();
|
||||
if (!string.IsNullOrEmpty(selected))
|
||||
{
|
||||
AppServices.Current.Settings.LastScenery =
|
||||
Path.GetFileNameWithoutExtension(selected);
|
||||
RememberScenery(selected);
|
||||
RestoreLastScenery();
|
||||
}
|
||||
if (AppServices.Current.State.CurrentScenery != null)
|
||||
@@ -430,6 +526,13 @@ public partial class Scenarios : UserControl
|
||||
RefreshSelectedConsist();
|
||||
}
|
||||
|
||||
/// <summary>The one the tree opens on next, if there was one.</summary>
|
||||
private static void RememberScenery(string? path)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
AppServices.Current.Settings.LastScenery = Path.GetFileNameWithoutExtension(path);
|
||||
}
|
||||
|
||||
private bool _reloading;
|
||||
|
||||
private async void ReloadSceneries_OnClick(object? sender, RoutedEventArgs e)
|
||||
@@ -438,15 +541,18 @@ public partial class Scenarios : UserControl
|
||||
_reloading = true;
|
||||
try
|
||||
{
|
||||
// Reading the files again drops every change not saved to them.
|
||||
if (Sceneries.Any(s => s.Trainsets.Any(SceneryTrainsetText.IsModified)) &&
|
||||
TopLevel.GetTopLevel(this) is Window owner &&
|
||||
!await MessageBox.Show(owner, App.Loc["ReloadSceneriesConfirm"],
|
||||
App.Loc["ReloadSceneries"], MessageBoxButtons.YesNo))
|
||||
return;
|
||||
|
||||
string? selected = AppServices.Current.State.CurrentScenery?.Path;
|
||||
await Task.Run(() => AppServices.Current.Library.ReloadSceneries());
|
||||
Sceneries = AppServices.Current.Library.Sceneries;
|
||||
BuildSceneryTree();
|
||||
if (!string.IsNullOrEmpty(selected))
|
||||
{
|
||||
AppServices.Current.Settings.LastScenery =
|
||||
Path.GetFileNameWithoutExtension(selected);
|
||||
}
|
||||
RememberScenery(selected);
|
||||
RestoreLastScenery();
|
||||
}
|
||||
finally
|
||||
@@ -455,6 +561,62 @@ public partial class Scenarios : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The scenery files changed outside the starter: the list follows, and only a
|
||||
/// consist edited here whose text changed in the file too has to ask.
|
||||
/// </summary>
|
||||
/// <param name="mayAsk">False while the window is in the background: asked once it is back.</param>
|
||||
public async Task FollowSceneryFilesAsync(IReadOnlyList<SceneryChange> changes, bool mayAsk)
|
||||
{
|
||||
if (_reloading) return;
|
||||
|
||||
var watch = AppServices.Current.SceneryFiles;
|
||||
bool followed = false;
|
||||
foreach (var change in changes)
|
||||
{
|
||||
var judgement = watch.Judge(change);
|
||||
if (judgement.Verdict == SceneryVerdict.Leave)
|
||||
continue;
|
||||
|
||||
if (judgement.Verdict == SceneryVerdict.AskFirst)
|
||||
{
|
||||
if (!mayAsk || TopLevel.GetTopLevel(this) is not Window owner)
|
||||
continue;
|
||||
|
||||
string names = string.Join(", ", judgement.Lost.Select(t => t.Name));
|
||||
if (!await MessageBox.Show(owner,
|
||||
string.Format(App.Loc["SceneryFileChangedConfirm"], Path.GetFileName(change.Current!.Path), names),
|
||||
App.Loc["SceneryFileChangedTitle"], MessageBoxButtons.YesNo))
|
||||
{
|
||||
change.Current!.Sources = change.Fresh!.Sources;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
watch.Apply(change);
|
||||
followed = true;
|
||||
}
|
||||
|
||||
if (followed)
|
||||
RebuildKeepingGroupsOpen();
|
||||
}
|
||||
|
||||
private void RebuildKeepingGroupsOpen()
|
||||
{
|
||||
var open = TreeGroups(sceneryList.Items).Where(g => g.IsExpanded).Select(g => g.Header).ToHashSet();
|
||||
string? selected = AppServices.Current.State.CurrentScenery?.Path;
|
||||
|
||||
BuildSceneryTree();
|
||||
foreach (var group in TreeGroups(sceneryList.Items))
|
||||
group.IsExpanded = open.Contains(group.Header);
|
||||
|
||||
RememberScenery(selected);
|
||||
RestoreLastScenery();
|
||||
}
|
||||
|
||||
private static IEnumerable<TreeViewItem> TreeGroups(ItemCollection items) =>
|
||||
items.OfType<TreeViewItem>().Where(i => i.Tag is null).SelectMany(i => TreeGroups(i.Items).Prepend(i));
|
||||
|
||||
public event Action<Scenery>? RandomizeTexturesRequested;
|
||||
|
||||
private void RandomTexturesButton_OnClick(object? sender, RoutedEventArgs e)
|
||||
@@ -519,29 +681,14 @@ public partial class Scenarios : UserControl
|
||||
string dir = scn != null
|
||||
? (Path.GetDirectoryName(Path.GetFullPath(scn.Path)) ?? "scenery")
|
||||
: Path.GetFullPath("scenery");
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
StarterNG.Infrastructure.Diagnostics.ReportOnUiThread(string.Format(App.Loc["FaultFileNotFound"], dir));
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(dir) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StarterNG.Infrastructure.Diagnostics.ReportOnUiThread(
|
||||
$"{dir}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {ex.Message}");
|
||||
}
|
||||
StarterNG.Infrastructure.Diagnostics.OpenOrReport(dir);
|
||||
}
|
||||
|
||||
private void VehicleList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
|
||||
var tItem = sceneryList.SelectedItem as TreeViewItem;
|
||||
if (tItem?.Tag is not int tTag)
|
||||
if (SelectedScenery is not { } selectedScn)
|
||||
return;
|
||||
Scenery selectedScn = Sceneries[tTag];
|
||||
|
||||
ListBoxItem? vItem = vehicleList.SelectedItem as ListBoxItem;
|
||||
if (vItem?.Tag is not int vTag)
|
||||
@@ -827,14 +974,8 @@ public partial class Scenarios : UserControl
|
||||
ContextMenu VehicleMenu(Dynamic vehicle) => ConsistContextMenu.Build(
|
||||
consistStack,
|
||||
() => trainset,
|
||||
vehicles =>
|
||||
{
|
||||
trainset.Vehicles = vehicles;
|
||||
RefreshVehicleLabels();
|
||||
ShowConsist(trainset);
|
||||
UpdateTrainStats(trainset);
|
||||
},
|
||||
removeAll: null,
|
||||
vehicles => ApplyConsist(trainset, vehicles),
|
||||
removeAll: () => ApplyConsist(trainset, new List<Dynamic>()),
|
||||
getVehicle: () => vehicle,
|
||||
removeVehicle: v =>
|
||||
{
|
||||
@@ -851,9 +992,7 @@ public partial class Scenarios : UserControl
|
||||
AppServices.Current.State.NotifyChanged();
|
||||
}
|
||||
|
||||
RefreshVehicleLabels();
|
||||
ShowConsist(trainset);
|
||||
UpdateTrainStats(trainset);
|
||||
RedrawConsist(trainset);
|
||||
});
|
||||
|
||||
var db = AppServices.Current.Library.Vehicles;
|
||||
@@ -1127,9 +1266,7 @@ public partial class Scenarios : UserControl
|
||||
|
||||
vehicles.InsertRange(target, block);
|
||||
|
||||
RefreshVehicleLabels();
|
||||
ShowConsist(trainset);
|
||||
UpdateTrainStats(trainset);
|
||||
RedrawConsist(trainset);
|
||||
}
|
||||
|
||||
private static bool HoldsNext(Dynamic car) =>
|
||||
@@ -1158,13 +1295,8 @@ public partial class Scenarios : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
var db = AppServices.Current.Library.Vehicles;
|
||||
StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields(
|
||||
trainset,
|
||||
car => AppServices.Current.Physics.For(car.DataFolder, db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile)?.Model)
|
||||
?? AppServices.Current.Physics.For(car.DataFolder, car.MmdFile)
|
||||
?? AppServices.Current.Physics.For(car.DataFolder, car.SkinFile),
|
||||
car => db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile)?.ResolvedCategory));
|
||||
trainset, _info.PhysicsFor, _info.CategoryOf));
|
||||
}
|
||||
|
||||
private void RefreshSelectedConsist()
|
||||
|
||||
Reference in New Issue
Block a user