From 7054d81650091b51d52c96aa46c1283613e9c5ae Mon Sep 17 00:00:00 2001 From: Rangi <35663410+Rangi42@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:53:05 -0400 Subject: [PATCH] Implement grayscale DMG palette specs (#1709) --- include/gfx/main.hpp | 31 +++-------- include/gfx/pal_spec.hpp | 5 +- man/rgbgfx.1 | 20 ++++++++ src/gfx/main.cpp | 48 +++--------------- src/gfx/pal_packing.cpp | 2 +- src/gfx/pal_spec.cpp | 79 +++++++++++++++++++++++++++++ src/gfx/process.cpp | 25 +++++++-- src/gfx/reverse.cpp | 13 +++-- src/gfx/rgba.cpp | 12 ++++- test/gfx/dmg_1bit_round_trip.1bpp | Bin 0 -> 8 bytes test/gfx/dmg_1bit_round_trip.flags | 2 + test/gfx/dmg_2bit.flags | 2 + test/gfx/dmg_2bit.out.2bpp | Bin 0 -> 16 bytes test/gfx/dmg_2bit.out.pal | Bin 0 -> 8 bytes test/gfx/dmg_2bit.png | Bin 0 -> 79 bytes test/gfx/dmg_plte.flags | 2 + test/gfx/dmg_plte.out.2bpp | Bin 0 -> 16 bytes test/gfx/dmg_plte.out.pal | Bin 0 -> 8 bytes test/gfx/dmg_plte.png | Bin 0 -> 118 bytes test/gfx/dmg_round_trip.2bpp | Bin 0 -> 16 bytes test/gfx/dmg_round_trip.flags | 1 + 21 files changed, 166 insertions(+), 76 deletions(-) create mode 100644 test/gfx/dmg_1bit_round_trip.1bpp create mode 100644 test/gfx/dmg_1bit_round_trip.flags create mode 100644 test/gfx/dmg_2bit.flags create mode 100644 test/gfx/dmg_2bit.out.2bpp create mode 100644 test/gfx/dmg_2bit.out.pal create mode 100644 test/gfx/dmg_2bit.png create mode 100644 test/gfx/dmg_plte.flags create mode 100644 test/gfx/dmg_plte.out.2bpp create mode 100644 test/gfx/dmg_plte.out.pal create mode 100644 test/gfx/dmg_plte.png create mode 100644 test/gfx/dmg_round_trip.2bpp create mode 100644 test/gfx/dmg_round_trip.flags diff --git a/include/gfx/main.hpp b/include/gfx/main.hpp index f951cca6..e4bedb13 100644 --- a/include/gfx/main.hpp +++ b/include/gfx/main.hpp @@ -29,8 +29,10 @@ struct Options { NO_SPEC, EXPLICIT, EMBEDDED, + DMG, } palSpecType = NO_SPEC; // -c std::vector, 4>> palSpec{}; + uint8_t palSpecDmg = 0; uint8_t bitDepth = 2; // -d std::string inputTileset{}; // -i struct { @@ -65,6 +67,12 @@ struct Options { mutable bool hasTransparentPixels = false; uint8_t maxOpaqueColors() const { return nbColorsPerPal - hasTransparentPixels; } + + uint8_t dmgColors[4] = {}; + uint8_t dmgValue(uint8_t i) const { + assume(i < 4); + return (palSpecDmg >> (2 * i)) & 0b11; + } }; extern Options options; @@ -119,27 +127,4 @@ static constexpr auto flipTable = ([]() constexpr { return table; })(); -// Parsing helpers. - -static constexpr uint8_t nibble(char c) { - if (c >= 'a') { - assume(c <= 'f'); - return c - 'a' + 10; - } else if (c >= 'A') { - assume(c <= 'F'); - return c - 'A' + 10; - } else { - assume(c >= '0' && c <= '9'); - return c - '0'; - } -} - -static constexpr uint8_t toHex(char c1, char c2) { - return nibble(c1) * 16 + nibble(c2); -} - -static constexpr uint8_t singleToHex(char c) { - return toHex(c, c); -} - #endif // RGBDS_GFX_MAIN_HPP diff --git a/include/gfx/pal_spec.hpp b/include/gfx/pal_spec.hpp index 0b3abd4f..9423b97f 100644 --- a/include/gfx/pal_spec.hpp +++ b/include/gfx/pal_spec.hpp @@ -3,7 +3,10 @@ #ifndef RGBDS_GFX_PAL_SPEC_HPP #define RGBDS_GFX_PAL_SPEC_HPP -void parseInlinePalSpec(char const * const arg); +void parseInlinePalSpec(char const * const rawArg); void parseExternalPalSpec(char const *arg); +void parseDmgPalSpec(char const * const rawArg); + +void parseBackgroundPalSpec(char const *arg); #endif // RGBDS_GFX_PAL_SPEC_HPP diff --git a/man/rgbgfx.1 b/man/rgbgfx.1 index 62eca623..8db8e1a3 100644 --- a/man/rgbgfx.1 +++ b/man/rgbgfx.1 @@ -159,6 +159,24 @@ is the case-insensitive word then the first four colors of the input PNG's embedded palette are used. It is an error if the PNG is not indexed, or if colors other than these 4 are used. .Pq This is different from the default behavior of indexed PNGs, as then unused entries in the embedded palette are ignored, whereas they are not with Fl c Cm embedded . +.It Sy DMG palette spec +If +.Ar pal_spec +starts with case-insensitive +.Cm dmg= , +then the following two-digit hexadecimal number specifies four grayscale DMG color indexes. +The number functions like the DMG's $FF47 +.Sy BGP +register +(see +.Lk https://gbdev.io/pandocs/Palettes.html Pan Docs +for more information): +the low two bits 0-1 specify which gray shade goes in color index 0, +the next two bits 2-3 specify which gray shade goes in color index 1, +and so on. +Gray shade 0 is the lightest (white), 3 is the darkest (black). +The same gray shade cannot go in two color indexes. +To specify a DMG palette, the input PNG must have all its colors in shades of gray, without any transparent colors. .It Sy external palette spec Otherwise, .Ar pal_spec @@ -528,6 +546,8 @@ Otherwise, if the PNG only contains shades of gray, they will be categorized int .Dq bins as there are colors per palette, and the palette is set to these bins. The darkest gray will end up in bin #0, and so on; note that this is the opposite of the RGB method below. +This is equivalent to having specified a DMG palette of +.Fl c Cm dmg=E4 . If two distinct grays end up in the same bin, the RGB method is used instead. .Pp Be careful that diff --git a/src/gfx/main.cpp b/src/gfx/main.cpp index fa4a0235..9de3857e 100644 --- a/src/gfx/main.cpp +++ b/src/gfx/main.cpp @@ -16,7 +16,6 @@ #include "extern/getopt.hpp" #include "file.hpp" -#include "helpers.hpp" // assume #include "platform.hpp" #include "version.hpp" @@ -354,7 +353,6 @@ static char *parseArgv(int argc, char *argv[]) { for (int ch; (ch = musl_getopt_long_only(argc, argv, optstring, longopts, nullptr)) != -1;) { char *arg = musl_optarg; // Make a copy for scanning uint16_t number; - size_t size; switch (ch) { case 'A': localOptions.autoAttrmap = true; @@ -367,41 +365,7 @@ static char *parseArgv(int argc, char *argv[]) { options.attrmap = musl_optarg; break; case 'B': - if (strcasecmp(musl_optarg, "transparent") == 0) { - options.bgColor = Rgba(0x00, 0x00, 0x00, 0x00); - break; - } - if (musl_optarg[0] != '#') { - error("Background color specification must be `#rgb`, `#rrggbb`, or `transparent`"); - break; - } - size = strspn(&musl_optarg[1], "0123456789ABCDEFabcdef"); - switch (size) { - case 3: - options.bgColor = Rgba( - singleToHex(musl_optarg[1]), - singleToHex(musl_optarg[2]), - singleToHex(musl_optarg[3]), - 0xFF - ); - break; - case 6: - options.bgColor = Rgba( - toHex(musl_optarg[1], musl_optarg[2]), - toHex(musl_optarg[3], musl_optarg[4]), - toHex(musl_optarg[5], musl_optarg[6]), - 0xFF - ); - break; - default: - error("Unknown background color specification \"%s\"", musl_optarg); - } - if (musl_optarg[size + 1] != '\0') { - error( - "Unexpected text \"%s\" after background color specification", - &musl_optarg[size + 1] - ); - } + parseBackgroundPalSpec(musl_optarg); break; case 'b': number = parseNumber(arg, "Bank 0 base tile ID", 0); @@ -442,18 +406,18 @@ static char *parseArgv(int argc, char *argv[]) { options.useColorCurve = true; break; case 'c': + localOptions.externalPalSpec = nullptr; // Allow overriding a previous pal spec if (musl_optarg[0] == '#') { options.palSpecType = Options::EXPLICIT; parseInlinePalSpec(musl_optarg); } else if (strcasecmp(musl_optarg, "embedded") == 0) { // Use PLTE, error out if missing options.palSpecType = Options::EMBEDDED; + } else if (strncasecmp(musl_optarg, "dmg=", literal_strlen("dmg=")) == 0) { + options.palSpecType = Options::DMG; + parseDmgPalSpec(&musl_optarg[literal_strlen("dmg=")]); } else { options.palSpecType = Options::EXPLICIT; - // Can't parse the file yet, as "flat" color collections need to know the palette - // size to be split; thus, we defer that. - // If a following `-c` overrides a previous one, the `fmt` part of an overridden - // external palette spec will not be validated, but I guess that's okay. localOptions.externalPalSpec = musl_optarg; } break; @@ -877,6 +841,8 @@ int main(int argc, char *argv[]) { return "Explicit"; case Options::EMBEDDED: return "Embedded"; + case Options::DMG: + return "DMG"; } return "???"; }()); diff --git a/src/gfx/pal_packing.cpp b/src/gfx/pal_packing.cpp index a4540eb9..a08c829e 100644 --- a/src/gfx/pal_packing.cpp +++ b/src/gfx/pal_packing.cpp @@ -299,7 +299,7 @@ static void decant( break; } auto attrs = from.begin(); - std::advance(attrs, (iter - processed.begin())); + std::advance(attrs, iter - processed.begin()); // Build up the "component"... colors.clear(); diff --git a/src/gfx/pal_spec.cpp b/src/gfx/pal_spec.cpp index 65f8fef9..23113550 100644 --- a/src/gfx/pal_spec.cpp +++ b/src/gfx/pal_spec.cpp @@ -28,6 +28,27 @@ static void skipWhitespace(Str const &str, size_t &pos) { pos = std::min(str.find_first_not_of(" \t"sv, pos), str.length()); } +static constexpr uint8_t nibble(char c) { + if (c >= 'a') { + assume(c <= 'f'); + return c - 'a' + 10; + } else if (c >= 'A') { + assume(c <= 'F'); + return c - 'A' + 10; + } else { + assume(c >= '0' && c <= '9'); + return c - '0'; + } +} + +static constexpr uint8_t toHex(char c1, char c2) { + return nibble(c1) * 16 + nibble(c2); +} + +static constexpr uint8_t singleToHex(char c) { + return toHex(c, c); +} + void parseInlinePalSpec(char const * const rawArg) { // List of #rrggbb/#rgb colors (or #none); comma-separated. // Palettes are separated by colons. @@ -595,3 +616,61 @@ void parseExternalPalSpec(char const *arg) { std::get<1> (*iter)(file); } + +void parseDmgPalSpec(char const * const rawArg) { + // Two hex digit DMG palette spec + + std::string_view arg(rawArg); + + if (arg.length() != 2 + || arg.find_first_not_of("0123456789ABCDEFabcdef"sv) != std::string_view::npos) { + error("Unknown DMG palette specification \"%s\"", rawArg); + return; + } + + options.palSpecDmg = toHex(arg[0], arg[1]); + + // Map gray shades to their DMG color indexes for fast lookup by `Rgba::grayIndex` + for (uint8_t i = 0; i < 4; ++i) { + options.dmgColors[options.dmgValue(i)] = i; + } + + // Validate that DMG palette spec does not have conflicting colors + for (uint8_t i = 0; i < 3; ++i) { + for (uint8_t j = i + 1; j < 4; ++j) { + if (options.dmgValue(i) == options.dmgValue(j)) { + error("DMG palette specification maps two gray shades to the same color index"); + return; + } + } + } +} + +void parseBackgroundPalSpec(char const *arg) { + if (strcasecmp(arg, "transparent") == 0) { + options.bgColor = Rgba(0x00, 0x00, 0x00, 0x00); + return; + } + + if (arg[0] != '#') { + error("Background color specification must be `#rgb`, `#rrggbb`, or `transparent`"); + return; + } + + size_t size = strspn(&arg[1], "0123456789ABCDEFabcdef"); + switch (size) { + case 3: + options.bgColor = Rgba(singleToHex(arg[1]), singleToHex(arg[2]), singleToHex(arg[3]), 0xFF); + break; + case 6: + options.bgColor = + Rgba(toHex(arg[1], arg[2]), toHex(arg[3], arg[4]), toHex(arg[5], arg[6]), 0xFF); + break; + default: + error("Unknown background color specification \"%s\"", arg); + } + + if (arg[size + 1] != '\0') { + error("Unexpected text \"%s\" after background color specification", &arg[size + 1]); + } +} diff --git a/src/gfx/process.cpp b/src/gfx/process.cpp index ac8d4c9a..369a4c86 100644 --- a/src/gfx/process.cpp +++ b/src/gfx/process.cpp @@ -586,8 +586,10 @@ static std::tuple, std::vector> } // "Sort" colors in the generated palettes, see the man page for the flowchart - auto [embPalSize, embPalRGB, embPalAlphaSize, embPalAlpha] = png.getEmbeddedPal(); - if (embPalRGB != nullptr) { + if (options.palSpecType == Options::DMG) { + sortGrayscale(palettes, png.getColors().raw()); + } else if (auto [embPalSize, embPalRGB, embPalAlphaSize, embPalAlpha] = png.getEmbeddedPal(); + embPalRGB != nullptr) { sortIndexed(palettes, embPalSize, embPalRGB, embPalAlphaSize, embPalAlpha); } else if (png.isSuitableForGrayscale()) { sortGrayscale(palettes, png.getColors().raw()); @@ -1139,6 +1141,18 @@ void process() { } // LCOV_EXCL_STOP + if (options.palSpecType == Options::DMG) { + if (options.hasTransparentPixels) { + fatal( + "Image contains transparent pixels, not compatible with a DMG palette specification" + ); + } + if (!png.isSuitableForGrayscale()) { + fatal("Image contains too many or non-gray colors, not compatible with a DMG palette " + "specification"); + } + } + // 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 @@ -1248,9 +1262,10 @@ continue_visiting_tiles:; if (options.palSpecType == Options::EMBEDDED) { generatePalSpec(png); } - auto [mappings, palettes] = options.palSpecType == Options::NO_SPEC - ? generatePalettes(protoPalettes, png) - : makePalsAsSpecified(protoPalettes); + auto [mappings, palettes] = + options.palSpecType == Options::NO_SPEC || options.palSpecType == Options::DMG + ? generatePalettes(protoPalettes, png) + : makePalsAsSpecified(protoPalettes); outputPalettes(palettes); // If deduplication is not happening, we just need to output the tile data and/or maps as-is diff --git a/src/gfx/reverse.cpp b/src/gfx/reverse.cpp index 51d7a72a..6e3c3125 100644 --- a/src/gfx/reverse.cpp +++ b/src/gfx/reverse.cpp @@ -195,10 +195,13 @@ void reverse() { Options::VERB_INTERM, "Reversed image dimensions: %zux%zu tiles\n", width, height ); - std::vector, 4>> palettes{ - {Rgba(0xFFFFFFFF), Rgba(0xAAAAAAFF), Rgba(0x555555FF), Rgba(0x000000FF)} + Rgba const grayColors[4] = { + Rgba(0xFFFFFFFF), Rgba(0xAAAAAAFF), Rgba(0x555555FF), Rgba(0x000000FF) }; - // If a palette file is used as input, it overrides the default colors. + std::vector, 4>> palettes{ + {grayColors[0], grayColors[1], grayColors[2], grayColors[3]} + }; + // If a palette file or palette spec is used as input, it overrides the default colors. if (!options.palettes.empty()) { File file; if (!file.open(options.palettes, std::ios::in | std::ios::binary)) { @@ -255,6 +258,10 @@ void reverse() { putc('\n', stderr); } } + } else if (options.palSpecType == Options::DMG) { + for (size_t i = 0; i < palettes[0].size(); ++i) { + palettes[0][i] = grayColors[options.dmgValue(i)]; + } } else if (options.palSpecType == Options::EMBEDDED) { warning("An embedded palette was requested, but no palette file was specified; ignoring " "request."); diff --git a/src/gfx/rgba.cpp b/src/gfx/rgba.cpp index ace209f3..aefe041d 100644 --- a/src/gfx/rgba.cpp +++ b/src/gfx/rgba.cpp @@ -58,7 +58,15 @@ uint16_t Rgba::cgbColor() const { uint8_t Rgba::grayIndex() const { assume(isGray()); - // Convert from 0..<256 to hasTransparentPixels..hE&{2{_%f4V@KFpriB** b1ell^+9$C8?C$>h8KlzF)z4*}Q$iB}{g)R^ literal 0 HcmV?d00001 diff --git a/test/gfx/dmg_plte.flags b/test/gfx/dmg_plte.flags new file mode 100644 index 00000000..bd22040c --- /dev/null +++ b/test/gfx/dmg_plte.flags @@ -0,0 +1,2 @@ +-c embedded +-c DMG=E4 diff --git a/test/gfx/dmg_plte.out.2bpp b/test/gfx/dmg_plte.out.2bpp new file mode 100644 index 0000000000000000000000000000000000000000..e5633a61f7729d712ce4eea0ba3efed919ca4325 GIT binary patch literal 16 QcmZQzU|{$UgdhS004wAN`2YX_ literal 0 HcmV?d00001 diff --git a/test/gfx/dmg_plte.out.pal b/test/gfx/dmg_plte.out.pal new file mode 100644 index 0000000000000000000000000000000000000000..0ef9254a0c759df4203f038735fd80ffe91ae907 GIT binary patch literal 8 PcmexgzctKDlYs#M69@wR literal 0 HcmV?d00001 diff --git a/test/gfx/dmg_plte.png b/test/gfx/dmg_plte.png new file mode 100644 index 0000000000000000000000000000000000000000..bb7b65d230ebbe0d5a3ac8ee8696ed0f41712c00 GIT binary patch literal 118 zcmeAS@N?(olHy`uVBq!ia0vp^93afW3?x5a^xFxf7>k44ofy`glX(f`@C5jTxCRA* z6#oDJf9=|}uL3Kc0XY($E{-7_Gm{gVnAj|uCAkE6d=7B17O*i0YA__e-j*B-RKwuu L>gTe~DWM4fiPIkj literal 0 HcmV?d00001 diff --git a/test/gfx/dmg_round_trip.2bpp b/test/gfx/dmg_round_trip.2bpp new file mode 100644 index 0000000000000000000000000000000000000000..45ee74337c63461ecabed771666796347c349374 GIT binary patch literal 16 Scmey*@Sov75HNrMkO=@w#0U8R literal 0 HcmV?d00001 diff --git a/test/gfx/dmg_round_trip.flags b/test/gfx/dmg_round_trip.flags new file mode 100644 index 00000000..956671b9 --- /dev/null +++ b/test/gfx/dmg_round_trip.flags @@ -0,0 +1 @@ +-c dmg=72