diff --git a/src/Ray1MapRepacker/Exporter.cs b/src/Ray1MapRepacker/Exporter.cs
index 9e6485d..c1924e9 100644
--- a/src/Ray1MapRepacker/Exporter.cs
+++ b/src/Ray1MapRepacker/Exporter.cs
@@ -1,5 +1,6 @@
using BinarySerializer;
using BinarySerializer.Image;
+using BinarySerializer.Ray1;
using BinarySerializer.Ray1.PC;
namespace Ray1MapRepacker;
@@ -12,15 +13,29 @@ public class Exporter(Context context)
///
/// Path to the lev file that should be exported.
/// Path to the map dir, the lev data should be exported to.
- /// Name prefix for the created tileset file.
/// Name of the map file, the lev data is exported to.
- public void ExportLevel(string levFilePath, string mapFileDir, string tilesetNamePrefix, string mapFileName)
+ /// Name prefix for the created tileset file.
+ public void ExportLevel(string levFilePath, string mapFileDir, string mapFileName, string tilesetNamePrefix)
{
Console.WriteLine($"Starting export process for {levFilePath}");
LevelFile levFile = ContextHelper.ReadLevelFile(context, levFilePath);
ExportTileSet(levFile, mapFileDir, tilesetNamePrefix);
- ExportMap(levFile, mapFileDir, mapFileName);
+ ExportAndSaveMap(levFile, mapFileDir, mapFileName, []);
+
+ Console.WriteLine($"Finished export process for {levFilePath}");
+ }
+
+ public void ExportLevelForceUsingTileSet(string levFilePath, string mapFileDir, string mapFileName, string tileSetPathPCX)
+ {
+ Console.WriteLine($"Starting export process for {levFilePath}");
+
+ context.AddFile(new LinearFile(context, tileSetPathPCX));
+ PCX pcx = FileFactory.Read(context, tileSetPathPCX);
+ LevelFile levFile = ContextHelper.ReadLevelFile(context, levFilePath);
+
+ ushort[][] tileSetIndexMaps = TileSetHelpers.CreateTileSetIndexMaps(levFile, pcx);
+ ExportAndSaveMap(levFile, mapFileDir, mapFileName, tileSetIndexMaps);
Console.WriteLine($"Finished export process for {levFilePath}");
}
@@ -54,26 +69,46 @@ private void ExportTileSet(LevelFile levFile, string mapFileDir, string tilesetN
Console.WriteLine("Finished exporting tileset");
}
- private void ExportMap(LevelFile levFile, string mapFileDir, string mapName)
+ private void ExportAndSaveMap(LevelFile levFile, string mapFileDir, string mapName, ushort[][] tileSetIndexMaps)
{
- Console.WriteLine("Exporting map");
-
- // Export map in the universal format (used by the Mapper)
- UniversalMap map = new()
+ Console.WriteLine("Exporting and saving map");
+
+ UniversalMap map = new UniversalMap()
{
Width = levFile.MapInfo.Width,
Height = levFile.MapInfo.Height,
- Tiles = levFile.MapInfo.Blocks.Select(x => new UniversalMapBlock
- {
- TileIndex = x.TileIndex,
- BlockType = x.BlockType
- }).ToArray()
+ Tiles = MapBlocksToTiles(levFile.MapInfo.Blocks, tileSetIndexMaps)
};
-
+
string mapFilePath = Path.Combine(mapFileDir, mapName);
context.AddFile(new LinearFile(context, mapFilePath));
FileFactory.Write(context, mapFilePath, map);
- Console.WriteLine("Finished exporting map");
+ Console.WriteLine("Finished exporting and saving map");
+ }
+
+ private UniversalMapBlock[] MapBlocksToTiles(Block[] blocks, ushort[][] tileSetIndexMaps)
+ {
+ if (tileSetIndexMaps.Length == 0)
+ {
+ return blocks.Select(x => new UniversalMapBlock
+ {
+ TileIndex = x.TileIndex,
+ BlockType = x.BlockType
+ }).ToArray();
+ }
+ else
+ {
+ // TODO array out of bounds..
+ return blocks.Select(x => new UniversalMapBlock
+ {
+ TileIndex = x.RenderMode == Block.BlockRenderMode.Opaque
+ ? tileSetIndexMaps[0][Math.Min(tileSetIndexMaps[0].Length, x.TileIndex)]
+ : x.RenderMode == Block.BlockRenderMode.Transparent
+ ? tileSetIndexMaps[1][Math.Min(tileSetIndexMaps[1].Length, x.TileIndex)]
+ : (ushort)0, // TODO handle fully transparent mode correctly..
+ BlockType = x.BlockType
+ }).ToArray();
+ }
}
}
\ No newline at end of file
diff --git a/src/Ray1MapRepacker/ImageHelpers.cs b/src/Ray1MapRepacker/ImageHelpers.cs
index 3854255..cb060c8 100644
--- a/src/Ray1MapRepacker/ImageHelpers.cs
+++ b/src/Ray1MapRepacker/ImageHelpers.cs
@@ -39,6 +39,104 @@ public static RGB666Color[] ConvertPal888To666(RGB888Color[] pal888)
return pal666;
}
+
+ // Map all indices of the lev file palettes onto the PCX palette, to allow a comparison between tiles later on
+ public static byte[][] CreateColorPaletteIndexMap(RGB666Color[][] palettesLevel, RGB666Color[] palettePCX)
+ {
+ if(palettesLevel.Length == 0 || palettePCX.Length == 0)
+ return [];
+
+ RGB666Color black = new RGB666Color(0f, 0f, 0f);
+ RGB666Color white = new RGB666Color(1f, 1f, 1f);
+
+
+ // Create a mapping for every palette in the level
+ byte[][] indexMap = new byte[palettesLevel.Length][];
+ for (ushort palIndex = 0; palIndex < palettesLevel.Length; palIndex++)
+ {
+ RGB666Color[] palette = palettesLevel[palIndex];
+ HashSet zeroMappings = [];
+ // Handle empty palette
+ if (palettePCX.Length == 0)
+ {
+ indexMap[palIndex] = [];
+ continue;
+ }
+
+ // Map every palette color index onto one of the PCX palette
+ indexMap[palIndex] = new byte[palette.Length];
+ for (ushort colorIndex = 100; colorIndex != palette.Length; colorIndex++)
+ {
+ RGB666Color currentColor = palette[colorIndex];
+
+ int index = 255;
+ if (!CompareColorsFuzzy(currentColor, black, 0f)
+ && !CompareColorsFuzzy(currentColor, white, 0f))
+ {
+ index = Array.FindIndex(palettePCX, c => CompareColorsFuzzy(currentColor, c, 0f));
+ }
+
+ byte clampedIndex = (byte) Math.Min(palette.Length - 1, Math.Max(0, index));
+
+ if (index != -1)
+ Console.WriteLine($"mapping colorIndex {colorIndex}->{clampedIndex} with color: {currentColor}->{palettePCX[clampedIndex]}");
+
+ if (index == -1)
+ zeroMappings.Add(colorIndex);
+
+ indexMap[palIndex][colorIndex] = clampedIndex;
+ }
+
+
+ if (zeroMappings.Count != 0)
+ Console.WriteLine($"mapped {palette.Length - zeroMappings.Count} of {palette.Length} without threshold ({zeroMappings.Count} invalid) for palette {palIndex}");
+
+ const float startThreshold = 0.008f; // rounding errors are at around 0.016f
+ const float baseThreshold = 0.004f; // lower value => higher accuracy - 0.004f is around value 1 difference in RGBA value
+ const byte maxIterationCount = 32; // higher value => more mapping hits, but larger color differences in mapping possible and longer processing time
+ // => up to RGB value difference for each color value of 34 is possible with startThreshold = 0.008f, baseThreshold = 0.004f and maxIterationCount = 32
+ for (byte thresholdIteration = 0; thresholdIteration < maxIterationCount; thresholdIteration++)
+ {
+ if (zeroMappings.Count == 0)
+ break;
+
+ ushort[] currentZeroMappings = zeroMappings.ToArray();
+
+ foreach (ushort colorIndex in currentZeroMappings)
+ {
+ RGB666Color currentColor = palette[colorIndex];
+ float threshold = startThreshold + (thresholdIteration * baseThreshold);
+
+ int index = 255;
+ if (!CompareColorsFuzzy(currentColor, black, threshold)
+ && !CompareColorsFuzzy(currentColor, white, threshold))
+ {
+ index = Array.FindIndex(palettePCX, c => CompareColorsFuzzy(currentColor, c, threshold));
+ }
+
+ if (index != -1)
+ {
+ zeroMappings.Remove(colorIndex);
+ byte clampedIndex = (byte) Math.Min(palette.Length - 1, Math.Max(0, index));
+ Console.WriteLine($"retried mapping colorIndex {colorIndex}->{index} with color: {currentColor}->{palettePCX[index]} in iteration {thresholdIteration}");
+ indexMap[palIndex][colorIndex] = clampedIndex;
+ }
+ }
+ }
+
+ Console.WriteLine($"mapped {palette.Length - zeroMappings.Count} of {palette.Length} ({zeroMappings.Count} invalid) for palette {palIndex}");
+ }
+
+ return indexMap;
+ }
+
+ private static bool CompareColorsFuzzy(RGB666Color color0, RGB666Color color1, float threshold)
+ {
+ return Math.Abs(color0.Red - color1.Red) <= threshold
+ && Math.Abs(color0.Green - color1.Green) <= threshold
+ && Math.Abs(color0.Blue - color1.Blue) <= threshold;
+ }
+
///
/// Creates a new PCX instance from image data
///
diff --git a/src/Ray1MapRepacker/Importer.cs b/src/Ray1MapRepacker/Importer.cs
index 651ebbc..55431d7 100644
--- a/src/Ray1MapRepacker/Importer.cs
+++ b/src/Ray1MapRepacker/Importer.cs
@@ -14,9 +14,9 @@ public class Importer(Context context)
///
/// Path to the lev file that should be replaced by the import.
/// Path to the map dir, the lev data should be imported from.
- /// Name prefix for the tileset PCX file, to import from.
/// Name of the map file, the lev data should be imported from.
- public void ImportLevel(string levFilePath, string mapFileDir, string tilesetNamePrefix, string mapFileName)
+ /// Name prefix for the tileset PCX file, to import from.
+ public void ImportLevel(string levFilePath, string mapFileDir, string mapFileName, string tilesetNamePrefix)
{
Console.WriteLine($"Starting import process for {levFilePath}");
diff --git a/src/Ray1MapRepacker/Program.cs b/src/Ray1MapRepacker/Program.cs
index 834baf5..1faee78 100644
--- a/src/Ray1MapRepacker/Program.cs
+++ b/src/Ray1MapRepacker/Program.cs
@@ -12,6 +12,8 @@ void ShowHelpScreen()
"Usage:\n" +
" -e | Exports the tileset and map from the level to the output path.\n" +
" A separate tileset PCX file is exported per available palette.\n" +
+ " | In addition, a tileset path to an existing PCX file can be attached\n" +
+ " to avoid exporting the tileset, but forcing the map to use the existing one.\n" +
" -i | Imports the tileset and map to the level from the input path.\n" +
" The files have to be named the same as when exporting. Only the\n" +
" first tileset PCX will be used for the tiles. The rest will only\n" +
@@ -19,7 +21,7 @@ void ShowHelpScreen()
}
// Parse args
-if (args.Length != 3)
+if (args.Length is < 3 or > 4)
{
ShowHelpScreen();
return;
@@ -43,12 +45,20 @@ void ShowHelpScreen()
// Export
if (mode == "-e")
{
- new Exporter(context).ExportLevel(levFilePath, mapFileDir, TileSetFileNamePrefix, MapFileName);
+ if (args.Length > 3)
+ {
+ string tileSetPathPCX = args[3];
+ new Exporter(context).ExportLevelForceUsingTileSet(levFilePath, mapFileDir, MapFileName, tileSetPathPCX);
+ }
+ else
+ {
+ new Exporter(context).ExportLevel(levFilePath, mapFileDir, MapFileName, TileSetFileNamePrefix);
+ }
}
// Import
else if (mode == "-i")
{
- new Importer(context).ImportLevel(levFilePath, mapFileDir, TileSetFileNamePrefix, MapFileName);
+ new Importer(context).ImportLevel(levFilePath, mapFileDir, MapFileName, TileSetFileNamePrefix);
}
else
{
diff --git a/src/Ray1MapRepacker/TileSetHelpers.cs b/src/Ray1MapRepacker/TileSetHelpers.cs
index 557640d..d2fac90 100644
--- a/src/Ray1MapRepacker/TileSetHelpers.cs
+++ b/src/Ray1MapRepacker/TileSetHelpers.cs
@@ -1,9 +1,14 @@
-using BinarySerializer.Ray1.PC;
+using System.Text;
+using BinarySerializer;
+using BinarySerializer.Image;
+using BinarySerializer.Ray1.PC;
namespace Ray1MapRepacker;
public static class TileSetHelpers
{
+ private const ushort NonMatchingBlockNumber = ushort.MaxValue;
+
public const int TileSize = 16;
public const int TileDataLength = TileSize * TileSize;
@@ -229,4 +234,148 @@ public static TileSetNormal PCXScanLinesToPCBinaryTileSet(byte[][] scanLines)
}
};
}
+
+ public static ushort[][] CreateTileSetIndexMaps(LevelFile levelFile, PCX pcx)
+ {
+ // Get PCX palette
+ RGB666Color[] palettePCX = ImageHelpers.ConvertPal888To666(pcx.VGAPalette);
+ palettePCX[0] = new RGB666Color(0, 0, 0);
+
+ // Create palette mapping
+ byte[][] colorIndexMap = ImageHelpers.CreateColorPaletteIndexMap(levelFile.MapInfo.Palettes, palettePCX);
+
+ TileSetNormalMapBlocks targetBlocks = PCXScanLinesToPCBinaryTileSet(pcx.ScanLines).MapBlocks;
+
+ ushort[] opaqueMap =
+ CreateTileSetIndexMap(colorIndexMap, levelFile.TileSetNormal.MapBlocks.OpaqueBlocks, targetBlocks.OpaqueBlocks);
+ ushort[] transparentMap =
+ CreateTileSetIndexMap(colorIndexMap, levelFile.TileSetNormal.MapBlocks.TransparentBlocks, targetBlocks.TransparentBlocks);
+
+ return new ushort[][]
+ {
+ opaqueMap,
+ transparentMap
+ };
+ }
+
+ private static ushort[] CreateTileSetIndexMap(byte[][] colorIndexMap, TileSetBlock[] sourceBlocks, TileSetBlock[] targetBlocks)
+ {
+ TileSetBlock[][] alteredSourceBlocks = CreateAlteredTileSetBlocksByPaletteIndexMap(colorIndexMap, sourceBlocks);
+
+ ushort[] indexMapping = [];
+ for (ushort paletteIndex = 0; paletteIndex < alteredSourceBlocks.Length; paletteIndex++)
+ {
+ indexMapping = CreateTileSetIndexMap(alteredSourceBlocks[paletteIndex], targetBlocks, indexMapping);
+ }
+
+ return CleanNonMatchingBlockIndices(indexMapping);
+ }
+
+ private static TileSetBlock[][] CreateAlteredTileSetBlocksByPaletteIndexMap(byte[][] paletteIndexMap, TileSetBlock[] sourceBlocks)
+ {
+ if (paletteIndexMap.Length == 0 || sourceBlocks.Length == 0)
+ return [];
+
+
+ TileSetBlock[][] alteredBlocks = new TileSetBlock[paletteIndexMap.Length][];
+
+ for (ushort paletteIndex = 0; paletteIndex < paletteIndexMap.Length; paletteIndex++)
+ {
+ alteredBlocks[paletteIndex] = CreateAlteredTileSetBlocksByPaletteIndexMap(paletteIndexMap[paletteIndex], sourceBlocks);
+ }
+
+ return alteredBlocks;
+ }
+
+ private static TileSetBlock[] CreateAlteredTileSetBlocksByPaletteIndexMap(byte[] paletteIndexMap, TileSetBlock[] sourceBlocks)
+ {
+ if (paletteIndexMap.Length == 0)
+ return [];
+
+ TileSetBlock[] alteredBlocks = new TileSetBlock[sourceBlocks.Length];
+
+ for (ushort blockIndex = 0; blockIndex < sourceBlocks.Length; blockIndex++)
+ {
+ TileSetBlock sourceBlock = sourceBlocks[blockIndex];
+
+ TileSetBlock alteredBlock = new TileSetBlock
+ {
+ ImgData = new byte[sourceBlock.ImgData.Length],
+ Pre_HasAlpha = sourceBlock.Pre_HasAlpha,
+ TransparencyMode = sourceBlock.TransparencyMode,
+ UnkownBytes = (byte[]) sourceBlock.UnkownBytes.Clone(),
+ Alpha = (byte[])sourceBlock.Alpha?.Clone()!
+ };
+
+ for (ushort pixelIndex = 0; pixelIndex < sourceBlock.ImgData.Length; pixelIndex++)
+ {
+ alteredBlock.ImgData[pixelIndex] = paletteIndexMap[sourceBlock.ImgData[pixelIndex]];
+ }
+ alteredBlocks[blockIndex] = alteredBlock;
+ }
+
+ return alteredBlocks;
+ }
+
+ private static ushort[] CreateTileSetIndexMap(TileSetBlock[] alteredSourceBlocks,
+ TileSetBlock[] targetBlocks, ushort[] indexMapping)
+ {
+ if (alteredSourceBlocks.Length == 0 || targetBlocks.Length == 0)
+ return indexMapping;
+
+ int sourceLength = alteredSourceBlocks.Length;
+ int targetLength = targetBlocks.Length;
+
+ if (indexMapping.Length == 0)
+ {
+ indexMapping = new ushort[sourceLength];
+ Array.Fill(indexMapping, NonMatchingBlockNumber);
+ }
+
+ for (ushort sourceIndex = 0; sourceIndex < sourceLength; sourceIndex++)
+ {
+ if (indexMapping[sourceIndex] != NonMatchingBlockNumber)
+ {
+ continue;
+ }
+
+ byte[] sourceBlockImgData = alteredSourceBlocks[sourceIndex].ImgData;
+ for (ushort targetIndex = 0; targetIndex < targetLength; targetIndex++)
+ { // TODO seems to still have way to less hits. Maybe the colors require adjustment, or here is something wrong
+ byte[] targetBlockImgData = targetBlocks[targetIndex].ImgData;
+ if (sourceBlockImgData.SequenceEqual(targetBlockImgData))
+ {
+ indexMapping[sourceIndex] = targetIndex;
+
+ break;
+ }
+ }
+ }
+
+ return indexMapping;
+ }
+
+ private static ushort[] CleanNonMatchingBlockIndices(ushort[] indexMapping)
+ {
+ StringBuilder stringBuilder = new StringBuilder();
+ int sourceLength = indexMapping.Length;
+ int count = 0;
+
+ for (ushort sourceIndex = 0; sourceIndex < sourceLength; sourceIndex++)
+ {
+ if (indexMapping[sourceIndex] == NonMatchingBlockNumber)
+ {
+ count++;
+ stringBuilder.Append(sourceIndex).Append(", ");
+
+ indexMapping[sourceIndex] = 0;
+ }
+ }
+ stringBuilder.Remove(stringBuilder.Length - 2, 2);
+
+ if (count > 0)
+ ConsoleHelpers.WriteWarning($"No tileset match found for {count} block indices: {stringBuilder}!");
+
+ return indexMapping;
+ }
}
\ No newline at end of file