Files
freedom-fighter/AssetExtractor/Program.cs
2026-09-06 20:08:23 -04:00

417 lines
16 KiB
C#

using System.Drawing;
using System.Drawing.Imaging;
using System.Text;
using System.Text.Json;
namespace AssetExtractor;
/// <summary>
/// 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.
/// </summary>
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<string>(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, List<ImageItem>>();
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");
}
/// <summary>Delphi TColor: 0x00BBGGRR integer or a clXxx name.</summary>
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);
}
/// <summary>Delphi TPicture blob: [1B class-name length][class name][payload].</summary>
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<byte>(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}'");
}
}
/// <summary>Re-encode as PNG with the given color made fully transparent (Omega's Transparent=True).</summary>
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);
}
/// <summary>Convert BMP to PNG, keying out the bottom-left pixel color (Delphi Transparent convention).</summary>
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<object>();
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];
/// <summary>
/// .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).
/// </summary>
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");
}
}