diff --git a/Makefile b/Makefile index dab6c013..209d8808 100644 --- a/Makefile +++ b/Makefile @@ -110,6 +110,7 @@ rgbgfx_obj := \ src/gfx/pal_sorting.o \ src/gfx/process.o \ src/gfx/proto_palette.o \ + src/gfx/reverse.o \ src/gfx/rgba.o \ src/extern/getopt.o \ src/error.o diff --git a/include/gfx/main.hpp b/include/gfx/main.hpp index f3a778cf..cf6c5ad6 100644 --- a/include/gfx/main.hpp +++ b/include/gfx/main.hpp @@ -20,6 +20,9 @@ #include "gfx/rgba.hpp" struct Options { + uint8_t reversedWidth = 0; // -r, in pixels + bool reverse() const { return reversedWidth != 0; } + bool useColorCurve = false; // -C bool fixInput = false; // -f bool allowMirroring = false; // -m @@ -36,7 +39,7 @@ struct Options { } palSpecType = NO_SPEC; // -c std::vector> palSpec{}; uint8_t bitDepth = 2; // -d - std::array inputSlice{0, 0, 0, 0}; // -L + std::array inputSlice{0, 0, 0, 0}; // -L (margins in clockwise order, like CSS) std::array maxNbTiles{UINT16_MAX, 0}; // -N uint8_t nbPalettes = 8; // -n std::string output{}; // -o @@ -84,4 +87,12 @@ struct Palette { uint8_t size() const; }; +static constexpr uint8_t flip(uint8_t byte) { + // To flip all the bits, we'll flip both nibbles, then each nibble half, etc. + byte = (byte & 0b0000'1111) << 4 | (byte & 0b1111'0000) >> 4; + byte = (byte & 0b0011'0011) << 2 | (byte & 0b1100'1100) >> 2; + byte = (byte & 0b0101'0101) << 1 | (byte & 0b1010'1010) >> 1; + return byte; +} + #endif /* RGBDS_GFX_MAIN_HPP */ diff --git a/include/gfx/reverse.hpp b/include/gfx/reverse.hpp new file mode 100644 index 00000000..aa1aef5b --- /dev/null +++ b/include/gfx/reverse.hpp @@ -0,0 +1,14 @@ +/* + * This file is part of RGBDS. + * + * Copyright (c) 2022, Eldred Habert and RGBDS contributors. + * + * SPDX-License-Identifier: MIT + */ + +#ifndef RGBDS_GFX_REVERSE_HPP +#define RGBDS_GFX_REVERSE_HPP + +void reverse(); + +#endif /* RGBDS_GFX_REVERSE_HPP */ diff --git a/include/gfx/rgba.hpp b/include/gfx/rgba.hpp index a9917fb7..3f71fe19 100644 --- a/include/gfx/rgba.hpp +++ b/include/gfx/rgba.hpp @@ -17,13 +17,23 @@ struct Rgba { uint8_t blue; uint8_t alpha; - Rgba(uint8_t r, uint8_t g, uint8_t b, uint8_t a) : red(r), green(g), blue(b), alpha(a) {} + constexpr Rgba(uint8_t r, uint8_t g, uint8_t b, uint8_t a) + : red(r), green(g), blue(b), alpha(a) {} /** * Constructs the color from a "packed" RGBA representation (0xRRGGBBAA) */ - explicit Rgba(uint32_t rgba = 0) + explicit constexpr Rgba(uint32_t rgba = 0) : red(rgba >> 24), green(rgba >> 16), blue(rgba >> 8), alpha(rgba) {} + static constexpr Rgba fromCGBColor(uint16_t cgbColor) { + constexpr auto _5to8 = [](uint8_t fiveBpp) -> uint8_t { + fiveBpp &= 0b11111; // For caller's convenience + return fiveBpp << 3 | fiveBpp >> 2; + }; + return {_5to8(cgbColor), _5to8(cgbColor >> 5), _5to8(cgbColor >> 10), + (uint8_t)(cgbColor & 0x8000 ? 0x00 : 0xFF)}; + } + /** * Returns this RGBA as a 32-bit number that can be printed in hex (`%08x`) to yield its CSS * representation diff --git a/man/rgbgfx.1 b/man/rgbgfx.1 index 6b771c97..4b09b21d 100644 --- a/man/rgbgfx.1 +++ b/man/rgbgfx.1 @@ -14,6 +14,7 @@ .Nd Game Boy graphics converter .Sh SYNOPSIS .Nm +.Op Fl r Ar stride .Op Fl CfmuVZ .Op Fl v Op Fl v No ... .Op Fl a Ar attrmap | Fl A @@ -33,7 +34,7 @@ .Sh DESCRIPTION The .Nm -program converts PNG images into data suitable for display on the Game Boy and Game Boy Color. +program converts PNG images into data suitable for display on the Game Boy and Game Boy Color, or vice-versa. .Pp The main function of .Nm @@ -214,6 +215,22 @@ Specify how many colors each palette contains, including the transparent one if cannot be more than .Ql 1 << Ar depth .Pq see Fl d . +.It Fl r Ar width , Fl Fl reverse Ar width +Switches +.Nm +into +.Dq Sy reverse +mode. +In this mode, instead of converting a PNG image into Game Boy data, +.Nm +will attempt to reverse the process, and render Game Boy data into an image. +See +.Sx REVERSE MODE +below for details. +.Pp +.Ar width +is the image's width, in tiles +.Pq including any margins specified by Fl L . .It Fl t Ar tilemap , Fl Fl tilemap Ar tilemap Generate a file of tile indices. For each square of the input image, its corresponding tile map byte contains the index of the associated tile in the tile data file. @@ -430,6 +447,46 @@ There is no padding between colors, nor between palettes; however, empty colors TODO. .Ss Attrmap data TODO. +.Sh REVERSE MODE +.Nm +can produce a PNG image from valid data. +This may be useful for ripping graphics, recovering lost source images, etc. +An important caveat on that last one, though: the conversion process is +.Sy lossy +both ways, so the +.Do reversed Dc image won't be perfectly identical to the original\(embut it should be close to a Game Boy's output . +.Pq Keep in mind that many of consoles output different colors, so there is no true reference rendering. +.Pp +When using reverse mode, make sure to pass the same flags that were given when generating the data, especially +.Fl C , d , N , s , x , +and +.Fl Z . +.Do At-files Dc may help with this . +.Nm +will warn about any inconsistencies it detects. +.Pp +Files that are normally outputs +.Pq Fl a , p , t +become inputs, and +.Ar file +will be written to instead of read from, and thus needs not exist beforehand. +Any of these inputs not passed is assumed to be some default: +.Bl -column "attribute map" +.It palettes Ta Unspecified palette data makes +.Nm +assume DMG (monochrome Game Boy) mode: a single palette of 4 grays. +It is possible to pass palettes using +.Fl c +instead of +.Fl p . +.It tile data Ta Tile data must be provided, as there is no reasonable assumption to fall back on. +.It tile map Ta A missing tile map makes +.Nm +assume that tiles were not deduplicated, and should be laid out in the order they are stored. +.It attribute map Ta Without an attribute map, +.Nm +assumes that no tiles were mirrored. +.El .Sh NOTES Some flags have had their functionality removed. .Fl D diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6273d74f..77709a72 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -75,6 +75,7 @@ set(rgbgfx_src "gfx/pal_sorting.cpp" "gfx/process.cpp" "gfx/proto_palette.cpp" + "gfx/reverse.cpp" "gfx/rgba.cpp" "extern/getopt.c" "error.c" diff --git a/src/gfx/main.cpp b/src/gfx/main.cpp index ea216657..8a125047 100644 --- a/src/gfx/main.cpp +++ b/src/gfx/main.cpp @@ -27,6 +27,7 @@ #include "version.h" #include "gfx/process.hpp" +#include "gfx/reverse.hpp" using namespace std::literals::string_view_literals; @@ -83,7 +84,7 @@ void Options::verbosePrint(uint8_t level, char const *fmt, ...) const { } // Short options -static char const *optstring = "-Aa:b:Cc:Dd:FfhL:mN:n:o:Pp:s:Tt:U:uVvx:Z"; +static char const *optstring = "-Aa:b:Cc:Dd:FfhL:mN:n:o:Pp:r:s:Tt:U:uVvx:Z"; /* * Equivalent long options @@ -113,6 +114,7 @@ static struct option const longopts[] = { {"output", required_argument, NULL, 'o'}, {"output-palette", no_argument, NULL, 'P'}, {"palette", required_argument, NULL, 'p'}, + {"reverse", required_argument, NULL, 'r'}, {"output-tilemap", no_argument, NULL, 'T'}, {"tilemap", required_argument, NULL, 't'}, {"unit-size", required_argument, NULL, 'U'}, @@ -125,7 +127,7 @@ static struct option const longopts[] = { }; static void printUsage(void) { - fputs("Usage: rgbgfx [-CfmuVZ] [-v [-v ...]] [-a | -A] [-b base_ids]\n" + fputs("Usage: rgbgfx [-r] [-CfmuVZ] [-v [-v ...]] [-a | -A] [-b base_ids]\n" " [-c color_spec] [-d ] [-L slice] [-N nb_tiles] [-n nb_pals]\n" " [-o ] [-p | -P] [-s nb_colors] [-t | -T]\n" " [-U unit_size] [-x ] \n" @@ -430,6 +432,14 @@ static char *parseArgv(int argc, char **argv, bool &autoAttrmap, bool &autoTilem break; case 'n': options.nbPalettes = parseNumber(arg, "Number of palettes", 8); + if (*arg != '\0') { + error("Number of palettes (-n) must be a valid number, not \"%s\"", musl_optarg); + } + if (options.nbPalettes > 8) { + error("Number of palettes (-n) must not exceed 8!"); + } else if (options.nbPalettes == 0) { + error("Number of palettes (-n) may not be 0!"); + } break; case 'o': options.output = musl_optarg; @@ -441,15 +451,24 @@ static char *parseArgv(int argc, char **argv, bool &autoAttrmap, bool &autoTilem autoPalettes = false; options.palettes = musl_optarg; break; + case 'r': + options.reversedWidth = parseNumber(arg, "Reversed image stride"); + if (*arg != '\0') { + error("Reversed image stride (-r) must be a valid number, not \"%s\"", musl_optarg); + } + if (options.reversedWidth == 0) { + error("Reversed image stride (-r) may not be 0!"); + } + break; case 's': options.nbColorsPerPal = parseNumber(arg, "Number of colors per palette", 4); if (*arg != '\0') { - error("Palette size (-s) argument must be a valid number, not \"%s\"", musl_optarg); + error("Palette size (-s) must be a valid number, not \"%s\"", musl_optarg); } if (options.nbColorsPerPal > 4) { - error("Palette size (-s) argument must not exceed 4!"); + error("Palette size (-s) must not exceed 4!"); } else if (options.nbColorsPerPal == 0) { - error("Palette size (-s) argument may not be 0!"); + error("Palette size (-s) may not be 0!"); } break; case 'T': @@ -678,7 +697,11 @@ int main(int argc, char *argv[]) { return 0; } - process(); + if (options.reverse()) { + reverse(); + } else { + process(); + } return 0; } diff --git a/src/gfx/process.cpp b/src/gfx/process.cpp index cb1255e1..6c867880 100644 --- a/src/gfx/process.cpp +++ b/src/gfx/process.cpp @@ -552,14 +552,6 @@ static void outputPalettes(std::vector const &palettes) { } } -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 _data; // The hash is a bit lax: it's the XOR of all lines, and every other nibble is identical diff --git a/src/gfx/reverse.cpp b/src/gfx/reverse.cpp new file mode 100644 index 00000000..065f03ec --- /dev/null +++ b/src/gfx/reverse.cpp @@ -0,0 +1,301 @@ +/* + * This file is part of RGBDS. + * + * Copyright (c) 2022, Eldred Habert and RGBDS contributors. + * + * SPDX-License-Identifier: MIT + */ + +#include "gfx/reverse.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "defaultinitalloc.hpp" +#include "helpers.h" + +#include "gfx/main.hpp" + +static DefaultInitVec readInto(std::string path) { + std::filebuf file; + file.open(path, std::ios::in | std::ios::binary); + DefaultInitVec data(128 * 16); // Begin with some room pre-allocated + + size_t curSize = 0; + for (;;) { + size_t oldSize = curSize; + curSize = data.size(); + + // Fill the new area ([oldSize; curSize[) with bytes + size_t nbRead = + file.sgetn(reinterpret_cast(&data.data()[oldSize]), curSize - oldSize); + if (nbRead != curSize - oldSize) { + // Shrink the vector to discard bytes that weren't read + data.resize(oldSize + nbRead); + break; + } + // If the vector has some capacity left, use it; otherwise, double the current size + + // Arbitrary, but if you got a better idea... + size_t newSize = oldSize != data.capacity() ? data.capacity() : oldSize * 2; + assert(oldSize != newSize); + data.resize(newSize); + } + + return data; +} + +[[noreturn]] static void pngError(png_structp png, char const *msg) { + fatal("Error writing reversed image (\"%s\"): %s", + static_cast(png_get_error_ptr(png)), msg); +} + +static void pngWarning(png_structp png, char const *msg) { + warning("While writing reversed image (\"%s\"): %s", + static_cast(png_get_error_ptr(png)), msg); +} + +void writePng(png_structp png, png_bytep data, size_t length) { + auto &pngFile = *static_cast(png_get_io_ptr(png)); + pngFile.sputn(reinterpret_cast(data), length); +} + +void flushPng(png_structp png) { + auto &pngFile = *static_cast(png_get_io_ptr(png)); + pngFile.pubsync(); +} + +void reverse() { + options.verbosePrint(Options::VERB_CFG, "Using libpng %s\n", png_get_libpng_ver(nullptr)); + + // Check for weird flag combinations + + if (options.output.empty()) { + fatal("Tile data must be provided when reversing an image!"); + } + + if (!options.allowDedup && options.tilemap.empty()) { + warning("Tile deduplication is enabled, but no tilemap is provided?"); + } + + if (options.useColorCurve) { + warning("The color curve is not yet supported in reverse mode..."); + } + + options.verbosePrint(Options::VERB_LOG_ACT, "Reading tiles...\n"); + auto const tiles = readInto(options.output); + uint8_t tileSize = 8 * options.bitDepth; + if (tiles.size() % tileSize != 0) { + fatal("Tile data size must be a multiple of %" PRIu8 " bytes! (Read %zu)", tileSize, + tiles.size()); + } + + // By default, assume tiles are not deduplicated, and add the (allegedly) trimmed tiles + size_t nbTileInstances = tiles.size() / tileSize + options.trim; // Image size in tiles + options.verbosePrint(Options::VERB_INTERM, "Read %zu tiles.\n", nbTileInstances); + std::optional> tilemap; + if (!options.tilemap.empty()) { + tilemap = readInto(options.tilemap); + nbTileInstances = tilemap->size(); + + // TODO: range check + } + + if (nbTileInstances > options.maxNbTiles[0] + options.maxNbTiles[1]) { + warning("Read %zu tiles, more than the limit of %zu + %zu", nbTileInstances, + options.maxNbTiles[0], options.maxNbTiles[1]); + } + + if (nbTileInstances % options.reversedWidth) { + fatal("Image size (%zu tiles) is not divisible by the provided stride (%zu tiles), cannot " + "determine image dimensions", + nbTileInstances, options.reversedWidth); + } + size_t width, height; + size_t usefulWidth = options.reversedWidth - options.inputSlice[1] - options.inputSlice[3]; + if (usefulWidth % 8 != 0) { + fatal( + "No input slice specified (`-L`), and specified image width (%zu) not a multiple of 8", + usefulWidth); + } else { + width = usefulWidth / 8; + if (nbTileInstances % width != 0) { + fatal("Total number of tiles read (%zu) cannot be divided by image width (%zu tiles)", + nbTileInstances, width); + } + height = nbTileInstances / width; + } + options.verbosePrint(Options::VERB_INTERM, "Reversed image dimensions: %zux%zu tiles\n", width, + height); + + // TODO: -U + + std::vector> palettes{ + {Rgba(0xffffffff), Rgba(0xaaaaaaff), Rgba(0x555555ff), Rgba(0x000000ff)} + }; + if (!options.palettes.empty()) { + std::filebuf file; + file.open(options.palettes, std::ios::in | std::ios::binary); + + palettes.clear(); + std::array buf; // 4 colors + size_t nbRead; + do { + nbRead = file.sgetn(reinterpret_cast(buf.data()), buf.size()); + if (nbRead == buf.size()) { + // Expand the colors + auto &palette = palettes.emplace_back(); + std::generate(palette.begin(), palette.begin() + options.nbColorsPerPal, + [&buf, i = 0]() mutable { + i += 2; + return Rgba::fromCGBColor(buf[i - 2] + (buf[i - 1] << 8)); + }); + } else if (nbRead != 0) { + fatal("Palette data size (%zu) is not a multiple of %zu bytes!\n", + palettes.size() * buf.size() + nbRead, buf.size()); + } + } while (nbRead != 0); + + if (palettes.size() > options.nbPalettes) { + warning("Read %zu palettes, more than the specified limit of %zu", palettes.size(), + options.nbPalettes); + } + } + + std::optional> attrmap; + if (!options.attrmap.empty()) { + attrmap = readInto(options.attrmap); + if (attrmap->size() != nbTileInstances) { + fatal("Attribute map size (%zu tiles) doesn't match image's (%zu)", attrmap->size(), + nbTileInstances); + } + + // Scan through the attributes for inconsistencies + // We do this now for two reasons: + // 1. Checking those during the main loop is harmful to optimization, and + // 2. It clutters the code more, and it's not in great shape to begin with + } + + // TODO: palette map (overrides attributes) + + options.verbosePrint(Options::VERB_LOG_ACT, "Writing image...\n"); + std::filebuf pngFile; + pngFile.open(options.input, std::ios::out | std::ios::binary); + png_structp png = png_create_write_struct( + PNG_LIBPNG_VER_STRING, + const_cast(static_cast(options.input.c_str())), pngError, + pngWarning); + if (!png) { + fatal("Couldn't create PNG write struct: %s", strerror(errno)); + } + png_infop pngInfo = png_create_info_struct(png); + if (!pngInfo) { + fatal("Couldn't create PNG info struct: %s", strerror(errno)); + } + png_set_write_fn(png, &pngFile, writePng, flushPng); + + // TODO: if `-f` is passed, write the image indexed instead of RGB + png_set_IHDR(png, pngInfo, options.reversedWidth, + height * 8 + options.inputSlice[0] + options.inputSlice[2], 8, + PNG_COLOR_TYPE_RGB_ALPHA, PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, + PNG_FILTER_TYPE_DEFAULT); + png_write_info(png, pngInfo); + + png_color_8 sbitChunk; + sbitChunk.red = 5; + sbitChunk.green = 5; + sbitChunk.blue = 5; + sbitChunk.alpha = 1; + png_set_sBIT(png, pngInfo, &sbitChunk); + + constexpr uint8_t SIZEOF_PIXEL = 4; // Each pixel is 4 bytes (RGBA @ 8 bits/component) + size_t const SIZEOF_ROW = options.reversedWidth * SIZEOF_PIXEL; + std::vector tileRow(8 * SIZEOF_ROW, 0xFF); // Data for 8 rows of pixels + uint8_t * const rowPtrs[8] = { + &tileRow.data()[0 * SIZEOF_ROW + options.inputSlice[3]], + &tileRow.data()[1 * SIZEOF_ROW + options.inputSlice[3]], + &tileRow.data()[2 * SIZEOF_ROW + options.inputSlice[3]], + &tileRow.data()[3 * SIZEOF_ROW + options.inputSlice[3]], + &tileRow.data()[4 * SIZEOF_ROW + options.inputSlice[3]], + &tileRow.data()[5 * SIZEOF_ROW + options.inputSlice[3]], + &tileRow.data()[6 * SIZEOF_ROW + options.inputSlice[3]], + &tileRow.data()[7 * SIZEOF_ROW + options.inputSlice[3]], + }; + + auto const fillRows = [&png, &tileRow](size_t nbRows) { + for (size_t _ = 0; _ < nbRows; ++_) { + png_write_row(png, tileRow.data()); + } + }; + fillRows(options.inputSlice[0]); + + for (size_t ty = 0; ty < height; ++ty) { + for (size_t tx = 0; tx < width; ++tx) { + size_t index = options.columnMajor ? ty + tx * width : ty * width + tx; + // Get the tile ID at this location + uint8_t gbcTileID = tilemap.has_value() ? (*tilemap)[index] : index; + // By default, a tile is unflipped, in bank 0, and uses palette #0 + uint8_t attribute = attrmap.has_value() ? (*attrmap)[index] : 0x00; + bool bank = attribute & 0x08; + gbcTileID -= options.baseTileIDs[bank]; + size_t tileID = gbcTileID + bank * options.maxNbTiles[0]; + assert(tileID < nbTileInstances); // Should have been checked earlier + + // We do not have data for tiles trimmed with `-x`, so assume they are "blank" + static std::array const trimmedTile{ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + uint8_t const *tileData = tileID > nbTileInstances - options.trim + ? trimmedTile.data() + : &tiles[tileID * tileSize]; + assert((attribute & 0b111) < palettes.size()); // Should be ensured on data read + auto const &palette = palettes[attribute & 0b111]; + for (uint8_t y = 0; y < 8; ++y) { + // If vertically mirrored, fetch the bytes from the other end + uint8_t realY = attribute & 0x40 ? 7 - y : y; + uint8_t bitplane0 = tileData[realY * 2], bitplane1 = tileData[realY * 2 + 1]; + if (attribute & 0x20) { // Handle horizontal flip + bitplane0 = flip(bitplane0); + bitplane1 = flip(bitplane1); + } + uint8_t *ptr = &rowPtrs[y][tx * 8 * SIZEOF_PIXEL]; + for (uint8_t x = 0; x < 8; ++x) { + uint8_t bit0 = bitplane0 & 0x80, bit1 = bitplane1 & 0x80; + Rgba const &pixel = palette[bit0 >> 7 | bit1 >> 6]; + *ptr++ = pixel.red; + *ptr++ = pixel.green; + *ptr++ = pixel.blue; + *ptr++ = pixel.alpha; + + // Shift the pixel out + bitplane0 <<= 1; + bitplane1 <<= 1; + } + } + } + // We never modify the pointers, and neither should libpng, despite the overly lax function + // signature. + // (AIUI, casting away const-ness is okay as long as you don't actually modify the + // pointed-to data) + png_write_rows(png, const_cast(rowPtrs), 8); + } + // Clear the first row again for the function + std::fill(tileRow.begin(), tileRow.begin() + SIZEOF_ROW, 0xFF); + fillRows(options.inputSlice[2]); + + // Finalize the write + png_write_end(png, pngInfo); + + png_destroy_write_struct(&png, &pngInfo); + pngFile.close(); +}