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.
This commit is contained in:
maj00r
2026-10-04 19:05:32 +02:00
parent 37dd038b96
commit e5c46d23ea
12 changed files with 99 additions and 30 deletions

View File

@@ -117,4 +117,34 @@ public class VehicleCatalogTests
/// <summary>Builds an entry through the real parser, for catalogue-level tests.</summary>
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));
}
}

View File

@@ -60,7 +60,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
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<ConsistItem>
}
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<ConsistItem>
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<ConsistItem>
}
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<ConsistItem>
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;

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,9 @@ public sealed class VehicleCatalog
public Dictionary<string, VehicleTexture> TextureBySkin { get; } = new();
/// <summary>For a skin shared by several models, which one dresses which.</summary>
private readonly Dictionary<string, VehicleTexture> _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;
/// <summary>
/// The texture a vehicle wears: by the skin, and where one skin dresses several
/// models, by the model too - the scenery names both.
/// </summary>
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))

View File

@@ -8,6 +8,9 @@ public sealed class TexturesTxtParser
{
private const string DeriveCategory = "*";
/// <summary>Goes into the vehicle cache stamp, so a change to what is parsed rebuilds the cache.</summary>
public const int Version = 2;
public VehicleEntry? Parse(string directory, IReadOnlyList<string> 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) ||

View File

@@ -79,7 +79,8 @@ public sealed class VehicleCacheRepository : IVehicleRepository
/// </summary>
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())

View File

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

View File

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

View File

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

View File

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