Welcome to mirror list, hosted at ThFree Co, Russian Federation.

Program.cs « NesTiler - github.com/ClusterM/NesTiler.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
blob: 92f50fd0b05822de7bcf35a62be446aeea69595d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.Data;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace com.clusterrr.Famicom.NesTiler
{
    public class Program
    {
        public const string REPO_PATH = "https://github.com/ClusterM/NesTiler";
        public static DateTime BUILD_TIME = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc).AddSeconds(long.Parse(Properties.Resources.buildtime.Trim()));
        public const int MAX_BG_COLOR_AUTODETECT_ITERATIONS = 5;

        static void PrintAppInfo()
        {
            Console.WriteLine($"NesTiler v{Assembly.GetExecutingAssembly()?.GetName()?.Version?.Major}.{Assembly.GetExecutingAssembly()?.GetName()?.Version?.Minor}");
            Console.WriteLine($"  Commit {Properties.Resources.gitCommit} @ {REPO_PATH}");
#if DEBUG
            Console.WriteLine($"  Debug version, build time: {BUILD_TIME.ToLocalTime()}");
#endif
            Console.WriteLine("  (c) Alexey 'Cluster' Avdyukhin / https://clusterrr.com / clusterrr@clusterrr.com");
            Console.WriteLine("");
        }

        static void PrintHelp()
        {
            Console.WriteLine($"Usage: {Path.GetFileName(Process.GetCurrentProcess()?.MainModule?.FileName)} <options>");
            Console.WriteLine();
            Console.WriteLine("Available options:");
            foreach (var arg in IArg.Args)
            {
                var s = "-" + (arg.HasIndex ? (arg.Short + "<#>") : arg.Short);
                var l = "--" + (arg.HasIndex ? (arg.Long + "-<#>") : arg.Long) + (arg.Params != null ? " " + arg.Params : "");
                var description = arg.Description.Replace("\n", "\n" + String.Join("", Enumerable.Repeat(" ", 48)));
                Console.WriteLine("{0,-5} {1,-42}{2}", s, l, description);
            }
        }

        public static int Main(string[] args)
        {
            try
            {
                if (args.Length == 0 || args.Contains("help") || args.Contains("--help"))
                {
                    PrintAppInfo();
                    PrintHelp();
                    return 0;
                }

                var c = Config.Parse(args);
                Trace.Listeners.Clear();
                if (!c.quiet)
                {
                    PrintAppInfo();
                    Trace.Listeners.Add(new TextWriterTraceListener(Console.Out));
                }
                if (!c.ImageFiles.Any() && string.IsNullOrEmpty(c.OutColorsTable))
                {
                    Console.WriteLine("Nothing to do.");
                    Console.WriteLine();
                    PrintHelp();
                    return 1;
                }

                // Data
                var images = new Dictionary<int, FastBitmap>();
                var paletteIndexes = new Dictionary<int, byte[,]>();
                var patternTables = new Dictionary<int, Dictionary<Tile, int>>();
                var nameTables = new Dictionary<int, List<int>>();
                int tileID = 0;

                // Loading and parsing palette JSON
                var nesColors = new ColorFinder(c.ColorsFile);

                // CSV output
                var outTilesCsvLines = !string.IsNullOrEmpty(c.OutTilesCsv) ? new List<string>() : null;
                var outPalettesCsvLines = !string.IsNullOrEmpty(c.OutPalettesCsv) ? new List<string>() : null;

                if (c.OutColorsTable != null)
                {
                    Trace.WriteLine($"Writing color tables to {c.OutColorsTable}...");
                    nesColors.WriteColorsTable(c.OutColorsTable);
                }

                // Stop if there are no images
                if (!c.ImageFiles.Any()) return 0;

                // Change the fixed palettes to colors from the NES palette
                for (int i = 0; i < c.FixedPalettes.Length; i++)
                {
                    if (c.FixedPalettes[i] == null) continue;
                    var colorsInPalette = c.FixedPalettes[i]!.ToArray();
                    for (int j = 0; j < colorsInPalette.Length; j++)
                        colorsInPalette[j] = nesColors.FindSimilarColor(colorsInPalette[j]);
                    c.FixedPalettes[i] = new Palette(colorsInPalette);
                }

                // Loading images
                foreach (var imageFile in c.ImageFiles)
                {
                    Trace.WriteLine($"Loading image #{imageFile.Key} - {Path.GetFileName(imageFile.Value)}...");
                    var offsetRegex = new Regex(@"^(?<filename>.*?)(:(?<offset>[0-9]+)(:(?<height>[0-9]+))?)?$");
                    var match = offsetRegex.Match(imageFile.Value);
                    var filename = match.Groups["filename"].Value;
                    var offsetS = match.Groups["offset"].Value;
                    var heightS = match.Groups["height"].Value;
                    // Crop it if need
                    int offset = 0;
                    int height = -1;
                    if (!string.IsNullOrEmpty(offsetS))
                    {
                        offset = int.Parse(offsetS);
                        if (!string.IsNullOrEmpty(heightS)) height = int.Parse(heightS);
                    }
                    if (!File.Exists(filename)) throw new FileNotFoundException($"Could not find file '{filename}'.", filename);
                    var image = FastBitmap.Decode(filename, offset, height);
                    if (image == null) throw new InvalidDataException($"Can't load {filename}.");
                    images[imageFile.Key] = image;

                    if (c.Mode == Config.TilesMode.Backgrounds && image.Width != 256) throw new ArgumentException("Image width must be 256 for backgrounds mode.", filename);
                    if (image.Width % c.TileWidth != 0) throw new ArgumentException($"Image width must be divisible by {c.TileWidth}.", filename);
                    if (image.Height % c.TileHeight != 0) throw new ArgumentException($"Image height must be divisible by {c.TileHeight}.", filename);
                }

                // Change all colors in the images to colors from the NES palette
                foreach (var imageNum in images.Keys)
                {
                    Trace.WriteLine($"Adjusting colors for image #{imageNum}...");
                    var image = images[imageNum];
                    for (int y = 0; y < image.Height; y++)
                    {
                        for (int x = 0; x < image.Width; x++)
                        {
                            var color = image.GetPixelColor(x, y);
                            if (color.Alpha >= 0x80 || c.Mode == Config.TilesMode.Backgrounds)
                            {
                                // TODO: more lossy levels?
                                var similarColor = nesColors.FindSimilarColor(color);
                                image.SetPixelColor(x, y, similarColor);
                            }
                            else
                            {
                                if (!c.BgColor.HasValue) throw new InvalidDataException("You must specify background color for images with transparency.");
                                image.SetPixelColor(x, y, c.BgColor.Value);
                            }
                        }
                    }
                }

                List<Palette> calculatedPalettes;
                var maxCalculatedPaletteCount = Enumerable.Range(0, 4)
                    .Select(i => c.PaletteEnabled[i] && c.FixedPalettes[i] == null).Count();
                SKColor bgColor;
                // Detect background color
                if (c.BgColor.HasValue)
                {
                    // Manually
                    bgColor = nesColors.FindSimilarColor(c.BgColor.Value);
                    calculatedPalettes = CalculatePalettes(images,
                                                           c.PaletteEnabled,
                                                           c.FixedPalettes,
                                                           c.PattributeTableYOffsets,
                                                           c.TilePalWidth,
                                                           c.TilePalHeight,
                                                           c.BgColor.Value).ToList();
                }
                else
                {
                    // Autodetect most used color
                    Trace.Write($"Background color autodetect... ");
                    Dictionary<SKColor, int> colorPerTileCounter = new();
                    foreach (var imageNum in images.Keys)
                    {
                        var image = images[imageNum];
                        for (int tileY = 0; tileY < image.Height / c.TilePalHeight; tileY++)
                        {
                            for (int tileX = 0; tileX < image.Width / c.TilePalWidth; tileX++)
                            {
                                // Count each color only once per tile/sprite
                                var colorsInTile = new List<SKColor>();
                                for (int y = 0; y < c.TilePalHeight; y++)
                                {
                                    for (int x = 0; x < c.TilePalWidth; x++)
                                    {
                                        var color = image.GetPixelColor((tileX * c.TilePalWidth) + x, (tileY * c.TilePalHeight) + y);
                                        if (!colorsInTile.Contains(color))
                                            colorsInTile.Add(color);
                                    }
                                }

                                foreach (var color in colorsInTile)
                                {
                                    if (!colorPerTileCounter.ContainsKey(color))
                                        colorPerTileCounter[color] = 0;
                                    colorPerTileCounter[color]++;
                                }
                            }
                        }
                    }
                    // Most used colors
                    var candidates = colorPerTileCounter.OrderByDescending(kv => kv.Value).Select(kv => kv.Key).ToArray();
                    // Try to calculate palettes for every background color
                    var calcResults = new Dictionary<SKColor, Palette[]>();
                    for (int i = 0; i < Math.Min(candidates.Length, MAX_BG_COLOR_AUTODETECT_ITERATIONS); i++)
                    {
                        calcResults[candidates[i]] = CalculatePalettes(images,
                                                                       c.PaletteEnabled,
                                                                       c.FixedPalettes,
                                                                       c.PattributeTableYOffsets,
                                                                       c.TilePalWidth,
                                                                       c.TilePalHeight,
                                                                       candidates[i]);
                    }
                    // Select background color which uses minimum palettes
                    var kv = calcResults.OrderBy(kv => kv.Value.Length).First();
                    (bgColor, calculatedPalettes) = (kv.Key, kv.Value.ToList());
                    Trace.WriteLine(ColorTranslator.ToHtml(bgColor.ToColor()));
                }

                if (calculatedPalettes.Count > maxCalculatedPaletteCount && !c.Lossy)
                {
                    throw new InvalidOperationException($"Can't fit {calculatedPalettes.Count} palettes, {maxCalculatedPaletteCount} is maximum.");
                }

                // Select palettes
                var palettes = new Palette?[4] { null, null, null, null };
                outPalettesCsvLines?.Add("palette_id,color0,color1,color2,color3");
                for (var i = 0; i < palettes.Length; i++)
                {
                    if (c.PaletteEnabled[i])
                    {
                        if (c.FixedPalettes[i] != null)
                        {
                            palettes[i] = c.FixedPalettes[i];
                        }
                        else if (calculatedPalettes.Any())
                        {
                            palettes[i] = calculatedPalettes.First();
                            calculatedPalettes.RemoveAt(0);
                        }

                        if (palettes[i] != null)
                        {
                            Trace.WriteLine($"Palette #{i}: {ColorTranslator.ToHtml(bgColor.ToColor())}(BG) {string.Join(" ", palettes[i]!.Select(p => ColorTranslator.ToHtml(p.ToColor())))}");
                            // Write CSV if required
                            outPalettesCsvLines?.Add($"{i},{ColorTranslator.ToHtml(bgColor.ToColor())},{string.Join(",", Enumerable.Range(1, 3).Select(c => (palettes[i]![c] != null ? ColorTranslator.ToHtml(palettes[i]![c]!.Value.ToColor()) : "")))}");
                        }
                    }
                }

                // Calculate palette as color indices and save them to files
                var bgColorIndex = nesColors.FindSimilarColorIndex(bgColor);
                for (int p = 0; p < palettes.Length; p++)
                {
                    if (c.PaletteEnabled[p] && c.OutPalette.ContainsKey(p))
                    {
                        var paletteRaw = new byte[4];
                        paletteRaw[0] = bgColorIndex;
                        for (int colorIndex = 1; colorIndex <= 3; colorIndex++)
                        {
                            if (palettes[p] == null)
                                paletteRaw[colorIndex] = 0;
                            else if (palettes[p]![colorIndex].HasValue)
                                paletteRaw[colorIndex] = nesColors.FindSimilarColorIndex(palettes[p]![colorIndex]!.Value);
                        }
                        File.WriteAllBytes(c.OutPalette[p], paletteRaw);
                        Trace.WriteLine($"Palette #{p} saved to {c.OutPalette[p]}");
                    }
                }

                // Select palette for each tile/sprite and recolorize using them
                foreach (var imageNum in images.Keys)
                {
                    Trace.WriteLine($"Mapping palettes for image #{imageNum}...");
                    var image = images[imageNum];
                    c.PattributeTableYOffsets.TryGetValue(imageNum, out int attributeTableOffset);
                    paletteIndexes[imageNum] = new byte[image.Width / c.TilePalWidth, (int)Math.Ceiling((image.Height + attributeTableOffset) / (float)c.TilePalHeight)];
                    // For each tile/sprite
                    for (int tilePalY = 0; tilePalY < (int)Math.Ceiling((image.Height + attributeTableOffset) / (float)c.TilePalHeight); tilePalY++)
                    {
                        for (int tilePalX = 0; tilePalX < image.Width / c.TilePalWidth; tilePalX++)
                        {
                            double minDelta = double.MaxValue;
                            byte bestPaletteIndex = 0;
                            // Try each palette
                            for (byte paletteIndex = 0; paletteIndex < palettes.Length; paletteIndex++)
                            {
                                if (palettes[paletteIndex] == null) continue;
                                double delta = palettes[paletteIndex]!.GetTileDelta(
                                    image, tilePalX * c.TilePalWidth, (tilePalY * c.TilePalHeight) - attributeTableOffset,
                                    c.TilePalWidth, c.TilePalHeight, bgColor);
                                // Find palette with most similar colors
                                if (delta < minDelta)
                                {
                                    minDelta = delta;
                                    bestPaletteIndex = paletteIndex;
                                }
                            }
                            Palette bestPalette = palettes[bestPaletteIndex]!; // at least one palette enabled, so can't be null here
                            // Remember palette index
                            paletteIndexes[imageNum][tilePalX, tilePalY] = bestPaletteIndex;

                            // Change tile colors to colors from the palette
                            for (int y = 0; y < c.TilePalHeight; y++)
                            {
                                for (int x = 0; x < c.TilePalWidth; x++)
                                {
                                    var cy = (tilePalY * c.TilePalHeight) + y - attributeTableOffset;
                                    if (cy < 0) continue;
                                    var color = image.GetPixelColor((tilePalX * c.TilePalWidth) + x, cy);
                                    var similarColor = nesColors.FindSimilarColor(Enumerable.Concat(
                                            bestPalette,
                                            new SKColor[] { bgColor }
                                        ), color);
                                    image.SetPixelColor(
                                        (tilePalX * c.TilePalWidth) + x,
                                        cy,
                                        similarColor);
                                }
                            }
                        } // tile X
                    } // tile Y

                    // Save preview if required
                    if (c.OutPreview.ContainsKey(imageNum))
                    {
                        File.WriteAllBytes(c.OutPreview[imageNum], image.Encode(SKEncodedImageFormat.Png, 0));
                        Trace.WriteLine($"Preview #{imageNum} saved to {c.OutPreview[imageNum]}");
                    }
                }

                // Generate attribute tables
                foreach (var imageNum in c.OutAttributeTable.Keys)
                {
                    if (c.Mode != Config.TilesMode.Backgrounds)
                        throw new InvalidOperationException("Attribute table generation available for backgrounds mode only.");
                    Trace.WriteLine($"Creating attribute table for image #{imageNum}...");
                    var image = images[imageNum];
                    c.PattributeTableYOffsets.TryGetValue(imageNum, out int attributeTableOffset);
                    var attributeTableRaw = new List<byte>();
                    int width = paletteIndexes[imageNum].GetLength(0);
                    int height = paletteIndexes[imageNum].GetLength(1);
                    for (int ptileY = 0; ptileY < Math.Ceiling(height / 2.0); ptileY++)
                    {
                        for (int ptileX = 0; ptileX < Math.Ceiling(width / 2.0); ptileX++)
                        {
                            byte topLeft = 0;
                            byte topRight = 0;
                            byte bottomLeft = 0;
                            byte bottomRight = 0;

                            topLeft = paletteIndexes[imageNum][ptileX * 2, ptileY * 2];
                            topLeft = paletteIndexes[imageNum][ptileX * 2, ptileY * 2];
                            topRight = paletteIndexes[imageNum][(ptileX * 2) + 1, ptileY * 2];
                            topRight = paletteIndexes[imageNum][(ptileX * 2) + 1, ptileY * 2];
                            if ((ptileY * 2) + 1 < height)
                            {
                                bottomLeft = paletteIndexes[imageNum][ptileX * 2, (ptileY * 2) + 1];
                                bottomLeft = paletteIndexes[imageNum][ptileX * 2, (ptileY * 2) + 1];
                                bottomRight = paletteIndexes[imageNum][(ptileX * 2) + 1, (ptileY * 2) + 1];
                                bottomRight = paletteIndexes[imageNum][(ptileX * 2) + 1, (ptileY * 2) + 1];
                            }

                            var atv = (byte)
                                (topLeft // top left
                                | (topRight << 2) // top right
                                | (bottomLeft << 4) // bottom left
                                | (bottomRight << 6)); // bottom right
                            attributeTableRaw.Add(atv);
                        }
                    }

                    // Save to file
                    if (c.OutAttributeTable.ContainsKey(imageNum))
                    {
                        File.WriteAllBytes(c.OutAttributeTable[imageNum], attributeTableRaw.ToArray());
                        Trace.WriteLine($"Attribute table #{imageNum} saved to {c.OutAttributeTable[imageNum]}");
                    }
                }

                // Generate pattern tables and nametables
                outTilesCsvLines?.Add("image_id,image_file,line,column,tile_x,tile_y,tile_width,tile_height,tile_id,palette_id");
                foreach (var imageNum in images.Keys)
                {
                    Trace.WriteLine($"Creating pattern table for image #{imageNum}...");
                    var image = images[imageNum];
                    c.PattributeTableYOffsets.TryGetValue(imageNum, out int attributeTableOffset);
                    if (!patternTables.ContainsKey(!c.SharePatternTable ? imageNum : 0)) patternTables[!c.SharePatternTable ? imageNum : 0] = new Dictionary<Tile, int>();
                    var patternTable = patternTables[!c.SharePatternTable ? imageNum : 0];
                    if (!nameTables.ContainsKey(imageNum)) nameTables[imageNum] = new List<int>();
                    var nameTable = nameTables[imageNum];
                    if (!c.SharePatternTable)
                    {
                        if (!c.PatternTableStartOffsets.ContainsKey(imageNum))
                            c.PatternTableStartOffsets[imageNum] = 0;
                        tileID = c.PatternTableStartOffsets[imageNum];
                    }
                    else
                    {
                        tileID = Math.Max(tileID, c.PatternTableStartOffsetShared);
                        c.PatternTableStartOffsets[imageNum] = tileID;
                    }

                    for (int tileY = 0; tileY < image.Height / c.TileHeight; tileY++)
                    {
                        for (int tileX = 0; tileX < image.Width / c.TileWidth; tileX++)
                        {
                            var tileData = new byte[c.TileWidth * c.TileHeight];
                            byte paletteID = 0;
                            for (int y = 0; y < c.TileHeight; y++)
                                for (int x = 0; x < c.TileWidth; x++)
                                {
                                    var color = image.GetPixelColor((tileX * c.TileWidth) + x, (tileY * c.TileHeight) + y);
                                    paletteID = paletteIndexes[imageNum][tileX / (c.TilePalWidth / c.TileWidth), (tileY + (attributeTableOffset / c.TileHeight)) / (c.TilePalHeight / c.TileHeight)];
                                    var palette = palettes[paletteID];
                                    byte colorIndex = 0;
                                    if (color != bgColor)
                                    {
                                        colorIndex = 1;
                                        while (palette![colorIndex] != color) colorIndex++;
                                    }
                                    tileData[(y * c.TileWidth) + x] = colorIndex;
                                }
                            var tile = new Tile(tileData, c.TileHeight);
                            int currentTileID;
                            if (patternTable.TryGetValue(tile, out int id))
                            {
                                if (c.Mode == Config.TilesMode.Backgrounds) nameTable.Add(id);
                                currentTileID = id;
                            }
                            else
                            {
                                patternTable[tile] = tileID;
                                if (c.Mode == Config.TilesMode.Backgrounds) nameTable.Add(tileID);
                                currentTileID = tileID;
                                tileID++;
                            }
                            currentTileID = ((currentTileID & 0x7F) << 1) | ((currentTileID & 0x80) >> 7);

                            // Write CSV if required
                            outTilesCsvLines?.Add($"{imageNum},{c.ImageFiles[imageNum]},{tileY},{tileX},{tileX * c.TileWidth},{tileY * c.TileHeight},{c.TileWidth},{c.TileHeight},{currentTileID},{paletteID}");
                        }
                    }
                    if (c.SharePatternTable && tileID > c.PatternTableStartOffsetShared)
                        Trace.WriteLine($"#{imageNum} tiles range: {c.PatternTableStartOffsetShared}-{tileID - 1}");
                    else if (tileID > c.PatternTableStartOffsets[imageNum])
                        Trace.WriteLine($"#{imageNum} tiles range: {c.PatternTableStartOffsets[imageNum]}-{tileID - 1}");
                    else
                        Trace.WriteLine($"Pattern table is empty.");
                    if (tileID > 256 && !c.IgnoreTilesRange)
                        throw new ArgumentOutOfRangeException("Tiles out of range.");

                    // Save pattern table to file
                    if (c.OutPatternTable.ContainsKey(imageNum) && !c.SharePatternTable)
                    {
                        var patternTableReversed = patternTable.ToDictionary(kv => kv.Value, kv => kv.Key);
                        var patternTableRaw = new List<byte>();
                        for (int t = c.PatternTableStartOffsets[imageNum]; t < tileID; t++)
                        {
                            var raw = patternTableReversed[t].GetAsPatternData();
                            patternTableRaw.AddRange(raw);
                        }
                        File.WriteAllBytes(c.OutPatternTable[imageNum], patternTableRaw.ToArray());
                        Trace.WriteLine($"Pattern table #{imageNum} saved to {c.OutPatternTable[imageNum]}");
                    }

                    // Save nametable to file
                    if (c.OutNameTable.ContainsKey(imageNum))
                    {
                        if (c.Mode != Config.TilesMode.Backgrounds)
                            throw new InvalidOperationException("Nametable table generation available for backgrounds mode only.");
                        File.WriteAllBytes(c.OutNameTable[imageNum], nameTable.Select(i => (byte)i).ToArray());
                        Trace.WriteLine($"Nametable #{imageNum} saved to {c.OutNameTable[imageNum]}");
                    }
                }

                // Save shared pattern table to file
                if (c.SharePatternTable && c.OutPatternTableShared != null)
                {
                    var patternTableReversed = patternTables[0].ToDictionary(kv => kv.Value, kv => kv.Key);
                    var patternTableRaw = new List<byte>();
                    for (int t = c.PatternTableStartOffsetShared; t < tileID; t++)
                    {
                        var raw = patternTableReversed[t].GetAsPatternData();
                        patternTableRaw.AddRange(raw);
                    }
                    File.WriteAllBytes(c.OutPatternTableShared, patternTableRaw.ToArray());
                    Trace.WriteLine($"Pattern table saved to {c.OutPatternTableShared}");
                }

                // Save CSV tiles report
                if (c.OutTilesCsv != null && outTilesCsvLines != null)
                {
                    File.WriteAllLines(c.OutTilesCsv, outTilesCsvLines);
                }
                // Save CSV palettes report
                if (c.OutPalettesCsv != null && outPalettesCsvLines != null)
                {
                    File.WriteAllLines(c.OutPalettesCsv, outPalettesCsvLines);
                }

                return 0;
            }
            catch (ArgumentException ex)
            {
                Console.Error.WriteLine($"Error. {ex.Message}");
                return 1;
            }
            catch (JsonException ex)
            {
                Console.Error.WriteLine($"Can't parse JSON: {ex.Message}");
                return 1;
            }
            catch (Exception ex) when (ex is InvalidDataException || ex is InvalidOperationException || ex is ArgumentOutOfRangeException || ex is FileNotFoundException)
            {
                Console.Error.WriteLine($"Error. {ex.Message}");
                return 1;
            }
            catch (Exception ex)
            {
                Console.Error.WriteLine($"Unexpected error {ex.GetType()}: {ex.Message}{ex.StackTrace}");
                return 1;
            }
        }

        static Palette[] CalculatePalettes(Dictionary<int, FastBitmap> images, bool[] paletteEnabled, Palette?[] fixedPalettes, Dictionary<int, int> attributeTableOffsets, int tilePalWidth, int tilePalHeight, SKColor bgColor)
        {
            var required = Enumerable.Range(0, 4).Select(i => paletteEnabled[i] && fixedPalettes[i] == null);
            // Creating and counting the palettes
            var paletteCounter = new Dictionary<Palette, int>();
            foreach (var imageNum in images.Keys)
            {
                var image = images[imageNum];
                attributeTableOffsets.TryGetValue(imageNum, out int attributeTableOffset);
                // For each tile/sprite
                for (int tileY = 0; tileY < (image.Height + attributeTableOffset) / tilePalHeight; tileY++)
                {
                    for (int tileX = 0; tileX < image.Width / tilePalWidth; tileX++)
                    {
                        // Create palette using up to three most used colors
                        var palette = new Palette(
                            image, tileX * tilePalWidth, (tileY * tilePalHeight) - attributeTableOffset,
                            tilePalWidth, tilePalHeight, bgColor);

                        // Skip tiles with only background color
                        if (!palette.Any()) continue;

                        // Do not count predefined palettes
                        if (fixedPalettes.Where(p => p != null && p.Contains(palette)).Any())
                            continue;

                        // Count palette usage
                        if (!paletteCounter.ContainsKey(palette))
                            paletteCounter[palette] = 0;
                        paletteCounter[palette]++;
                    }
                }
            }

            // Group palettes
            var result = new Palette[0];
            // Multiple iterations
            while (true)
            {
                // Remove unused palettes
                paletteCounter = paletteCounter.Where(kv => kv.Value > 0).ToDictionary(kv => kv.Key, kv => kv.Value);
                // Sort by usage
                result = paletteCounter.OrderByDescending(kv => kv.Value).Select(kv => kv.Key).ToArray();

                // Some palettes can contain all colors from other palettes, so we need to combine them
                foreach (var palette2 in result)
                    foreach (var palette1 in result)
                    {
                        if ((palette2 != palette1) && (palette2.Count >= palette1.Count) && palette2.Contains(palette1))
                        {
                            // Move counter
                            paletteCounter[palette2] += paletteCounter[palette1];
                            paletteCounter[palette1] = 0;
                        }
                    }

                // Remove unused palettes
                paletteCounter = paletteCounter.Where(kv => kv.Value > 0).ToDictionary(kv => kv.Key, kv => kv.Value);
                // Sort them again
                result = paletteCounter.OrderByDescending(kv => kv.Value).Select(kv => kv.Key).ToArray();

                // Get most used palettes
                var top = result.Take(required.Count()).ToList();
                // Use free colors in palettes to store less popular palettes
                bool grouped = false;
                foreach (var t in top)
                {
                    if (paletteCounter[t] > 0 && t.Count < 3)
                    {
                        foreach (var p in result)
                        {
                            var newColors = p.Where(c => !t.Contains(c));
                            if ((p != t) && (paletteCounter[p] > 0) && (newColors.Count() + t.Count <= 3))
                            {
                                var count1 = paletteCounter[t];
                                var count2 = paletteCounter[p];
                                paletteCounter[t] = 0;
                                paletteCounter[p] = 0;
                                var newPalette = new Palette(Enumerable.Concat(t, p).Distinct());
                                paletteCounter[newPalette] = count1 + count2;
                                grouped = true;
                            }
                        }
                    }
                }

                if (!grouped) break; // Nothing changed, stop iterations
            }

            // Remove unused palettes
            paletteCounter = paletteCounter.Where(kv => kv.Value > 0).ToDictionary(kv => kv.Key, kv => kv.Value);
            // Sort them again
            result = paletteCounter.OrderByDescending(kv => kv.Value).Select(kv => kv.Key).ToArray();

            return result;
        }
    }
}