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