// SPDX-License-Identifier: MIT #include "gfx/process.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "diagnostics.hpp" #include "file.hpp" #include "helpers.hpp" #include "itertools.hpp" #include "verbosity.hpp" #include "gfx/color_set.hpp" #include "gfx/flip.hpp" #include "gfx/main.hpp" #include "gfx/pal_packing.hpp" #include "gfx/pal_sorting.hpp" #include "gfx/palette.hpp" #include "gfx/png.hpp" #include "gfx/rgba.hpp" #include "gfx/warning.hpp" static bool isBgColorTransparent() { return options.bgColor.has_value() && options.bgColor->isTransparent(); } class ImagePalette { std::array, NB_COLOR_SLOTS> _colors; public: ImagePalette() = default; // Registers a color in the palette. // If the newly inserted color "conflicts" with another one (different color, but same CGB // color), then the other color is returned. Otherwise, `nullptr` is returned. [[nodiscard]] Rgba const *registerColor(Rgba const &rgba) { uint16_t color = rgba.cgbColor(); std::optional &slot = _colors[color]; if (color == Rgba::transparent && !isBgColorTransparent()) { options.hasTransparentPixels = true; } if (!slot.has_value()) { slot.emplace(rgba); } else if (*slot != rgba) { assume(slot->cgbColor() != UINT16_MAX); return &*slot; } return nullptr; } size_t size() const { return std::count_if(RANGE(_colors), [](std::optional const &slot) { return slot.has_value() && slot->isOpaque(); }); } decltype(_colors) const &raw() const { return _colors; } auto begin() const -> decltype(_colors)::const_iterator { return _colors.begin(); } auto end() const -> decltype(_colors)::const_iterator { return _colors.end(); } }; struct Image { Png png; ImagePalette colors; Rgba &pixel(uint32_t x, uint32_t y) { return png.pixels[y * png.width + x]; } Rgba const &pixel(uint32_t x, uint32_t y) const { return png.pixels[y * png.width + x]; } enum GrayscaleResult { GRAY_OK, GRAY_TOO_MANY, GRAY_NONGRAY, GRAY_CONFLICT, }; std::pair> isSuitableForGrayscale() const { // Check that all of the grays don't fall into the same "bin" if (colors.size() > options.maxOpaqueColors()) { // Apply the Pigeonhole Principle verbosePrint( VERB_DEBUG, "Too many colors for grayscale sorting (%zu > %" PRIu8 ")\n", colors.size(), options.maxOpaqueColors() ); return {GrayscaleResult::GRAY_TOO_MANY, std::nullopt}; } uint8_t bins = 0; for (std::optional const &color : colors) { if (!color.has_value() || color->isTransparent()) { continue; } if (!color->isGray()) { verbosePrint( VERB_DEBUG, "Found non-gray color #%08x, not using grayscale sorting\n", color->toCSS() ); return {GrayscaleResult::GRAY_NONGRAY, color}; } uint8_t mask = 1 << color->grayIndex(); if (bins & mask) { // Two in the same bin! verbosePrint( VERB_DEBUG, "Color #%08x conflicts with another one, not using grayscale sorting\n", color->toCSS() ); return {GrayscaleResult::GRAY_CONFLICT, color}; } bins |= mask; } return {GrayscaleResult::GRAY_OK, std::nullopt}; } explicit Image(Png &&png_) : png(std::move(png_)), colors() { // Validate input slice if (options.inputSlice.width == 0 && png.width % 8 != 0) { fatal("Image width (%" PRIu32 " pixels) is not a multiple of 8", png.width); } if (options.inputSlice.height == 0 && png.height % 8 != 0) { fatal("Image height (%" PRIu32 " pixels) is not a multiple of 8", png.height); } if (options.inputSlice.right() > png.width || options.inputSlice.bottom() > png.height) { error( "Image slice ((%" PRIu16 ", %" PRIu16 ") to (%" PRIu32 ", %" PRIu32 ")) is outside the image bounds (%" PRIu32 "x%" PRIu32 ")", options.inputSlice.left, options.inputSlice.top, options.inputSlice.right(), options.inputSlice.bottom(), png.width, png.height ); if (options.inputSlice.width % 8 == 0 && options.inputSlice.height % 8 == 0) { fprintf( stderr, " (Did you mean the slice \"%" PRIu32 ",%" PRIu32 ":%" PRId32 ",%" PRId32 "\"? The width and height are in tiles, not pixels!)\n", options.inputSlice.left, options.inputSlice.top, options.inputSlice.width / 8, options.inputSlice.height / 8 ); } giveUp(); } // Holds colors whose alpha value is ambiguous to avoid erroring about them twice. std::unordered_set ambiguous; // Holds fused color pairs to avoid warning about them twice. // We don't need to worry about transitivity, as ImagePalette slots are immutable once // assigned, and conflicts always occur between that and another color. // For the same reason, we don't need to worry about order, either. auto hashPair = [](std::pair const &pair) { return pair.first * 31 + pair.second; }; std::unordered_set, decltype(hashPair)> fusions; // Register colors from `png` into `colors` uint32_t const pxLeft = options.inputSlice.width ? options.inputSlice.left : 0; uint32_t const pxTop = options.inputSlice.height ? options.inputSlice.top : 0; uint32_t const pxRight = options.inputSlice.width ? options.inputSlice.right() : png.width; uint32_t const pxBottom = options.inputSlice.height ? options.inputSlice.bottom() : png.height; for (uint32_t y = pxTop; y < pxBottom; ++y) { for (uint32_t x = pxLeft; x < pxRight; ++x) { if (Rgba const &color = pixel(x, y); color.isAmbiguous()) { // Report ambiguously transparent or opaque colors if (uint32_t css = color.toCSS(); ambiguous.find(css) == ambiguous.end()) { error( "Color #%08x is neither transparent (alpha < %u) nor opaque (alpha >= " "%u) (first seen at (%" PRIu32 ", %" PRIu32 "))", css, Rgba::transparency_threshold, Rgba::opacity_threshold, x, y ); ambiguous.insert(css); // Do not report this color again } } else if (Rgba const *other = colors.registerColor(color); other) { // Report fused colors that reduce to the same RGB555 value if (std::pair fused{color.toCSS(), other->toCSS()}; fusions.find(fused) == fusions.end()) { warnx( "Colors #%08x and #%08x both reduce to the same RGB555 color %s " "(first seen at (%" PRIu32 ", %" PRIu32 "))", fused.first, fused.second, toCGB(color.cgbColor()).c_str(), x, y ); fusions.insert(fused); // Do not report this fusion again } } } } if (size_t nbAmbiguousColors = ambiguous.size(); nbAmbiguousColors > 0) { fatal( "Image contains %zu ambiguous color%s (neither transparent nor opaque)", nbAmbiguousColors, nbAmbiguousColors == 1 ? "" : "s" ); } } class TilesVisitor { Image const &_image; bool const _columnMajor; uint32_t const _width, _height; uint32_t const _limit = _columnMajor ? _height : _width; public: TilesVisitor(Image const &image, bool columnMajor, uint32_t width, uint32_t height) : _image(image), _columnMajor(columnMajor), _width(width), _height(height) {} class Tile { Image const &_image; public: uint32_t const x, y; Tile(Image const &image, uint32_t x_, uint32_t y_) : _image(image), x(x_), y(y_) {} Rgba pixel(uint32_t xOfs, uint32_t yOfs) const { return _image.pixel(x + xOfs, y + yOfs); } }; private: struct Iterator { TilesVisitor const &parent; uint32_t const limit; uint32_t x, y; std::pair coords() const { return {x + options.inputSlice.left, y + options.inputSlice.top}; } Tile operator*() const { return {parent._image, x + options.inputSlice.left, y + options.inputSlice.top}; } 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(); } }; 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() const { return { *this, options.columnMajor, options.inputSlice.width ? options.inputSlice.width * 8 : png.width, options.inputSlice.height ? options.inputSlice.height * 8 : png.height, }; } }; class RawTiles { // A tile which only contains indices into the image's global palette class RawTile { std::array, 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 _tiles; public: // Creates a new raw tile, and returns a reference to it so it can be filled in RawTile &newTile() { return _tiles.emplace_back(); } }; struct AttrmapEntry { // This field can either be a color set ID, or `transparent` to indicate that the // corresponding tile is fully transparent. If you are looking to get the palette ID for this // attrmap entry while correctly handling the above, use `getPalID`. size_t colorSetID; // 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 size_t transparent = static_cast(-1); static constexpr size_t background = static_cast(-2); bool isBackgroundTile() const { return colorSetID == background; } size_t getPalID(std::vector const &mappings) const { return mappings[isBackgroundTile() || colorSetID == transparent ? 0 : colorSetID]; } }; static std::pair, std::vector> generatePalettes(std::vector const &colorSets, Image const &image) { // Run a "pagination" problem solver auto [mappings, nbPalettes] = overloadAndRemove(colorSets); assume(mappings.size() == colorSets.size()); // LCOV_EXCL_START // Ideally we'd use an implicit `[&]` capture, but C++20 P0588R1 (which allows "reference to // local binding declared in enclosing function") is not sufficiently supported by clang. verboseDo(VERB_INFO, [&mappingsV = mappings, &nbPalettesV = nbPalettes]() { fprintf( stderr, "Color set mappings: (%zu palette%s)\n", nbPalettesV, nbPalettesV != 1 ? "s" : "" ); for (size_t i = 0; i < mappingsV.size(); ++i) { fprintf(stderr, "%zu -> %zu\n", i, mappingsV[i]); } }); // LCOV_EXCL_STOP std::vector 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 colorSetID = 0; colorSetID < mappings.size(); ++colorSetID) { Palette &pal = palettes[mappings[colorSetID]]; for (uint16_t color : colorSets[colorSetID]) { pal.addColor(color); } } assume(!palettes.empty()); // "Sort" colors in the generated palettes, see the man page for the flowchart if (options.palSpecType == Options::DMG) { sortGrayscale(palettes, image.colors.raw()); } else if (image.png.isIndexed) { // A PNG image using PNG_COLOR_TYPE_RGB (2) or PNG_COLOR_TYPE_RGBA (6) can still // contain a PLTE chunk. From the PNG spec: "If present, it provides a suggested set of // from 1 to 256 colors to which the truecolor image can be quantized if the viewer // cannot display truecolor directly." We only sort palette colors by the PLTE chunk's // color order if the image uses PNG_COLOR_TYPE_PALETTE (3), since that guarantees every // color used will also be in the embedded palette. assume(!image.png.palette.empty()); warning( WARNING_EMBEDDED, "Sorting palette colors by PNG's embedded PLTE chunk without '-c/--colors embedded'" ); sortIndexed(palettes, image.png.palette); } else if (image.isSuitableForGrayscale().first == Image::GRAY_OK) { sortGrayscale(palettes, image.colors.raw()); } else { sortRgb(palettes); } return {mappings, palettes}; } static std::pair, std::vector> makePalsAsSpecified(std::vector const &colorSets) { std::vector palettes(options.palSpec.size()); // 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; } } // Convert the palette spec to actual palettes for (auto [spec, pal] : zip(options.palSpec, palettes)) { bool skipFirst = false; // If the image contains any transparent pixels, color #0 of all palettes is transparent. // Thus, all explicit palette specs should leave color #0 as "#none" or transparent. // If they specify an opaque color #0, we have legacy behavior of implicitly inserting // a transparent color #0, and expecting the spec to only cover the subsequent colors. if (options.hasTransparentPixels && spec.front().has_value() && spec.front()->isOpaque()) { skipFirst = true; } for (size_t i = 0; i < options.nbColorsPerPal; ++i) { // If the spec has a gap, there's no need to copy anything. if (!spec[i].has_value() || !spec[i]->isOpaque()) { continue; } // If we're skipping color #0 as implicitly transparent, a full spec // plus the implicit transparent color will be too large for a palette. if (i + skipFirst >= options.nbColorsPerPal) { error( "Each palette spec can only contain up to %" PRIu8 " color%s plus the implicit transparent color", options.nbColorsPerPal - 1, options.nbColorsPerPal - 1 == 1 ? "" : "s" ); giveUp(); } pal[i + skipFirst] = spec[i]->cgbColor(); } } // Iterate through color sets, and try mapping them to the specified palettes std::vector mappings(colorSets.size()); bool bad = false; for (size_t i = 0; i < colorSets.size(); ++i) { ColorSet const &colorSet = colorSets[i]; // Find the palette... auto iter = std::find_if(RANGE(palettes), [&colorSet](Palette const &pal) { // ...which contains all colors in this color set return std::all_of(RANGE(colorSet), [&pal](uint16_t color) { return std::find(RANGE(pal), color) != pal.end(); }); }); if (iter == palettes.end()) { assume(!colorSet.empty()); error( "Failed to fit tile colors [%s] in specified palettes", listCGBColors(colorSet).c_str() ); bad = true; } mappings[i] = iter - palettes.begin(); // Bogus value, but whatever } if (bad) { fprintf( stderr, "note: The following palette%s specified:\n", palettes.size() == 1 ? " was" : "s were" ); for (Palette const &pal : palettes) { fprintf(stderr, " - [%s]\n", listCGBColors(pal).c_str()); } giveUp(); } return {mappings, palettes}; } static void outputPalettes(std::vector const &palettes) { // LCOV_EXCL_START verboseDo(VERB_INFO, [&]() { for (Palette const &palette : palettes) { fprintf(stderr, "- { %s }\n", listCGBColors(palette).c_str()); } }); // LCOV_EXCL_STOP if (size_t nbPals = palettes.size(); nbPals > 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 %" PRIu16, nbPals, options.nbPalettes); } else if (nbPals > 8 && !options.attrmap.empty() && options.palmap.empty()) { // With `-n/--nb-palettes` greater than 8, palette IDs may be truncated in the attrmap // (though not in the palmap), so warn about that. warnx("Generated %zu palettes, of which only 8 are representable in the attrmap", nbPals); } if (!options.palettes.empty()) { File output; if (!output.open(options.palettes, std::ios_base::out | std::ios_base::binary)) { // LCOV_EXCL_START fatal("Failed to create \"%s\": %s", output.c_str(options.palettes), strerror(errno)); // LCOV_EXCL_STOP } for (Palette const &palette : palettes) { for (uint8_t i = 0; i < options.nbColorsPerPal; ++i) { // Will output `UINT16_MAX` for unused slots uint16_t color = palette.colors[i]; output->sputc(color & 0xFF); output->sputc(color >> 8); } } } } static void hashBitplanes(uint16_t bitplanes, uint16_t &hash) { hash ^= bitplanes; if (options.allowMirroringX) { // Count the line itself as mirrored, which ensures the same hash as the tile's horizontal // flip; vertical mirroring is already taken care of because the symmetric line will be // XOR'd the same way. (This can trivially create some collisions, but real-world tile data // generally doesn't trigger them.) hash ^= flipTable[bitplanes >> 8] << 8 | flipTable[bitplanes & 0xFF]; } } class TileData { // Importantly, `TileData` is **always** 2bpp. // If the active bit depth is 1bpp, all tiles are processed as 2bpp nonetheless, but emitted as // 1bpp. This massively simplifies internal processing, since bit depth is always identical // outside of I/O / serialization boundaries. std::array _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(Image::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()); assume(index < palette.size()); // The color should be in the palette if (index & 1) { row |= 1; } if (index & 2) { row |= 0x100; } } return row; } TileData(std::array &&raw) : _data(raw), _hash(0) { for (uint8_t y = 0; y < 8; ++y) { uint16_t bitplanes = _data[y * 2] | _data[y * 2 + 1] << 8; hashBitplanes(bitplanes, _hash); } } TileData(Image::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); hashBitplanes(bitplanes, _hash); _data[writeIndex++] = bitplanes & 0xFF; _data[writeIndex++] = bitplanes >> 8; } } std::array 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; } // Check if we have horizontal mirroring, which scans the array forward again if (options.allowMirroringX && std::equal(RANGE(_data), other._data.begin(), [](uint8_t lhs, uint8_t rhs) { return lhs == flipTable[rhs]; })) { return MatchType::HFLIP; } // The remaining possibilities for matching all require vertical mirroring if (!options.allowMirroringY) { return MatchType::NOPE; } // 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 != flipTable[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 if (hasVFlip) { return MatchType::VFLIP; } // If we allow both and have both, then use both if (options.allowMirroringX && hasVHFlip) { return MatchType::VHFLIP; } return MatchType::NOPE; } bool operator==(TileData const &rhs) const { return tryMatching(rhs) != MatchType::NOPE; } }; template<> struct std::hash { size_t operator()(TileData const &tile) const { return tile.hash(); } }; static void outputUnoptimizedTileData( Image const &image, std::vector const &attrmap, std::vector const &palettes, std::vector const &mappings ) { File output; if (!output.open(options.output, std::ios_base::out | std::ios_base::binary)) { // LCOV_EXCL_START fatal("Failed to create \"%s\": %s", output.c_str(options.output), strerror(errno)); // LCOV_EXCL_STOP } size_t nbTiles = std::count_if(RANGE(attrmap), [](AttrmapEntry const &attr) { return !attr.isBackgroundTile(); }); size_t nbKeptTiles = nbTiles > options.trim ? nbTiles - options.trim : 0; size_t tileIdx = 0; for (auto const &[tile, attr] : zip(image.visitAsTiles(), attrmap)) { // Do not emit fully-background tiles. if (attr.isBackgroundTile()) { continue; } // If the tile is fully transparent, this defaults to palette 0. Palette const &palette = palettes[attr.getPalID(mappings)]; bool empty = true; for (uint32_t y = 0; y < 8; ++y) { uint16_t bitplanes = TileData::rowBitplanes(tile, palette, y); if (bitplanes != 0) { empty = false; } if (tileIdx < nbKeptTiles) { output->sputc(bitplanes & 0xFF); if (options.bitDepth == 2) { output->sputc(bitplanes >> 8); } } } if (!empty && tileIdx >= nbKeptTiles) { warning( WARNING_TRIM_NONEMPTY, "Trimming a nonempty tile (configure with '-x/--trim-end')" ); break; // Don't repeat the warning for subsequent tiles } ++tileIdx; } assume(nbKeptTiles <= tileIdx && tileIdx <= nbTiles); } static void outputUnoptimizedMaps( std::vector const &attrmap, std::vector const &mappings ) { std::optional tilemapOutput, attrmapOutput, palmapOutput; auto autoOpenPath = [](std::string const &path, std::optional &file) { if (!path.empty()) { file.emplace(); if (!file->open(path, std::ios_base::out | std::ios_base::binary)) { // LCOV_EXCL_START fatal("Failed to create \"%s\": %s", file->c_str(path), strerror(errno)); // LCOV_EXCL_STOP } } }; autoOpenPath(options.tilemap, tilemapOutput); autoOpenPath(options.attrmap, attrmapOutput); autoOpenPath(options.palmap, palmapOutput); auto const emit = [](std::optional &output, uint8_t byte) { if (output.has_value()) { output.value()->sputc(byte); } }; uint16_t tileIdx = 0; uint8_t bank = 0; for (AttrmapEntry const &attr : attrmap) { // A non-zero base ID may make this addition overflow, wrapping around the available // palette IDs. Since the operands are unsigned, this won't cause undefined behavior. // With `-n/--nb-palettes` greater than 8, palette IDs may be truncated in the attrmap // (though not in the palmap), which was already warned about. uint8_t palID = attr.getPalID(mappings) + options.basePalID; if (attr.isBackgroundTile()) { // The tile bank may be 2 here, which is fine since background tiles are emitted as // if they used the base tile ID and bank 0. assume(bank <= 2); emit(tilemapOutput, options.baseTileIDs[0]); emit(attrmapOutput, palID & 0b111); // The other flags are all zeros. emit(palmapOutput, palID); // Since background tiles are not in tile data, they do not increment the tile index. } else { // The only valid tile banks are 0 and 1. assume(bank < 2); // A non-zero base ID may make this addition overflow, wrapping around the available // tile IDs. Since the operands are unsigned, this won't cause undefined behavior. // With `-N/--nb-tiles` unlimited (by default) for bank 0, tile IDs may be truncated in // the tilemap, which was already warned about. uint8_t tileID = tileIdx + options.baseTileIDs[bank]; emit(tilemapOutput, tileID); emit(attrmapOutput, (palID & 0b111) | bank << 3); // The other flags are all zeros. emit(palmapOutput, palID); ++tileIdx; // The `bank` may increment from 1 to 2, if banks 0 and 1 are both full. By then all // the tiles should have been emitted, since there cannot be more tiles than could fit // in both banks, but there may still be background tiles to skip. if (tileIdx >= options.maxNbTiles[bank]) { tileIdx = 0; ++bank; } } } } struct UniqueTiles { std::unordered_set tileset; std::vector 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::pair addTile(TileData newTile) { if (auto [tileData, inserted] = tileset.insert(newTile); inserted) { // Give the new tile the next available unique ID tileData->tileID = static_cast(tiles.size()); tiles.emplace_back(&*tileData); // Pointers are never invalidated! return {tileData->tileID, TileData::NOPE}; } else { return {tileData->tileID, tileData->tryMatching(newTile)}; } } size_t size() const { return tiles.size(); } auto begin() const -> decltype(tiles)::const_iterator { return tiles.begin(); } auto end() const -> decltype(tiles)::const_iterator { 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( Image const &image, std::vector &attrmap, std::vector const &palettes, std::vector 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; if (!options.inputTileset.empty()) { File inputTileset; if (!inputTileset.open(options.inputTileset, std::ios::in | std::ios::binary)) { fatal("Failed to open \"%s\": %s", options.inputTileset.c_str(), strerror(errno)); } std::array tile; size_t const tileSize = options.bitDepth * 8; for (;;) { // It's okay to cast between character types. size_t len = inputTileset->sgetn(reinterpret_cast(tile.data()), tileSize); if (len == 0) { // EOF! break; } else if (len != tileSize) { fatal( "\"%s\" does not contain a multiple of %zu bytes; is it actually tile data?", options.inputTileset.c_str(), tileSize ); } else if (len == 8) { // Expand the tile data to 2bpp. for (size_t i = 8; i--;) { tile[i * 2 + 1] = 0; tile[i * 2] = tile[i]; } } auto [tileID, matchType] = tiles.addTile(std::move(tile)); if (matchType != TileData::NOPE) { error( "The input tileset's tile #%hu was deduplicated; please check that your " "deduplication flags ('-u', '-m') are consistent with what was used to " "generate the input tileset", tileID ); } } } bool inputWithoutOutput = !options.inputTileset.empty() && options.output.empty(); for (auto const &[tile, attr] : zip(image.visitAsTiles(), attrmap)) { if (attr.isBackgroundTile()) { attr.xFlip = false; attr.yFlip = false; attr.bank = 0; attr.tileID = options.baseTileIDs[attr.bank]; } else { auto [tileIdx, matchType] = tiles.addTile({tile, palettes[attr.getPalID(mappings)]}); if (inputWithoutOutput && matchType == TileData::NOPE) { error( "Tile at (%" PRIu32 ", %" PRIu32 ") is not within the input tileset, and '-o' was not given", tile.x, tile.y ); } attr.xFlip = matchType == TileData::HFLIP || matchType == TileData::VHFLIP; attr.yFlip = matchType == TileData::VFLIP || matchType == TileData::VHFLIP; attr.bank = tileIdx >= options.maxNbTiles[0]; attr.tileID = (attr.bank ? tileIdx - options.maxNbTiles[0] : tileIdx) + options.baseTileIDs[attr.bank]; } } // Copy elision should prevent the contained `unordered_set` from being re-constructed return tiles; } static void outputTileData(UniqueTiles const &tiles) { File output; if (!output.open(options.output, std::ios_base::out | std::ios_base::binary)) { // LCOV_EXCL_START fatal("Failed to create \"%s\": %s", output.c_str(options.output), strerror(errno)); // LCOV_EXCL_STOP } size_t nbTiles = tiles.size(); size_t nbKeptTiles = nbTiles > options.trim ? nbTiles - options.trim : 0; size_t tileIdx = 0; for (TileData const *tile : tiles) { assume(tile->tileID == tileIdx); bool empty = true; for (uint32_t y = 0; y < 8; ++y) { uint8_t bitplane0 = tile->data()[y * 2]; uint8_t bitplane1 = tile->data()[y * 2 + 1]; if (bitplane0 || bitplane1) { empty = false; } if (tileIdx < nbKeptTiles) { output->sputc(bitplane0); if (options.bitDepth == 2) { output->sputc(bitplane1); } } } if (!empty && tileIdx >= nbKeptTiles) { warning( WARNING_TRIM_NONEMPTY, "Trimming a nonempty tile (configure with '-x/--trim-end')" ); break; // Don't repeat the warning for subsequent tiles } ++tileIdx; } assume(nbKeptTiles <= tileIdx && tileIdx <= nbTiles); } static void outputTilemap(std::vector const &attrmap) { File output; if (!output.open(options.tilemap, std::ios_base::out | std::ios_base::binary)) { // LCOV_EXCL_START fatal("Failed to create \"%s\": %s", output.c_str(options.tilemap), strerror(errno)); // LCOV_EXCL_STOP } // With `-N/--nb-tiles` unlimited (by default) for bank 0, tile IDs may be truncated in the // tilemap, which was already warned about. for (AttrmapEntry const &entry : attrmap) { output->sputc(entry.tileID); // The tile ID has already been converted } } static void outputAttrmap(std::vector const &attrmap, std::vector const &mappings) { File output; if (!output.open(options.attrmap, std::ios_base::out | std::ios_base::binary)) { // LCOV_EXCL_START fatal("Failed to create \"%s\": %s", output.c_str(options.attrmap), strerror(errno)); // LCOV_EXCL_STOP } for (AttrmapEntry const &entry : attrmap) { uint8_t attr = entry.xFlip << 5 | entry.yFlip << 6; attr |= entry.bank << 3; // The unsigned underflow for the palette ID is intentional, since a // nonzero base palette ID may overflow and continue with IDs from 0. // With `-n/--nb-palettes` greater than 8, palette IDs may be truncated in the attrmap // (though not in the palmap), which was already warned about. attr |= (entry.getPalID(mappings) + options.basePalID) & 0b111; output->sputc(attr); } } static void outputPalmap(std::vector const &attrmap, std::vector const &mappings) { File output; if (!output.open(options.palmap, std::ios_base::out | std::ios_base::binary)) { // LCOV_EXCL_START fatal("Failed to create \"%s\": %s", output.c_str(options.palmap), strerror(errno)); // LCOV_EXCL_STOP } for (AttrmapEntry const &entry : attrmap) { // The unsigned underflow for the palette ID is intentional, since a // nonzero base palette ID may overflow and continue with IDs from 0. output->sputc(entry.getPalID(mappings) + options.basePalID); } } void processPalettes() { std::vector colorSets; std::vector palettes; std::tie(std::ignore, palettes) = makePalsAsSpecified(colorSets); outputPalettes(palettes); } void process(Png &&png) { verbosePrint(VERB_NOTICE, "Reading tiles...\n"); Image image(std::move(png)); // This also sets `hasTransparentPixels` as a side effect // LCOV_EXCL_START verboseDo(VERB_INFO, [&]() { fputs("Image colors: [ ", stderr); for (std::optional const &slot : image.colors) { if (!slot.has_value()) { continue; } fprintf(stderr, "#%08x, ", slot->toCSS()); } fputs("]\n", stderr); }); // LCOV_EXCL_STOP if (options.palSpecType == Options::DMG) { char const *prefix = "Image is not compatible with a DMG palette specification: it contains"; if (options.hasTransparentPixels) { fatal("%s transparent pixels", prefix); } switch (auto const [result, color] = image.isSuitableForGrayscale(); result) { case Image::GRAY_OK: break; case Image::GRAY_TOO_MANY: fatal("%s too many colors (%zu)", prefix, image.colors.size()); case Image::GRAY_NONGRAY: fatal("%s a non-gray color #%08x", prefix, color->toCSS()); case Image::GRAY_CONFLICT: fatal( "%s a color #%08x that reduces to the same gray shade as another one", prefix, color->toCSS() ); } } // Now, iterate through the tiles, generating color sets 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 colorSets; std::vector attrmap{}; for (auto tile : image.visitAsTiles()) { AttrmapEntry &attrs = attrmap.emplace_back(); // Count the unique opaque colors for packing std::unordered_set tileColors; for (uint32_t y = 0; y < 8; ++y) { for (uint32_t x = 0; x < 8; ++x) { Rgba color = tile.pixel(x, y); // Ambiguous colors should not be in `tileColors` assume(color.isOpaque() != color.isTransparent()); if (color.isOpaque() || !options.hasTransparentPixels) { tileColors.insert(color.cgbColor()); } } } if (tileColors.size() > options.maxOpaqueColors()) { fatal( "Tile at (%" PRIu32 ", %" PRIu32 ") has %zu colors, more than %" PRIu8, tile.x, tile.y, tileColors.size(), options.maxOpaqueColors() ); } if (tileColors.empty()) { // "Empty" color sets screw with the packing process, so discard those assume(!isBgColorTransparent()); attrs.colorSetID = AttrmapEntry::transparent; continue; } ColorSet colorSet; for (uint16_t color : tileColors) { colorSet.add(color); } if (options.bgColor.has_value() && std::find(RANGE(tileColors), options.bgColor->cgbColor()) != tileColors.end()) { if (tileColors.size() == 1) { // The tile contains just the background color, skip it. attrs.colorSetID = AttrmapEntry::background; continue; } fatal( "Tile (%" PRIu32 ", %" PRIu32 ") contains the background color (#%08x)", tile.x, tile.y, options.bgColor->toCSS() ); } // Insert the color set, making sure to avoid overlaps for (size_t n = 0; n < colorSets.size(); ++n) { switch (colorSet.compare(colorSets[n])) { case ColorSet::STRICT_SUPERSET: // Override the previous color set that this one is a strict superset of verbosePrint( VERB_DEBUG, "- Tile (%" PRIu32 ", %" PRIu32 ") overrides color set #%zu: [%s] becomes [%s]\n", tile.x, tile.y, n, listCGBColors(colorSets[n]).c_str(), listCGBColors(colorSet).c_str() ); colorSets[n] = colorSet; // Remove any other color sets that we are also a strict superset of // (example: we have [(0, 1), (0, 2)] and are inserting (0, 1, 2)) for (size_t m = n + 1; m < colorSets.size();) { if (colorSet.compare(colorSets[m]) != ColorSet::STRICT_SUPERSET) { ++m; } else { // We are about to remove a set, which will shift sets that may be // already referenced in the attrmap: re-number to keep it consistent for (size_t i = 0; i + 1 < attrmap.size(); ++i) { AttrmapEntry &entry = attrmap[i]; if (entry.colorSetID == AttrmapEntry::transparent || entry.colorSetID == AttrmapEntry::background) { continue; } if (entry.colorSetID == m) { entry.colorSetID = n; } else if (entry.colorSetID > m) { --entry.colorSetID; } } colorSets.erase(colorSets.begin() + m); } } [[fallthrough]]; case ColorSet::SUBSET_OR_EQUAL: // Use the previous color set that this one is a subset or duplicate of attrs.colorSetID = n; goto continue_visiting_tiles; // Can't `continue` from within a nested loop case ColorSet::INCOMPARABLE: // This color set is incomparable so far, so keep going break; } } // This color set is incomparable with all previous ones, so add it as a new one if (colorSets.size() == AttrmapEntry::background) { // Check for overflow fatal("Cannot create more than %zu color sets", colorSets.size()); } attrs.colorSetID = colorSets.size(); colorSets.push_back(colorSet); verbosePrint( VERB_DEBUG, "- Tile (%" PRIu32 ", %" PRIu32 ") adds color set #%zu: [%s]\n", tile.x, tile.y, attrs.colorSetID, listCGBColors(colorSet).c_str() ); continue_visiting_tiles:; } verbosePrint( VERB_INFO, "Image contains %zu color set%s\n", colorSets.size(), colorSets.size() != 1 ? "s" : "" ); // LCOV_EXCL_START verboseDo(VERB_INFO, [&]() { for (ColorSet const &colorSet : colorSets) { fprintf(stderr, "- [%s]\n", listCGBColors(colorSet).c_str()); } }); // LCOV_EXCL_STOP if (colorSets.empty()) { fatal("Image does not contain any colors"); } auto [mappings, palettes] = options.palSpecType == Options::NO_SPEC || options.palSpecType == Options::DMG ? generatePalettes(colorSets, image) : makePalsAsSpecified(colorSets); outputPalettes(palettes); auto checkTileCountLimit = [](size_t nbTiles) { if (nbTiles > options.maxNbTiles[0] + options.maxNbTiles[1]) { fatal( "Image contains %zu tiles, exceeding the limit of %" PRIu16 " + %" PRIu16, nbTiles, options.maxNbTiles[0], options.maxNbTiles[1] ); } else if (((nbTiles > 256 && options.maxNbTiles[0] > 256) || (nbTiles > options.maxNbTiles[0] + 256u && options.maxNbTiles[1] > 256)) && !options.tilemap.empty()) { // With `-N/--nb-tiles` unlimited (by default) for bank 0, tile IDs may be truncated in // the tilemap, so warn about that. warnx( "Image contains %zu tiles, of which only 256 are representable in the tilemap", nbTiles ); } }; // If deduplication is not happening, we just need to output the tile data and/or maps as-is if (!options.allowDedup) { checkTileCountLimit(std::count_if(RANGE(attrmap), [](AttrmapEntry const &attr) { return !attr.isBackgroundTile(); })); // I currently cannot figure out useful semantics for this combination of flags. if (!options.inputTileset.empty()) { fatal("Input tilesets are not supported without '-u'"); } if (!options.output.empty()) { verbosePrint(VERB_NOTICE, "Generating unoptimized tile data...\n"); outputUnoptimizedTileData(image, attrmap, palettes, mappings); } if (!options.tilemap.empty() || !options.attrmap.empty() || !options.palmap.empty()) { verbosePrint( VERB_NOTICE, "Generating unoptimized tilemap and/or attrmap and/or palmap...\n" ); outputUnoptimizedMaps(attrmap, mappings); } } else { // All of these require the deduplication process to be performed to be output verbosePrint(VERB_NOTICE, "Deduplicating tiles...\n"); UniqueTiles tiles = dedupTiles(image, attrmap, palettes, mappings); checkTileCountLimit(tiles.size()); if (!options.output.empty()) { verbosePrint(VERB_NOTICE, "Generating optimized tile data...\n"); outputTileData(tiles); } if (!options.tilemap.empty()) { verbosePrint(VERB_NOTICE, "Generating optimized tilemap...\n"); outputTilemap(attrmap); } if (!options.attrmap.empty()) { verbosePrint(VERB_NOTICE, "Generating optimized attrmap...\n"); outputAttrmap(attrmap, mappings); } if (!options.palmap.empty()) { verbosePrint(VERB_NOTICE, "Generating optimized palmap...\n"); outputPalmap(attrmap, mappings); } } }