diff --git a/AssetExtractor/AssetExtractor.csproj b/AssetExtractor/AssetExtractor.csproj
new file mode 100644
index 0000000..0ebfe78
--- /dev/null
+++ b/AssetExtractor/AssetExtractor.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net8.0-windows
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/AssetExtractor/Lzh.cs b/AssetExtractor/Lzh.cs
new file mode 100644
index 0000000..1edd554
--- /dev/null
+++ b/AssetExtractor/Lzh.cs
@@ -0,0 +1,234 @@
+namespace AssetExtractor;
+
+///
+/// Decoder for the LZHUF format (LZSS + adaptive Huffman, Okumura/Yoshizaki 1988),
+/// as written by the Delphi TLZRW1 component in "Good" mode.
+/// Stream layout: [4B magic "LZH!" = 0x4C5A4821 LE][4B LE original size][LZHUF bitstream].
+///
+public static class Lzh
+{
+ public const uint LzhMagic = (((((uint)'L' << 8) + 'Z') << 8) + 'H' << 8) + '!';
+
+ const int N = 4096; // ring buffer size
+ const int F = 60; // lookahead buffer size
+ const int Threshold = 2;
+ const int NChar = 256 - Threshold + F; // 314 character codes
+ const int T = NChar * 2 - 1; // 627, size of freq table
+ const int R = T - 1; // 626, root position
+ const int MaxFreq = 0x8000;
+
+ // Tables for decoding the upper 6 bits of the match position
+ static readonly byte[] DCode = BuildDCode();
+ static readonly byte[] DLen = BuildDLen();
+
+ static byte[] BuildDCode()
+ {
+ // Canonical LZHUF d_code table: value 0 ×32; 1..3 ×16; 4..11 ×8; 12..23 ×4; 24..47 ×2; 48..63 ×1
+ var t = new byte[256];
+ int i = 0, v = 0;
+ foreach (var (values, run) in new[] { (1, 32), (3, 16), (8, 8), (12, 4), (24, 2), (16, 1) })
+ for (int g = 0; g < values; g++, v++)
+ for (int k = 0; k < run; k++)
+ t[i++] = (byte)v;
+ return t;
+ }
+
+ static byte[] BuildDLen()
+ {
+ // Canonical LZHUF d_len table: 3 ×32, 4 ×48, 5 ×64, 6 ×48, 7 ×48, 8 ×16
+ var t = new byte[256];
+ int i = 0;
+ foreach (var (count, val) in new[] { (32, 3), (48, 4), (64, 5), (48, 6), (48, 7), (16, 8) })
+ for (int k = 0; k < count; k++)
+ t[i++] = (byte)val;
+ return t;
+ }
+
+ public static byte[] Decompress(byte[] file)
+ {
+ if (file.Length < 8 || BitConverter.ToUInt32(file, 0) != LzhMagic)
+ throw new InvalidDataException("Not an LZH!-compressed TLZRW1 stream.");
+ int origSize = BitConverter.ToInt32(file, 4);
+ return Decode(file, 8, origSize);
+ }
+
+ static byte[] Decode(byte[] src, int srcOffset, int origSize)
+ {
+ var output = new byte[origSize];
+ int outPos = 0;
+
+ // adaptive Huffman state
+ var freq = new int[T + 1];
+ var prnt = new int[T + NChar];
+ var son = new int[T];
+
+ // init tree
+ for (int i = 0; i < NChar; i++)
+ {
+ freq[i] = 1;
+ son[i] = i + T;
+ prnt[i + T] = i;
+ }
+ for (int i = 0, j = NChar; j <= R; i += 2, j++)
+ {
+ freq[j] = freq[i] + freq[i + 1];
+ son[j] = i;
+ prnt[i] = j;
+ prnt[i + 1] = j;
+ }
+ freq[T] = 0xFFFF;
+ prnt[R] = 0;
+
+ // ring buffer, initialized to spaces
+ var ring = new byte[N + F - 1];
+ Array.Fill(ring, (byte)0x20);
+ int r = N - F;
+
+ // bit reader
+ int srcPos = srcOffset;
+ uint getbuf = 0;
+ int getlen = 0;
+
+ int GetBit()
+ {
+ while (getlen <= 8)
+ {
+ uint b = srcPos < src.Length ? src[srcPos++] : 0u;
+ getbuf |= b << (8 - getlen);
+ getlen += 8;
+ }
+ int result = (int)((getbuf >> 15) & 1);
+ getbuf = (getbuf << 1) & 0xFFFF;
+ getlen--;
+ return result;
+ }
+
+ int GetByte()
+ {
+ while (getlen <= 8)
+ {
+ uint b = srcPos < src.Length ? src[srcPos++] : 0u;
+ getbuf |= b << (8 - getlen);
+ getlen += 8;
+ }
+ int result = (int)((getbuf >> 8) & 0xFF);
+ getbuf = (getbuf << 8) & 0xFFFF;
+ getlen -= 8;
+ return result;
+ }
+
+ void Update(int c)
+ {
+ if (freq[R] == MaxFreq)
+ Reconstruct();
+
+ c = prnt[c + T];
+ do
+ {
+ int k = ++freq[c];
+ // if order is disturbed, swap nodes
+ int l = c + 1;
+ if (k > freq[l])
+ {
+ while (k > freq[l + 1]) l++;
+ freq[c] = freq[l];
+ freq[l] = k;
+
+ int i = son[c];
+ prnt[i] = l;
+ if (i < T) prnt[i + 1] = l;
+
+ int j = son[l];
+ son[l] = i;
+
+ prnt[j] = c;
+ if (j < T) prnt[j + 1] = c;
+ son[c] = j;
+
+ c = l;
+ }
+ c = prnt[c];
+ } while (c != 0);
+ }
+
+ void Reconstruct()
+ {
+ // collect leaf nodes, halve frequencies
+ int j = 0;
+ for (int i = 0; i < T; i++)
+ {
+ if (son[i] >= T)
+ {
+ freq[j] = (freq[i] + 1) / 2;
+ son[j] = son[i];
+ j++;
+ }
+ }
+ // rebuild internal nodes
+ for (int i = 0, k = NChar; k < T; i += 2, k++)
+ {
+ int f = freq[i] + freq[i + 1];
+ int l = k;
+ while (f < freq[l - 1]) l--;
+ Array.Copy(freq, l, freq, l + 1, k - l);
+ freq[l] = f;
+ Array.Copy(son, l, son, l + 1, k - l);
+ son[l] = i;
+ }
+ // reconnect parent pointers
+ for (int i = 0; i < T; i++)
+ {
+ int k = son[i];
+ prnt[k] = i;
+ if (k < T) prnt[k + 1] = i;
+ }
+ }
+
+ int DecodeChar()
+ {
+ int c = son[R];
+ while (c < T)
+ c = son[c + GetBit()];
+ c -= T;
+ Update(c);
+ return c;
+ }
+
+ int DecodePosition()
+ {
+ // upper 6 bits from table
+ int i = GetByte();
+ int c = DCode[i] << 6;
+ int j = DLen[i] - 2;
+ // read lower 6 bits verbatim
+ while (j-- > 0)
+ i = (i << 1) + GetBit();
+ return c | (i & 0x3F);
+ }
+
+ while (outPos < origSize)
+ {
+ int c = DecodeChar();
+ if (c < 256)
+ {
+ output[outPos++] = (byte)c;
+ ring[r] = (byte)c;
+ r = (r + 1) & (N - 1);
+ }
+ else
+ {
+ int pos = (r - DecodePosition() - 1) & (N - 1);
+ int len = c - 255 + Threshold;
+ for (int k = 0; k < len && outPos < origSize; k++)
+ {
+ byte b = ring[(pos + k) & (N - 1)];
+ output[outPos++] = b;
+ ring[r] = b;
+ r = (r + 1) & (N - 1);
+ }
+ }
+ }
+
+ return output;
+ }
+}
diff --git a/AssetExtractor/Program.cs b/AssetExtractor/Program.cs
new file mode 100644
index 0000000..28eb77c
--- /dev/null
+++ b/AssetExtractor/Program.cs
@@ -0,0 +1,360 @@
+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;
+ 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));
+ 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;
+ }
+
+ 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("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 TPicture blob: [1B class-name length][class name][payload].
+ static void SaveGraphic(byte[] data, string outPath)
+ {
+ int nameLen = data[0];
+ string cls = Encoding.ASCII.GetString(data, 1, nameLen);
+ int payloadStart = 1 + nameLen;
+
+ if (cls == "TPNGObject")
+ {
+ File.WriteAllBytes(outPath, data[payloadStart..]);
+ }
+ else if (cls == "TBitmap")
+ {
+ // payload: [4B length][BMP bytes]
+ int len = BitConverter.ToInt32(data, payloadStart);
+ var bmpBytes = new ReadOnlySpan(data, payloadStart + 4, len);
+ SaveBmpAsKeyedPng(bmpBytes.ToArray(), outPath);
+ }
+ else
+ {
+ throw new InvalidDataException($"Unknown graphic class '{cls}'");
+ }
+ }
+
+ /// 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