diff --git a/StarterNG/Domain/SceneryI18n.cs b/StarterNG/Domain/SceneryI18n.cs
new file mode 100644
index 0000000..898dc80
--- /dev/null
+++ b/StarterNG/Domain/SceneryI18n.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+using StarterNG.Classes;
+
+namespace StarterNG.Domain;
+
+///
+/// Pascal TI18n / TrToken: scenery strings starting with @
+/// resolve against scenery/i18n/{id}_{lang}.json.
+///
+public static class SceneryI18n
+{
+ private static readonly Dictionary Map =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ public static void Clear() => Map.Clear();
+
+ /// Short UI language code (pl/en/cz/hu/ru).
+ public static void LoadFor(Scenery scenery, string langCode = "en")
+ {
+ Map.Clear();
+ string scnDir = Path.GetDirectoryName(Path.GetFullPath(scenery.Path)) ?? "scenery";
+ string id = Path.GetFileNameWithoutExtension(scenery.Path);
+ string lang = NormalizeLang(langCode);
+ string i18nDir = Path.Combine(scnDir, "i18n");
+
+ // Fallback first (en), then active language overlays — same as Pascal.
+ TryLoad(Path.Combine(i18nDir, $"{id}_en.json"));
+ if (!string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase))
+ TryLoad(Path.Combine(i18nDir, $"{id}_{lang}.json"));
+ }
+
+ /// Translate @token / @nested.key; plain text passes through.
+ public static string T(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return text ?? "";
+ string t = text.TrimStart();
+ if (t.Length == 0 || t[0] != '@') return text;
+ string key = t[1..].Trim();
+ return Map.TryGetValue(key, out var v) ? v : text;
+ }
+
+ private static void TryLoad(string path)
+ {
+ if (!File.Exists(path)) return;
+ try
+ {
+ using var doc = JsonDocument.Parse(File.ReadAllText(path));
+ if (doc.RootElement.ValueKind == JsonValueKind.Object)
+ Flatten("", doc.RootElement);
+ }
+ catch { /* ignore bad i18n files */ }
+ }
+
+ private static void Flatten(string prefix, JsonElement obj)
+ {
+ foreach (var prop in obj.EnumerateObject())
+ {
+ string key = string.IsNullOrEmpty(prefix) ? prop.Name : prefix + "." + prop.Name;
+ switch (prop.Value.ValueKind)
+ {
+ case JsonValueKind.String:
+ Map[key] = prop.Value.GetString() ?? "";
+ break;
+ case JsonValueKind.Number:
+ case JsonValueKind.True:
+ case JsonValueKind.False:
+ Map[key] = prop.Value.ToString();
+ break;
+ case JsonValueKind.Object:
+ Flatten(key, prop.Value);
+ break;
+ }
+ }
+ }
+
+ private static string NormalizeLang(string language)
+ {
+ string l = language.Trim().ToLowerInvariant();
+ return l switch
+ {
+ "polski" or "pl" => "pl",
+ "english" or "en" => "en",
+ "čeština" or "cesky" or "czech" or "cs" or "cz" => "cz",
+ "magyar" or "hungarian" or "hu" => "hu",
+ "русский" or "russian" or "ru" => "ru",
+ _ => l.Length >= 2 ? l[..2] : "en"
+ };
+ }
+}
diff --git a/StarterNG/Domain/TexRandomizer.cs b/StarterNG/Domain/TexRandomizer.cs
new file mode 100644
index 0000000..7925fd6
--- /dev/null
+++ b/StarterNG/Domain/TexRandomizer.cs
@@ -0,0 +1,241 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using StarterNG.Classes;
+
+namespace StarterNG.Domain;
+
+///
+/// Skin shuffle for the depot consist — same idea as Pascal TTexRandomizer:
+/// pick another texture of a similar mini/class (expanded by starter/reguly.txt),
+/// optionally clamped by operator and revision year.
+///
+public sealed class TexRandomizer
+{
+ private readonly VehicleDatabase _db;
+ private readonly Random _rng;
+ private readonly List _stock = new();
+
+ public int RevisionTolerance { get; set; } = -1;
+ public int RevYear { get; set; } = -1;
+ public bool WithoutArchival { get; set; } = true;
+
+ public static string RulesPath
+ {
+ get
+ {
+ string cwd = Directory.GetCurrentDirectory();
+ string primary = Path.Combine(cwd, "starter", "reguly.txt");
+ if (File.Exists(primary)) return primary;
+ string bundled = Path.Combine(AppContext.BaseDirectory, "startercfg", "reguly.txt");
+ if (File.Exists(bundled)) return bundled;
+ return primary;
+ }
+ }
+
+ public TexRandomizer(VehicleDatabase db, Random? rng = null)
+ {
+ _db = db;
+ _rng = rng ?? Random.Shared;
+ LoadRules();
+ }
+
+ public void LoadRules()
+ {
+ _stock.Clear();
+ string path = RulesPath;
+ if (!File.Exists(path)) return;
+
+ try
+ {
+ foreach (string line in File.ReadAllLines(path))
+ {
+ string trimmed = line.Trim();
+ if (trimmed.Length == 0 || trimmed[0] == '#') continue;
+ string[] parts = trimmed.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parts.Length > 0)
+ _stock.Add(parts);
+ }
+ }
+ catch
+ {
+ // bad rules file — keep going with an empty stock
+ }
+ }
+
+ ///
+ /// Reassigns skins on in place. Returns how many units changed.
+ ///
+ public int Apply(IList consist, Func makeCar)
+ {
+ int changed = 0;
+ for (int i = 0; i < consist.Count; i++)
+ {
+ var item = consist[i];
+ if (item.Cars.Count == 0) continue;
+
+ var current = _db.TextureForSkin(item.Cars[0].SkinFile);
+ if (current != null && _db.IsSetFollower(current))
+ continue;
+
+ string? mini = ResolveFilterMini(item, i, consist, current);
+ if (string.IsNullOrEmpty(mini)) continue;
+
+ // Pascal skips units that have a revision filter on but no parseable year.
+ int? revYear = null;
+ if (RevisionTolerance > 0)
+ {
+ revYear = RevYear > 0 ? RevYear : ParseRevisionYear(current?.Meta?.RevisionDate);
+ if (revYear is null) continue;
+ }
+
+ string? owner = null;
+ string? cat = current?.ResolvedCategory;
+ // Keep operator match for non-EMU / non-railbus (Pascal skips EZT/SZYNOBUS).
+ if (current != null && cat is not ("z" or "a"))
+ owner = current.Meta?.Operator;
+
+ var aliases = ExpandMini(mini);
+ var pool = FindCandidates(aliases, owner, revYear, current);
+ if (pool.Count < 2) continue;
+
+ var pick = pool[_rng.Next(pool.Count)];
+ if (ReferenceEquals(pick, current) ||
+ string.Equals(pick.Skinfile, current?.Skinfile, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ var set = _db.ResolveSet(pick);
+ var unit = set ?? new List { pick };
+ consist[i] = new ConsistItem
+ {
+ Cars = unit.Select(makeCar).ToList(),
+ Grouped = unit.Count > 1,
+ Driver = item.Driver,
+ Flipped = item.Flipped
+ };
+ changed++;
+ }
+ return changed;
+ }
+
+ private string? ResolveFilterMini(ConsistItem item, int index, IList consist, VehicleTexture? current)
+ {
+ if (current != null)
+ {
+ string skin = current.Skinfile ?? "";
+ if (skin.Contains("112E", StringComparison.OrdinalIgnoreCase))
+ return null;
+ if (current.Wreck) return null;
+
+ string mini = current.ResolvedClass;
+ if (string.IsNullOrEmpty(mini))
+ mini = _db.ResolveMiniName(current) ?? "";
+ return string.IsNullOrEmpty(mini) ? null : mini;
+ }
+
+ // No texture in DB — borrow from a neighbour like Pascal does for loose dynamics.
+ if (index > 0)
+ {
+ var prev = _db.TextureForSkin(consist[index - 1].Cars[0].SkinFile);
+ 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);
+ if (next != null && !string.IsNullOrEmpty(next.ResolvedClass))
+ return next.ResolvedClass;
+ }
+
+ bool driven = item.Driver is eDriverType.Headdriver or eDriverType.Reardriver;
+ return driven ? "st44" : "A_112A";
+ }
+
+ private List ExpandMini(string mini)
+ {
+ var list = new List { mini };
+ string upper = mini.ToUpperInvariant();
+ foreach (var stock in _stock)
+ {
+ bool hit = stock.Any(s => s.ToUpperInvariant().Contains(upper) || upper.Contains(s.ToUpperInvariant()));
+ if (!hit) continue;
+ foreach (string s in stock)
+ {
+ if (!list.Contains(s, StringComparer.OrdinalIgnoreCase))
+ list.Add(s);
+ }
+ break;
+ }
+ return list;
+ }
+
+ private List FindCandidates(
+ IReadOnlyList minis,
+ string? owner,
+ int? revYear,
+ VehicleTexture? current)
+ {
+ var result = new List();
+ foreach (var t in _db.Textures)
+ {
+ if (t.Wreck) continue;
+ if (WithoutArchival && t.ResolvedArchived) continue;
+ if (_db.IsSetFollower(t)) continue;
+
+ string candidateMini = t.ResolvedClass;
+ if (string.IsNullOrEmpty(candidateMini))
+ candidateMini = _db.ResolveMiniName(t) ?? "";
+ if (string.IsNullOrEmpty(candidateMini)) continue;
+
+ bool miniOk = minis.Any(m =>
+ string.Equals(m, candidateMini, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(m, t.TextureMini, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(m, t.MiniRef, StringComparison.OrdinalIgnoreCase));
+ if (!miniOk) continue;
+
+ if (!string.IsNullOrEmpty(owner) &&
+ !string.Equals(owner, t.Meta?.Operator, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ if (revYear is int year && RevisionTolerance > 0)
+ {
+ int? y = ParseRevisionYear(t.Meta?.RevisionDate);
+ if (y is null || Math.Abs(y.Value - year) > RevisionTolerance)
+ continue;
+ }
+
+ result.Add(t);
+ }
+
+ // Always keep the current skin as a possible "no change" so Count < 2 means
+ // there is nothing else to pick from.
+ if (current != null && !result.Contains(current))
+ result.Add(current);
+
+ return result;
+ }
+
+ /// Pulls a 4-digit year from dates like 12.05.2018 or 2018-05-12.
+ public static int? ParseRevisionYear(string? revision)
+ {
+ if (string.IsNullOrWhiteSpace(revision)) return null;
+
+ // Pascal: Copy(Revision, 7, 4) on dd.mm.yyyy
+ if (revision.Length >= 10 && revision[2] == '.' && revision[5] == '.' &&
+ int.TryParse(revision.AsSpan(6, 4), out int yDot) && yDot is >= 1900 and <= 2100)
+ return yDot;
+
+ for (int i = 0; i + 3 < revision.Length; i++)
+ {
+ if (char.IsDigit(revision[i]) && char.IsDigit(revision[i + 1]) &&
+ char.IsDigit(revision[i + 2]) && char.IsDigit(revision[i + 3]))
+ {
+ int y = (revision[i] - '0') * 1000 + (revision[i + 1] - '0') * 100
+ + (revision[i + 2] - '0') * 10 + (revision[i + 3] - '0');
+ if (y is >= 1900 and <= 2100) return y;
+ }
+ }
+ return null;
+ }
+}
diff --git a/StarterNG/Domain/TrainsetDisplay.cs b/StarterNG/Domain/TrainsetDisplay.cs
new file mode 100644
index 0000000..1e0d345
--- /dev/null
+++ b/StarterNG/Domain/TrainsetDisplay.cs
@@ -0,0 +1,272 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using StarterNG.Classes;
+
+namespace StarterNG.Domain;
+
+/// Shared trainset list labels and status-bar formatting for Scenarios / Depot.
+public static class TrainsetDisplay
+{
+ public static bool IsEmpty(Trainset trainset) =>
+ trainset.Vehicles.Count == 0 ||
+ trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name) && string.IsNullOrWhiteSpace(v.SkinFile));
+
+ ///
+ /// Composition caption like Pascal PrepareTrainsetDesc: first loco/EZT by
+ /// name, then collapse consecutive same-class wagons as Mini(N).
+ ///
+ public static string? CompositionText(Trainset trainset)
+ {
+ if (IsEmpty(trainset))
+ return null;
+
+ var db = GameData.Instance.Vehicles;
+ var sb = new StringBuilder();
+ string? prevKey = null;
+ int run = 0;
+
+ void Flush()
+ {
+ if (prevKey is null || run == 0) return;
+ if (sb.Length > 0) sb.Append(" + ");
+ sb.Append(run > 1 ? $"{prevKey}({run})" : prevKey);
+ prevKey = null;
+ run = 0;
+ }
+
+ for (int i = 0; i < trainset.Vehicles.Count; i++)
+ {
+ var v = trainset.Vehicles[i];
+ var tex = db.TextureForSkin(v.SkinFile);
+ string cat = tex?.ResolvedCategory ?? "";
+ bool powered = cat is "e" or "s" or "p" or "z" or "a";
+
+ if (i == 0 && powered)
+ {
+ string head = !string.IsNullOrWhiteSpace(v.Name) ? v.Name! :
+ (!string.IsNullOrEmpty(tex?.ResolvedClass) ? tex!.ResolvedClass : BaseSkin(v));
+ sb.Append(head);
+ prevKey = null;
+ run = 0;
+ continue;
+ }
+
+ string key = !string.IsNullOrEmpty(tex?.ResolvedClass) ? tex!.ResolvedClass
+ : (!string.IsNullOrWhiteSpace(v.Name) ? v.Name! : BaseSkin(v));
+
+ if (string.Equals(key, prevKey, StringComparison.OrdinalIgnoreCase))
+ {
+ run++;
+ }
+ else
+ {
+ Flush();
+ prevKey = key;
+ run = 1;
+ }
+ }
+ Flush();
+ return sb.Length > 0 ? sb.ToString() : string.Join(" + ", trainset.Vehicles.Select(v =>
+ !string.IsNullOrWhiteSpace(v.Name) ? v.Name! : BaseSkin(v)));
+ }
+
+ private static string BaseSkin(Dynamic v) =>
+ string.IsNullOrWhiteSpace(v.SkinFile) ? "?" :
+ System.IO.Path.GetFileNameWithoutExtension(v.SkinFile);
+
+ ///
+ /// Like Pascal SelectTrain: prefer the last car if it has cab crew
+ /// (push-pull), else the first staffed/passenger car, else the first car.
+ ///
+ public static string? DefaultStartingVehicle(Trainset trainset)
+ {
+ if (trainset.Vehicles.Count == 0)
+ return null;
+
+ var last = trainset.Vehicles[^1];
+ if (last.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)
+ return last.Name;
+
+ var staffed = trainset.Vehicles.FirstOrDefault(v =>
+ v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger);
+ return staffed?.Name ?? trainset.Vehicles[0].Name;
+ }
+
+ ///
+ /// Length / mass / vehicle count / track (+ speed), with Pascal mass rules
+ /// (IncludeVehicleToMass + 200 t AllVehicles retry).
+ ///
+ public static string FormatStats(Trainset? trainset, Func physicsFor,
+ string lengthLabel, string massLabel, string vehiclesLabel, string trackLabel,
+ Func? categoryOf = null,
+ Func? loadWeightKg = null)
+ {
+ if (trainset is null)
+ return "";
+
+ var (lengthM, massKg, loadKg) = RecalcParams(trainset, physicsFor, categoryOf, loadWeightKg, allVehicles: false);
+ if (massKg < 200_000)
+ (lengthM, massKg, loadKg) = RecalcParams(trainset, physicsFor, categoryOf, loadWeightKg, allVehicles: true);
+
+ var inv = CultureInfo.InvariantCulture;
+ string track = trainset.Track ?? "";
+ if (trainset.Velocity > 0)
+ track = $"{track} ({trainset.Velocity.ToString("0.#", inv)} km/h)";
+
+ string massPart = $"{massLabel}: {(massKg / 1000.0).ToString("0.#", inv)} t";
+ if (loadKg > 0)
+ massPart += $" / {((massKg + loadKg) / 1000.0).ToString("0.#", inv)} t";
+
+ return $"{lengthLabel}: {lengthM.ToString("0.#", inv)} m · " +
+ $"{massPart} · " +
+ $"{vehiclesLabel}: {trainset.Vehicles.Count} · " +
+ $"{trackLabel}: {track}";
+ }
+
+ private static (double lengthM, double massKg, double loadKg) RecalcParams(
+ Trainset trainset,
+ Func physicsFor,
+ Func? categoryOf,
+ Func? loadWeightKg,
+ bool allVehicles)
+ {
+ double lengthM = 0, massKg = 0, loadKg = 0;
+ foreach (var car in trainset.Vehicles)
+ {
+ var p = physicsFor(car);
+ if (p != null)
+ lengthM += p.Length;
+
+ if (!IncludeVehicleToMass(car, p, categoryOf?.Invoke(car), allVehicles))
+ continue;
+
+ if (p != null)
+ massKg += p.Mass;
+
+ if (loadWeightKg != null &&
+ car.LoadCount > 0 &&
+ !string.IsNullOrEmpty(car.LoadType) &&
+ LoadAccepted(p, car.LoadType))
+ loadKg += (double)car.LoadCount * loadWeightKg(car.LoadType);
+ }
+ return (lengthM, massKg, loadKg);
+ }
+
+ private static bool IncludeVehicleToMass(Dynamic car, Physics? p, string? category, bool allVehicles)
+ {
+ if (p == null) return false;
+ if (allVehicles) return true;
+ if (category is "z" or "a") return true;
+ return car.DriverType is eDriverType.Passenger or eDriverType.Nobody;
+ }
+
+ private static bool LoadAccepted(Physics? p, string loadType)
+ {
+ if (p == null || string.IsNullOrWhiteSpace(p.LoadAccepted))
+ return true;
+ return p.LoadAccepted.Contains(loadType, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Pascal UniqueVehicleName / UniqueVehiclesName at launch:
+ /// EZT/szynobus → rename every car; otherwise only the start vehicle.
+ /// Returns the (possibly new) name of the start vehicle.
+ ///
+ public static string? UniquifyForLaunch(Trainset trainset, Scenery scenery, string? startName)
+ {
+ var used = CollectNames(scenery);
+ var db = GameData.Instance.Vehicles;
+
+ bool mu = trainset.Vehicles.Any(v =>
+ {
+ string? cat = db.TextureForSkin(v.SkinFile)?.ResolvedCategory;
+ return cat is "z" or "a";
+ });
+
+ Dynamic? target = null;
+ if (!string.IsNullOrEmpty(startName))
+ target = trainset.Vehicles.FirstOrDefault(v =>
+ string.Equals(v.Name, startName, StringComparison.OrdinalIgnoreCase));
+ target ??= trainset.Vehicles.FirstOrDefault(v =>
+ v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger);
+ target ??= trainset.Vehicles.FirstOrDefault();
+
+ if (mu)
+ {
+ foreach (var v in trainset.Vehicles)
+ {
+ if (!string.IsNullOrEmpty(v.Name))
+ used.Remove(v.Name!);
+ v.Name = EnsureUnique(PreferredBaseName(v), used);
+ }
+ return target?.Name;
+ }
+
+ if (target != null)
+ {
+ if (!string.IsNullOrEmpty(target.Name))
+ used.Remove(target.Name!);
+ target.Name = EnsureUnique(PreferredBaseName(target), used);
+ }
+ return target?.Name;
+ }
+
+ public static string PreferredBaseName(Dynamic v)
+ {
+ var tex = GameData.Instance.Vehicles.TextureForSkin(v.SkinFile);
+ if (tex != null)
+ {
+ // Prefer texture_mini when it is more specific than the class mini (≈ MiniD).
+ if (!string.IsNullOrEmpty(tex.TextureMini) &&
+ !string.Equals(tex.TextureMini, tex.ResolvedClass, StringComparison.OrdinalIgnoreCase))
+ return SanitizeName(tex.TextureMini!);
+ if (!string.IsNullOrEmpty(tex.Skinfile))
+ return SanitizeName(Path.GetFileNameWithoutExtension(tex.Skinfile));
+ }
+ if (!string.IsNullOrWhiteSpace(v.SkinFile))
+ return SanitizeName(Path.GetFileNameWithoutExtension(v.SkinFile));
+ return SanitizeName(string.IsNullOrWhiteSpace(v.Name) ? "vehicle" : v.Name!);
+ }
+
+ public static string EnsureUnique(string baseName, HashSet used)
+ {
+ baseName = SanitizeName(baseName);
+ if (!used.Contains(baseName))
+ {
+ used.Add(baseName);
+ return baseName;
+ }
+ int n = 1;
+ string candidate;
+ do
+ candidate = $"{baseName}_{n++}";
+ while (used.Contains(candidate));
+ used.Add(candidate);
+ return candidate;
+ }
+
+ public static HashSet CollectNames(Scenery scenery, Trainset? except = null)
+ {
+ var used = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (string n in scenery.LooseVehicleNames)
+ used.Add(n);
+ foreach (var d in scenery.LooseVehicles)
+ if (!string.IsNullOrEmpty(d.Name))
+ used.Add(d.Name!);
+ foreach (var ts in scenery.Trainsets)
+ {
+ if (ReferenceEquals(ts, except)) continue;
+ foreach (var v in ts.Vehicles)
+ if (!string.IsNullOrEmpty(v.Name))
+ used.Add(v.Name!);
+ }
+ return used;
+ }
+
+ private static string SanitizeName(string name) =>
+ string.IsNullOrWhiteSpace(name) ? "vehicle" : name.Replace(' ', '_');
+}
diff --git a/StarterNG/Infrastructure/LoadingScreen.cs b/StarterNG/Infrastructure/LoadingScreen.cs
new file mode 100644
index 0000000..47e662c
--- /dev/null
+++ b/StarterNG/Infrastructure/LoadingScreen.cs
@@ -0,0 +1,127 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+
+namespace StarterNG.Infrastructure;
+
+///
+/// Pascal PrepareLoadingScreen: pick a logo and write textures/logo.bmp
+/// for the simulator splash.
+///
+public static class LoadingScreen
+{
+ public static void Prepare(string? logoKey, string? sceneryName = null)
+ {
+ try
+ {
+ string? src = ResolveSource(logoKey) ?? ResolveSource(sceneryName) ?? PickRandom();
+ if (src is null) return;
+
+ string destDir = Path.Combine(Directory.GetCurrentDirectory(), "textures");
+ Directory.CreateDirectory(destDir);
+ string dest = Path.Combine(destDir, "logo.bmp");
+
+ if (src.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase))
+ {
+ File.Copy(src, dest, overwrite: true);
+ return;
+ }
+
+ WriteBmp(src, dest);
+ }
+ catch
+ {
+ // never block launch on a bad logo
+ }
+ }
+
+ private static string? ResolveSource(string? key)
+ {
+ if (string.IsNullOrWhiteSpace(key)) return null;
+ key = Path.GetFileNameWithoutExtension(key.Trim());
+ string dir = Path.Combine(Directory.GetCurrentDirectory(), "textures", "logo");
+ foreach (string ext in new[] { ".bmp", ".jpg", ".jpeg", ".png" })
+ {
+ string path = Path.Combine(dir, key + ext);
+ if (File.Exists(path)) return path;
+ }
+ return null;
+ }
+
+ private static string? PickRandom()
+ {
+ string dir = Path.Combine(Directory.GetCurrentDirectory(), "textures", "logo");
+ if (!Directory.Exists(dir)) return null;
+ var files = Directory.GetFiles(dir, "logo*.*")
+ .Where(f =>
+ {
+ string e = Path.GetExtension(f).ToLowerInvariant();
+ return e is ".bmp" or ".jpg" or ".jpeg" or ".png";
+ })
+ .ToArray();
+ if (files.Length == 0) return null;
+ return files[Random.Shared.Next(files.Length)];
+ }
+
+ private static void WriteBmp(string sourcePath, string destPath)
+ {
+ using var src = new Bitmap(sourcePath);
+ int w = src.PixelSize.Width;
+ int h = src.PixelSize.Height;
+ if (w <= 0 || h <= 0) return;
+
+ var wb = new WriteableBitmap(src.PixelSize, src.Dpi, PixelFormat.Bgra8888, AlphaFormat.Opaque);
+ using var fb = wb.Lock();
+ src.CopyPixels(new PixelRect(0, 0, w, h), fb.Address, fb.RowBytes * h, fb.RowBytes);
+
+ int srcStride = fb.RowBytes;
+ var pixels = new byte[srcStride * h];
+ Marshal.Copy(fb.Address, pixels, 0, pixels.Length);
+
+ int rowStride = ((w * 3 + 3) / 4) * 4;
+ int pixelBytes = rowStride * h;
+ var file = new byte[54 + pixelBytes];
+
+ file[0] = (byte)'B'; file[1] = (byte)'M';
+ WriteInt32(file, 2, file.Length);
+ WriteInt32(file, 10, 54);
+ WriteInt32(file, 14, 40);
+ WriteInt32(file, 18, w);
+ WriteInt32(file, 22, h);
+ WriteInt16(file, 26, 1);
+ WriteInt16(file, 28, 24);
+ WriteInt32(file, 34, pixelBytes);
+
+ for (int y = 0; y < h; y++)
+ {
+ int destY = h - 1 - y;
+ int destOff = 54 + destY * rowStride;
+ int srcOff = y * srcStride;
+ for (int x = 0; x < w; x++)
+ {
+ int si = srcOff + x * 4;
+ int di = destOff + x * 3;
+ file[di] = pixels[si];
+ file[di + 1] = pixels[si + 1];
+ file[di + 2] = pixels[si + 2];
+ }
+ }
+
+ File.WriteAllBytes(destPath, file);
+ }
+
+ private static void WriteInt32(byte[] b, int o, int v)
+ {
+ b[o] = (byte)v; b[o + 1] = (byte)(v >> 8);
+ b[o + 2] = (byte)(v >> 16); b[o + 3] = (byte)(v >> 24);
+ }
+
+ private static void WriteInt16(byte[] b, int o, int v)
+ {
+ b[o] = (byte)v; b[o + 1] = (byte)(v >> 8);
+ }
+}