From e5c46d23ead5209e65baf49e9e1010d446fea4c4 Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 4 Oct 2026 19:05:32 +0200 Subject: [PATCH] Tell apart the halves of a unit that share one skin The ET42-026 dresses both halves in 112E-026 and the scenery tells them apart by the model. Textures were keyed by the skin alone, so the B half became a second A: not part of the set, and listed on its own. A shared skin now gets the model in its id, vehicles are looked up by skin and model, and the vehicle cache is rebuilt for the new ids. --- StarterNG.Tests/VehicleCatalogTests.cs | 30 +++++++++++++++ StarterNG/Domain/Consist.cs | 12 +++--- StarterNG/Domain/TexRandomizer.cs | 6 +-- StarterNG/Domain/TrainsetDisplay.cs | 8 ++-- StarterNG/Domain/VehicleInfo.cs | 2 +- StarterNG/Domain/Vehicles/VehicleCatalog.cs | 37 ++++++++++++++++--- .../Vehicles/TexturesTxtParser.cs | 12 +++++- .../Vehicles/VehicleCacheRepository.cs | 3 +- StarterNG/Views/Depot.axaml.cs | 4 +- StarterNG/Views/Depot/VehicleBrowser.cs | 3 +- StarterNG/Views/Depot/VehicleDetails.cs | 2 +- StarterNG/Views/Scenarios.axaml.cs | 10 ++--- 12 files changed, 99 insertions(+), 30 deletions(-) diff --git a/StarterNG.Tests/VehicleCatalogTests.cs b/StarterNG.Tests/VehicleCatalogTests.cs index 63901e2..9c3fedb 100644 --- a/StarterNG.Tests/VehicleCatalogTests.cs +++ b/StarterNG.Tests/VehicleCatalogTests.cs @@ -117,4 +117,34 @@ public class VehicleCatalogTests /// Builds an entry through the real parser, for catalogue-level tests. private static VehicleEntry ParsedEntry(string directory, params string[] lines) => new StarterNG.Infrastructure.Vehicles.TexturesTxtParser().Parse(directory, lines)!; + + // The ET42-026: one skin for both halves, told apart by the model. + private static VehicleCatalog SharedSkinPair() + { + var files = new InMemoryFileSystem() + .WithFile(TexturesFile("pkp", "et42_v2"), + "!=e,ET42-A,ET42-B\n^2\n112E-026.mat=112E-A,ET42-A,ET42-026-A\n112E-026.mat=112E-B,ET42-B,ET42-026-B"); + var installation = new TestInstallation(files); + installation.Vehicles.Load(installation.Library.Vehicles); + return installation.Library.Vehicles; + } + + [Fact] + public void A_skin_shared_by_two_models_is_found_by_the_model() + { + var catalog = SharedSkinPair(); + + Assert.Equal("ET42-026-A", catalog.TextureFor("112E-026", "112E-A")!.TextureMini); + Assert.Equal("ET42-026-B", catalog.TextureFor("112E-026", "112E-B")!.TextureMini); + } + + [Fact] + public void The_second_half_on_a_shared_skin_follows_the_first_in_its_set() + { + var catalog = SharedSkinPair(); + var b = catalog.TextureFor("112E-026", "112E-B")!; + + Assert.True(catalog.IsSetFollower(b)); + Assert.Equal(new[] { "112E-A", "112E-B" }, catalog.ResolveSet(b)!.Select(t => t.Model)); + } } diff --git a/StarterNG/Domain/Consist.cs b/StarterNG/Domain/Consist.cs index ac374e9..b67ecb9 100644 --- a/StarterNG/Domain/Consist.cs +++ b/StarterNG/Domain/Consist.cs @@ -60,7 +60,7 @@ public sealed class Consist : IReadOnlyList var cars = trainset.Vehicles.Select(v => { var c = v.Clone(); - c.MiniName = _db.MiniForSkin(c.SkinFile) ?? c.MiniName; + c.MiniName = _db.MiniFor(c.SkinFile, c.MmdFile) ?? c.MiniName; return c; }).ToList(); @@ -86,7 +86,7 @@ public sealed class Consist : IReadOnlyList } VehicleSet? set = null; - if (_db.TextureForSkin(cars[i].SkinFile)?.Uuid is { } uuid) + if (_db.TextureFor(cars[i].SkinFile, cars[i].MmdFile)?.Uuid is { } uuid) _db.SetByTextureUuid.TryGetValue(uuid, out set); if (set?.TextureRefs is { Count: > 1 }) @@ -97,7 +97,7 @@ public sealed class Consist : IReadOnlyList int j = i; while (j < cars.Count && group.Count < set.TextureRefs.Count) { - if (_db.TextureForSkin(cars[j].SkinFile)?.Uuid is { } u + if (_db.TextureFor(cars[j].SkinFile, cars[j].MmdFile)?.Uuid is { } u && members.Contains(u)) group.Add(cars[j++]); else @@ -285,7 +285,7 @@ public sealed class Consist : IReadOnlyList } private bool IsPowered(ConsistItem item) => - item.Cars.Count > 0 && _db.TextureForSkin(item.Cars[0].SkinFile) is { } texture && + item.Cars.Count > 0 && _db.TextureFor(item.Cars[0].SkinFile, item.Cars[0].MmdFile) is { } texture && VehicleInfo.IsPoweredCategory(VehicleInfo.CategoryOf(texture)); public void MoveLeft(ConsistItem item) @@ -611,8 +611,8 @@ public sealed class Consist : IReadOnlyList if (IsUnitCar(a) && IsUnitCar(b) && UnitKey(a) == UnitKey(b)) return true; - string? ua = _db.TextureForSkin(a.SkinFile)?.Uuid; - string? ub = _db.TextureForSkin(b.SkinFile)?.Uuid; + string? ua = _db.TextureFor(a.SkinFile, a.MmdFile)?.Uuid; + string? ub = _db.TextureFor(b.SkinFile, b.MmdFile)?.Uuid; if (string.IsNullOrEmpty(ua) || string.IsNullOrEmpty(ub)) return false; diff --git a/StarterNG/Domain/TexRandomizer.cs b/StarterNG/Domain/TexRandomizer.cs index 450afd9..849b364 100644 --- a/StarterNG/Domain/TexRandomizer.cs +++ b/StarterNG/Domain/TexRandomizer.cs @@ -68,7 +68,7 @@ public sealed class TexRandomizer var item = consist[i]; if (item.Cars.Count == 0) continue; - var current = _db.TextureForSkin(item.Cars[0].SkinFile); + var current = _db.TextureFor(item.Cars[0].SkinFile, item.Cars[0].MmdFile); if (current != null && _db.IsSetFollower(current)) continue; @@ -128,13 +128,13 @@ public sealed class TexRandomizer if (index > 0) { - var prev = _db.TextureForSkin(consist[index - 1].Cars[0].SkinFile); + var prev = _db.TextureFor(consist[index - 1].Cars[0].SkinFile, consist[index - 1].Cars[0].MmdFile); if (prev != null && !string.IsNullOrEmpty(prev.ResolvedClass)) return prev.ResolvedClass; } if (index + 1 < consist.Count) { - var next = _db.TextureForSkin(consist[index + 1].Cars[0].SkinFile); + var next = _db.TextureFor(consist[index + 1].Cars[0].SkinFile, consist[index + 1].Cars[0].MmdFile); if (next != null && !string.IsNullOrEmpty(next.ResolvedClass)) return next.ResolvedClass; } diff --git a/StarterNG/Domain/TrainsetDisplay.cs b/StarterNG/Domain/TrainsetDisplay.cs index dbc29e6..13f9c40 100644 --- a/StarterNG/Domain/TrainsetDisplay.cs +++ b/StarterNG/Domain/TrainsetDisplay.cs @@ -67,14 +67,14 @@ public static class TrainsetDisplay public static string VehicleLabel(Dynamic v, bool head = false) { - var tex = AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile); + var tex = AppServices.Current.Library.Vehicles.TextureFor(v.SkinFile, v.MmdFile); string? cls = string.IsNullOrEmpty(tex?.ResolvedClass) ? null : tex!.ResolvedClass; string? name = string.IsNullOrWhiteSpace(v.Name) ? null : v.Name; return (head ? name ?? cls : cls ?? name) ?? BaseSkin(v); } public static bool IsPowered(Dynamic v) => - AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile)?.ResolvedCategory + AppServices.Current.Library.Vehicles.TextureFor(v.SkinFile, v.MmdFile)?.ResolvedCategory is "e" or "s" or "p" or "z" or "a"; private static string BaseSkin(Dynamic v) => @@ -206,7 +206,7 @@ public static class TrainsetDisplay bool mu = trainset.Vehicles.Any(v => { - string? cat = db.TextureForSkin(v.SkinFile)?.ResolvedCategory; + string? cat = db.TextureFor(v.SkinFile, v.MmdFile)?.ResolvedCategory; return cat is "z" or "a"; }); @@ -232,7 +232,7 @@ public static class TrainsetDisplay public static string PreferredBaseName(Dynamic v, VehicleCatalog db) { - var tex = db.TextureForSkin(v.SkinFile); + var tex = db.TextureFor(v.SkinFile, v.MmdFile); if (tex != null) { diff --git a/StarterNG/Domain/VehicleInfo.cs b/StarterNG/Domain/VehicleInfo.cs index 537d31c..67c1c8a 100644 --- a/StarterNG/Domain/VehicleInfo.cs +++ b/StarterNG/Domain/VehicleInfo.cs @@ -22,7 +22,7 @@ public sealed class VehicleInfo public static bool IsPoweredCategory(string? c) => c is "e" or "s" or "p" or "z" or "a"; - public VehicleTexture? TextureFor(Dynamic car) => _db.TextureForSkin(car.SkinFile); + public VehicleTexture? TextureFor(Dynamic car) => _db.TextureFor(car.SkinFile, car.MmdFile); public string? CategoryOf(Dynamic car) => TextureFor(car) is { } t ? CategoryOf(t) : null; diff --git a/StarterNG/Domain/Vehicles/VehicleCatalog.cs b/StarterNG/Domain/Vehicles/VehicleCatalog.cs index 095837e..2b3108b 100644 --- a/StarterNG/Domain/Vehicles/VehicleCatalog.cs +++ b/StarterNG/Domain/Vehicles/VehicleCatalog.cs @@ -27,6 +27,9 @@ public sealed class VehicleCatalog public Dictionary TextureBySkin { get; } = new(); + /// For a skin shared by several models, which one dresses which. + private readonly Dictionary _textureBySkinAndModel = new(); + public void BeginLoad() { Textures.Clear(); @@ -35,6 +38,7 @@ public sealed class VehicleCatalog SetByTextureUuid.Clear(); TextureByUuid.Clear(); TextureBySkin.Clear(); + _textureBySkinAndModel.Clear(); } public void Ingest(VehicleEntry entry) @@ -49,7 +53,10 @@ public sealed class VehicleCatalog TextureByUuid[texture.Uuid] = texture; if (!string.IsNullOrEmpty(texture.Skinfile)) - TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture); + { + TextureBySkin.TryAdd(Key(texture.Skinfile), texture); + _textureBySkinAndModel.TryAdd(Key(texture.Skinfile, texture.Model), texture); + } if (texture.Wreck) continue; @@ -105,18 +112,38 @@ public sealed class VehicleCatalog return texture.TextureMini; } - public string? MiniForSkin(string? skinFile) => - TextureForSkin(skinFile) is { } texture ? ResolveMiniName(texture) : null; + public string? MiniFor(string? skinFile, string? model) => + TextureFor(skinFile, model) is { } texture ? ResolveMiniName(texture) : null; + + /// + /// The texture a vehicle wears: by the skin, and where one skin dresses several + /// models, by the model too - the scenery names both. + /// + public VehicleTexture? TextureFor(string? skinFile, string? model) + { + if (string.IsNullOrEmpty(skinFile)) + return null; + + if (!string.IsNullOrEmpty(model) && + _textureBySkinAndModel.TryGetValue(Key(skinFile, model), out var exact)) + return exact; + + return TextureForSkin(skinFile); + } public VehicleTexture? TextureForSkin(string? skinFile) { if (string.IsNullOrEmpty(skinFile)) return null; - string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant(); - return TextureBySkin.TryGetValue(key, out var texture) ? texture : null; + return TextureBySkin.TryGetValue(Key(skinFile), out var texture) ? texture : null; } + private static string Key(string skinFile) => Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant(); + + private static string Key(string skinFile, string? model) => + Key(skinFile) + "|" + (string.IsNullOrEmpty(model) ? "" : Path.GetFileNameWithoutExtension(model).ToLowerInvariant()); + public string GroupHeader(string? groupId) { if (string.IsNullOrEmpty(groupId) || !GroupsById.TryGetValue(groupId, out var group)) diff --git a/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs b/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs index ba731ec..3eebfe8 100644 --- a/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs +++ b/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs @@ -8,6 +8,9 @@ public sealed class TexturesTxtParser { private const string DeriveCategory = "*"; + /// Goes into the vehicle cache stamp, so a change to what is parsed rebuilds the cache. + public const int Version = 2; + public VehicleEntry? Parse(string directory, IReadOnlyList lines) { var entry = new VehicleEntry { Uuid = "legacy:" + directory }; @@ -133,9 +136,16 @@ public sealed class TexturesTxtParser Implicit = true }); + // One skin may dress several models - the ET42-026 has a single 112E-026 for + // both halves - and the scenery tells them apart by the model, so the later + // ones are named by it as well, or they would all be the first. + string uuid = $"legacy:{directory}{skin}"; + if (entry.Textures.Exists(t => string.Equals(t.Skinfile, skin, StringComparison.OrdinalIgnoreCase))) + uuid += "#" + lead.Model; + var texture = new VehicleTexture { - Uuid = $"legacy:{directory}{skin}", + Uuid = uuid, Directory = directory, Skinfile = skin, Wreck = skin.Contains("wreck", StringComparison.OrdinalIgnoreCase) || diff --git a/StarterNG/Infrastructure/Vehicles/VehicleCacheRepository.cs b/StarterNG/Infrastructure/Vehicles/VehicleCacheRepository.cs index 5befa5a..3e83ece 100644 --- a/StarterNG/Infrastructure/Vehicles/VehicleCacheRepository.cs +++ b/StarterNG/Infrastructure/Vehicles/VehicleCacheRepository.cs @@ -79,7 +79,8 @@ public sealed class VehicleCacheRepository : IVehicleRepository /// private (string Text, int Sources) Stamp() { - var stamp = new StringBuilder().Append("root|").Append(Path.GetFullPath(_paths.Root)).Append('\n'); + var stamp = new StringBuilder().Append("root|").Append(Path.GetFullPath(_paths.Root)).Append('\n') + .Append("parser|").Append(TexturesTxtParser.Version).Append('\n'); int sources = 0; foreach (string path in _legacy.Sources()) diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs index 88be57d..f277a74 100644 --- a/StarterNG/Views/Depot.axaml.cs +++ b/StarterNG/Views/Depot.axaml.cs @@ -254,7 +254,7 @@ public partial class Depot : UserControl StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields( _consist.EditingTrainset, _info.PhysicsFor, - car => _db.TextureForSkin(car.SkinFile) is { } t ? VehicleInfo.CategoryOf(t) : null, + car => _db.TextureFor(car.SkinFile, car.MmdFile) is { } t ? VehicleInfo.CategoryOf(t) : null, AppServices.Current.LoadWeights.Table.WeightOf)); } @@ -607,7 +607,7 @@ public partial class Depot : UserControl private void AddCopiesAfter(ConsistItem item, int count) { int index = _consist.IndexOf(item); - if (index < 0 || item.Cars.Count == 0 || _db.TextureForSkin(item.Cars[0].SkinFile) is not { } texture) + if (index < 0 || item.Cars.Count == 0 || _db.TextureFor(item.Cars[0].SkinFile, item.Cars[0].MmdFile) is not { } texture) return; InsertTexturesAt(texture, index + 1, count); diff --git a/StarterNG/Views/Depot/VehicleBrowser.cs b/StarterNG/Views/Depot/VehicleBrowser.cs index b699225..2e0f7c7 100644 --- a/StarterNG/Views/Depot/VehicleBrowser.cs +++ b/StarterNG/Views/Depot/VehicleBrowser.cs @@ -590,7 +590,7 @@ public sealed class VehicleBrowser public void SelectInBrowser(Dynamic car) { - var texture = _db.TextureForSkin(car.SkinFile); + var texture = _db.TextureFor(car.SkinFile, car.MmdFile); if (texture is null) return; @@ -635,6 +635,7 @@ public sealed class VehicleBrowser foreach (var obj in vehicleListBox.Items) if (obj is ListBoxItem { Tag: VehicleTexture t } entry && string.Equals(t.Skinfile, texture.Skinfile, StringComparison.OrdinalIgnoreCase) && + string.Equals(t.Model, texture.Model, StringComparison.OrdinalIgnoreCase) && string.Equals(t.Directory, texture.Directory, StringComparison.OrdinalIgnoreCase)) { vehicleListBox.SelectedItem = entry; diff --git a/StarterNG/Views/Depot/VehicleDetails.cs b/StarterNG/Views/Depot/VehicleDetails.cs index 6ee32b7..a7d42ac 100644 --- a/StarterNG/Views/Depot/VehicleDetails.cs +++ b/StarterNG/Views/Depot/VehicleDetails.cs @@ -97,7 +97,7 @@ public sealed class VehicleDetails BuildGeneralInfo(_consist.Selected); - if (_db.TextureForSkin(_consist.ActiveCar(_consist.Selected).SkinFile) is { } tex) + if (_db.TextureFor(_consist.ActiveCar(_consist.Selected).SkinFile, _consist.ActiveCar(_consist.Selected).MmdFile) is { } tex) _showTextureInfo(tex, consistTexturePanel); else consistTexturePanel.Children.Add(PanelNote(App.Loc["NoTextureInfo"])); diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs index 7e8d6da..7bcad5d 100644 --- a/StarterNG/Views/Scenarios.axaml.cs +++ b/StarterNG/Views/Scenarios.axaml.cs @@ -363,14 +363,14 @@ public partial class Scenarios : UserControl foreach (var v in ts.Vehicles) { if (string.IsNullOrEmpty(v.SkinFile)) continue; - if (db.TextureForSkin(v.SkinFile) is null) + if (db.TextureFor(v.SkinFile, v.MmdFile) is null) lines.Add($"# unknown texture: {v.Name} ({v.SkinFile})"); } } foreach (var v in scenery.LooseVehicles) { - if (db.TextureForSkin(v.SkinFile) is null) + if (db.TextureFor(v.SkinFile, v.MmdFile) is null) lines.Add($"# unknown loose texture: {v.Name} ({v.SkinFile})"); } @@ -860,7 +860,7 @@ public partial class Scenarios : UserControl foreach (var train in trainset.Vehicles) { - string miniName = db.MiniForSkin(train.SkinFile) ?? train.SkinFile; + string miniName = db.MiniFor(train.SkinFile, train.MmdFile) ?? train.SkinFile; if (!AppServices.Current.MiniTextures.Has(miniName) && AppServices.Current.MiniTextures.Has(train.SkinFile)) miniName = train.SkinFile; int thumbH = AppServices.Current.Settings.LargeThumbnails ? 64 : 32; @@ -1161,10 +1161,10 @@ public partial class Scenarios : UserControl var db = AppServices.Current.Library.Vehicles; StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields( trainset, - car => AppServices.Current.Physics.For(car.DataFolder, db.TextureForSkin(car.SkinFile)?.Model) + car => AppServices.Current.Physics.For(car.DataFolder, db.TextureFor(car.SkinFile, car.MmdFile)?.Model) ?? AppServices.Current.Physics.For(car.DataFolder, car.MmdFile) ?? AppServices.Current.Physics.For(car.DataFolder, car.SkinFile), - car => db.TextureForSkin(car.SkinFile)?.ResolvedCategory)); + car => db.TextureFor(car.SkinFile, car.MmdFile)?.ResolvedCategory)); } private void RefreshSelectedConsist()