Compare commits

..

13 Commits

Author SHA1 Message Date
maj00r
6e2dbf9eac test: the system graphics libraries and the software render switch
Taking the libraries and the switch's options are apart from Main, so they are
tested on every system, not only where Windows has them to load.
2026-11-15 17:30:13 +01:00
maj00r
91c496ab48 fix: graphics wrappers beside the starter no longer reach its window
ReShade installed for Direct3D as dxgi.dll in the simulator's folder was
loaded into the starter as well, and its window came up empty. The system
dxgi, d3d11, d3d9 and opengl32 are loaded first, and the window code's own
imports are handed those. The simulator still finds its wrappers.

--software-render draws the window on the processor, on Windows and X11,
for a machine on which it still comes up empty.
2026-11-15 14:41:48 +01:00
maj00r
744720d922 Merge pull request #44 from MaSzyna-EU07/fix/dotted-model-names
fix: a dot in a model name is part of the name, not an extension
2026-11-12 21:33:51 +01:00
maj00r
b5ba67430f fix: a dot in a model name is part of the name, not an extension
The 9-107 and 9-110 coal wagons (9-107.13.fiz and the like) found no physics, so no load could be set on them.
2026-11-12 20:57:36 +01:00
maj00r
c261d4b0ab Merge pull request #43 from MaSzyna-EU07/fix/version-no-suffix
fix: the version is the number alone, with no public beta after it
2026-11-12 19:41:31 +01:00
maj00r
3aa6a256a5 fix: the version is the number alone, with no public beta after it 2026-11-12 19:38:11 +01:00
maj00r
95c510b3ac Merge pull request #41 from MaSzyna-EU07/fix/version-in-title
The version in the window's title
2026-11-12 17:44:17 +01:00
maj00r
5af567be7f Merge pull request #40 from MaSzyna-EU07/feature/blackbox
Black box player: the simulator's recording of a run on a map, with the cab, the timetable and a summary
2026-11-12 17:44:02 +01:00
maj00r
b201b6f7ab Merge pull request #42 from MaSzyna-EU07/fix/consist-strip-selection
fix(scenarios): frame the vehicle started in, no background line between vehicles
2026-11-12 17:43:46 +01:00
maj00r
3a19fd5da2 Merge pull request #38 from MaSzyna-EU07/feat/sonarcloud-analysis
ci: analyse with SonarQube Cloud
2026-11-11 20:16:17 +01:00
maj00r
36df14abee fix(scenarios): the vehicle started in is framed, and no line of the background shows between two vehicles at a scale such as 125 % 2026-11-11 19:01:57 +01:00
maj00r
da5a1efea1 fix: the version in the window's title, where it has room at any width 2026-11-11 17:53:01 +01:00
jerrrrycho
e5330d91a6 ci: analyse with SonarQube Cloud
Runs SonarScanner for .NET around a full build and the tests, with
OpenCover coverage from coverlet, on pushes to main and on pull requests
from branches of this repository. Fork and Dependabot pull requests are
skipped: the C# analysis runs inside the build, so the token and the
built code cannot be kept on separate machines as in maszyna.

The scanner is pinned in .config/dotnet-tools.json for Dependabot.
2026-11-07 06:27:16 +01:00
12 changed files with 450 additions and 5 deletions

13
.config/dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-sonarscanner": {
"version": "12.1.0",
"commands": [
"dotnet-sonarscanner"
],
"rollForward": true
}
}
}

114
.github/workflows/sonar.yml vendored Normal file
View File

@@ -0,0 +1,114 @@
# .github/workflows/sonar.yml
# Has SONAR_TOKEN, so it only ever builds code that is already trusted: pushes to
# main and pull requests opened from a branch of this repository.
#
# Unlike the C++ analysis of maszyna, this one cannot be split into an untrusted
# build and a trusted scan. The C# analyzers run inside the compiler, and the
# scanner's begin step, which needs the token, has to run before the build on the
# same machine. A fork's build could leave a process behind to read the token from
# the end step, so fork pull requests are not analysed here. GitHub withholds the
# secret from them (and from Dependabot) anyway.
#
# The scanner is pinned in .config/dotnet-tools.json, where Dependabot updates it.
# SonarScanner for .NET takes its settings on the command line only; it refuses
# to run next to a sonar-project.properties, so there is none.
name: SonarQube Cloud
on:
# Main branch only: analysing other branches is a paid feature, pull requests
# are not.
push:
branches:
- main
pull_request:
branches:
- main
- staging
types: [ opened, reopened, synchronize ]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: sonar-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
DOTNET_NOLOGO: true
SOLUTION_PATH: StarterNG.sln
TEST_PROJECT_PATH: StarterNG.Tests/StarterNG.Tests.csproj
jobs:
sonar:
name: Analyze
if: >
github.event_name != 'pull_request' ||
(github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]')
runs-on: ubuntu-22.04
timeout-minutes: 30
steps:
# Full history, so new code and blame are told apart correctly.
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Setup .NET 9
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 9.0.x
- name: Restore tools
run: dotnet tool restore
# sonar.qualitygate.wait=true blocks the end step until the server has
# processed the report, so the job's result is the quality gate's.
- name: Begin analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: >
dotnet tool run dotnet-sonarscanner begin
/k:"MaSzyna-EU07_StarterNG"
/o:"maszyna-eu07"
/n:"StarterNG"
/d:sonar.host.url="https://sonarcloud.io"
/d:sonar.token="$SONAR_TOKEN"
/d:sonar.scm.revision="${{ github.event.pull_request.head.sha || github.sha }}"
/d:sonar.cs.opencover.reportsPaths="TestResults/**/coverage.opencover.xml"
/d:sonar.cs.vstest.reportsPaths="TestResults/*.trx"
/d:sonar.qualitygate.wait=true
# A full, non-incremental build: the analyzers only see what is compiled.
- name: Build
run: >
dotnet build "${{ env.SOLUTION_PATH }}"
-c Release
--no-incremental
# Coverlet is already referenced by the test project; OpenCover is the
# format the C# analyzer reads.
- name: Test with coverage
run: >
dotnet test "${{ env.TEST_PROJECT_PATH }}"
-c Release
--no-build
--results-directory TestResults
--logger trx
--collect "XPlat Code Coverage"
--
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- name: End analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: >
dotnet tool run dotnet-sonarscanner end
/d:sonar.token="$SONAR_TOKEN"

View File

@@ -65,6 +65,19 @@ public class PhysicsAndAssetsTests
Assert.Equal(125, physics.VMax);
}
[Fact]
public void A_dot_in_the_model_name_is_part_of_the_name()
{
// As in the game: cd/9-107_v1 holds 9-107.13.fiz, 9-107.6.fiz and more.
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Fiz("cd", "9-107_v1", "9-107.13"), "Load: LoadAccepted=coal MaxLoad=58")
.WithFile(Fiz("cd", "9-107_v1", "9-107.6"), "Load: LoadAccepted=coal MaxLoad=55"));
Assert.Equal(58, installation.Physics.For("cd/9-107_v1/", "9-107.13")!.MaxLoad);
Assert.Equal(55, installation.Physics.For("cd/9-107_v1/", "9-107.6")!.MaxLoad);
Assert.Equal(58, installation.Physics.For("cd/9-107_v1/", "9-107.13.e3d")!.MaxLoad);
}
[Fact]
public void A_dumb_variant_stands_in_when_the_model_has_no_fiz_of_its_own()
{

View File

@@ -0,0 +1,45 @@
using Avalonia;
using StarterNG.Infrastructure;
namespace StarterNG.Tests;
public class SoftwareRenderTests
{
[Theory]
[InlineData("--software-render")]
[InlineData("--Software-Render")]
public void The_switch_asks_for_it_in_any_case(string arg)
{
Assert.True(SoftwareRender.AskedIn(new[] { "--other", arg }));
}
[Fact]
public void Without_the_switch_the_graphics_card_draws()
{
Assert.False(SoftwareRender.AskedIn(Array.Empty<string>()));
Assert.False(SoftwareRender.AskedIn(new[] { "--software" }));
}
[Fact]
public void Windows_draws_on_the_processor_into_a_plain_window_surface()
{
var options = SoftwareRender.Win32Options;
Assert.Equal(new[] { Win32RenderingMode.Software }, options.RenderingMode);
Assert.Equal(new[] { Win32CompositionMode.RedirectionSurface }, options.CompositionMode);
}
[Fact]
public void X11_draws_on_the_processor()
{
Assert.Equal(new[] { X11RenderingMode.Software }, SoftwareRender.X11Options.RenderingMode);
}
[Fact]
public void Both_platforms_options_go_to_the_same_builder()
{
var builder = AppBuilder.Configure<Avalonia.Application>();
Assert.Same(builder, SoftwareRender.ApplyTo(builder));
}
}

View File

@@ -0,0 +1,98 @@
using StarterNG.Infrastructure;
namespace StarterNG.Tests;
public class SystemGraphicsLibrariesTests
{
private const string System32 = @"C:\Windows\System32";
private const string Game = @"E:\SteamLibrary\steamapps\common\MaSzyna";
private static readonly IntPtr Dxgi = new(0x1000);
private static readonly IntPtr D3d11 = new(0x2000);
private static SystemGraphicsLibraries Take(Func<string, bool> beside, Dictionary<string, IntPtr> system)
{
return SystemGraphicsLibraries.Take(System32, Game, beside, Load);
bool Load(string path, out IntPtr handle)
{
handle = system.GetValueOrDefault(Path.GetFileName(path));
return handle != IntPtr.Zero;
}
}
private static SystemGraphicsLibraries WithDxgiAndD3d11(Func<string, bool> beside) =>
Take(beside, new Dictionary<string, IntPtr> { ["dxgi.dll"] = Dxgi, ["d3d11.dll"] = D3d11 });
[Fact]
public void An_import_by_name_gets_the_system_library_with_or_without_its_extension()
{
var taken = WithDxgiAndD3d11(_ => false);
Assert.Equal(Dxgi, taken.Resolve("dxgi"));
Assert.Equal(Dxgi, taken.Resolve("dxgi.dll"));
Assert.Equal(D3d11, taken.Resolve("D3D11.DLL"));
}
[Fact]
public void Other_libraries_and_ones_the_system_lacks_are_left_to_the_usual_search()
{
var taken = WithDxgiAndD3d11(_ => false);
Assert.Equal(IntPtr.Zero, taken.Resolve("user32.dll"));
Assert.Equal(IntPtr.Zero, taken.Resolve("opengl32"));
}
[Fact]
public void They_are_loaded_from_the_system_folder_dxgi_first()
{
var asked = new List<string>();
SystemGraphicsLibraries.Take(System32, Game, _ => false, Load);
Assert.Equal(SystemGraphicsLibraries.Names.Select(n => Path.Combine(System32, n + ".dll")), asked);
Assert.Equal("dxgi", SystemGraphicsLibraries.Names[0]);
bool Load(string path, out IntPtr handle)
{
asked.Add(path);
handle = IntPtr.Zero;
return false;
}
}
[Fact]
public void A_wrapper_beside_the_starter_is_noted()
{
var taken = WithDxgiAndD3d11(path => path == Path.Combine(Game, "dxgi.dll"));
Assert.Equal(new[] { "dxgi.dll" }, taken.Shadowed);
Assert.Contains("dxgi.dll", taken.Note);
}
[Fact]
public void With_nothing_beside_the_starter_there_is_nothing_to_note()
{
var taken = WithDxgiAndD3d11(_ => false);
Assert.Empty(taken.Shadowed);
Assert.Null(taken.Note);
}
[Fact]
public void Only_windows_has_system_libraries_to_take()
{
var taken = SystemGraphicsLibraries.TakeOnWindows();
Assert.Equal(OperatingSystem.IsWindows(), taken is not null);
}
[Fact]
public void An_assembly_with_a_resolver_of_its_own_keeps_it()
{
var taken = WithDxgiAndD3d11(_ => false);
var assembly = typeof(SystemGraphicsLibrariesTests).Assembly;
Assert.True(taken.ServeTo(assembly));
Assert.False(taken.ServeTo(assembly));
}
}

View File

@@ -13,6 +13,6 @@ public static class AppInfo
#if DEBUG
text += " (DEBUG)";
#endif
return text + " public beta";
return text;
}
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia;
namespace StarterNG.Infrastructure;
/// <summary>
/// Drawn by the processor instead of the graphics card, for a machine on which the
/// window comes up empty. Each platform reads only its own options, so both are given.
/// </summary>
public static class SoftwareRender
{
public const string Switch = "--software-render";
public static bool AskedIn(IEnumerable<string> args) =>
args.Any(a => string.Equals(a, Switch, StringComparison.OrdinalIgnoreCase));
public static Win32PlatformOptions Win32Options => new()
{
RenderingMode = new[] { Win32RenderingMode.Software },
CompositionMode = new[] { Win32CompositionMode.RedirectionSurface }
};
public static X11PlatformOptions X11Options => new()
{
RenderingMode = new[] { X11RenderingMode.Software }
};
public static AppBuilder ApplyTo(AppBuilder builder) =>
builder.With(Win32Options).With(X11Options);
}

View File

@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
namespace StarterNG.Infrastructure;
/// <summary>
/// The starter sits in the simulator's folder, and so do the graphics wrappers a player
/// puts there for the simulator - ReShade and the like, named after the system library
/// they stand in for. Windows looks in the program's own folder first, so the starter's
/// window drew through them too, and with some of them it drew nothing at all.
/// Loaded from System32 by full path before anything asks for them, these names are
/// taken: a native library that asks by name gets the system one. .NET looks beside the
/// program by full path before it asks by name, so the window code's own imports are
/// handed the same libraries. The simulator is a process of its own and still finds its
/// wrappers.
/// Elsewhere there is nothing of the kind to keep out. Under Wine or Proton the system
/// folder is the prefix's, so what is taken there is what would have been taken anyway.
/// </summary>
public sealed class SystemGraphicsLibraries
{
public delegate bool Loader(string path, out IntPtr handle);
// dxgi ahead of d3d11, which asks for it.
public static readonly IReadOnlyList<string> Names = new[] { "dxgi", "d3d11", "d3d9", "opengl32" };
private readonly Dictionary<string, IntPtr> _loaded = new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> _shadowed = new();
private SystemGraphicsLibraries()
{
}
/// <summary>The files of these names that lay beside the starter.</summary>
public IReadOnlyList<string> Shadowed => _shadowed;
/// <summary>What goes to the log about them, or null when none lay there.</summary>
public string? Note => _shadowed.Count > 0
? $"Graphics libraries beside the starter, left to the simulator: {string.Join(", ", _shadowed)}"
: null;
/// <summary>The libraries of this Windows, or null on any other system.</summary>
public static SystemGraphicsLibraries? TakeOnWindows() =>
OperatingSystem.IsWindows()
? Take(Environment.SystemDirectory, AppContext.BaseDirectory, File.Exists, NativeLibrary.TryLoad)
: null;
public static SystemGraphicsLibraries Take(string systemDirectory, string ownDirectory,
Func<string, bool> exists, Loader load)
{
var taken = new SystemGraphicsLibraries();
foreach (string name in Names)
{
string file = name + ".dll";
if (exists(Path.Combine(ownDirectory, file)))
taken._shadowed.Add(file);
if (load(Path.Combine(systemDirectory, file), out IntPtr handle))
taken._loaded[name] = handle;
}
return taken;
}
/// <summary>The system library for an import of one of these names; zero leaves it to the usual search.</summary>
public IntPtr Resolve(string libraryName)
{
string name = libraryName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)
? libraryName[..^4]
: libraryName;
return _loaded.TryGetValue(name, out IntPtr handle) ? handle : IntPtr.Zero;
}
/// <summary>
/// The assembly's imports of these names get the system libraries. False when it
/// has a resolver of its own already; that one decides.
/// </summary>
public bool ServeTo(Assembly importer)
{
try
{
NativeLibrary.SetDllImportResolver(importer, (name, _, _) => Resolve(name));
return true;
}
catch (InvalidOperationException)
{
return false;
}
}
}

View File

@@ -51,7 +51,7 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
if (string.IsNullOrEmpty(model))
return null;
string name = Path.GetFileNameWithoutExtension(model);
string name = Path.GetFileName(model);
if (name.Length == 0)
return null;
@@ -60,7 +60,10 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
if (_cache.TryGetValue(key, out var cached))
return cached;
var physics = Read(folder, name);
// The name as written first: in 9-107.13 the dot is part of the name, not an extension.
string bare = Path.GetFileNameWithoutExtension(name);
var physics = Read(folder, name)
?? (bare.Length > 0 && bare != name ? Read(folder, bare) : null);
_cache[key] = physics;
return physics;
}

View File

@@ -46,6 +46,9 @@ public partial class MainWindow : Window
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
InitializeComponent();
// The top bar lets the version go first when it runs short of room, which at the usual widths it does;
// the title has room for it at any width.
Title = $"{Title} {AppInfo.Version}";
ExtendClientAreaToDecorationsHint = true;
ExtendClientAreaTitleBarHeightHint = TitleBarHeight;

View File

@@ -10,12 +10,22 @@ namespace StarterNG;
static class Program
{
private static bool _softwareRender;
[STAThread]
public static void Main(string[] args)
{
// Ahead of everything that could draw: what lies beside the starter is the simulator's.
var graphics = SystemGraphicsLibraries.TakeOnWindows();
graphics?.ServeTo(typeof(Win32PlatformOptions).Assembly);
_softwareRender = SoftwareRender.AskedIn(args);
// Composition root first: everything below, including the crash handlers,
// resolves its collaborators from this graph.
AppServices.Initialize();
if (graphics?.Note is { } note)
Diagnostics.Log(note);
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
@@ -63,9 +73,13 @@ static class Program
};
}
private static AppBuilder WithSoftwareRenderIfAsked(this AppBuilder builder) =>
_softwareRender ? SoftwareRender.ApplyTo(builder) : builder;
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithSoftwareRenderIfAsked()
.WithInterFont()
.WithDiagnosticsLogging()
.AfterSetup(_ => LogUiExceptions());

View File

@@ -1009,12 +1009,14 @@ public partial class Scenarios : UserControl
int thumbH = AppServices.Current.Settings.LargeThumbnails ? 64 : 32;
var bmp = LoadMini(miniName, thumbH);
// Fill, not Uniform: at a scale such as 125 % layout rounds the slot up to whole pixels,
// and a mini keeping its own width inside it left a line of the background between two vehicles.
Control visual = bmp is not null
? MiniTextures.Sharp(new Image
{
Source = bmp,
Height = thumbH,
Stretch = Avalonia.Media.Stretch.Uniform,
Stretch = Avalonia.Media.Stretch.Fill,
Margin = new Thickness(0)
})
: MiniTextures.MissingVisual(train.SkinFile, thumbH);
@@ -1022,6 +1024,22 @@ public partial class Scenarios : UserControl
bool selected = !string.IsNullOrEmpty(train.Name) &&
string.Equals(train.Name, AppServices.Current.State.StartingVehicleName,
StringComparison.OrdinalIgnoreCase);
// The vehicle started in gets a frame over its picture as well as the lines above and below:
// two thin lines alone were hard to see. Drawn over the picture, it moves no vehicle aside.
if (selected)
visual = new Panel
{
Children =
{
visual,
new Border
{
BorderThickness = new Thickness(3),
BorderBrush = SelectedVehicleBrush,
IsHitTestVisible = false
}
}
};
var slot = new Border
{
@@ -1210,7 +1228,9 @@ public partial class Scenarios : UserControl
var source = consistStack.Children[i];
// The ghost mirrors what the slot shows, stand-in wagons included, so
// dragging a vehicle with no thumbnail still carries something visible.
Control copy = source is Border { Child: Image image }
// The picked vehicle's picture sits under its frame.
var shown = source is Border { Child: Panel { Children: [Image framed, ..] } } ? framed : (source as Border)?.Child;
Control copy = shown is Image image
? new Image { Source = image.Source, Height = image.Height, Stretch = image.Stretch }
: MiniTextures.MissingVisual(null, (int)source.Bounds.Height);
panel.Children.Add(copy);