From 905d795f545a0127f57d4645e26958f3ef1a8523 Mon Sep 17 00:00:00 2001 From: maj00r Date: Sat, 22 Aug 2026 23:15:18 +0200 Subject: [PATCH] Move new domain/infra modules out of Classes into Domain/ and Infrastructure/ Consist/scenery/vehicle logic (TrainsetDisplay, TexRandomizer, SceneryI18n) goes under Domain/; the loading-screen bitmap writer under Infrastructure/, instead of the flat Classes/ bucket. --- StarterNG/Domain/SceneryI18n.cs | 92 ++++++++ StarterNG/Domain/TexRandomizer.cs | 241 +++++++++++++++++++ StarterNG/Domain/TrainsetDisplay.cs | 272 ++++++++++++++++++++++ StarterNG/Infrastructure/LoadingScreen.cs | 127 ++++++++++ 4 files changed, 732 insertions(+) create mode 100644 StarterNG/Domain/SceneryI18n.cs create mode 100644 StarterNG/Domain/TexRandomizer.cs create mode 100644 StarterNG/Domain/TrainsetDisplay.cs create mode 100644 StarterNG/Infrastructure/LoadingScreen.cs 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); + } +}