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