shithub: rgbds

Download patch

ref: 188027bccce01fc744a845c0626c7c6c8559966c
parent: 79adcdb7eac7e7ea22e2a65f33092f2c764f550a
author: ISSOtm <[email protected]>
date: Tue Mar 22 14:42:33 EDT 2022

Rename `convert` to `process`

More consistent with its "main" function's name

--- a/Makefile
+++ b/Makefile
@@ -105,10 +105,10 @@
 	src/error.o
 
 rgbgfx_obj := \
-	src/gfx/convert.o \
 	src/gfx/main.o \
 	src/gfx/pal_packing.o \
 	src/gfx/pal_sorting.o \
+	src/gfx/process.o \
 	src/gfx/proto_palette.o \
 	src/gfx/rgba.o \
 	src/extern/getopt.o \
--- a/include/gfx/convert.hpp
+++ /dev/null
@@ -1,14 +1,0 @@
-/*
- * This file is part of RGBDS.
- *
- * Copyright (c) 2022, Eldred Habert and RGBDS contributors.
- *
- * SPDX-License-Identifier: MIT
- */
-
-#ifndef RGBDS_GFX_CONVERT_HPP
-#define RGBDS_GFX_CONVERT_HPP
-
-void process();
-
-#endif /* RGBDS_GFX_CONVERT_HPP */
--- /dev/null
+++ b/include/gfx/process.hpp
@@ -1,0 +1,14 @@
+/*
+ * This file is part of RGBDS.
+ *
+ * Copyright (c) 2022, Eldred Habert and RGBDS contributors.
+ *
+ * SPDX-License-Identifier: MIT
+ */
+
+#ifndef RGBDS_GFX_CONVERT_HPP
+#define RGBDS_GFX_CONVERT_HPP
+
+void process();
+
+#endif /* RGBDS_GFX_CONVERT_HPP */
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -70,10 +70,10 @@
     )
 
 set(rgbgfx_src
-    "gfx/convert.cpp"
     "gfx/main.cpp"
     "gfx/pal_packing.cpp"
     "gfx/pal_sorting.cpp"
+    "gfx/process.cpp"
     "gfx/proto_palette.cpp"
     "gfx/rgba.cpp"
     "extern/getopt.c"
--- a/src/gfx/convert.cpp
+++ /dev/null
@@ -1,1025 +1,0 @@
-/*
- * This file is part of RGBDS.
- *
- * Copyright (c) 2022, Eldred Habert and RGBDS contributors.
- *
- * SPDX-License-Identifier: MIT
- */
-
-#include "gfx/convert.hpp"
-
-#include <algorithm>
-#include <assert.h>
-#include <cinttypes>
-#include <errno.h>
-#include <fstream>
-#include <memory>
-#include <optional>
-#include <png.h>
-#include <setjmp.h>
-#include <string.h>
-#include <tuple>
-#include <unordered_set>
-#include <utility>
-#include <vector>
-
-#include "defaultinitalloc.hpp"
-#include "helpers.h"
-
-#include "gfx/main.hpp"
-#include "gfx/pal_packing.hpp"
-#include "gfx/pal_sorting.hpp"
-#include "gfx/proto_palette.hpp"
-
-class ImagePalette {
-	// Use as many slots as there are CGB colors (plus transparency)
-	std::array<std::optional<Rgba>, 0x8001> _colors;
-
-public:
-	ImagePalette() = default;
-
-	void registerColor(Rgba const &rgba) {
-		decltype(_colors)::value_type &slot = _colors[rgba.cgbColor()];
-
-		if (rgba.cgbColor() == Rgba::transparent) {
-			options.hasTransparentPixels = true;
-		}
-
-		if (!slot.has_value()) {
-			slot.emplace(rgba);
-		} else if (*slot != rgba) {
-			warning("Different colors melded together (#%08x into #%08x as %04x)", rgba.toCSS(),
-			        slot->toCSS(), rgba.cgbColor()); // TODO: indicate position
-		}
-	}
-
-	size_t size() const {
-		return std::count_if(_colors.begin(), _colors.end(),
-		                     [](decltype(_colors)::value_type const &slot) {
-			                     return slot.has_value() && !slot->isTransparent();
-		                     });
-	}
-	decltype(_colors) const &raw() const { return _colors; }
-
-	auto begin() const { return _colors.begin(); }
-	auto end() const { return _colors.end(); }
-};
-
-class Png {
-	std::string const &path;
-	std::filebuf file{};
-	png_structp png = nullptr;
-	png_infop info = nullptr;
-
-	// These are cached for speed
-	uint32_t width, height;
-	DefaultInitVec<Rgba> pixels;
-	ImagePalette colors;
-	int colorType;
-	int nbColors;
-	png_colorp embeddedPal = nullptr;
-	png_bytep transparencyPal = nullptr;
-
-	[[noreturn]] static void handleError(png_structp png, char const *msg) {
-		Png *self = reinterpret_cast<Png *>(png_get_error_ptr(png));
-
-		fatal("Error reading input image (\"%s\"): %s", self->path.c_str(), msg);
-	}
-
-	static void handleWarning(png_structp png, char const *msg) {
-		Png *self = reinterpret_cast<Png *>(png_get_error_ptr(png));
-
-		warning("In input image (\"%s\"): %s", self->path.c_str(), msg);
-	}
-
-	static void readData(png_structp png, png_bytep data, size_t length) {
-		Png *self = reinterpret_cast<Png *>(png_get_io_ptr(png));
-		std::streamsize expectedLen = length;
-		std::streamsize nbBytesRead = self->file.sgetn(reinterpret_cast<char *>(data), expectedLen);
-
-		if (nbBytesRead != expectedLen) {
-			fatal("Error reading input image (\"%s\"): file too short (expected at least %zd more "
-			      "bytes after reading %lld)",
-			      self->path.c_str(), length - nbBytesRead,
-			      self->file.pubseekoff(0, std::ios_base::cur));
-		}
-	}
-
-public:
-	ImagePalette const &getColors() const { return colors; }
-
-	int getColorType() const { return colorType; }
-
-	std::tuple<int, png_const_colorp, png_bytep> getEmbeddedPal() const {
-		return {nbColors, embeddedPal, transparencyPal};
-	}
-
-	uint32_t getWidth() const { return width; }
-
-	uint32_t getHeight() const { return height; }
-
-	Rgba &pixel(uint32_t x, uint32_t y) { return pixels[y * width + x]; }
-
-	Rgba const &pixel(uint32_t x, uint32_t y) const { return pixels[y * width + x]; }
-
-	bool isSuitableForGrayscale() const {
-		// Check that all of the grays don't fall into the same "bin"
-		if (colors.size() > options.maxOpaqueColors()) { // Apply the Pigeonhole Principle
-			options.verbosePrint(Options::VERB_DEBUG,
-			                     "Too many colors for grayscale sorting (%zu > %" PRIu8 ")\n",
-			                     colors.size(), options.maxOpaqueColors());
-			return false;
-		}
-		uint8_t bins = 0;
-		for (auto const &color : colors) {
-			if (color->isTransparent()) {
-				continue;
-			}
-			if (!color->isGray()) {
-				options.verbosePrint(Options::VERB_DEBUG,
-				                     "Found non-gray color #%08x, not using grayscale sorting\n",
-				                     color->toCSS());
-				return false;
-			}
-			uint8_t mask = 1 << color->grayIndex();
-			if (bins & mask) { // Two in the same bin!
-				options.verbosePrint(
-				    Options::VERB_DEBUG,
-				    "Color #%08x conflicts with another one, not using grayscale sorting\n",
-				    color->toCSS());
-				return false;
-			}
-			bins |= mask;
-		}
-		return true;
-	}
-
-	/**
-	 * Reads a PNG and notes all of its colors
-	 *
-	 * This code is more complicated than strictly necessary, but that's because of the API
-	 * being used: the "high-level" interface doesn't provide all the transformations we need,
-	 * so we use the "lower-level" one instead.
-	 * We also use that occasion to only read the PNG one line at a time, since we store all of
-	 * the pixel data in `pixels`, which saves on memory allocations.
-	 */
-	explicit Png(std::string const &filePath) : path(filePath), colors() {
-		if (file.open(path, std::ios_base::in | std::ios_base::binary) == nullptr) {
-			fatal("Failed to open input image (\"%s\"): %s", path.c_str(), strerror(errno));
-		}
-
-		options.verbosePrint(Options::VERB_LOG_ACT, "Opened input file\n");
-
-		std::array<unsigned char, 8> pngHeader;
-
-		if (file.sgetn(reinterpret_cast<char *>(pngHeader.data()), pngHeader.size())
-		        != static_cast<std::streamsize>(pngHeader.size()) // Not enough bytes?
-		    || png_sig_cmp(pngHeader.data(), 0, pngHeader.size()) != 0) {
-			fatal("Input file (\"%s\") is not a PNG image!", path.c_str());
-		}
-
-		options.verbosePrint(Options::VERB_INTERM, "PNG header signature is OK\n");
-
-		png = png_create_read_struct(PNG_LIBPNG_VER_STRING, (png_voidp)this, handleError,
-		                             handleWarning);
-		if (!png) {
-			fatal("Failed to allocate PNG structure: %s", strerror(errno));
-		}
-
-		info = png_create_info_struct(png);
-		if (!info) {
-			png_destroy_read_struct(&png, nullptr, nullptr);
-			fatal("Failed to allocate PNG info structure: %s", strerror(errno));
-		}
-
-		png_set_read_fn(png, this, readData);
-		png_set_sig_bytes(png, pngHeader.size());
-
-		// TODO: png_set_crc_action(png, PNG_CRC_ERROR_QUIT, PNG_CRC_WARN_DISCARD);
-
-		// Skipping chunks we don't use should improve performance
-		// TODO: png_set_keep_unknown_chunks(png, ...);
-
-		// Process all chunks up to but not including the image data
-		png_read_info(png, info);
-
-		int bitDepth, interlaceType; //, compressionType, filterMethod;
-
-		png_get_IHDR(png, info, &width, &height, &bitDepth, &colorType, &interlaceType, nullptr,
-		             nullptr);
-
-		if (width % 8 != 0) {
-			fatal("Image width (%" PRIu32 " pixels) is not a multiple of 8!", width);
-		}
-		if (height % 8 != 0) {
-			fatal("Image height (%" PRIu32 " pixels) is not a multiple of 8!", height);
-		}
-
-		pixels.resize(static_cast<size_t>(width) * static_cast<size_t>(height));
-
-		auto colorTypeName = [this]() {
-			switch (colorType) {
-			case PNG_COLOR_TYPE_GRAY:
-				return "grayscale";
-			case PNG_COLOR_TYPE_GRAY_ALPHA:
-				return "grayscale + alpha";
-			case PNG_COLOR_TYPE_PALETTE:
-				return "palette";
-			case PNG_COLOR_TYPE_RGB:
-				return "RGB";
-			case PNG_COLOR_TYPE_RGB_ALPHA:
-				return "RGB + alpha";
-			default:
-				fatal("Unknown color type %d", colorType);
-			}
-		};
-		auto interlaceTypeName = [&interlaceType]() {
-			switch (interlaceType) {
-			case PNG_INTERLACE_NONE:
-				return "not interlaced";
-			case PNG_INTERLACE_ADAM7:
-				return "interlaced (Adam7)";
-			default:
-				fatal("Unknown interlace type %d", interlaceType);
-			}
-		};
-		options.verbosePrint(Options::VERB_INTERM,
-		                     "Input image: %" PRIu32 "x%" PRIu32 " pixels, %dbpp %s, %s\n", height,
-		                     width, bitDepth, colorTypeName(), interlaceTypeName());
-
-		if (png_get_PLTE(png, info, &embeddedPal, &nbColors) != 0) {
-			int nbTransparentEntries;
-			if (png_get_tRNS(png, info, &transparencyPal, &nbTransparentEntries, nullptr)) {
-				assert(nbTransparentEntries == nbColors);
-			}
-
-			options.verbosePrint(Options::VERB_INTERM, "Embedded palette has %d colors: [",
-			                     nbColors);
-			for (int i = 0; i < nbColors; ++i) {
-				auto const &color = embeddedPal[i];
-				options.verbosePrint(
-				    Options::VERB_INTERM, "#%02x%02x%02x%02x%s", color.red, color.green, color.blue,
-				    transparencyPal ? transparencyPal[i] : 0xFF, i != nbColors - 1 ? ", " : "]\n");
-			}
-		} else {
-			options.verbosePrint(Options::VERB_INTERM, "No embedded palette\n");
-		}
-
-		// Set up transformations; to turn everything into RGBA888
-		// TODO: it's not necessary to uniformize the pixel data (in theory), and not doing
-		// so *might* improve performance, and should reduce memory usage.
-
-		// Convert grayscale to RGB
-		switch (colorType & ~PNG_COLOR_MASK_ALPHA) {
-		case PNG_COLOR_TYPE_GRAY:
-			png_set_gray_to_rgb(png); // This also converts tRNS to alpha
-			break;
-		case PNG_COLOR_TYPE_PALETTE:
-			png_set_palette_to_rgb(png);
-			break;
-		}
-
-		if (png_get_valid(png, info, PNG_INFO_tRNS)) {
-			// If we read a tRNS chunk, convert it to alpha
-			png_set_tRNS_to_alpha(png);
-		} else if (!(colorType & PNG_COLOR_MASK_ALPHA)) {
-			// Otherwise, if we lack an alpha channel, default to full opacity
-			png_set_add_alpha(png, 0xFFFF, PNG_FILLER_AFTER);
-		}
-
-		// Scale 16bpp back to 8 (we don't need all of that precision anyway)
-		if (bitDepth == 16) {
-			png_set_scale_16(png);
-		} else if (bitDepth < 8) {
-			png_set_packing(png);
-		}
-
-		// Set interlace handling (MUST be done before `png_read_update_info`)
-		int nbPasses = png_set_interlace_handling(png);
-
-		// Update `info` with the transformations
-		png_read_update_info(png, info);
-		// These shouldn't have changed
-		assert(png_get_image_width(png, info) == width);
-		assert(png_get_image_height(png, info) == height);
-		// These should have changed, however
-		assert(png_get_color_type(png, info) == PNG_COLOR_TYPE_RGBA);
-		assert(png_get_bit_depth(png, info) == 8);
-
-		// Now that metadata has been read, we can process the image data
-
-		std::vector<png_byte> row(png_get_rowbytes(png, info));
-
-		if (interlaceType == PNG_INTERLACE_NONE) {
-			for (png_uint_32 y = 0; y < height; ++y) {
-				png_read_row(png, row.data(), nullptr);
-
-				for (png_uint_32 x = 0; x < width; ++x) {
-					Rgba rgba(row[x * 4], row[x * 4 + 1], row[x * 4 + 2], row[x * 4 + 3]);
-					colors.registerColor(rgba);
-					pixel(x, y) = rgba;
-				}
-			}
-		} else {
-			// For interlace to work properly, we must read the image `nbPasses` times
-			for (int pass = 0; pass < nbPasses; ++pass) {
-				// The interlacing pass must be skipped if its width or height is reported as zero
-				if (PNG_PASS_COLS(width, pass) == 0 || PNG_PASS_ROWS(height, pass) == 0) {
-					continue;
-				}
-
-				png_uint_32 xStep = 1u << PNG_PASS_COL_SHIFT(pass);
-				png_uint_32 yStep = 1u << PNG_PASS_ROW_SHIFT(pass);
-
-				for (png_uint_32 y = PNG_PASS_START_ROW(pass); y < height; y += yStep) {
-					png_bytep ptr = row.data();
-					png_read_row(png, ptr, nullptr);
-
-					for (png_uint_32 x = PNG_PASS_START_COL(pass); x < width; x += xStep) {
-						Rgba rgba(ptr[0], ptr[1], ptr[2], ptr[3]);
-						colors.registerColor(rgba);
-						pixel(x, y) = rgba;
-						ptr += 4;
-					}
-				}
-			}
-		}
-
-		// We don't care about chunks after the image data (comments, etc.)
-		png_read_end(png, nullptr);
-	}
-
-	~Png() { png_destroy_read_struct(&png, &info, nullptr); }
-
-	class TilesVisitor {
-		Png const &_png;
-		bool const _columnMajor;
-		uint32_t const _width, _height;
-		uint32_t const _limit = _columnMajor ? _height : _width;
-
-	public:
-		TilesVisitor(Png const &png, bool columnMajor, uint32_t width, uint32_t height)
-		    : _png(png), _columnMajor(columnMajor), _width(width), _height(height) {}
-
-		class Tile {
-			Png const &_png;
-			uint32_t const _x, _y;
-
-		public:
-			Tile(Png const &png, uint32_t x, uint32_t y) : _png(png), _x(x), _y(y) {}
-
-			Rgba pixel(uint32_t xOfs, uint32_t yOfs) const {
-				return _png.pixel(_x + xOfs, _y + yOfs);
-			}
-		};
-
-	private:
-		struct iterator {
-			TilesVisitor const &parent;
-			uint32_t const limit;
-			uint32_t x, y;
-
-			std::pair<uint32_t, uint32_t> coords() const { return {x, y}; }
-			Tile operator*() const { return {parent._png, x, y}; }
-
-			iterator &operator++() {
-				auto [major, minor] = parent._columnMajor ? std::tie(y, x) : std::tie(x, y);
-				major += 8;
-				if (major == limit) {
-					minor += 8;
-					major = 0;
-				}
-				return *this;
-			}
-
-			bool operator!=(iterator const &rhs) const {
-				return coords() != rhs.coords(); // Compare the returned coord pairs
-			}
-		};
-
-	public:
-		iterator begin() const { return {*this, _limit, 0, 0}; }
-		iterator end() const {
-			iterator it{*this, _limit, _width - 8, _height - 8}; // Last valid one...
-			return ++it; // ...now one-past-last!
-		}
-	};
-public:
-	TilesVisitor visitAsTiles(bool columnMajor) const {
-		return {*this, columnMajor, width, height};
-	}
-};
-
-class RawTiles {
-	/**
-	 * A tile which only contains indices into the image's global palette
-	 */
-	class RawTile {
-		std::array<std::array<size_t, 8>, 8> _pixelIndices{};
-
-	public:
-		// Not super clean, but it's closer to matrix notation
-		size_t &operator()(size_t x, size_t y) { return _pixelIndices[y][x]; }
-	};
-
-private:
-	std::vector<RawTile> _tiles;
-
-public:
-	/**
-	 * Creates a new raw tile, and returns a reference to it so it can be filled in
-	 */
-	RawTile &newTile() {
-		_tiles.emplace_back();
-		return _tiles.back();
-	}
-};
-
-struct AttrmapEntry {
-	size_t protoPaletteID; // Only this field is used when outputting "unoptimized" data
-	uint8_t tileID; // This is the ID as it will be output to the tilemap
-	bool bank;
-	bool yFlip;
-	bool xFlip;
-
-	static constexpr decltype(protoPaletteID) transparent = SIZE_MAX;
-};
-
-static std::tuple<DefaultInitVec<size_t>, std::vector<Palette>>
-    generatePalettes(std::vector<ProtoPalette> const &protoPalettes, Png const &png) {
-	// Run a "pagination" problem solver
-	// TODO: allow picking one of several solvers?
-	auto [mappings, nbPalettes] = packing::overloadAndRemove(protoPalettes);
-	assert(mappings.size() == protoPalettes.size());
-
-	if (options.verbosity >= Options::VERB_INTERM) {
-		fprintf(stderr, "Proto-palette mappings: (%zu palette%s)\n", nbPalettes,
-		        nbPalettes != 1 ? "s" : "");
-		for (size_t i = 0; i < mappings.size(); ++i) {
-			fprintf(stderr, "%zu -> %zu\n", i, mappings[i]);
-		}
-	}
-
-	std::vector<Palette> palettes(nbPalettes);
-	// If the image contains at least one transparent pixel, force transparency in the first slot of
-	// all palettes
-	if (options.hasTransparentPixels) {
-		for (Palette &pal : palettes) {
-			pal.colors[0] = Rgba::transparent;
-		}
-	}
-	// Generate the actual palettes from the mappings
-	for (size_t protoPalID = 0; protoPalID < mappings.size(); ++protoPalID) {
-		auto &pal = palettes[mappings[protoPalID]];
-		for (uint16_t color : protoPalettes[protoPalID]) {
-			pal.addColor(color);
-		}
-	}
-
-	// "Sort" colors in the generated palettes, see the man page for the flowchart
-	auto [embPalSize, embPalRGB, embPalAlpha] = png.getEmbeddedPal();
-	if (embPalRGB != nullptr) {
-		sorting::indexed(palettes, embPalSize, embPalRGB, embPalAlpha);
-	} else if (png.isSuitableForGrayscale()) {
-		sorting::grayscale(palettes, png.getColors().raw());
-	} else {
-		sorting::rgb(palettes);
-	}
-	return {mappings, palettes};
-}
-
-static std::tuple<DefaultInitVec<size_t>, std::vector<Palette>>
-    makePalsAsSpecified(std::vector<ProtoPalette> const &protoPalettes, Png const &png) {
-	if (options.palSpecType == Options::EMBEDDED) {
-		// Generate a palette spec from the first few colors in the embedded palette
-		auto [embPalSize, embPalRGB, embPalAlpha] = png.getEmbeddedPal();
-		if (embPalRGB == nullptr) {
-			fatal("`-c embedded` was given, but the PNG does not have an embedded palette!");
-		}
-
-		// Fill in the palette spec
-		options.palSpec.emplace_back(); // A single palette, with `#00000000`s (transparent)
-		assert(options.palSpec.size() == 1);
-		// TODO: abort if ignored colors are being used; do it now for a friendlier error
-		// message
-		if (embPalSize > options.maxOpaqueColors()) { // Ignore extraneous colors if they are unused
-			embPalSize = options.maxOpaqueColors();
-		}
-		for (int i = 0; i < embPalSize; ++i) {
-			options.palSpec[0][i] = Rgba(embPalRGB[i].red, embPalRGB[i].green, embPalRGB[i].blue,
-			                             embPalAlpha ? embPalAlpha[i] : 0xFF);
-		}
-	}
-
-	// Convert the palette spec to actual palettes
-	std::vector<Palette> palettes(options.palSpec.size());
-	auto palIter = palettes.begin(); // TODO: `zip`
-	for (auto const &spec : options.palSpec) {
-		for (size_t i = 0; i < options.nbColorsPerPal; ++i) {
-			(*palIter)[i] = spec[i].cgbColor();
-		}
-		++palIter;
-	}
-
-	// Iterate through proto-palettes, and try mapping them to the specified palettes
-	DefaultInitVec<size_t> mappings(protoPalettes.size());
-	for (size_t i = 0; i < protoPalettes.size(); ++i) {
-		ProtoPalette const &protoPal = protoPalettes[i];
-		// Find the palette...
-		auto iter = std::find_if(palettes.begin(), palettes.end(), [&protoPal](Palette const &pal) {
-			// ...which contains all colors in this proto-pal
-			return std::all_of(protoPal.begin(), protoPal.end(), [&pal](uint16_t color) {
-				return std::find(pal.begin(), pal.end(), color) != pal.end();
-			});
-		});
-		assert(iter != palettes.end()); // TODO: produce a proper error message
-		mappings[i] = iter - palettes.begin();
-	}
-
-	return {mappings, palettes};
-}
-
-static void outputPalettes(std::vector<Palette> const &palettes) {
-	std::filebuf output;
-	output.open(options.palettes, std::ios_base::out | std::ios_base::binary);
-
-	for (Palette const &palette : palettes) {
-		for (uint8_t i = 0; i < options.nbColorsPerPal; ++i) {
-			uint16_t color = palette.colors[i]; // Will return `UINT16_MAX` for unused slots
-			output.sputc(color & 0xFF);
-			output.sputc(color >> 8);
-		}
-	}
-}
-
-static uint8_t flip(uint8_t byte) {
-	// To flip all the bits, we'll flip both nibbles, then each nibble half, etc.
-	byte = (byte & 0x0F) << 4 | (byte & 0xF0) >> 4;
-	byte = (byte & 0x33) << 2 | (byte & 0xCC) >> 2;
-	byte = (byte & 0x55) << 1 | (byte & 0xAA) >> 1;
-	return byte;
-}
-
-class TileData {
-	std::array<uint8_t, 16> _data;
-	// The hash is a bit lax: it's the XOR of all lines, and every other nibble is identical
-	// if horizontal mirroring is in effect. It should still be a reasonable tie-breaker in
-	// non-pathological cases.
-	uint16_t _hash;
-public:
-	// This is an index within the "global" pool; no bank info is encoded here
-	// It's marked as `mutable` so that it can be modified even on a `const` object;
-	// this is necessary because the `set` in which it's inserted refuses any modification for fear
-	// of altering the element's hash, but the tile ID is not part of it.
-	mutable uint16_t tileID;
-
-	static uint16_t rowBitplanes(Png::TilesVisitor::Tile const &tile, Palette const &palette,
-	                             uint32_t y) {
-		uint16_t row = 0;
-		for (uint32_t x = 0; x < 8; ++x) {
-			row <<= 1;
-			uint8_t index = palette.indexOf(tile.pixel(x, y).cgbColor());
-			if (index & 1) {
-				row |= 1;
-			}
-			if (index & 2) {
-				row |= 0x100;
-			}
-		}
-		return row;
-	}
-
-	TileData(Png::TilesVisitor::Tile const &tile, Palette const &palette) : _hash(0) {
-		size_t writeIndex = 0;
-		for (uint32_t y = 0; y < 8; ++y) {
-			uint16_t bitplanes = rowBitplanes(tile, palette, y);
-			_data[writeIndex++] = bitplanes & 0xFF;
-			if (options.bitDepth == 2) {
-				_data[writeIndex++] = bitplanes >> 8;
-			}
-
-			// Update the hash
-			_hash ^= bitplanes;
-			if (options.allowMirroring) {
-				// Count the line itself as mirrorred; vertical mirroring is
-				// already taken care of because the symmetric line will be XOR'd
-				// the same way. (...which is a problem, but probably benign.)
-				_hash ^= flip(bitplanes >> 8) << 8 | flip(bitplanes & 0xFF);
-			}
-		}
-	}
-
-	auto const &data() const { return _data; }
-	uint16_t hash() const { return _hash; }
-
-	enum MatchType {
-		NOPE,
-		EXACT,
-		HFLIP,
-		VFLIP,
-		VHFLIP,
-	};
-	MatchType tryMatching(TileData const &other) const {
-		// Check for strict equality first, as that can typically be optimized, and it allows
-		// hoisting the mirroring check out of the loop
-		if (_data == other._data) {
-			return MatchType::EXACT;
-		}
-
-		if (!options.allowMirroring) {
-			return MatchType::NOPE;
-		}
-
-		// Check if we have horizontal mirroring, which scans the array forward again
-		if (std::equal(_data.begin(), _data.end(), other._data.begin(),
-		               [](uint8_t lhs, uint8_t rhs) { return lhs == flip(rhs); })) {
-			return MatchType::HFLIP;
-		}
-
-		// Check if we have vertical or vertical+horizontal mirroring, for which we have to read
-		// bitplane *pairs*  backwards
-		bool hasVFlip = true, hasVHFlip = true;
-		for (uint8_t i = 0; i < _data.size(); ++i) {
-			// Flip the bottom bit to get the corresponding row's bitplane 0/1
-			// (This works because the array size is even)
-			uint8_t lhs = _data[i], rhs = other._data[(15 - i) ^ 1];
-			if (lhs != rhs) {
-				hasVFlip = false;
-			}
-			if (lhs != flip(rhs)) {
-				hasVHFlip = false;
-			}
-			if (!hasVFlip && !hasVHFlip) {
-				return MatchType::NOPE; // If both have been eliminated, all hope is lost!
-			}
-		}
-
-		// If we have both (i.e. we have symmetry), default to vflip only
-		assert(hasVFlip || hasVHFlip);
-		return hasVFlip ? MatchType::VFLIP : MatchType::VHFLIP;
-	}
-	friend bool operator==(TileData const &lhs, TileData const &rhs) {
-		return lhs.tryMatching(rhs) != MatchType::NOPE;
-	}
-};
-
-template<>
-struct std::hash<TileData> {
-	std::size_t operator()(TileData const &tile) const { return tile.hash(); }
-};
-
-namespace unoptimized {
-
-// TODO: this is very redundant with `TileData::TileData`; try merging both?
-static void outputTileData(Png const &png, DefaultInitVec<AttrmapEntry> const &attrmap,
-                           std::vector<Palette> const &palettes,
-                           DefaultInitVec<size_t> const &mappings) {
-	std::filebuf output;
-	output.open(options.output, std::ios_base::out | std::ios_base::binary);
-
-	uint64_t remainingTiles = (png.getWidth() / 8) * (png.getHeight() / 8);
-	if (remainingTiles <= options.trim) {
-		return;
-	}
-	remainingTiles -= options.trim;
-
-	auto iter = attrmap.begin();
-	for (auto tile : png.visitAsTiles(options.columnMajor)) {
-		size_t protoPaletteID = iter->protoPaletteID;
-		// If the tile is fully transparent, default to palette 0
-		Palette const &palette = palettes[protoPaletteID != AttrmapEntry::transparent ? mappings[protoPaletteID] : 0];
-		for (uint32_t y = 0; y < 8; ++y) {
-			uint16_t bitplanes = TileData::rowBitplanes(tile, palette, y);
-			output.sputc(bitplanes & 0xFF);
-			if (options.bitDepth == 2) {
-				output.sputc(bitplanes >> 8);
-			}
-		}
-		++iter;
-
-		--remainingTiles;
-		if (remainingTiles == 0) {
-			break;
-		}
-	}
-	assert(remainingTiles == 0);
-	assert(iter + options.trim == attrmap.end());
-}
-
-static void outputMaps(Png const &png, DefaultInitVec<AttrmapEntry> const &attrmap,
-                       DefaultInitVec<size_t> const &mappings) {
-	std::optional<std::filebuf> tilemapOutput, attrmapOutput;
-	if (!options.tilemap.empty()) {
-		tilemapOutput.emplace();
-		tilemapOutput->open(options.tilemap, std::ios_base::out | std::ios_base::binary);
-	}
-	if (!options.attrmap.empty()) {
-		attrmapOutput.emplace();
-		attrmapOutput->open(options.attrmap, std::ios_base::out | std::ios_base::binary);
-	}
-
-	uint8_t tileID = 0;
-	uint8_t bank = 0;
-	auto iter = attrmap.begin();
-	for ([[maybe_unused]] auto tile : png.visitAsTiles(options.columnMajor)) {
-		if (tileID == options.maxNbTiles[bank]) {
-			assert(bank == 0);
-			bank = 1;
-			tileID = 0;
-		}
-
-		if (tilemapOutput.has_value()) {
-			tilemapOutput->sputc(tileID + options.baseTileIDs[bank]);
-		}
-		if (attrmapOutput.has_value()) {
-			uint8_t palID = mappings[iter->protoPaletteID] & 7;
-			attrmapOutput->sputc(palID | bank << 3); // The other flags are all 0
-			++iter;
-		}
-		++tileID;
-	}
-	assert(iter == attrmap.end());
-}
-
-} // namespace unoptimized
-
-namespace optimized {
-
-struct UniqueTiles {
-	std::unordered_set<TileData> tileset;
-	std::vector<TileData const *> tiles;
-
-	UniqueTiles() = default;
-	// Copies are likely to break pointers, so we really don't want those.
-	// Copy elision should be relied on to be more sure that refs won't be invalidated, too!
-	UniqueTiles(UniqueTiles const &) = delete;
-	UniqueTiles(UniqueTiles &&) = default;
-
-	/**
-	 * Adds a tile to the collection, and returns its ID
-	 */
-	std::tuple<uint16_t, TileData::MatchType> addTile(Png::TilesVisitor::Tile const &tile,
-	                                                  Palette const &palette) {
-		TileData newTile(tile, palette);
-		auto [tileData, inserted] = tileset.insert(newTile);
-
-		TileData::MatchType matchType = TileData::EXACT;
-		if (inserted) {
-			// Give the new tile the next available unique ID
-			tileData->tileID = static_cast<uint16_t>(tiles.size());
-			// Pointers are never invalidated!
-			tiles.emplace_back(&*tileData);
-		} else {
-			matchType = tileData->tryMatching(newTile);
-		}
-		return {tileData->tileID, matchType};
-	}
-
-	auto size() const { return tiles.size(); }
-
-	auto begin() const { return tiles.begin(); }
-	auto end() const { return tiles.end(); }
-};
-
-/**
- * Generate tile data while deduplicating unique tiles (via mirroring if enabled)
- * Additionally, while we have the info handy, convert from the 16-bit "global" tile IDs to
- * 8-bit tile IDs + the bank bit; this will save the work when we output the data later (potentially
- * twice)
- */
-static UniqueTiles dedupTiles(Png const &png, DefaultInitVec<AttrmapEntry> &attrmap,
-                              std::vector<Palette> const &palettes,
-                              DefaultInitVec<size_t> const &mappings) {
-	// Iterate throughout the image, generating tile data as we go
-	// (We don't need the full tile data to be able to dedup tiles, but we don't lose anything
-	// by caching the full tile data anyway, so we might as well.)
-	UniqueTiles tiles;
-
-	auto iter = attrmap.begin();
-	for (auto tile : png.visitAsTiles(options.columnMajor)) {
-		auto [tileID, matchType] = tiles.addTile(tile, palettes[mappings[iter->protoPaletteID]]);
-
-		iter->xFlip = matchType == TileData::HFLIP || matchType == TileData::VHFLIP;
-		iter->yFlip = matchType == TileData::VFLIP || matchType == TileData::VHFLIP;
-		iter->bank = tileID >= options.maxNbTiles[0];
-		iter->tileID = (iter->bank ? tileID - options.maxNbTiles[0] : tileID)
-		               + options.baseTileIDs[iter->bank];
-
-		++iter;
-	}
-	assert(iter == attrmap.end());
-
-	// Copy elision should prevent the contained `unordered_set` from being re-constructed
-	return tiles;
-}
-
-static void outputTileData(UniqueTiles const &tiles) {
-	std::filebuf output;
-	output.open(options.output, std::ios_base::out | std::ios_base::binary);
-
-	uint16_t tileID = 0;
-	for (auto iter = tiles.begin(), end = tiles.end() - options.trim; iter != end; ++iter) {
-		TileData const *tile = *iter;
-		assert(tile->tileID == tileID);
-		++tileID;
-		output.sputn(reinterpret_cast<char const *>(tile->data().data()), options.bitDepth * 8);
-	}
-}
-
-static void outputTilemap(DefaultInitVec<AttrmapEntry> const &attrmap) {
-	std::filebuf output;
-	output.open(options.tilemap, std::ios_base::out | std::ios_base::binary);
-
-	for (AttrmapEntry const &entry : attrmap) {
-		output.sputc(entry.tileID); // The tile ID has already been converted
-	}
-}
-
-static void outputAttrmap(DefaultInitVec<AttrmapEntry> const &attrmap,
-                          DefaultInitVec<size_t> const &mappings) {
-	std::filebuf output;
-	output.open(options.attrmap, std::ios_base::out | std::ios_base::binary);
-
-	for (AttrmapEntry const &entry : attrmap) {
-		uint8_t attr = entry.xFlip << 5 | entry.yFlip << 6;
-		attr |= entry.bank << 3;
-		attr |= mappings[entry.protoPaletteID] & 7;
-		output.sputc(attr);
-	}
-}
-
-} // namespace optimized
-
-void process() {
-	options.verbosePrint(Options::VERB_CFG, "Using libpng %s\n", png_get_libpng_ver(nullptr));
-
-	options.verbosePrint(Options::VERB_LOG_ACT, "Reading tiles...\n");
-	Png png(options.input); // This also sets `hasTransparentPixels` as a side effect
-	ImagePalette const &colors = png.getColors();
-
-	// Now, we have all the image's colors in `colors`
-	// The next step is to order the palette
-
-	if (options.verbosity >= Options::VERB_INTERM) {
-		fputs("Image colors: [ ", stderr);
-		for (auto const &slot : colors) {
-			if (!slot.has_value()) {
-				continue;
-			}
-			fprintf(stderr, "#%08x, ", slot->toCSS());
-		}
-		fputs("]\n", stderr);
-	}
-
-	// Now, iterate through the tiles, generating proto-palettes as we go
-	// We do this unconditionally because this performs the image validation (which we want to
-	// perform even if no output is requested), and because it's necessary to generate any
-	// output (with the exception of an un-duplicated tilemap, but that's an acceptable loss.)
-	std::vector<ProtoPalette> protoPalettes;
-	DefaultInitVec<AttrmapEntry> attrmap{};
-
-	for (auto tile : png.visitAsTiles(options.columnMajor)) {
-		ProtoPalette tileColors;
-		AttrmapEntry &attrs = attrmap.emplace_back();
-
-		for (uint32_t y = 0; y < 8; ++y) {
-			for (uint32_t x = 0; x < 8; ++x) {
-				Rgba color = tile.pixel(x, y);
-				if (!color.isTransparent()) { // Do not count transparency in for packing
-					tileColors.add(color.cgbColor());
-				}
-			}
-		}
-
-		if (tileColors.empty()) {
-			// "Empty" proto-palettes screw with the packing process, so discard those
-			attrs.protoPaletteID = AttrmapEntry::transparent;
-			continue;
-		}
-
-		// Insert the proto-palette, making sure to avoid overlaps
-		for (size_t n = 0; n < protoPalettes.size(); ++n) {
-			switch (tileColors.compare(protoPalettes[n])) {
-			case ProtoPalette::WE_BIGGER:
-				protoPalettes[n] = tileColors; // Override them
-				// Remove any other proto-palettes that we encompass
-				// (Example [(0, 1), (0, 2)], inserting (0, 1, 2))
-				/* The following code does its job, except that references to the removed
-				 * proto-palettes are not updated, causing issues.
-				 * TODO: overlap might not be detrimental to the packing algorithm.
-				 * Investigation is necessary, especially if pathological cases are found.
-
-				for (size_t i = protoPalettes.size(); --i != n;) {
-				    if (tileColors.compare(protoPalettes[i]) == ProtoPalette::WE_BIGGER) {
-				        protoPalettes.erase(protoPalettes.begin() + i);
-				    }
-				}
-				*/
-				[[fallthrough]];
-
-			case ProtoPalette::THEY_BIGGER:
-				// Do nothing, they already contain us
-				attrs.protoPaletteID = n;
-				goto contained;
-
-			case ProtoPalette::NEITHER:
-				break; // Keep going
-			}
-		}
-		attrs.protoPaletteID = protoPalettes.size();
-		if (protoPalettes.size() == AttrmapEntry::transparent) {
-			abort(); // TODO: nice error message
-		}
-		protoPalettes.push_back(tileColors);
-contained:;
-	}
-
-	options.verbosePrint(Options::VERB_INTERM, "Image contains %zu proto-palette%s\n",
-	                     protoPalettes.size(), protoPalettes.size() != 1 ? "s" : "");
-	if (options.verbosity >= Options::VERB_INTERM) {
-		for (auto const &protoPal : protoPalettes) {
-			fputs("[ ", stderr);
-			for (uint16_t color : protoPal) {
-				fprintf(stderr, "$%04x, ", color);
-			}
-			fputs("]\n", stderr);
-		}
-	}
-
-	// Sort the proto-palettes by size, which improves the packing algorithm's efficiency
-	// We sort after all insertions to avoid moving items: https://stackoverflow.com/a/2710332
-	std::sort(
-	    protoPalettes.begin(), protoPalettes.end(),
-	    [](ProtoPalette const &lhs, ProtoPalette const &rhs) { return lhs.size() < rhs.size(); });
-
-	auto [mappings, palettes] = options.palSpecType == Options::NO_SPEC
-	                                ? generatePalettes(protoPalettes, png)
-	                                : makePalsAsSpecified(protoPalettes, png);
-
-	if (options.verbosity >= Options::VERB_INTERM) {
-		for (auto &&palette : palettes) {
-			fputs("{ ", stderr);
-			for (uint16_t colorIndex : palette) {
-				fprintf(stderr, "%04" PRIx16 ", ", colorIndex);
-			}
-			fputs("}\n", stderr);
-		}
-	}
-
-	if (palettes.size() > options.nbPalettes) {
-		// If the palette generation is wrong, other (dependee) operations are likely to be
-		// nonsensical, so fatal-error outright
-		fatal("Generated %zu palettes, over the maximum of %" PRIu8, palettes.size(),
-		      options.nbPalettes);
-	}
-
-	if (!options.palettes.empty()) {
-		outputPalettes(palettes);
-	}
-
-	// If deduplication is not happening, we just need to output the tile data and/or maps as-is
-	if (!options.allowDedup) {
-		uint32_t const nbTilesH = png.getHeight() / 8, nbTilesW = png.getWidth() / 8;
-
-		// Check the tile count
-		if (nbTilesW * nbTilesH > options.maxNbTiles[0] + options.maxNbTiles[1]) {
-			fatal("Image contains %" PRIu32 " tiles, exceeding the limit of %" PRIu16 " + %" PRIu16,
-			      nbTilesW * nbTilesH, options.maxNbTiles[0], options.maxNbTiles[1]);
-		}
-
-		if (!options.output.empty()) {
-			options.verbosePrint(Options::VERB_LOG_ACT, "Generating unoptimized tile data...\n");
-			unoptimized::outputTileData(png, attrmap, palettes, mappings);
-		}
-
-		if (!options.tilemap.empty() || !options.attrmap.empty()) {
-			options.verbosePrint(Options::VERB_LOG_ACT,
-			                     "Generating unoptimized tilemap and/or attrmap...\n");
-			unoptimized::outputMaps(png, attrmap, mappings);
-		}
-	} else {
-		// All of these require the deduplication process to be performed to be output
-		options.verbosePrint(Options::VERB_LOG_ACT, "Deduplicating tiles...\n");
-		optimized::UniqueTiles tiles = optimized::dedupTiles(png, attrmap, palettes, mappings);
-
-		if (tiles.size() > options.maxNbTiles[0] + options.maxNbTiles[1]) {
-			fatal("Image contains %zu tiles, exceeding the limit of %" PRIu16 " + %" PRIu16,
-			      tiles.size(), options.maxNbTiles[0], options.maxNbTiles[1]);
-		}
-
-		if (!options.output.empty()) {
-			options.verbosePrint(Options::VERB_LOG_ACT, "Generating optimized tile data...\n");
-			optimized::outputTileData(tiles);
-		}
-
-		if (!options.tilemap.empty()) {
-			options.verbosePrint(Options::VERB_LOG_ACT, "Generating optimized tilemap...\n");
-			optimized::outputTilemap(attrmap);
-		}
-
-		if (!options.attrmap.empty()) {
-			options.verbosePrint(Options::VERB_LOG_ACT, "Generating optimized attrmap...\n");
-			optimized::outputAttrmap(attrmap, mappings);
-		}
-	}
-}
--- a/src/gfx/main.cpp
+++ b/src/gfx/main.cpp
@@ -24,7 +24,7 @@
 #include "platform.h"
 #include "version.h"
 
-#include "gfx/convert.hpp"
+#include "gfx/process.hpp"
 
 using namespace std::literals::string_view_literals;
 
--- a/src/gfx/pal_sorting.cpp
+++ b/src/gfx/pal_sorting.cpp
@@ -7,8 +7,8 @@
 
 #include "helpers.h"
 
-#include "gfx/convert.hpp"
 #include "gfx/main.hpp"
+#include "gfx/process.hpp"
 
 namespace sorting {
 
--- /dev/null
+++ b/src/gfx/process.cpp
@@ -1,0 +1,1026 @@
+/*
+ * This file is part of RGBDS.
+ *
+ * Copyright (c) 2022, Eldred Habert and RGBDS contributors.
+ *
+ * SPDX-License-Identifier: MIT
+ */
+
+#include "gfx/process.hpp"
+
+#include <algorithm>
+#include <assert.h>
+#include <cinttypes>
+#include <errno.h>
+#include <fstream>
+#include <memory>
+#include <optional>
+#include <png.h>
+#include <setjmp.h>
+#include <string.h>
+#include <tuple>
+#include <unordered_set>
+#include <utility>
+#include <vector>
+
+#include "defaultinitalloc.hpp"
+#include "helpers.h"
+
+#include "gfx/main.hpp"
+#include "gfx/pal_packing.hpp"
+#include "gfx/pal_sorting.hpp"
+#include "gfx/proto_palette.hpp"
+
+class ImagePalette {
+	// Use as many slots as there are CGB colors (plus transparency)
+	std::array<std::optional<Rgba>, 0x8001> _colors;
+
+public:
+	ImagePalette() = default;
+
+	void registerColor(Rgba const &rgba) {
+		decltype(_colors)::value_type &slot = _colors[rgba.cgbColor()];
+
+		if (rgba.cgbColor() == Rgba::transparent) {
+			options.hasTransparentPixels = true;
+		}
+
+		if (!slot.has_value()) {
+			slot.emplace(rgba);
+		} else if (*slot != rgba) {
+			warning("Different colors melded together (#%08x into #%08x as %04x)", rgba.toCSS(),
+			        slot->toCSS(), rgba.cgbColor()); // TODO: indicate position
+		}
+	}
+
+	size_t size() const {
+		return std::count_if(_colors.begin(), _colors.end(),
+		                     [](decltype(_colors)::value_type const &slot) {
+			                     return slot.has_value() && !slot->isTransparent();
+		                     });
+	}
+	decltype(_colors) const &raw() const { return _colors; }
+
+	auto begin() const { return _colors.begin(); }
+	auto end() const { return _colors.end(); }
+};
+
+class Png {
+	std::string const &path;
+	std::filebuf file{};
+	png_structp png = nullptr;
+	png_infop info = nullptr;
+
+	// These are cached for speed
+	uint32_t width, height;
+	DefaultInitVec<Rgba> pixels;
+	ImagePalette colors;
+	int colorType;
+	int nbColors;
+	png_colorp embeddedPal = nullptr;
+	png_bytep transparencyPal = nullptr;
+
+	[[noreturn]] static void handleError(png_structp png, char const *msg) {
+		Png *self = reinterpret_cast<Png *>(png_get_error_ptr(png));
+
+		fatal("Error reading input image (\"%s\"): %s", self->path.c_str(), msg);
+	}
+
+	static void handleWarning(png_structp png, char const *msg) {
+		Png *self = reinterpret_cast<Png *>(png_get_error_ptr(png));
+
+		warning("In input image (\"%s\"): %s", self->path.c_str(), msg);
+	}
+
+	static void readData(png_structp png, png_bytep data, size_t length) {
+		Png *self = reinterpret_cast<Png *>(png_get_io_ptr(png));
+		std::streamsize expectedLen = length;
+		std::streamsize nbBytesRead = self->file.sgetn(reinterpret_cast<char *>(data), expectedLen);
+
+		if (nbBytesRead != expectedLen) {
+			fatal("Error reading input image (\"%s\"): file too short (expected at least %zd more "
+			      "bytes after reading %lld)",
+			      self->path.c_str(), length - nbBytesRead,
+			      self->file.pubseekoff(0, std::ios_base::cur));
+		}
+	}
+
+public:
+	ImagePalette const &getColors() const { return colors; }
+
+	int getColorType() const { return colorType; }
+
+	std::tuple<int, png_const_colorp, png_bytep> getEmbeddedPal() const {
+		return {nbColors, embeddedPal, transparencyPal};
+	}
+
+	uint32_t getWidth() const { return width; }
+
+	uint32_t getHeight() const { return height; }
+
+	Rgba &pixel(uint32_t x, uint32_t y) { return pixels[y * width + x]; }
+
+	Rgba const &pixel(uint32_t x, uint32_t y) const { return pixels[y * width + x]; }
+
+	bool isSuitableForGrayscale() const {
+		// Check that all of the grays don't fall into the same "bin"
+		if (colors.size() > options.maxOpaqueColors()) { // Apply the Pigeonhole Principle
+			options.verbosePrint(Options::VERB_DEBUG,
+			                     "Too many colors for grayscale sorting (%zu > %" PRIu8 ")\n",
+			                     colors.size(), options.maxOpaqueColors());
+			return false;
+		}
+		uint8_t bins = 0;
+		for (auto const &color : colors) {
+			if (color->isTransparent()) {
+				continue;
+			}
+			if (!color->isGray()) {
+				options.verbosePrint(Options::VERB_DEBUG,
+				                     "Found non-gray color #%08x, not using grayscale sorting\n",
+				                     color->toCSS());
+				return false;
+			}
+			uint8_t mask = 1 << color->grayIndex();
+			if (bins & mask) { // Two in the same bin!
+				options.verbosePrint(
+				    Options::VERB_DEBUG,
+				    "Color #%08x conflicts with another one, not using grayscale sorting\n",
+				    color->toCSS());
+				return false;
+			}
+			bins |= mask;
+		}
+		return true;
+	}
+
+	/**
+	 * Reads a PNG and notes all of its colors
+	 *
+	 * This code is more complicated than strictly necessary, but that's because of the API
+	 * being used: the "high-level" interface doesn't provide all the transformations we need,
+	 * so we use the "lower-level" one instead.
+	 * We also use that occasion to only read the PNG one line at a time, since we store all of
+	 * the pixel data in `pixels`, which saves on memory allocations.
+	 */
+	explicit Png(std::string const &filePath) : path(filePath), colors() {
+		if (file.open(path, std::ios_base::in | std::ios_base::binary) == nullptr) {
+			fatal("Failed to open input image (\"%s\"): %s", path.c_str(), strerror(errno));
+		}
+
+		options.verbosePrint(Options::VERB_LOG_ACT, "Opened input file\n");
+
+		std::array<unsigned char, 8> pngHeader;
+
+		if (file.sgetn(reinterpret_cast<char *>(pngHeader.data()), pngHeader.size())
+		        != static_cast<std::streamsize>(pngHeader.size()) // Not enough bytes?
+		    || png_sig_cmp(pngHeader.data(), 0, pngHeader.size()) != 0) {
+			fatal("Input file (\"%s\") is not a PNG image!", path.c_str());
+		}
+
+		options.verbosePrint(Options::VERB_INTERM, "PNG header signature is OK\n");
+
+		png = png_create_read_struct(PNG_LIBPNG_VER_STRING, (png_voidp)this, handleError,
+		                             handleWarning);
+		if (!png) {
+			fatal("Failed to allocate PNG structure: %s", strerror(errno));
+		}
+
+		info = png_create_info_struct(png);
+		if (!info) {
+			png_destroy_read_struct(&png, nullptr, nullptr);
+			fatal("Failed to allocate PNG info structure: %s", strerror(errno));
+		}
+
+		png_set_read_fn(png, this, readData);
+		png_set_sig_bytes(png, pngHeader.size());
+
+		// TODO: png_set_crc_action(png, PNG_CRC_ERROR_QUIT, PNG_CRC_WARN_DISCARD);
+
+		// Skipping chunks we don't use should improve performance
+		// TODO: png_set_keep_unknown_chunks(png, ...);
+
+		// Process all chunks up to but not including the image data
+		png_read_info(png, info);
+
+		int bitDepth, interlaceType; //, compressionType, filterMethod;
+
+		png_get_IHDR(png, info, &width, &height, &bitDepth, &colorType, &interlaceType, nullptr,
+		             nullptr);
+
+		if (width % 8 != 0) {
+			fatal("Image width (%" PRIu32 " pixels) is not a multiple of 8!", width);
+		}
+		if (height % 8 != 0) {
+			fatal("Image height (%" PRIu32 " pixels) is not a multiple of 8!", height);
+		}
+
+		pixels.resize(static_cast<size_t>(width) * static_cast<size_t>(height));
+
+		auto colorTypeName = [this]() {
+			switch (colorType) {
+			case PNG_COLOR_TYPE_GRAY:
+				return "grayscale";
+			case PNG_COLOR_TYPE_GRAY_ALPHA:
+				return "grayscale + alpha";
+			case PNG_COLOR_TYPE_PALETTE:
+				return "palette";
+			case PNG_COLOR_TYPE_RGB:
+				return "RGB";
+			case PNG_COLOR_TYPE_RGB_ALPHA:
+				return "RGB + alpha";
+			default:
+				fatal("Unknown color type %d", colorType);
+			}
+		};
+		auto interlaceTypeName = [&interlaceType]() {
+			switch (interlaceType) {
+			case PNG_INTERLACE_NONE:
+				return "not interlaced";
+			case PNG_INTERLACE_ADAM7:
+				return "interlaced (Adam7)";
+			default:
+				fatal("Unknown interlace type %d", interlaceType);
+			}
+		};
+		options.verbosePrint(Options::VERB_INTERM,
+		                     "Input image: %" PRIu32 "x%" PRIu32 " pixels, %dbpp %s, %s\n", height,
+		                     width, bitDepth, colorTypeName(), interlaceTypeName());
+
+		if (png_get_PLTE(png, info, &embeddedPal, &nbColors) != 0) {
+			int nbTransparentEntries;
+			if (png_get_tRNS(png, info, &transparencyPal, &nbTransparentEntries, nullptr)) {
+				assert(nbTransparentEntries == nbColors);
+			}
+
+			options.verbosePrint(Options::VERB_INTERM, "Embedded palette has %d colors: [",
+			                     nbColors);
+			for (int i = 0; i < nbColors; ++i) {
+				auto const &color = embeddedPal[i];
+				options.verbosePrint(
+				    Options::VERB_INTERM, "#%02x%02x%02x%02x%s", color.red, color.green, color.blue,
+				    transparencyPal ? transparencyPal[i] : 0xFF, i != nbColors - 1 ? ", " : "]\n");
+			}
+		} else {
+			options.verbosePrint(Options::VERB_INTERM, "No embedded palette\n");
+		}
+
+		// Set up transformations; to turn everything into RGBA888
+		// TODO: it's not necessary to uniformize the pixel data (in theory), and not doing
+		// so *might* improve performance, and should reduce memory usage.
+
+		// Convert grayscale to RGB
+		switch (colorType & ~PNG_COLOR_MASK_ALPHA) {
+		case PNG_COLOR_TYPE_GRAY:
+			png_set_gray_to_rgb(png); // This also converts tRNS to alpha
+			break;
+		case PNG_COLOR_TYPE_PALETTE:
+			png_set_palette_to_rgb(png);
+			break;
+		}
+
+		if (png_get_valid(png, info, PNG_INFO_tRNS)) {
+			// If we read a tRNS chunk, convert it to alpha
+			png_set_tRNS_to_alpha(png);
+		} else if (!(colorType & PNG_COLOR_MASK_ALPHA)) {
+			// Otherwise, if we lack an alpha channel, default to full opacity
+			png_set_add_alpha(png, 0xFFFF, PNG_FILLER_AFTER);
+		}
+
+		// Scale 16bpp back to 8 (we don't need all of that precision anyway)
+		if (bitDepth == 16) {
+			png_set_scale_16(png);
+		} else if (bitDepth < 8) {
+			png_set_packing(png);
+		}
+
+		// Set interlace handling (MUST be done before `png_read_update_info`)
+		int nbPasses = png_set_interlace_handling(png);
+
+		// Update `info` with the transformations
+		png_read_update_info(png, info);
+		// These shouldn't have changed
+		assert(png_get_image_width(png, info) == width);
+		assert(png_get_image_height(png, info) == height);
+		// These should have changed, however
+		assert(png_get_color_type(png, info) == PNG_COLOR_TYPE_RGBA);
+		assert(png_get_bit_depth(png, info) == 8);
+
+		// Now that metadata has been read, we can process the image data
+
+		std::vector<png_byte> row(png_get_rowbytes(png, info));
+
+		if (interlaceType == PNG_INTERLACE_NONE) {
+			for (png_uint_32 y = 0; y < height; ++y) {
+				png_read_row(png, row.data(), nullptr);
+
+				for (png_uint_32 x = 0; x < width; ++x) {
+					Rgba rgba(row[x * 4], row[x * 4 + 1], row[x * 4 + 2], row[x * 4 + 3]);
+					colors.registerColor(rgba);
+					pixel(x, y) = rgba;
+				}
+			}
+		} else {
+			// For interlace to work properly, we must read the image `nbPasses` times
+			for (int pass = 0; pass < nbPasses; ++pass) {
+				// The interlacing pass must be skipped if its width or height is reported as zero
+				if (PNG_PASS_COLS(width, pass) == 0 || PNG_PASS_ROWS(height, pass) == 0) {
+					continue;
+				}
+
+				png_uint_32 xStep = 1u << PNG_PASS_COL_SHIFT(pass);
+				png_uint_32 yStep = 1u << PNG_PASS_ROW_SHIFT(pass);
+
+				for (png_uint_32 y = PNG_PASS_START_ROW(pass); y < height; y += yStep) {
+					png_bytep ptr = row.data();
+					png_read_row(png, ptr, nullptr);
+
+					for (png_uint_32 x = PNG_PASS_START_COL(pass); x < width; x += xStep) {
+						Rgba rgba(ptr[0], ptr[1], ptr[2], ptr[3]);
+						colors.registerColor(rgba);
+						pixel(x, y) = rgba;
+						ptr += 4;
+					}
+				}
+			}
+		}
+
+		// We don't care about chunks after the image data (comments, etc.)
+		png_read_end(png, nullptr);
+	}
+
+	~Png() { png_destroy_read_struct(&png, &info, nullptr); }
+
+	class TilesVisitor {
+		Png const &_png;
+		bool const _columnMajor;
+		uint32_t const _width, _height;
+		uint32_t const _limit = _columnMajor ? _height : _width;
+
+	public:
+		TilesVisitor(Png const &png, bool columnMajor, uint32_t width, uint32_t height)
+		    : _png(png), _columnMajor(columnMajor), _width(width), _height(height) {}
+
+		class Tile {
+			Png const &_png;
+			uint32_t const _x, _y;
+
+		public:
+			Tile(Png const &png, uint32_t x, uint32_t y) : _png(png), _x(x), _y(y) {}
+
+			Rgba pixel(uint32_t xOfs, uint32_t yOfs) const {
+				return _png.pixel(_x + xOfs, _y + yOfs);
+			}
+		};
+
+	private:
+		struct iterator {
+			TilesVisitor const &parent;
+			uint32_t const limit;
+			uint32_t x, y;
+
+			std::pair<uint32_t, uint32_t> coords() const { return {x, y}; }
+			Tile operator*() const { return {parent._png, x, y}; }
+
+			iterator &operator++() {
+				auto [major, minor] = parent._columnMajor ? std::tie(y, x) : std::tie(x, y);
+				major += 8;
+				if (major == limit) {
+					minor += 8;
+					major = 0;
+				}
+				return *this;
+			}
+
+			bool operator!=(iterator const &rhs) const {
+				return coords() != rhs.coords(); // Compare the returned coord pairs
+			}
+		};
+
+	public:
+		iterator begin() const { return {*this, _limit, 0, 0}; }
+		iterator end() const {
+			iterator it{*this, _limit, _width - 8, _height - 8}; // Last valid one...
+			return ++it; // ...now one-past-last!
+		}
+	};
+public:
+	TilesVisitor visitAsTiles(bool columnMajor) const {
+		return {*this, columnMajor, width, height};
+	}
+};
+
+class RawTiles {
+	/**
+	 * A tile which only contains indices into the image's global palette
+	 */
+	class RawTile {
+		std::array<std::array<size_t, 8>, 8> _pixelIndices{};
+
+	public:
+		// Not super clean, but it's closer to matrix notation
+		size_t &operator()(size_t x, size_t y) { return _pixelIndices[y][x]; }
+	};
+
+private:
+	std::vector<RawTile> _tiles;
+
+public:
+	/**
+	 * Creates a new raw tile, and returns a reference to it so it can be filled in
+	 */
+	RawTile &newTile() {
+		_tiles.emplace_back();
+		return _tiles.back();
+	}
+};
+
+struct AttrmapEntry {
+	size_t protoPaletteID; // Only this field is used when outputting "unoptimized" data
+	uint8_t tileID; // This is the ID as it will be output to the tilemap
+	bool bank;
+	bool yFlip;
+	bool xFlip;
+
+	static constexpr decltype(protoPaletteID) transparent = SIZE_MAX;
+};
+
+static std::tuple<DefaultInitVec<size_t>, std::vector<Palette>>
+    generatePalettes(std::vector<ProtoPalette> const &protoPalettes, Png const &png) {
+	// Run a "pagination" problem solver
+	// TODO: allow picking one of several solvers?
+	auto [mappings, nbPalettes] = packing::overloadAndRemove(protoPalettes);
+	assert(mappings.size() == protoPalettes.size());
+
+	if (options.verbosity >= Options::VERB_INTERM) {
+		fprintf(stderr, "Proto-palette mappings: (%zu palette%s)\n", nbPalettes,
+		        nbPalettes != 1 ? "s" : "");
+		for (size_t i = 0; i < mappings.size(); ++i) {
+			fprintf(stderr, "%zu -> %zu\n", i, mappings[i]);
+		}
+	}
+
+	std::vector<Palette> palettes(nbPalettes);
+	// If the image contains at least one transparent pixel, force transparency in the first slot of
+	// all palettes
+	if (options.hasTransparentPixels) {
+		for (Palette &pal : palettes) {
+			pal.colors[0] = Rgba::transparent;
+		}
+	}
+	// Generate the actual palettes from the mappings
+	for (size_t protoPalID = 0; protoPalID < mappings.size(); ++protoPalID) {
+		auto &pal = palettes[mappings[protoPalID]];
+		for (uint16_t color : protoPalettes[protoPalID]) {
+			pal.addColor(color);
+		}
+	}
+
+	// "Sort" colors in the generated palettes, see the man page for the flowchart
+	auto [embPalSize, embPalRGB, embPalAlpha] = png.getEmbeddedPal();
+	if (embPalRGB != nullptr) {
+		sorting::indexed(palettes, embPalSize, embPalRGB, embPalAlpha);
+	} else if (png.isSuitableForGrayscale()) {
+		sorting::grayscale(palettes, png.getColors().raw());
+	} else {
+		sorting::rgb(palettes);
+	}
+	return {mappings, palettes};
+}
+
+static std::tuple<DefaultInitVec<size_t>, std::vector<Palette>>
+    makePalsAsSpecified(std::vector<ProtoPalette> const &protoPalettes, Png const &png) {
+	if (options.palSpecType == Options::EMBEDDED) {
+		// Generate a palette spec from the first few colors in the embedded palette
+		auto [embPalSize, embPalRGB, embPalAlpha] = png.getEmbeddedPal();
+		if (embPalRGB == nullptr) {
+			fatal("`-c embedded` was given, but the PNG does not have an embedded palette!");
+		}
+
+		// Fill in the palette spec
+		options.palSpec.emplace_back(); // A single palette, with `#00000000`s (transparent)
+		assert(options.palSpec.size() == 1);
+		// TODO: abort if ignored colors are being used; do it now for a friendlier error
+		// message
+		if (embPalSize > options.maxOpaqueColors()) { // Ignore extraneous colors if they are unused
+			embPalSize = options.maxOpaqueColors();
+		}
+		for (int i = 0; i < embPalSize; ++i) {
+			options.palSpec[0][i] = Rgba(embPalRGB[i].red, embPalRGB[i].green, embPalRGB[i].blue,
+			                             embPalAlpha ? embPalAlpha[i] : 0xFF);
+		}
+	}
+
+	// Convert the palette spec to actual palettes
+	std::vector<Palette> palettes(options.palSpec.size());
+	auto palIter = palettes.begin(); // TODO: `zip`
+	for (auto const &spec : options.palSpec) {
+		for (size_t i = 0; i < options.nbColorsPerPal; ++i) {
+			(*palIter)[i] = spec[i].cgbColor();
+		}
+		++palIter;
+	}
+
+	// Iterate through proto-palettes, and try mapping them to the specified palettes
+	DefaultInitVec<size_t> mappings(protoPalettes.size());
+	for (size_t i = 0; i < protoPalettes.size(); ++i) {
+		ProtoPalette const &protoPal = protoPalettes[i];
+		// Find the palette...
+		auto iter = std::find_if(palettes.begin(), palettes.end(), [&protoPal](Palette const &pal) {
+			// ...which contains all colors in this proto-pal
+			return std::all_of(protoPal.begin(), protoPal.end(), [&pal](uint16_t color) {
+				return std::find(pal.begin(), pal.end(), color) != pal.end();
+			});
+		});
+		assert(iter != palettes.end()); // TODO: produce a proper error message
+		mappings[i] = iter - palettes.begin();
+	}
+
+	return {mappings, palettes};
+}
+
+static void outputPalettes(std::vector<Palette> const &palettes) {
+	std::filebuf output;
+	output.open(options.palettes, std::ios_base::out | std::ios_base::binary);
+
+	for (Palette const &palette : palettes) {
+		for (uint8_t i = 0; i < options.nbColorsPerPal; ++i) {
+			uint16_t color = palette.colors[i]; // Will return `UINT16_MAX` for unused slots
+			output.sputc(color & 0xFF);
+			output.sputc(color >> 8);
+		}
+	}
+}
+
+static uint8_t flip(uint8_t byte) {
+	// To flip all the bits, we'll flip both nibbles, then each nibble half, etc.
+	byte = (byte & 0x0F) << 4 | (byte & 0xF0) >> 4;
+	byte = (byte & 0x33) << 2 | (byte & 0xCC) >> 2;
+	byte = (byte & 0x55) << 1 | (byte & 0xAA) >> 1;
+	return byte;
+}
+
+class TileData {
+	std::array<uint8_t, 16> _data;
+	// The hash is a bit lax: it's the XOR of all lines, and every other nibble is identical
+	// if horizontal mirroring is in effect. It should still be a reasonable tie-breaker in
+	// non-pathological cases.
+	uint16_t _hash;
+public:
+	// This is an index within the "global" pool; no bank info is encoded here
+	// It's marked as `mutable` so that it can be modified even on a `const` object;
+	// this is necessary because the `set` in which it's inserted refuses any modification for fear
+	// of altering the element's hash, but the tile ID is not part of it.
+	mutable uint16_t tileID;
+
+	static uint16_t rowBitplanes(Png::TilesVisitor::Tile const &tile, Palette const &palette,
+	                             uint32_t y) {
+		uint16_t row = 0;
+		for (uint32_t x = 0; x < 8; ++x) {
+			row <<= 1;
+			uint8_t index = palette.indexOf(tile.pixel(x, y).cgbColor());
+			if (index & 1) {
+				row |= 1;
+			}
+			if (index & 2) {
+				row |= 0x100;
+			}
+		}
+		return row;
+	}
+
+	TileData(Png::TilesVisitor::Tile const &tile, Palette const &palette) : _hash(0) {
+		size_t writeIndex = 0;
+		for (uint32_t y = 0; y < 8; ++y) {
+			uint16_t bitplanes = rowBitplanes(tile, palette, y);
+			_data[writeIndex++] = bitplanes & 0xFF;
+			if (options.bitDepth == 2) {
+				_data[writeIndex++] = bitplanes >> 8;
+			}
+
+			// Update the hash
+			_hash ^= bitplanes;
+			if (options.allowMirroring) {
+				// Count the line itself as mirrorred; vertical mirroring is
+				// already taken care of because the symmetric line will be XOR'd
+				// the same way. (...which is a problem, but probably benign.)
+				_hash ^= flip(bitplanes >> 8) << 8 | flip(bitplanes & 0xFF);
+			}
+		}
+	}
+
+	auto const &data() const { return _data; }
+	uint16_t hash() const { return _hash; }
+
+	enum MatchType {
+		NOPE,
+		EXACT,
+		HFLIP,
+		VFLIP,
+		VHFLIP,
+	};
+	MatchType tryMatching(TileData const &other) const {
+		// Check for strict equality first, as that can typically be optimized, and it allows
+		// hoisting the mirroring check out of the loop
+		if (_data == other._data) {
+			return MatchType::EXACT;
+		}
+
+		if (!options.allowMirroring) {
+			return MatchType::NOPE;
+		}
+
+		// Check if we have horizontal mirroring, which scans the array forward again
+		if (std::equal(_data.begin(), _data.end(), other._data.begin(),
+		               [](uint8_t lhs, uint8_t rhs) { return lhs == flip(rhs); })) {
+			return MatchType::HFLIP;
+		}
+
+		// Check if we have vertical or vertical+horizontal mirroring, for which we have to read
+		// bitplane *pairs*  backwards
+		bool hasVFlip = true, hasVHFlip = true;
+		for (uint8_t i = 0; i < _data.size(); ++i) {
+			// Flip the bottom bit to get the corresponding row's bitplane 0/1
+			// (This works because the array size is even)
+			uint8_t lhs = _data[i], rhs = other._data[(15 - i) ^ 1];
+			if (lhs != rhs) {
+				hasVFlip = false;
+			}
+			if (lhs != flip(rhs)) {
+				hasVHFlip = false;
+			}
+			if (!hasVFlip && !hasVHFlip) {
+				return MatchType::NOPE; // If both have been eliminated, all hope is lost!
+			}
+		}
+
+		// If we have both (i.e. we have symmetry), default to vflip only
+		assert(hasVFlip || hasVHFlip);
+		return hasVFlip ? MatchType::VFLIP : MatchType::VHFLIP;
+	}
+	friend bool operator==(TileData const &lhs, TileData const &rhs) {
+		return lhs.tryMatching(rhs) != MatchType::NOPE;
+	}
+};
+
+template<>
+struct std::hash<TileData> {
+	std::size_t operator()(TileData const &tile) const { return tile.hash(); }
+};
+
+namespace unoptimized {
+
+// TODO: this is very redundant with `TileData::TileData`; try merging both?
+static void outputTileData(Png const &png, DefaultInitVec<AttrmapEntry> const &attrmap,
+                           std::vector<Palette> const &palettes,
+                           DefaultInitVec<size_t> const &mappings) {
+	std::filebuf output;
+	output.open(options.output, std::ios_base::out | std::ios_base::binary);
+
+	uint64_t remainingTiles = (png.getWidth() / 8) * (png.getHeight() / 8);
+	if (remainingTiles <= options.trim) {
+		return;
+	}
+	remainingTiles -= options.trim;
+
+	auto iter = attrmap.begin();
+	for (auto tile : png.visitAsTiles(options.columnMajor)) {
+		size_t protoPaletteID = iter->protoPaletteID;
+		// If the tile is fully transparent, default to palette 0
+		Palette const &palette =
+		    palettes[protoPaletteID != AttrmapEntry::transparent ? mappings[protoPaletteID] : 0];
+		for (uint32_t y = 0; y < 8; ++y) {
+			uint16_t bitplanes = TileData::rowBitplanes(tile, palette, y);
+			output.sputc(bitplanes & 0xFF);
+			if (options.bitDepth == 2) {
+				output.sputc(bitplanes >> 8);
+			}
+		}
+		++iter;
+
+		--remainingTiles;
+		if (remainingTiles == 0) {
+			break;
+		}
+	}
+	assert(remainingTiles == 0);
+	assert(iter + options.trim == attrmap.end());
+}
+
+static void outputMaps(Png const &png, DefaultInitVec<AttrmapEntry> const &attrmap,
+                       DefaultInitVec<size_t> const &mappings) {
+	std::optional<std::filebuf> tilemapOutput, attrmapOutput;
+	if (!options.tilemap.empty()) {
+		tilemapOutput.emplace();
+		tilemapOutput->open(options.tilemap, std::ios_base::out | std::ios_base::binary);
+	}
+	if (!options.attrmap.empty()) {
+		attrmapOutput.emplace();
+		attrmapOutput->open(options.attrmap, std::ios_base::out | std::ios_base::binary);
+	}
+
+	uint8_t tileID = 0;
+	uint8_t bank = 0;
+	auto iter = attrmap.begin();
+	for ([[maybe_unused]] auto tile : png.visitAsTiles(options.columnMajor)) {
+		if (tileID == options.maxNbTiles[bank]) {
+			assert(bank == 0);
+			bank = 1;
+			tileID = 0;
+		}
+
+		if (tilemapOutput.has_value()) {
+			tilemapOutput->sputc(tileID + options.baseTileIDs[bank]);
+		}
+		if (attrmapOutput.has_value()) {
+			uint8_t palID = mappings[iter->protoPaletteID] & 7;
+			attrmapOutput->sputc(palID | bank << 3); // The other flags are all 0
+			++iter;
+		}
+		++tileID;
+	}
+	assert(iter == attrmap.end());
+}
+
+} // namespace unoptimized
+
+namespace optimized {
+
+struct UniqueTiles {
+	std::unordered_set<TileData> tileset;
+	std::vector<TileData const *> tiles;
+
+	UniqueTiles() = default;
+	// Copies are likely to break pointers, so we really don't want those.
+	// Copy elision should be relied on to be more sure that refs won't be invalidated, too!
+	UniqueTiles(UniqueTiles const &) = delete;
+	UniqueTiles(UniqueTiles &&) = default;
+
+	/**
+	 * Adds a tile to the collection, and returns its ID
+	 */
+	std::tuple<uint16_t, TileData::MatchType> addTile(Png::TilesVisitor::Tile const &tile,
+	                                                  Palette const &palette) {
+		TileData newTile(tile, palette);
+		auto [tileData, inserted] = tileset.insert(newTile);
+
+		TileData::MatchType matchType = TileData::EXACT;
+		if (inserted) {
+			// Give the new tile the next available unique ID
+			tileData->tileID = static_cast<uint16_t>(tiles.size());
+			// Pointers are never invalidated!
+			tiles.emplace_back(&*tileData);
+		} else {
+			matchType = tileData->tryMatching(newTile);
+		}
+		return {tileData->tileID, matchType};
+	}
+
+	auto size() const { return tiles.size(); }
+
+	auto begin() const { return tiles.begin(); }
+	auto end() const { return tiles.end(); }
+};
+
+/**
+ * Generate tile data while deduplicating unique tiles (via mirroring if enabled)
+ * Additionally, while we have the info handy, convert from the 16-bit "global" tile IDs to
+ * 8-bit tile IDs + the bank bit; this will save the work when we output the data later (potentially
+ * twice)
+ */
+static UniqueTiles dedupTiles(Png const &png, DefaultInitVec<AttrmapEntry> &attrmap,
+                              std::vector<Palette> const &palettes,
+                              DefaultInitVec<size_t> const &mappings) {
+	// Iterate throughout the image, generating tile data as we go
+	// (We don't need the full tile data to be able to dedup tiles, but we don't lose anything
+	// by caching the full tile data anyway, so we might as well.)
+	UniqueTiles tiles;
+
+	auto iter = attrmap.begin();
+	for (auto tile : png.visitAsTiles(options.columnMajor)) {
+		auto [tileID, matchType] = tiles.addTile(tile, palettes[mappings[iter->protoPaletteID]]);
+
+		iter->xFlip = matchType == TileData::HFLIP || matchType == TileData::VHFLIP;
+		iter->yFlip = matchType == TileData::VFLIP || matchType == TileData::VHFLIP;
+		iter->bank = tileID >= options.maxNbTiles[0];
+		iter->tileID = (iter->bank ? tileID - options.maxNbTiles[0] : tileID)
+		               + options.baseTileIDs[iter->bank];
+
+		++iter;
+	}
+	assert(iter == attrmap.end());
+
+	// Copy elision should prevent the contained `unordered_set` from being re-constructed
+	return tiles;
+}
+
+static void outputTileData(UniqueTiles const &tiles) {
+	std::filebuf output;
+	output.open(options.output, std::ios_base::out | std::ios_base::binary);
+
+	uint16_t tileID = 0;
+	for (auto iter = tiles.begin(), end = tiles.end() - options.trim; iter != end; ++iter) {
+		TileData const *tile = *iter;
+		assert(tile->tileID == tileID);
+		++tileID;
+		output.sputn(reinterpret_cast<char const *>(tile->data().data()), options.bitDepth * 8);
+	}
+}
+
+static void outputTilemap(DefaultInitVec<AttrmapEntry> const &attrmap) {
+	std::filebuf output;
+	output.open(options.tilemap, std::ios_base::out | std::ios_base::binary);
+
+	for (AttrmapEntry const &entry : attrmap) {
+		output.sputc(entry.tileID); // The tile ID has already been converted
+	}
+}
+
+static void outputAttrmap(DefaultInitVec<AttrmapEntry> const &attrmap,
+                          DefaultInitVec<size_t> const &mappings) {
+	std::filebuf output;
+	output.open(options.attrmap, std::ios_base::out | std::ios_base::binary);
+
+	for (AttrmapEntry const &entry : attrmap) {
+		uint8_t attr = entry.xFlip << 5 | entry.yFlip << 6;
+		attr |= entry.bank << 3;
+		attr |= mappings[entry.protoPaletteID] & 7;
+		output.sputc(attr);
+	}
+}
+
+} // namespace optimized
+
+void process() {
+	options.verbosePrint(Options::VERB_CFG, "Using libpng %s\n", png_get_libpng_ver(nullptr));
+
+	options.verbosePrint(Options::VERB_LOG_ACT, "Reading tiles...\n");
+	Png png(options.input); // This also sets `hasTransparentPixels` as a side effect
+	ImagePalette const &colors = png.getColors();
+
+	// Now, we have all the image's colors in `colors`
+	// The next step is to order the palette
+
+	if (options.verbosity >= Options::VERB_INTERM) {
+		fputs("Image colors: [ ", stderr);
+		for (auto const &slot : colors) {
+			if (!slot.has_value()) {
+				continue;
+			}
+			fprintf(stderr, "#%08x, ", slot->toCSS());
+		}
+		fputs("]\n", stderr);
+	}
+
+	// Now, iterate through the tiles, generating proto-palettes as we go
+	// We do this unconditionally because this performs the image validation (which we want to
+	// perform even if no output is requested), and because it's necessary to generate any
+	// output (with the exception of an un-duplicated tilemap, but that's an acceptable loss.)
+	std::vector<ProtoPalette> protoPalettes;
+	DefaultInitVec<AttrmapEntry> attrmap{};
+
+	for (auto tile : png.visitAsTiles(options.columnMajor)) {
+		ProtoPalette tileColors;
+		AttrmapEntry &attrs = attrmap.emplace_back();
+
+		for (uint32_t y = 0; y < 8; ++y) {
+			for (uint32_t x = 0; x < 8; ++x) {
+				Rgba color = tile.pixel(x, y);
+				if (!color.isTransparent()) { // Do not count transparency in for packing
+					tileColors.add(color.cgbColor());
+				}
+			}
+		}
+
+		if (tileColors.empty()) {
+			// "Empty" proto-palettes screw with the packing process, so discard those
+			attrs.protoPaletteID = AttrmapEntry::transparent;
+			continue;
+		}
+
+		// Insert the proto-palette, making sure to avoid overlaps
+		for (size_t n = 0; n < protoPalettes.size(); ++n) {
+			switch (tileColors.compare(protoPalettes[n])) {
+			case ProtoPalette::WE_BIGGER:
+				protoPalettes[n] = tileColors; // Override them
+				// Remove any other proto-palettes that we encompass
+				// (Example [(0, 1), (0, 2)], inserting (0, 1, 2))
+				/* The following code does its job, except that references to the removed
+				 * proto-palettes are not updated, causing issues.
+				 * TODO: overlap might not be detrimental to the packing algorithm.
+				 * Investigation is necessary, especially if pathological cases are found.
+
+				for (size_t i = protoPalettes.size(); --i != n;) {
+				    if (tileColors.compare(protoPalettes[i]) == ProtoPalette::WE_BIGGER) {
+				        protoPalettes.erase(protoPalettes.begin() + i);
+				    }
+				}
+				*/
+				[[fallthrough]];
+
+			case ProtoPalette::THEY_BIGGER:
+				// Do nothing, they already contain us
+				attrs.protoPaletteID = n;
+				goto contained;
+
+			case ProtoPalette::NEITHER:
+				break; // Keep going
+			}
+		}
+		attrs.protoPaletteID = protoPalettes.size();
+		if (protoPalettes.size() == AttrmapEntry::transparent) {
+			abort(); // TODO: nice error message
+		}
+		protoPalettes.push_back(tileColors);
+contained:;
+	}
+
+	options.verbosePrint(Options::VERB_INTERM, "Image contains %zu proto-palette%s\n",
+	                     protoPalettes.size(), protoPalettes.size() != 1 ? "s" : "");
+	if (options.verbosity >= Options::VERB_INTERM) {
+		for (auto const &protoPal : protoPalettes) {
+			fputs("[ ", stderr);
+			for (uint16_t color : protoPal) {
+				fprintf(stderr, "$%04x, ", color);
+			}
+			fputs("]\n", stderr);
+		}
+	}
+
+	// Sort the proto-palettes by size, which improves the packing algorithm's efficiency
+	// We sort after all insertions to avoid moving items: https://stackoverflow.com/a/2710332
+	std::sort(
+	    protoPalettes.begin(), protoPalettes.end(),
+	    [](ProtoPalette const &lhs, ProtoPalette const &rhs) { return lhs.size() < rhs.size(); });
+
+	auto [mappings, palettes] = options.palSpecType == Options::NO_SPEC
+	                                ? generatePalettes(protoPalettes, png)
+	                                : makePalsAsSpecified(protoPalettes, png);
+
+	if (options.verbosity >= Options::VERB_INTERM) {
+		for (auto &&palette : palettes) {
+			fputs("{ ", stderr);
+			for (uint16_t colorIndex : palette) {
+				fprintf(stderr, "%04" PRIx16 ", ", colorIndex);
+			}
+			fputs("}\n", stderr);
+		}
+	}
+
+	if (palettes.size() > options.nbPalettes) {
+		// If the palette generation is wrong, other (dependee) operations are likely to be
+		// nonsensical, so fatal-error outright
+		fatal("Generated %zu palettes, over the maximum of %" PRIu8, palettes.size(),
+		      options.nbPalettes);
+	}
+
+	if (!options.palettes.empty()) {
+		outputPalettes(palettes);
+	}
+
+	// If deduplication is not happening, we just need to output the tile data and/or maps as-is
+	if (!options.allowDedup) {
+		uint32_t const nbTilesH = png.getHeight() / 8, nbTilesW = png.getWidth() / 8;
+
+		// Check the tile count
+		if (nbTilesW * nbTilesH > options.maxNbTiles[0] + options.maxNbTiles[1]) {
+			fatal("Image contains %" PRIu32 " tiles, exceeding the limit of %" PRIu16 " + %" PRIu16,
+			      nbTilesW * nbTilesH, options.maxNbTiles[0], options.maxNbTiles[1]);
+		}
+
+		if (!options.output.empty()) {
+			options.verbosePrint(Options::VERB_LOG_ACT, "Generating unoptimized tile data...\n");
+			unoptimized::outputTileData(png, attrmap, palettes, mappings);
+		}
+
+		if (!options.tilemap.empty() || !options.attrmap.empty()) {
+			options.verbosePrint(Options::VERB_LOG_ACT,
+			                     "Generating unoptimized tilemap and/or attrmap...\n");
+			unoptimized::outputMaps(png, attrmap, mappings);
+		}
+	} else {
+		// All of these require the deduplication process to be performed to be output
+		options.verbosePrint(Options::VERB_LOG_ACT, "Deduplicating tiles...\n");
+		optimized::UniqueTiles tiles = optimized::dedupTiles(png, attrmap, palettes, mappings);
+
+		if (tiles.size() > options.maxNbTiles[0] + options.maxNbTiles[1]) {
+			fatal("Image contains %zu tiles, exceeding the limit of %" PRIu16 " + %" PRIu16,
+			      tiles.size(), options.maxNbTiles[0], options.maxNbTiles[1]);
+		}
+
+		if (!options.output.empty()) {
+			options.verbosePrint(Options::VERB_LOG_ACT, "Generating optimized tile data...\n");
+			optimized::outputTileData(tiles);
+		}
+
+		if (!options.tilemap.empty()) {
+			options.verbosePrint(Options::VERB_LOG_ACT, "Generating optimized tilemap...\n");
+			optimized::outputTilemap(attrmap);
+		}
+
+		if (!options.attrmap.empty()) {
+			options.verbosePrint(Options::VERB_LOG_ACT, "Generating optimized attrmap...\n");
+			optimized::outputAttrmap(attrmap, mappings);
+		}
+	}
+}