Start a Windows build of the simulator through Wine on Linux

Where wine is on the PATH, an eu07.exe in the installation is found after any
native binary and needs no execute bit.
This commit is contained in:
maj00r
2026-10-04 13:28:07 +02:00
parent 3083523ceb
commit 76f104798c
5 changed files with 204 additions and 22 deletions

View File

@@ -79,4 +79,79 @@ public class ExecutableLocatorTests
Assert.Equal(new[] { "eu07" }, installation.Executables.ListCandidates());
}
private static readonly string Wine = Path.Combine("/usr/bin", "wine");
private static TestInstallation WithWine(InMemoryFileSystem files)
{
var installation = new TestInstallation(files.WithExecutable(Wine));
installation.Environment.With("PATH", "/usr/bin");
return installation;
}
[Fact]
public void Without_wine_a_windows_build_on_linux_is_not_a_candidate()
{
var installation = new TestInstallation(
new InMemoryFileSystem().WithFile(TestInstallation.At("eu07.exe"), PortableExecutable));
installation.Executables.Resolve(new SimulatorSettings(), out var problem);
Assert.Equal(ExeProblem.NotFound, problem);
Assert.Null(installation.Executables.Wine());
}
[Fact]
public void With_wine_a_windows_build_on_linux_is_found_even_without_the_execute_bit()
{
var installation = WithWine(
new InMemoryFileSystem().WithFile(TestInstallation.At("eu07.exe"), PortableExecutable));
string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem);
Assert.EndsWith("eu07.exe", resolved);
Assert.Equal(ExeProblem.WrongPlatform, problem);
Assert.Equal(new[] { "eu07.exe" }, installation.Executables.ListCandidates());
}
[Fact]
public void A_native_build_wins_over_the_windows_one()
{
var installation = WithWine(new InMemoryFileSystem()
.WithFile(TestInstallation.At("eu07.exe"), PortableExecutable)
.WithExecutable(TestInstallation.At("eu07-dev"), Elf));
string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem);
Assert.EndsWith("eu07-dev", resolved);
Assert.Equal(ExeProblem.None, problem);
}
[Fact]
public void Wine_starts_the_windows_build_and_a_native_one_starts_itself()
{
string exe = TestInstallation.At("eu07.exe");
string native = TestInstallation.At("eu07");
var installation = WithWine(new InMemoryFileSystem()
.WithFile(exe, PortableExecutable)
.WithExecutable(native, Elf));
string[] arguments = { "-s", "$td.scn" };
var (wine, wineArguments) = installation.Executables.LaunchCommand(exe, arguments);
var (program, programArguments) = installation.Executables.LaunchCommand(native, arguments);
Assert.Equal(Wine, wine);
Assert.Equal(new[] { exe, "-s", "$td.scn" }, wineArguments);
Assert.Equal(native, program);
Assert.Equal(arguments, programArguments);
}
[Fact]
public void Wine_is_never_looked_for_on_windows()
{
var installation = WithWine(new InMemoryFileSystem());
installation.Environment.AsWindows();
Assert.Null(installation.Executables.Wine());
}
}

View File

@@ -33,6 +33,27 @@ public class StartSimulationTests
Assert.NotNull(launcher.Executable);
}
[Fact]
public void A_windows_build_on_linux_is_started_through_wine()
{
string wine = Path.Combine("/usr/bin", "wine");
string exe = Path.Combine(Path.GetTempPath(), "maszyna", "eu07.exe");
var (start, launcher, installation) = Rig(files => files
.WithExecutable(wine)
.WithFile(exe, "MZ\u0090\u0000"));
installation.Environment.With("PATH", "/usr/bin");
installation.SettingsStore.Settings.ExecutablePath = exe;
var result = start.Execute(freeFly: true, saveSettings: false);
// Unpacked from the Windows zip, it has no execute bit - Wine does not need one.
Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
Assert.Equal(wine, launcher.Executable);
Assert.Equal(exe, launcher.Arguments![0]);
Assert.Equal("-s", launcher.Arguments[1]);
Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory);
}
[Fact]
public void A_file_that_cannot_be_run_is_not()
{
@@ -68,8 +89,8 @@ public class StartSimulationTests
var launcher = new RecordingLauncher();
var start = new StartSimulation(state, installation.SettingsStore, installation.Library.Vehicles,
installation.Files, installation.Paths, launcher, installation.Random,
installation.Log);
installation.Executables, installation.Files, installation.Paths, launcher,
installation.Random, installation.Log);
return (start, launcher, installation);
}

View File

@@ -59,8 +59,8 @@ public sealed class AppServices
assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion);
Updates = new UpdateCheck(SettingsStore.Settings, new GitHubReleaseFeed(log, version), version, commit,
files, clock, log, settingsPaths.UpdateCheckPath());
StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, files, paths, Processes,
Random, log);
StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, Executables, files, paths,
Processes, Random, log);
}
public static AppServices Current =>

View File

@@ -8,6 +8,7 @@ using StarterNG.Domain;
using StarterNG.Domain.Settings;
using StarterNG.Domain.Vehicles;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Infrastructure.Settings;
namespace StarterNG.Application;
@@ -39,18 +40,21 @@ public sealed class StartSimulation
private readonly AppState _state;
private readonly SettingsStore _settings;
private readonly VehicleCatalog _vehicles;
private readonly ExecutableLocator _executables;
private readonly IFileSystem _files;
private readonly IGamePaths _paths;
private readonly IProcessLauncher _processes;
private readonly IRandomSource _random;
private readonly IDiagnosticsLog _log;
public StartSimulation(AppState state, SettingsStore settings, VehicleCatalog vehicles, IFileSystem files,
IGamePaths paths, IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log)
public StartSimulation(AppState state, SettingsStore settings, VehicleCatalog vehicles,
ExecutableLocator executables, IFileSystem files, IGamePaths paths,
IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log)
{
_state = state;
_settings = settings;
_vehicles = vehicles;
_executables = executables;
_files = files;
_paths = paths;
_processes = processes;
@@ -123,7 +127,7 @@ public sealed class StartSimulation
string executable = Path.GetFullPath(_settings.ResolveExecutable(out var problem));
// A binary for the other system is let through: on Linux a Windows build
// runs under Wine or Proton, and only the launch can tell.
// runs under Wine, started below, or Proton, and only the launch can tell.
if (problem is not (ExeProblem.None or ExeProblem.WrongPlatform))
return new SimulationStartResult(SimulationStartOutcome.ExecutableProblem, ExecutablePath: executable,
Problem: problem);
@@ -134,7 +138,8 @@ 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 process = _processes.Start(executable, arguments, _paths.Root, out string? error);
var (program, programArguments) = _executables.LaunchCommand(executable, arguments);
var process = _processes.Start(program, programArguments, _paths.Root, out string? error);
if (process is null)
return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable,
Detail: error);

View File

@@ -19,6 +19,9 @@ public sealed class ExecutableLocator
private readonly IEnvironment _environment;
private readonly IDiagnosticsLog _log;
private string? _wine;
private bool _wineLookedUp;
public ExecutableLocator(IFileSystem files, IGamePaths paths, IEnvironment environment, IDiagnosticsLog log)
{
_files = files;
@@ -42,8 +45,9 @@ public sealed class ExecutableLocator
{
var candidates = _files.GetFiles(_paths.Root, "eu07*")
.Where(IsCandidate)
.OrderBy(path => string.Equals(Path.GetFileName(path), CanonicalName,
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.OrderBy(path => IsExeOnLinux(path) ? 1 : 0)
.ThenBy(path => string.Equals(Path.GetFileName(path), CanonicalName,
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(path => Path.GetFileName(path).Length);
foreach (string path in candidates)
@@ -94,10 +98,16 @@ public sealed class ExecutableLocator
if (!_files.FileExists(path))
return ExeProblem.NotFound;
bool foreign = !MatchesPlatform(path);
// Started through Wine, a Windows build needs no execute bit of its own.
if (foreign && RunsThroughWine(path))
return ExeProblem.WrongPlatform;
if (!_environment.IsWindows && !_files.IsExecutable(path))
return ExeProblem.NotExecutable;
return MatchesPlatform(path) ? ExeProblem.None : ExeProblem.WrongPlatform;
return foreign ? ExeProblem.WrongPlatform : ExeProblem.None;
}
catch (Exception)
{
@@ -105,24 +115,92 @@ public sealed class ExecutableLocator
}
}
private bool MatchesPlatform(string path)
/// <summary>
/// What to start for a simulator binary: the binary itself, or Wine with the
/// binary as its first argument when it is a Windows build on Linux.
/// </summary>
public (string FileName, IReadOnlyList<string> Arguments) LaunchCommand(string executable,
IReadOnlyList<string> arguments)
{
if (!RunsThroughWine(executable))
return (executable, arguments);
return (Wine()!, new[] { executable }.Concat(arguments).ToList());
}
/// <summary>Wine from the PATH, or null where there is none - and always on Windows.</summary>
public string? Wine()
{
if (_environment.IsWindows)
return null;
if (!_wineLookedUp)
{
_wine = FindOnPath("wine") ?? FindOnPath("wine64");
_wineLookedUp = true;
}
return _wine;
}
private string? FindOnPath(string name)
{
// The PATH of Linux and macOS, whatever the host the tests run on.
string[] directories = (_environment.GetVariable("PATH") ?? "").Split(':', StringSplitOptions.RemoveEmptyEntries);
foreach (string directory in directories)
{
string candidate = Path.Combine(directory, name);
if (_files.FileExists(candidate) && _files.IsExecutable(candidate))
return candidate;
}
return null;
}
private bool RunsThroughWine(string path)
{
try
{
return !_environment.IsWindows && Wine() is not null && Format(path) == BinaryFormat.PortableExecutable;
}
catch (Exception)
{
return false;
}
}
private bool MatchesPlatform(string path) => Format(path) switch
{
BinaryFormat.Elf => !_environment.IsWindows,
BinaryFormat.PortableExecutable => !_environment.IsLinux,
_ => true
};
private enum BinaryFormat
{
Unknown,
Elf,
PortableExecutable
}
private BinaryFormat Format(string path)
{
byte[] head = new byte[4];
using var stream = _files.OpenRead(path);
if (stream.Read(head, 0, 4) != 4)
return true;
return BinaryFormat.Unknown;
bool portableExecutable = head[0] == 0x4D && head[1] == 0x5A;
bool elf = head[0] == 0x7F && head[1] == 0x45 && head[2] == 0x4C && head[3] == 0x46;
if (head[0] == 0x4D && head[1] == 0x5A)
return BinaryFormat.PortableExecutable;
if (head[0] == 0x7F && head[1] == 0x45 && head[2] == 0x4C && head[3] == 0x46)
return BinaryFormat.Elf;
if (_environment.IsWindows && elf)
return false;
if (_environment.IsLinux && portableExecutable)
return false;
return true;
return BinaryFormat.Unknown;
}
private bool IsExeOnLinux(string path) =>
_environment.IsLinux && string.Equals(Path.GetExtension(path), ".exe", StringComparison.OrdinalIgnoreCase);
private bool IsCandidate(string path)
{
string extension = Path.GetExtension(path).ToLowerInvariant();
@@ -133,8 +211,11 @@ public sealed class ExecutableLocator
if (_environment.IsWindows)
return extension == ".exe";
// A Windows build is offered only where Wine can start it, and after any
// native one - see the ordering in Resolve.
if (_environment.IsLinux)
return extension.Length == 0 || extension is ".x86_64" or ".run" or ".appimage";
return extension.Length == 0 || extension is ".x86_64" or ".run" or ".appimage" ||
(IsExeOnLinux(path) && Wine() is not null);
return extension.Length == 0 || extension is ".exe" or ".x86_64" or ".run" or ".appimage";
}