Draw a stand-in for a missing thumbnail, pass the language code to eu07.ini

The file was getting "pl" or "en" whatever the user picked, so a simulator with
its own language file started in English.
This commit is contained in:
maj00r
2026-09-04 21:53:29 +02:00
parent 8e87eb8638
commit 27f0e97b43
11 changed files with 283 additions and 48 deletions

View File

@@ -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"));
}
}

View File

@@ -26,11 +26,50 @@ public class SettingsStoreTests
Assert.Equal(1920, settings.Width); Assert.Equal(1920, settings.Width);
Assert.Equal(1080, settings.Height); Assert.Equal(1080, settings.Height);
Assert.True(settings.Fullscreen); Assert.True(settings.Fullscreen);
Assert.Equal("Polski", settings.Language); Assert.Equal("pl", settings.LanguageCode);
Assert.True(settings.LanguageWasSet); Assert.True(settings.LanguageWasSet);
Assert.Equal(UserIni, installation.SettingsStore.LoadedFrom); 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] [Fact]
public void A_first_run_falls_back_to_the_ini_shipped_with_the_installation() public void A_first_run_falls_back_to_the_ini_shipped_with_the_installation()
{ {

View File

@@ -51,7 +51,12 @@ public partial class App : Avalonia.Application
{ {
var resolvedName = Loc.Load(langNameOrCode); 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.Language = resolvedName;
AppServices.Current.Settings.LanguageCode = Loc.CurrentLangCode;
ApplyCulture(Loc.CurrentLangCode); ApplyCulture(Loc.CurrentLangCode);
} }

View File

@@ -43,13 +43,19 @@ public sealed class AppServices
Localization = new LocalizationService(log); Localization = new LocalizationService(log);
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random)); Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random));
SceneryImages = new SceneryImageLocator(files); SceneryImages = new SceneryImageLocator(files);
MiniTextures = new MiniTextureIndex(files, paths); MiniTextures = new MiniTextureIndex(files, paths, environment);
Vehicles = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser()); Vehicles = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
Physics = new FizPhysicsRepository(files, paths, log); Physics = new FizPhysicsRepository(files, paths, log);
MissingAssets = new MissingAssetScanner(files, paths); MissingAssets = new MissingAssetScanner(files, paths);
SceneryTexts = new SceneryTranslations(files, log); SceneryTexts = new SceneryTranslations(files, log);
Timetables = new TimetableLocator(files); Timetables = new TimetableLocator(files);
LoadWeights = new LoadWeightsRepository(files, paths, log); 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); Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
State = new AppState(); State = new AppState();

View File

@@ -27,7 +27,19 @@ public sealed class SimulatorSettings
/// </summary> /// </summary>
public bool LanguageWasSet { get; internal set; } public bool LanguageWasSet { get; internal set; }
/// <summary>
/// Display name of the starter's language, as the picker shows it ("Polski",
/// "English", "中文"). Resolved from <see cref="LanguageCode"/> at startup.
/// </summary>
public string Language = "English"; public string Language = "English";
/// <summary>
/// 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.
/// </summary>
public string LanguageCode = "";
public bool Fullscreen; public bool Fullscreen;
public bool PauseWhenInactive = true; public bool PauseWhenInactive = true;
public bool PauseOnStart; public bool PauseOnStart;

View File

@@ -23,7 +23,13 @@ public sealed class SettingsSerializer
{ {
s.LanguageWasSet = c.Has("lang"); 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.Fullscreen = c.GetBool("fullscreen", false);
s.PauseWhenInactive = c.GetBool("inactivepause", true); s.PauseWhenInactive = c.GetBool("inactivepause", true);
s.PauseOnStart = c.GetBool("pause", false); s.PauseOnStart = c.GetBool("pause", false);
@@ -165,7 +171,7 @@ public sealed class SettingsSerializer
foreach (string key in ObsoleteKeys) foreach (string key in ObsoleteKeys)
c.Remove(key); 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("fullscreen", s.Fullscreen);
c.SetBool("inactivepause", s.PauseWhenInactive); c.SetBool("inactivepause", s.PauseWhenInactive);
c.SetBool("pause", s.PauseOnStart); c.SetBool("pause", s.PauseOnStart);

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using StarterNG.Application.Abstractions; using StarterNG.Application.Abstractions;
@@ -18,26 +19,45 @@ public sealed class MiniTextureIndex : IMiniTextureIndex
/// <summary>Thumbnail shown when a vehicle names one we do not have.</summary> /// <summary>Thumbnail shown when a vehicle names one we do not have.</summary>
private const string FallbackName = "other"; private const string FallbackName = "other";
/// <summary>
/// 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.
/// </summary>
public const string MissRateVariable = "STARTER_DEBUG_MISSING_MINI";
private readonly IFileSystem _files; private readonly IFileSystem _files;
private readonly IGamePaths _paths; private readonly IGamePaths _paths;
private readonly int _missRate;
private readonly object _gate = new(); private readonly object _gate = new();
private Dictionary<string, string>? _index; private Dictionary<string, string>? _index;
public MiniTextureIndex(IFileSystem files, IGamePaths paths) public MiniTextureIndex(IFileSystem files, IGamePaths paths, IEnvironment? environment = null)
{ {
_files = files; _files = files;
_paths = paths; _paths = paths;
_missRate = ReadMissRate(environment);
} }
/// <summary>
/// Percentage of thumbnails currently being hidden on purpose; 0 in a normal
/// run.
/// </summary>
public int SimulatedMissRate => _missRate;
public void Preload() => Index(); public void Preload() => Index();
public bool Has(string? miniName) => 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) public string? PathFor(string? miniName)
{ {
if (!string.IsNullOrEmpty(miniName) && if (!IsSimulatedMiss(miniName) && !string.IsNullOrEmpty(miniName) &&
Index().TryGetValue(miniName.ToLowerInvariant(), out string? path)) Index().TryGetValue(miniName.ToLowerInvariant(), out string? path))
return path; return path;
@@ -47,6 +67,35 @@ public sealed class MiniTextureIndex : IMiniTextureIndex
public string? FallbackPath => public string? FallbackPath =>
Index().TryGetValue(FallbackName, out string? path) ? path : null; Index().TryGetValue(FallbackName, out string? path) ? path : null;
/// <summary>
/// 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.
/// </summary>
private bool IsSimulatedMiss(string? miniName) =>
_missRate > 0 && !string.IsNullOrEmpty(miniName) && Bucket(miniName) < _missRate;
/// <summary>FNV-1a over the lower-cased name, folded into 0-99.</summary>
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<string, string> Index() private Dictionary<string, string> Index()
{ {
lock (_gate) lock (_gate)

View File

@@ -1,6 +1,10 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
@@ -25,6 +29,75 @@ public sealed class MiniTextures
return image; return image;
} }
/// <summary>Body of the drawn stand-in; the black wagon of the old starter.</summary>
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"));
/// <summary>Real minis are about 80x30, and the stand-in keeps that shape.</summary>
private const double MiniAspect = 80.0 / 30.0;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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) public Bitmap? Get(string? name, int height, bool strict = false)
{ {
if (string.IsNullOrEmpty(name)) if (string.IsNullOrEmpty(name))

View File

@@ -92,18 +92,9 @@ public sealed class VehicleCards
} }
else else
{ {
minis.Children.Add(MemberFrame(new Border minis.Children.Add(MemberFrame(
{ MiniTextures.MissingVisual(car.SkinFile, VehicleCardStyle.ThumbHeight),
Height = VehicleCardStyle.ThumbHeight, Width = 90, Background = VehicleCardStyle.Placeholder, item, car, CardCaption(item, car, index)));
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)));
} }
} }

View File

@@ -427,26 +427,6 @@ public partial class Scenarios : UserControl
private static readonly Avalonia.Media.IBrush SelectedVehicleBrush = private static readonly Avalonia.Media.IBrush SelectedVehicleBrush =
new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#41C400")); 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<string, Bitmap?> _miniCache = new(); private readonly Dictionary<string, Bitmap?> _miniCache = new();
private Bitmap? LoadMini(string? name, int height) private Bitmap? LoadMini(string? name, int height)
@@ -834,7 +814,7 @@ public partial class Scenarios : UserControl
Stretch = Avalonia.Media.Stretch.Uniform, Stretch = Avalonia.Media.Stretch.Uniform,
Margin = new Thickness(0) Margin = new Thickness(0)
}) })
: MissingMiniBox(train.SkinFile, thumbH); : MiniTextures.MissingVisual(train.SkinFile, thumbH);
bool selected = !string.IsNullOrEmpty(train.Name) && bool selected = !string.IsNullOrEmpty(train.Name) &&
string.Equals(train.Name, AppServices.Current.State.StartingVehicleName, 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++) for (int i = _dragBlockStart; i < _dragBlockStart + _dragBlockLength; i++)
{ {
var source = consistStack.Children[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 } Control copy = source is Border { Child: Image image }
? new Image { Source = image.Source, Height = image.Height, Stretch = image.Stretch } ? new Image { Source = image.Source, Height = image.Height, Stretch = image.Stretch }
: new Border : MiniTextures.MissingVisual(null, (int)source.Bounds.Height);
{
Width = source.Bounds.Width,
Height = source.Bounds.Height,
Background = MissingMiniBrush
};
panel.Children.Add(copy); panel.Children.Add(copy);
} }

View File

@@ -277,8 +277,9 @@ public partial class Settings : UserControl, ISettingsCapture
public void CaptureInto(SimulatorSettings s) public void CaptureInto(SimulatorSettings s)
{ {
s.Language = (ChangeLanguageCb.SelectedItem as ComboBoxItem)?.Content?.ToString() var language = ChangeLanguageCb.SelectedItem as ComboBoxItem;
?? App.Loc.CurrentLanguage; s.Language = language?.Content?.ToString() ?? App.Loc.CurrentLanguage;
s.LanguageCode = language?.Tag as string ?? App.Loc.CurrentLangCode;
s.Fullscreen = IsChecked(FullscreenCb); s.Fullscreen = IsChecked(FullscreenCb);
s.PauseWhenInactive = IsChecked(PauseInactiveCb); s.PauseWhenInactive = IsChecked(PauseInactiveCb);
s.PauseOnStart = IsChecked(PauseStartCb); s.PauseOnStart = IsChecked(PauseStartCb);