using System.Drawing; using System.Drawing.Imaging; using System.Text; using System.Text.Json; namespace AssetExtractor; /// /// One-time migration tool: pulls sprites out of the Delphi DFM, converts .omap files /// (TLZRW1 "LZH!" compressed text) to JSON, extracts tileset images from .oil files, /// and copies the sound/music files into the MonoGame Content folder. /// public static class Program { static string _srcDir = @"C:\Projects\Games\Delphi\Omega Games\Freedom Fighter"; static string _outDir = @"C:\Projects\Games\Visual Studio\FreedomFighter\FreedomFighter\Content"; static readonly string[] Maps = ["intro.omap", "test10.omap", "test7.omap", "test8.omap", "bonus.omap", "lvl5.omap"]; public static void Main(string[] args) { if (args.Length > 0) _srcDir = args[0]; if (args.Length > 1) _outDir = args[1]; foreach (var sub in new[] { "gfx", "maps", "tilesets", "sfx", "music" }) Directory.CreateDirectory(Path.Combine(_outDir, sub)); var oilsNeeded = new HashSet(StringComparer.OrdinalIgnoreCase); ExtractDfmImages(Path.Combine(_srcDir, "GameU.dfm")); foreach (var map in Maps) { var oil = ConvertMap(Path.Combine(_srcDir, map)); if (oil != null) oilsNeeded.Add(oil); } foreach (var oil in oilsNeeded) ExtractOilTileset(Path.Combine(_srcDir, oil)); CopyAudio(); Console.WriteLine("Done."); } // ---------------------------------------------------------------- DFM images record ImageItem(int Index, string Name, string File, int TileWidth, int TileHeight); static void ExtractDfmImages(string dfmPath) { var lines = File.ReadAllLines(dfmPath); var manifest = new Dictionary>(); string? currentList = null; string itemName = ""; int tileW = 0, tileH = 0; bool transparent = false; string transparentColor = "clBlack"; StringBuilder? hex = null; byte[]? pictureData = null; int itemIndex = 0; void FlushItem() { if (currentList == null || pictureData == null) return; string safeName = itemName.Length > 0 ? itemName : "unnamed"; string file = $"{currentList.ToLowerInvariant()}_{itemIndex:D2}_{safeName}.png"; SaveGraphic(pictureData, Path.Combine(_outDir, "gfx", file), transparent ? ParseDelphiColor(transparentColor) : null); manifest.TryAdd(currentList, []); // TileWidth/TileHeight of 0 means "whole image is one frame" manifest[currentList].Add(new ImageItem(itemIndex, itemName, file, tileW, tileH)); Console.WriteLine($" {currentList}[{itemIndex}] '{itemName}' -> {file} ({tileW}x{tileH})"); itemIndex++; pictureData = null; itemName = ""; tileW = tileH = 0; transparent = false; transparentColor = "clBlack"; } foreach (var raw in lines) { var line = raw.Trim(); if (hex != null) { bool end = line.EndsWith('}'); hex.Append(end ? line[..^1] : line); if (end) { pictureData = Convert.FromHexString(hex.ToString()); hex = null; } continue; } if (line.StartsWith("object ") && line.Contains(": TOmegaImageList")) { FlushItem(); currentList = line["object ".Length..line.IndexOf(':')].Trim(); itemIndex = 0; continue; } if (currentList == null) continue; if (line.StartsWith("object ")) // next non-imagelist component { FlushItem(); currentList = null; continue; } if (line == "item") FlushItem(); else if (line.StartsWith("Name = '")) itemName = line["Name = '".Length..^1]; else if (line.StartsWith("TileWidth = ")) tileW = int.Parse(line["TileWidth = ".Length..]); else if (line.StartsWith("TileHeight = ")) tileH = int.Parse(line["TileHeight = ".Length..]); else if (line.StartsWith("TransparentColor = ")) transparentColor = line["TransparentColor = ".Length..]; else if (line == "Transparent = True") transparent = true; else if (line.StartsWith("Picture.Data = {")) hex = new StringBuilder(line["Picture.Data = {".Length..]); } FlushItem(); var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(Path.Combine(_outDir, "gfx", "manifest.json"), json); Console.WriteLine(" wrote gfx/manifest.json"); } /// Delphi TColor: 0x00BBGGRR integer or a clXxx name. static Color ParseDelphiColor(string value) { switch (value) { case "clBlack": return Color.FromArgb(0, 0, 0); case "clWhite": return Color.FromArgb(255, 255, 255); case "clRed": return Color.FromArgb(255, 0, 0); case "clLime": return Color.FromArgb(0, 255, 0); case "clBlue": return Color.FromArgb(0, 0, 255); case "clNavy": return Color.FromArgb(0, 0, 128); case "clFuchsia": return Color.FromArgb(255, 0, 255); case "clYellow": return Color.FromArgb(255, 255, 0); case "clAqua": return Color.FromArgb(0, 255, 255); } int v = int.Parse(value); return Color.FromArgb(v & 0xFF, (v >> 8) & 0xFF, (v >> 16) & 0xFF); } /// Delphi TPicture blob: [1B class-name length][class name][payload]. static void SaveGraphic(byte[] data, string outPath, Color? colorKey) { int nameLen = data[0]; string cls = Encoding.ASCII.GetString(data, 1, nameLen); int payloadStart = 1 + nameLen; if (cls == "TPNGObject") { // PNGs keep their native alpha (Omega honored it); key applies on top if set byte[] png = data[payloadStart..]; if (colorKey != null) SaveWithColorKey(png, outPath, colorKey.Value); else File.WriteAllBytes(outPath, png); } else if (cls == "TBitmap") { // payload: [4B length][BMP bytes]; key only when the item was marked Transparent int len = BitConverter.ToInt32(data, payloadStart); var bmpBytes = new ReadOnlySpan(data, payloadStart + 4, len).ToArray(); using var ms = new MemoryStream(bmpBytes); using var src = new Bitmap(ms); SaveKeyed(src, outPath, colorKey); } else { throw new InvalidDataException($"Unknown graphic class '{cls}'"); } } /// Re-encode as PNG with the given color made fully transparent (Omega's Transparent=True). static void SaveWithColorKey(byte[] imageBytes, string outPath, Color key) { using var ms = new MemoryStream(imageBytes); using var src = new Bitmap(ms); SaveKeyed(src, outPath, key); } static void SaveKeyed(Bitmap src, string outPath, Color? key) { using var dst = new Bitmap(src.Width, src.Height, PixelFormat.Format32bppArgb); for (int y = 0; y < src.Height; y++) for (int x = 0; x < src.Width; x++) { var c = src.GetPixel(x, y); bool keyed = key != null && c.A > 0 && c.R == key.Value.R && c.G == key.Value.G && c.B == key.Value.B; dst.SetPixel(x, y, keyed ? Color.Transparent : c); } dst.Save(outPath, ImageFormat.Png); } /// Convert BMP to PNG, keying out the bottom-left pixel color (Delphi Transparent convention). static void SaveBmpAsKeyedPng(byte[] bmpBytes, string outPath) { using var ms = new MemoryStream(bmpBytes); using var src = new Bitmap(ms); using var dst = new Bitmap(src.Width, src.Height, PixelFormat.Format32bppArgb); var key = src.GetPixel(0, src.Height - 1); for (int y = 0; y < src.Height; y++) for (int x = 0; x < src.Width; x++) { var c = src.GetPixel(x, y); dst.SetPixel(x, y, c.R == key.R && c.G == key.G && c.B == key.B ? Color.Transparent : c); } dst.Save(outPath, ImageFormat.Png); } // ---------------------------------------------------------------- maps static string? ConvertMap(string mapPath) { if (!File.Exists(mapPath)) { Console.WriteLine($" MISSING map {mapPath}"); return null; } var text = Encoding.Latin1.GetString(Lzh.Decompress(File.ReadAllBytes(mapPath))); var lines = text.Split("\r\n"); int p = 0; string Next() => lines[p++]; int NextInt() => int.Parse(Next()); int w = NextInt(); int h = NextInt(); string oil = Next(); int tileW = NextInt(); int tileH = NextInt(); // Layer1: 17 lines per tile; keep only the pattern index (game uses nothing else) var layer1 = new int[w][]; for (int a = 0; a < w; a++) { layer1[a] = new int[h]; for (int b = 0; b < h; b++) { p += 8; // x,y,r,g,b,alpha,rotation,image.index layer1[a][b] = NextInt(); // imageindex (pattern) p += 8; // animcount..animlooped } } p += w * h * 18; // Layer2 (has extra DoCollision line) p += w * h * 17; // Layer3 var triggers = new List(); for (int a = 0; a < w; a++) for (int b = 0; b < h; b++) { string name = Next(); string action = Next(); string e1 = Next(), e2 = Next(), e3 = Next(); if (action.Length > 0) triggers.Add(new { x = a, y = b, name, action, extra1 = e1, extra2 = e2, extra3 = e3 }); } var mapObj = new { widthCount = w, heightCount = h, oil = Path.GetFileName(oil), tileWidth = tileW, tileHeight = tileH, layer1, triggers }; string outFile = Path.Combine(_outDir, "maps", Path.GetFileNameWithoutExtension(mapPath) + ".json"); File.WriteAllText(outFile, JsonSerializer.Serialize(mapObj)); Console.WriteLine($" {Path.GetFileName(mapPath)}: {w}x{h}, oil='{oil}', {triggers.Count} triggers -> {Path.GetFileName(outFile)}"); return oil; } // ---------------------------------------------------------------- OIL tilesets static readonly byte[] PngSig = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; /// /// .oil = binary Delphi component stream. Rather than fully parsing TPF0 we scan for /// embedded graphics: "TPNGObject"+PNG or "TBitmap"+[len]+BMP. Item order is preserved; /// the game's maps only use item 0 (the tileset). /// static void ExtractOilTileset(string oilPath) { if (!File.Exists(oilPath)) { Console.WriteLine($" MISSING oil {oilPath}"); return; } var data = File.ReadAllBytes(oilPath); string baseName = Path.GetFileNameWithoutExtension(oilPath).ToLowerInvariant(); int found = 0; for (int i = 0; i < data.Length - 10; i++) { if (data[i] == 0x0A && Matches(data, i + 1, "TPNGObject")) { int start = i + 11; if (start + 8 < data.Length && data.AsSpan(start, 8).SequenceEqual(PngSig)) { int end = FindPngEnd(data, start); if (end > 0) { Save(data[start..end], found); found++; i = end - 1; } } } else if (data[i] == 0x07 && Matches(data, i + 1, "TBitmap")) { int lenPos = i + 8; if (lenPos + 4 >= data.Length) continue; int len = BitConverter.ToInt32(data, lenPos); int start = lenPos + 4; if (len > 14 && start + len <= data.Length && data[start] == 'B' && data[start + 1] == 'M') { string file = Path.Combine(_outDir, "tilesets", $"{baseName}_{found:D2}.png"); SaveBmpAsKeyedPng(data[start..(start + len)], file); Console.WriteLine($" {Path.GetFileName(oilPath)}[{found}] (bmp) -> {Path.GetFileName(file)}"); found++; i = start + len - 1; } } } Console.WriteLine($" {Path.GetFileName(oilPath)}: {found} images extracted"); void Save(byte[] png, int idx) { string file = Path.Combine(_outDir, "tilesets", $"{baseName}_{idx:D2}.png"); File.WriteAllBytes(file, png); Console.WriteLine($" {Path.GetFileName(oilPath)}[{idx}] (png) -> {Path.GetFileName(file)}"); } } static bool Matches(byte[] data, int pos, string s) { if (pos + s.Length > data.Length) return false; for (int i = 0; i < s.Length; i++) if (data[pos + i] != (byte)s[i]) return false; return true; } static int FindPngEnd(byte[] data, int start) { // walk PNG chunks: [4B len BE][4B type][data][4B crc] until IEND int p = start + 8; while (p + 12 <= data.Length) { int len = (data[p] << 24) | (data[p + 1] << 16) | (data[p + 2] << 8) | data[p + 3]; string type = Encoding.ASCII.GetString(data, p + 4, 4); p += 12 + len; if (type == "IEND") return p; } return -1; } // ---------------------------------------------------------------- audio static void CopyAudio() { // Files referenced by OmegaMusic1.Songs in GameU.dfm. // 'Level test.mp3' (index 1, level2 music) is missing from the source folder; // Track06.mp3 is copied as a substitute. (string src, string sub)[] files = [ (@"Music\white light.mp3", "music"), (@"Music\G2 Final.mp3", "music"), (@"Music\Nazi_Rap.mid", "music"), (@"Music\Main Menu 2.mp3", "music"), (@"Music\G8 Final.mp3", "music"), (@"Music\Thunderization.mp3", "music"), (@"Music\Intro.mp3", "music"), (@"Music\Track06.mp3", "music"), (@"Sounds\fire1.wav", "sfx"), (@"Sounds\reload.wav", "sfx"), (@"Sounds\thump1.wav", "sfx"), (@"Sounds\beep.wav", "sfx"), (@"Sounds\hit4.wav", "sfx"), (@"Sounds\explode2.wav", "sfx"), (@"Sounds\missile.wav", "sfx"), ]; foreach (var (src, sub) in files) { string from = Path.Combine(_srcDir, src); if (!File.Exists(from)) { Console.WriteLine($" MISSING audio {src}"); continue; } string to = Path.Combine(_outDir, sub, Path.GetFileName(src)); File.Copy(from, to, overwrite: true); } Console.WriteLine(" audio copied"); } }