diff --git a/StarterNG.Tests/MiniTextureIndexTests.cs b/StarterNG.Tests/MiniTextureIndexTests.cs
new file mode 100644
index 0000000..a4b61d0
--- /dev/null
+++ b/StarterNG.Tests/MiniTextureIndexTests.cs
@@ -0,0 +1,76 @@
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Infrastructure.Vehicles;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class MiniTextureIndexTests
+{
+ private static readonly string[] Names =
+ {
+ "ep07", "et22", "111a", "112a", "203v", "401k", "en57", "sm42", "st44", "412w",
+ "406r", "424z", "621z", "z1kd", "gags", "eaos", "falns", "uacs", "sgs", "kbs"
+ };
+
+ private static MiniTextureIndex Build(int missRate)
+ {
+ var files = new InMemoryFileSystem();
+ foreach (string name in Names)
+ files.WithFile(TestInstallation.At("textures", "mini", name + ".bmp"), "bitmap");
+ files.WithFile(TestInstallation.At("textures", "mini", "other.bmp"), "bitmap");
+
+ var environment = new FakeEnvironment();
+ if (missRate > 0)
+ environment.With(MiniTextureIndex.MissRateVariable, missRate.ToString());
+
+ return new MiniTextureIndex(files, new GamePaths(TestInstallation.Root), environment);
+ }
+
+ [Fact]
+ public void Without_the_debug_variable_every_thumbnail_loads()
+ {
+ var index = Build(missRate: 0);
+
+ Assert.Equal(0, index.SimulatedMissRate);
+ Assert.All(Names, name => Assert.True(index.Has(name)));
+ }
+
+ [Fact]
+ public void The_debug_variable_hides_roughly_the_share_it_names()
+ {
+ var index = Build(missRate: 50);
+
+ int hidden = Names.Count(name => !index.Has(name));
+
+ // Twenty names is a small sample, so this only asserts that the switch
+ // bites and does not take everything with it.
+ Assert.InRange(hidden, 3, Names.Length - 3);
+ }
+
+ [Fact]
+ public void A_hidden_thumbnail_stays_hidden_rather_than_flickering()
+ {
+ var index = Build(missRate: 50);
+ string hidden = Names.First(name => !index.Has(name));
+
+ for (int i = 0; i < 20; i++)
+ Assert.False(index.Has(hidden));
+ }
+
+ [Fact]
+ public void A_hidden_thumbnail_falls_back_to_other_bmp_like_an_unknown_one()
+ {
+ var index = Build(missRate: 50);
+ string hidden = Names.First(name => !index.Has(name));
+
+ Assert.EndsWith("other.bmp", index.PathFor(hidden));
+ }
+
+ [Fact]
+ public void An_unknown_name_still_falls_back_to_other_bmp_in_a_normal_run()
+ {
+ var index = Build(missRate: 0);
+
+ Assert.EndsWith("other.bmp", index.PathFor("nie-ma-takiej"));
+ }
+}
diff --git a/StarterNG.Tests/SettingsStoreTests.cs b/StarterNG.Tests/SettingsStoreTests.cs
index 7caaa1c..2403a0d 100644
--- a/StarterNG.Tests/SettingsStoreTests.cs
+++ b/StarterNG.Tests/SettingsStoreTests.cs
@@ -26,11 +26,50 @@ public class SettingsStoreTests
Assert.Equal(1920, settings.Width);
Assert.Equal(1080, settings.Height);
Assert.True(settings.Fullscreen);
- Assert.Equal("Polski", settings.Language);
+ Assert.Equal("pl", settings.LanguageCode);
Assert.True(settings.LanguageWasSet);
Assert.Equal(UserIni, installation.SettingsStore.LoadedFrom);
}
+ [Fact]
+ public void A_language_the_starter_does_not_ship_survives_a_round_trip()
+ {
+ // A user-supplied translation must reach the simulator as written; folding
+ // every unknown code into "en" is what forced eu07.exe into English.
+ var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "lang zh\n"));
+ installation.SettingsStore.Load();
+
+ Assert.Equal("zh", installation.SettingsStore.Settings.LanguageCode);
+
+ installation.SettingsStore.Save();
+
+ Assert.Contains("lang zh", installation.Files.ReadAllText(UserIni));
+ }
+
+ [Fact]
+ public void A_language_chosen_in_the_picker_is_written_as_its_own_code()
+ {
+ var installation = WithHome();
+ installation.SettingsStore.Load();
+ installation.SettingsStore.Settings.Language = "中文";
+ installation.SettingsStore.Settings.LanguageCode = "zh";
+
+ installation.SettingsStore.Save();
+
+ Assert.Contains("lang zh", installation.Files.ReadAllText(UserIni));
+ }
+
+ [Fact]
+ public void With_no_language_recorded_the_file_still_names_one()
+ {
+ var installation = WithHome();
+ installation.SettingsStore.Load();
+
+ installation.SettingsStore.Save();
+
+ Assert.Contains("lang en", installation.Files.ReadAllText(UserIni));
+ }
+
[Fact]
public void A_first_run_falls_back_to_the_ini_shipped_with_the_installation()
{
diff --git a/StarterNG/App.axaml.cs b/StarterNG/App.axaml.cs
index 27954d5..6b363b2 100644
--- a/StarterNG/App.axaml.cs
+++ b/StarterNG/App.axaml.cs
@@ -51,7 +51,12 @@ public partial class App : Avalonia.Application
{
var resolvedName = Loc.Load(langNameOrCode);
+
+ // The service is the authority on which language actually loaded: the code
+ // goes to eu07.ini for the simulator, the name to the picker.
AppServices.Current.Settings.Language = resolvedName;
+ AppServices.Current.Settings.LanguageCode = Loc.CurrentLangCode;
+
ApplyCulture(Loc.CurrentLangCode);
}
diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs
index d12d0f9..dd65ffb 100644
--- a/StarterNG/Application/AppServices.cs
+++ b/StarterNG/Application/AppServices.cs
@@ -43,13 +43,19 @@ public sealed class AppServices
Localization = new LocalizationService(log);
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random));
SceneryImages = new SceneryImageLocator(files);
- MiniTextures = new MiniTextureIndex(files, paths);
+ MiniTextures = new MiniTextureIndex(files, paths, environment);
Vehicles = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
Physics = new FizPhysicsRepository(files, paths, log);
MissingAssets = new MissingAssetScanner(files, paths);
SceneryTexts = new SceneryTranslations(files, log);
Timetables = new TimetableLocator(files);
LoadWeights = new LoadWeightsRepository(files, paths, log);
+ // Leaves a trail when the thumbnail debug switch is on, so a run with
+ // missing minis is never mistaken for a broken installation.
+ if (MiniTextures is MiniTextureIndex { SimulatedMissRate: > 0 } debugMinis)
+ log.Log($"{MiniTextureIndex.MissRateVariable}={debugMinis.SimulatedMissRate}: " +
+ "ukrywam ten procent miniatur pojazdow");
+
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
State = new AppState();
diff --git a/StarterNG/Domain/Settings/SimulatorSettings.cs b/StarterNG/Domain/Settings/SimulatorSettings.cs
index e671649..f326691 100644
--- a/StarterNG/Domain/Settings/SimulatorSettings.cs
+++ b/StarterNG/Domain/Settings/SimulatorSettings.cs
@@ -27,7 +27,19 @@ public sealed class SimulatorSettings
///
public bool LanguageWasSet { get; internal set; }
+ ///
+ /// Display name of the starter's language, as the picker shows it ("Polski",
+ /// "English", "中文"). Resolved from at startup.
+ ///
public string Language = "English";
+
+ ///
+ /// The language's code, as its startercfg/lang/*.xml declares it and as
+ /// eu07.ini stores it. This is what the simulator reads, so it has to carry
+ /// whatever language the user actually picked rather than being folded into
+ /// one of two known ones.
+ ///
+ public string LanguageCode = "";
public bool Fullscreen;
public bool PauseWhenInactive = true;
public bool PauseOnStart;
diff --git a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs
index 2dd05e7..916e159 100644
--- a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs
+++ b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs
@@ -23,7 +23,13 @@ public sealed class SettingsSerializer
{
s.LanguageWasSet = c.Has("lang");
- s.Language = c.GetString("lang", "en").ToLowerInvariant() == "pl" ? "Polski" : "English";
+ // Kept as the code the file holds. Startup hands it to the localisation
+ // service, which resolves it to a display name and writes that back; the
+ // starter must not decide here that an unrecognised code means English,
+ // because the same key is what tells the simulator which language to run
+ // in.
+ s.LanguageCode = c.GetString("lang", "");
+ s.Language = s.LanguageCode;
s.Fullscreen = c.GetBool("fullscreen", false);
s.PauseWhenInactive = c.GetBool("inactivepause", true);
s.PauseOnStart = c.GetBool("pause", false);
@@ -165,7 +171,7 @@ public sealed class SettingsSerializer
foreach (string key in ObsoleteKeys)
c.Remove(key);
- c.Set("lang", s.Language == "Polski" ? "pl" : "en");
+ c.Set("lang", string.IsNullOrWhiteSpace(s.LanguageCode) ? "en" : s.LanguageCode);
c.SetBool("fullscreen", s.Fullscreen);
c.SetBool("inactivepause", s.PauseWhenInactive);
c.SetBool("pause", s.PauseOnStart);
diff --git a/StarterNG/Infrastructure/Vehicles/MiniTextureIndex.cs b/StarterNG/Infrastructure/Vehicles/MiniTextureIndex.cs
index 8871522..09c6ba4 100644
--- a/StarterNG/Infrastructure/Vehicles/MiniTextureIndex.cs
+++ b/StarterNG/Infrastructure/Vehicles/MiniTextureIndex.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using StarterNG.Application.Abstractions;
@@ -18,26 +19,45 @@ public sealed class MiniTextureIndex : IMiniTextureIndex
/// Thumbnail shown when a vehicle names one we do not have.
private const string FallbackName = "other";
+ ///
+ /// Set to 1-100 to make that percentage of thumbnails behave as if the
+ /// installation did not have their file - the vehicle then falls back to
+ /// other.bmp exactly as one naming an unknown thumbnail would. For checking
+ /// how a consist looks when some of its stock has no picture of its own.
+ /// Off unless the variable is set.
+ ///
+ public const string MissRateVariable = "STARTER_DEBUG_MISSING_MINI";
+
private readonly IFileSystem _files;
private readonly IGamePaths _paths;
+ private readonly int _missRate;
private readonly object _gate = new();
private Dictionary? _index;
- public MiniTextureIndex(IFileSystem files, IGamePaths paths)
+ public MiniTextureIndex(IFileSystem files, IGamePaths paths, IEnvironment? environment = null)
{
_files = files;
_paths = paths;
+ _missRate = ReadMissRate(environment);
}
+ ///
+ /// Percentage of thumbnails currently being hidden on purpose; 0 in a normal
+ /// run.
+ ///
+ public int SimulatedMissRate => _missRate;
+
public void Preload() => Index();
public bool Has(string? miniName) =>
- !string.IsNullOrEmpty(miniName) && Index().ContainsKey(miniName.ToLowerInvariant());
+ !string.IsNullOrEmpty(miniName) &&
+ !IsSimulatedMiss(miniName) &&
+ Index().ContainsKey(miniName.ToLowerInvariant());
public string? PathFor(string? miniName)
{
- if (!string.IsNullOrEmpty(miniName) &&
+ if (!IsSimulatedMiss(miniName) && !string.IsNullOrEmpty(miniName) &&
Index().TryGetValue(miniName.ToLowerInvariant(), out string? path))
return path;
@@ -47,6 +67,35 @@ public sealed class MiniTextureIndex : IMiniTextureIndex
public string? FallbackPath =>
Index().TryGetValue(FallbackName, out string? path) ? path : null;
+ ///
+ /// Whether this name is one of the thumbnails the debug switch is hiding.
+ /// Decided from the name itself rather than a random draw, so a vehicle keeps
+ /// the same answer every time the strip is rebuilt and can actually be looked
+ /// at.
+ ///
+ private bool IsSimulatedMiss(string? miniName) =>
+ _missRate > 0 && !string.IsNullOrEmpty(miniName) && Bucket(miniName) < _missRate;
+
+ /// FNV-1a over the lower-cased name, folded into 0-99.
+ private static int Bucket(string name)
+ {
+ uint hash = 2166136261;
+ foreach (char c in name.ToLowerInvariant())
+ {
+ hash ^= c;
+ hash *= 16777619;
+ }
+ return (int)(hash % 100);
+ }
+
+ private static int ReadMissRate(IEnvironment? environment)
+ {
+ string? raw = environment?.GetVariable(MissRateVariable);
+ return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int rate)
+ ? Math.Clamp(rate, 0, 100)
+ : 0;
+ }
+
private Dictionary Index()
{
lock (_gate)
diff --git a/StarterNG/Views/Depot/MiniTextures.cs b/StarterNG/Views/Depot/MiniTextures.cs
index 86a6862..787ddc4 100644
--- a/StarterNG/Views/Depot/MiniTextures.cs
+++ b/StarterNG/Views/Depot/MiniTextures.cs
@@ -1,6 +1,10 @@
+using System;
using System.Collections.Generic;
using System.IO;
+using Avalonia;
using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
@@ -25,6 +29,75 @@ public sealed class MiniTextures
return image;
}
+ /// Body of the drawn stand-in; the black wagon of the old starter.
+ private static readonly IBrush MissingBody = new SolidColorBrush(Color.Parse("#0B0C0D"));
+
+ private static readonly IBrush MissingEdge = new SolidColorBrush(Color.Parse("#3A424A"));
+
+ private static readonly IBrush MissingMark = new SolidColorBrush(Color.Parse("#E6E8EA"));
+
+ /// Real minis are about 80x30, and the stand-in keeps that shape.
+ private const double MiniAspect = 80.0 / 30.0;
+
+ ///
+ /// The stand-in for a vehicle with no thumbnail: a dark wagon marked with
+ /// question marks, the way the old starter drew textures/mini/other.bmp.
+ ///
+ ///
+ /// Reached only when the installation has no other.bmp either - a vehicle
+ /// naming a thumbnail we do not have already falls back to that file. Drawn
+ /// rather than loaded so the consist keeps its length whatever is missing;
+ /// a vehicle that is simply absent from the strip reads as a shorter train.
+ ///
+ public static Control MissingVisual(string? label, int height)
+ {
+ double width = height * MiniAspect;
+ double bodyTop = height * 0.23;
+ double bodyHeight = height * 0.47;
+ double wheelRadius = height * 0.13;
+
+ var canvas = new Canvas { Width = width, Height = height };
+
+ var body = new Border
+ {
+ Width = width - 4,
+ Height = bodyHeight,
+ Background = MissingBody,
+ BorderBrush = MissingEdge,
+ BorderThickness = new Thickness(1),
+ Child = new TextBlock
+ {
+ Text = "? ?",
+ FontSize = Math.Max(9, height * 0.34),
+ FontWeight = FontWeight.SemiBold,
+ Foreground = MissingMark,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ }
+ };
+ Canvas.SetLeft(body, 2);
+ Canvas.SetTop(body, bodyTop);
+ canvas.Children.Add(body);
+
+ foreach (double centre in new[] { width * 0.22, width * 0.78 })
+ {
+ var wheel = new Ellipse
+ {
+ Width = wheelRadius * 2,
+ Height = wheelRadius * 2,
+ Fill = MissingBody
+ };
+ Canvas.SetLeft(wheel, centre - wheelRadius);
+ Canvas.SetTop(wheel, bodyTop + bodyHeight - wheelRadius);
+ canvas.Children.Add(wheel);
+ }
+
+ if (!string.IsNullOrWhiteSpace(label))
+ ToolTip.SetTip(canvas, label);
+
+ return canvas;
+ }
+
public Bitmap? Get(string? name, int height, bool strict = false)
{
if (string.IsNullOrEmpty(name))
diff --git a/StarterNG/Views/Depot/VehicleCards.cs b/StarterNG/Views/Depot/VehicleCards.cs
index a4a84de..231c59c 100644
--- a/StarterNG/Views/Depot/VehicleCards.cs
+++ b/StarterNG/Views/Depot/VehicleCards.cs
@@ -92,18 +92,9 @@ public sealed class VehicleCards
}
else
{
- minis.Children.Add(MemberFrame(new Border
- {
- Height = VehicleCardStyle.ThumbHeight, Width = 90, Background = VehicleCardStyle.Placeholder,
- CornerRadius = new CornerRadius(4),
- Child = new TextBlock
- {
- Text = car.SkinFile, FontSize = 10, TextWrapping = TextWrapping.Wrap,
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Center,
- TextAlignment = TextAlignment.Center
- }
- }, item, car, CardCaption(item, car, index)));
+ minis.Children.Add(MemberFrame(
+ MiniTextures.MissingVisual(car.SkinFile, VehicleCardStyle.ThumbHeight),
+ item, car, CardCaption(item, car, index)));
}
}
diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs
index 84bf2ee..3339d08 100644
--- a/StarterNG/Views/Scenarios.axaml.cs
+++ b/StarterNG/Views/Scenarios.axaml.cs
@@ -427,26 +427,6 @@ public partial class Scenarios : UserControl
private static readonly Avalonia.Media.IBrush SelectedVehicleBrush =
new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#41C400"));
- private static readonly Avalonia.Media.IBrush MissingMiniBrush =
- new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#22808080"));
-
- private static Control MissingMiniBox(string? label, int height) => new Border
- {
- Height = height,
- Width = height * 2,
- Background = MissingMiniBrush,
- CornerRadius = new Avalonia.CornerRadius(4),
- Child = new TextBlock
- {
- Text = label,
- FontSize = 10,
- TextWrapping = Avalonia.Media.TextWrapping.Wrap,
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Center,
- TextAlignment = Avalonia.Media.TextAlignment.Center
- }
- };
-
private readonly Dictionary _miniCache = new();
private Bitmap? LoadMini(string? name, int height)
@@ -834,7 +814,7 @@ public partial class Scenarios : UserControl
Stretch = Avalonia.Media.Stretch.Uniform,
Margin = new Thickness(0)
})
- : MissingMiniBox(train.SkinFile, thumbH);
+ : MiniTextures.MissingVisual(train.SkinFile, thumbH);
bool selected = !string.IsNullOrEmpty(train.Name) &&
string.Equals(train.Name, AppServices.Current.State.StartingVehicleName,
@@ -1025,14 +1005,11 @@ public partial class Scenarios : UserControl
for (int i = _dragBlockStart; i < _dragBlockStart + _dragBlockLength; i++)
{
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 }
? new Image { Source = image.Source, Height = image.Height, Stretch = image.Stretch }
- : new Border
- {
- Width = source.Bounds.Width,
- Height = source.Bounds.Height,
- Background = MissingMiniBrush
- };
+ : MiniTextures.MissingVisual(null, (int)source.Bounds.Height);
panel.Children.Add(copy);
}
diff --git a/StarterNG/Views/Settings.axaml.cs b/StarterNG/Views/Settings.axaml.cs
index b7a6ea2..5580b24 100644
--- a/StarterNG/Views/Settings.axaml.cs
+++ b/StarterNG/Views/Settings.axaml.cs
@@ -277,8 +277,9 @@ public partial class Settings : UserControl, ISettingsCapture
public void CaptureInto(SimulatorSettings s)
{
- s.Language = (ChangeLanguageCb.SelectedItem as ComboBoxItem)?.Content?.ToString()
- ?? App.Loc.CurrentLanguage;
+ var language = ChangeLanguageCb.SelectedItem as ComboBoxItem;
+ s.Language = language?.Content?.ToString() ?? App.Loc.CurrentLanguage;
+ s.LanguageCode = language?.Tag as string ?? App.Loc.CurrentLangCode;
s.Fullscreen = IsChecked(FullscreenCb);
s.PauseWhenInactive = IsChecked(PauseInactiveCb);
s.PauseOnStart = IsChecked(PauseStartCb);