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(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()
{

View File

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

View File

@@ -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();

View File

@@ -27,7 +27,19 @@ public sealed class SimulatorSettings
/// </summary>
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";
/// <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 PauseWhenInactive = true;
public bool PauseOnStart;

View File

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

View File

@@ -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
/// <summary>Thumbnail shown when a vehicle names one we do not have.</summary>
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 IGamePaths _paths;
private readonly int _missRate;
private readonly object _gate = new();
private Dictionary<string, string>? _index;
public MiniTextureIndex(IFileSystem files, IGamePaths paths)
public MiniTextureIndex(IFileSystem files, IGamePaths paths, IEnvironment? environment = null)
{
_files = files;
_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 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;
/// <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()
{
lock (_gate)

View File

@@ -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;
}
/// <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)
{
if (string.IsNullOrEmpty(name))

View File

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

View File

@@ -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<string, Bitmap?> _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);
}

View File

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