// SPDX-License-Identifier: MIT #include "gfx/process.hpp" #include #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 "style.hpp" #include "verbosity.hpp" #include "gfx/color_set.hpp" #include "gfx/main.hpp" #include "gfx/pal_packing.hpp" #include "gfx/pal_sorting.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->isTransparent(); }); } decltype(_colors) const &raw() const { return _colors; } auto begin() const { return _colors.begin(); } auto end() const { 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]; } 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 verbosePrint( VERB_DEBUG, "Too many colors for grayscale sorting (%zu > %" PRIu8 ")\n", colors.size(), options.maxOpaqueColors() ); return false; } 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 false; } 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 false; } bins |= mask; } return true; } explicit Image(std::string const &path) { File input; if (input.open(path, std::ios_base::in | std::ios_base::binary) == nullptr) { fatal("Failed to open input image (\"%s\"): %s", input.c_str(path), strerror(errno)); } png = Png(input.c_str(path), *input); // 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, "note: Did you mean the slice \"%" PRIu32 ",%" PRIu32 ":%" PRId32 ",%" PRId32 "\"? (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` for (uint32_t y = 0; y < png.height; ++y) { for (uint32_t x = 0; x < png.width; ++x) { if (Rgba const &color = pixel(x, y); color.isTransparent() == color.isOpaque()) { // 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 x: %" PRIu32 ", y: %" 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( "Fusing colors #%08x and #%08x into Game Boy color $%04x [first seen " "at x: %" PRIu32 ", y: %" PRIu32 "]", fused.first, fused.second, color.cgbColor(), x, y ); fusions.insert(fused); // Do not report this fusion again } } } } } 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 void generatePalSpec(Image const &image) { // Generate a palette spec from the first few colors in the embedded palette std::vector const &embPal = image.png.palette; if (embPal.empty()) { fatal("\"-c embedded\" was given, but the PNG does not have an embedded palette!"); } // Ignore extraneous colors if they are unused size_t nbColors = embPal.size(); if (nbColors > options.maxOpaqueColors()) { nbColors = options.maxOpaqueColors(); } // Fill in the palette spec options.palSpec.clear(); auto &palette = options.palSpec.emplace_back(); assume(nbColors <= palette.size()); for (size_t i = 0; i < nbColors; ++i) { palette[i] = embPal[i]; } } 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 if (checkVerbosity(VERB_INFO)) { style_Set(stderr, STYLE_MAGENTA, false); fprintf( stderr, "Color set 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]); } style_Reset(stderr); } // 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); } } // "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.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()) { sortGrayscale(palettes, image.colors.raw()); } else { sortRgb(palettes); } return {mappings, palettes}; } static std::pair, std::vector> makePalsAsSpecified(std::vector const &colorSets) { // Convert the palette spec to actual palettes std::vector palettes(options.palSpec.size()); for (auto [spec, pal] : zip(options.palSpec, palettes)) { 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]->isTransparent()) { pal[i] = spec[i]->cgbColor(); } } } auto listColors = [](auto const &list) { static char buf[sizeof(", $XXXX, $XXXX, $XXXX, $XXXX")]; char *ptr = buf; for (uint16_t color : list) { ptr += snprintf(ptr, sizeof(", $XXXX"), ", $%04x", color); } return &buf[literal_strlen(", ")]; }; // 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", listColors(colorSet)); 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", listColors(pal)); } giveUp(); } return {mappings, palettes}; } static void outputPalettes(std::vector const &palettes) { // LCOV_EXCL_START if (checkVerbosity(VERB_INFO)) { style_Set(stderr, STYLE_MAGENTA, false); for (Palette const &palette : palettes) { fputs("{ ", stderr); for (uint16_t colorIndex : palette) { fprintf(stderr, "%04" PRIx16 ", ", colorIndex); } fputs("}\n", stderr); } style_Reset(stderr); } // LCOV_EXCL_STOP 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 %" PRIu16, palettes.size(), options.nbPalettes ); } 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 } uint16_t widthTiles = options.inputSlice.width ? options.inputSlice.width : image.png.width / 8; uint16_t heightTiles = options.inputSlice.height ? options.inputSlice.height : image.png.height / 8; uint64_t nbTiles = widthTiles * heightTiles; uint64_t nbKeptTiles = nbTiles > options.trim ? nbTiles - options.trim : 0; uint64_t tileIdx = 0; for (auto const &[tile, attr] : zip(image.visitAsTiles(), attrmap)) { // Do not emit fully-background tiles. if (attr.isBackgroundTile()) { ++tileIdx; 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(options.tilemap), strerror(errno)); // LCOV_EXCL_STOP } } }; autoOpenPath(options.tilemap, tilemapOutput); autoOpenPath(options.attrmap, attrmapOutput); autoOpenPath(options.palmap, palmapOutput); uint8_t tileID = 0; uint8_t bank = 0; for (AttrmapEntry const &attr : attrmap) { if (tileID == options.maxNbTiles[bank]) { assume(bank == 0); bank = 1; tileID = 0; } if (tilemapOutput.has_value()) { (*tilemapOutput) ->sputc((attr.isBackgroundTile() ? 0 : tileID) + options.baseTileIDs[bank]); } uint8_t palID = attr.getPalID(mappings) + options.basePalID; if (attrmapOutput.has_value()) { (*attrmapOutput)->sputc((palID & 0b111) | bank << 3); // The other flags are all 0 } if (palmapOutput.has_value()) { (*palmapOutput)->sputc(palID); } // Background tiles are skipped in the tile data, so they should be skipped in the maps too. if (!attr.isBackgroundTile()) { ++tileID; } } } 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 { 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( 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 = 0; } else { auto [tileID, matchType] = tiles.addTile({tile, palettes[mappings[attr.colorSetID]]}); 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 = tileID >= options.maxNbTiles[0]; attr.tileID = (attr.bank ? tileID - options.maxNbTiles[0] : tileID) + 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 } uint16_t tileID = 0; for (auto iter = tiles.begin(), end = tiles.end() - options.trim; iter != end; ++iter) { TileData const *tile = *iter; assume(tile->tileID == tileID); ++tileID; if (options.bitDepth == 2) { output->sputn(reinterpret_cast(tile->data().data()), 16); } else { assume(options.bitDepth == 1); for (size_t y = 0; y < 8; ++y) { output->sputc(tile->data()[y * 2]); } } } } 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 } 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; 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) { output->sputc(entry.getPalID(mappings) + options.basePalID); } } void processPalettes() { verbosePrint(VERB_CONFIG, "Using libpng %s\n", png_get_libpng_ver(nullptr)); std::vector colorSets; std::vector palettes; std::tie(std::ignore, palettes) = makePalsAsSpecified(colorSets); outputPalettes(palettes); } void process() { verbosePrint(VERB_CONFIG, "Using libpng %s\n", png_get_libpng_ver(nullptr)); verbosePrint(VERB_NOTICE, "Reading tiles...\n"); Image image(options.input); // This also sets `hasTransparentPixels` as a side effect // LCOV_EXCL_START if (checkVerbosity(VERB_INFO)) { style_Set(stderr, STYLE_MAGENTA, false); fputs("Image colors: [ ", stderr); for (std::optional const &slot : image.colors) { if (!slot.has_value()) { continue; } fprintf(stderr, "#%08x, ", slot->toCSS()); } fputs("]\n", stderr); style_Reset(stderr); } // LCOV_EXCL_STOP if (options.palSpecType == Options::DMG) { if (options.hasTransparentPixels) { fatal( "Image contains transparent pixels, not compatible with a DMG palette specification" ); } if (!image.isSuitableForGrayscale()) { fatal("Image contains too many or non-gray colors, not compatible with a DMG palette " "specification"); } } // 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 non-transparent colors for packing std::unordered_set tileColors; for (uint32_t y = 0; y < 8; ++y) { for (uint32_t x = 0; x < 8; ++x) { if (Rgba color = tile.pixel(x, y); !color.isTransparent() || !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::WE_BIGGER: colorSets[n] = colorSet; // Override them // Remove any other color sets that we encompass // (Example [(0, 1), (0, 2)], inserting (0, 1, 2)) [[fallthrough]]; case ColorSet::THEY_BIGGER: // Do nothing, they already contain us attrs.colorSetID = n; goto continue_visiting_tiles; // Can't `continue` from within a nested loop case ColorSet::NEITHER: break; // Keep going } } attrs.colorSetID = colorSets.size(); if (colorSets.size() == AttrmapEntry::background) { // Check for overflow fatal( "Reached %zu color sets... sorry, this image is too much for me to handle :(", AttrmapEntry::transparent ); } colorSets.push_back(colorSet); continue_visiting_tiles:; } verbosePrint( VERB_INFO, "Image contains %zu color set%s\n", colorSets.size(), colorSets.size() != 1 ? "s" : "" ); // LCOV_EXCL_START if (checkVerbosity(VERB_INFO)) { style_Set(stderr, STYLE_MAGENTA, false); for (ColorSet const &colorSet : colorSets) { fputs("[ ", stderr); for (uint16_t color : colorSet) { fprintf(stderr, "$%04x, ", color); } fputs("]\n", stderr); } style_Reset(stderr); } // LCOV_EXCL_STOP if (options.palSpecType == Options::EMBEDDED) { generatePalSpec(image); } auto [mappings, palettes] = options.palSpecType == Options::NO_SPEC || options.palSpecType == Options::DMG ? generatePalettes(colorSets, image) : makePalsAsSpecified(colorSets); 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 = image.png.height / 8, nbTilesW = image.png.width / 8; // Check the tile count if (uint32_t nbTiles = nbTilesW * nbTilesH; nbTiles > options.maxNbTiles[0] + options.maxNbTiles[1]) { fatal( "Image contains %" PRIu32 " tiles, exceeding the limit of %" PRIu16 " + %" PRIu16, nbTiles, options.maxNbTiles[0], options.maxNbTiles[1] ); } // 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); if (size_t nbTiles = tiles.size(); 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] ); } 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); } } }