Compare commits

...
Author SHA1 Message Date
Justin Mitchell 737b22a2df Fix XTC grayscale mapping for dark/light gray pixels
Swap the grayscale values for pixel values 1 and 2 to match the cover's kXthToBmp mapping. Previously 1 mapped to 170 (light gray) and 2 to 85 (dark gray), now corrected to 1->85 (dark gray) and 2->170 (light gray). Use lookup table for clarity.
2026-06-12 16:40:26 -04:00
Justin Mitchell be5ce94c0f Switch XTC cover BMP output from 1-bit to 2-bit grayscale
Replace the 1-bit BMP header generation with a new 2-bit grayscale format that includes a 4-color palette (black, dark gray, light gray, white). This matches the layout used by cover converters and writes the complete header as a single buffer.
2026-06-12 16:27:29 -04:00
+46 -35
View File
@@ -173,37 +173,53 @@ bool Xtc::generateCoverBmp() const {
return false;
}
// Write 1-bit BMP header (top-down row order)
BmpHeader bmpHeader;
createBmpHeader(&bmpHeader, pageInfo.width, pageInfo.height, BmpRowOrder::TopDown);
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeader), sizeof(bmpHeader));
const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4;
// Write bitmap data
// BMP requires 4-byte row alignment
const size_t dstRowSize = (pageInfo.width + 7) / 8; // 1-bit destination row size
if (bitDepth == 2) {
// XTH 2-bit mode: Two bit planes, column-major order
// XTH 2-bit mode: preserve all 4 gray levels in a 2-bit BMP so the sleep
// screen's grayscale pass can render them (a 1-bit cover would silently
// disable it). Source is two bit planes, column-major order:
// - Columns scanned right to left (x = width-1 down to 0)
// - 8 vertical pixels per byte (MSB = topmost pixel in group)
// - First plane: Bit1, Second plane: Bit2
// - Pixel value = (bit1 << 1) | bit2
// - Pixel value = (bit1 << 1) | bit2: 0=white, 1=dark gray,
// 2=light gray, 3=black
// 70-byte 2-bit BMP header (14 file + 40 DIB + 4-entry gray palette),
// top-down rows. Only the size and dimension fields vary; patch them in.
// clang-format off
uint8_t hdr[70] = {
'B', 'M', 0, 0, 0, 0, 0, 0, 0, 0, 70, 0, 0, 0, // file header
40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 2, 0, // DIB: w/h patched
0, 0, 0, 0, 0, 0, 0, 0, 0x13, 0x0B, 0, 0, 0x13, 0x0B, 0, 0, // 0, 2835 DPI
4, 0, 0, 0, 4, 0, 0, 0, // 4 palette colors
0x00, 0x00, 0x00, 0x00, 0x55, 0x55, 0x55, 0x00, // black, dark gray
0xAA, 0xAA, 0xAA, 0x00, 0xFF, 0xFF, 0xFF, 0x00}; // light gray, white
// clang-format on
const uint32_t rowSize2 = ((static_cast<uint32_t>(pageInfo.width) * 2 + 31) / 32) * 4;
const uint32_t imageSize = rowSize2 * pageInfo.height;
const uint32_t fileSize = sizeof(hdr) + imageSize;
const int32_t topDownHeight = -static_cast<int32_t>(pageInfo.height);
memcpy(hdr + 2, &fileSize, 4);
memcpy(hdr + 18, &pageInfo.width, 2); // biWidth (upper bytes stay 0)
memcpy(hdr + 22, &topDownHeight, 4); // negative biHeight = top-down
memcpy(hdr + 34, &imageSize, 4);
coverBmp.write(hdr, sizeof(hdr));
const size_t planeSize = (static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8;
const uint8_t* plane1 = pageBuffer; // Bit1 plane
const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane
const size_t colBytes = (pageInfo.height + 7) / 8; // Bytes per column
// Allocate a row buffer for 1-bit output
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(dstRowSize));
// 2 bits per pixel, MSB first, rows padded to 4 bytes
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(rowSize2));
if (!rowBuffer) {
free(pageBuffer);
return false;
}
// XTH value -> BMP palette index (palette: 0=black, 1=dark, 2=light, 3=white)
static constexpr uint8_t kXthToBmp[4] = {3, 1, 2, 0};
for (uint16_t y = 0; y < pageInfo.height; y++) {
memset(rowBuffer, 0xFF, dstRowSize); // Start with all white
memset(rowBuffer, 0x00, rowSize2);
for (uint16_t x = 0; x < pageInfo.width; x++) {
// Column-major, right to left: column index = (width - 1 - x)
@@ -216,28 +232,22 @@ bool Xtc::generateCoverBmp() const {
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
const uint8_t pixelValue = (bit1 << 1) | bit2;
// Threshold: 0=white (1); 1,2,3=black (0)
if (pixelValue >= 1) {
// Set bit to 0 (black) in BMP format
const size_t dstByte = x / 8;
const size_t dstBit = 7 - (x % 8);
rowBuffer[dstByte] &= ~(1 << dstBit);
}
const uint8_t bmpVal = kXthToBmp[pixelValue];
rowBuffer[(x * 2) / 8] |= bmpVal << (6 - ((x * 2) % 8));
}
// Write converted row
coverBmp.write(rowBuffer, dstRowSize);
// Pad to 4-byte boundary
uint8_t padding[4] = {0, 0, 0, 0};
size_t paddingSize = rowSize - dstRowSize;
if (paddingSize > 0) {
coverBmp.write(padding, paddingSize);
}
// Row buffer is rowSize2 bytes and zero-padded, write it whole
coverBmp.write(rowBuffer, rowSize2);
}
free(rowBuffer);
} else {
// Write 1-bit BMP header (top-down row order)
BmpHeader bmpHeader;
createBmpHeader(&bmpHeader, pageInfo.width, pageInfo.height, BmpRowOrder::TopDown);
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeader), sizeof(bmpHeader));
const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4;
// 1-bit source: write directly with proper padding
const size_t srcRowSize = (pageInfo.width + 7) / 8;
@@ -422,9 +432,10 @@ bool Xtc::generateThumbBmp(int height) const {
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
const uint8_t pixelValue = (bit1 << 1) | bit2;
// Convert 2-bit (0-3) to grayscale: 0=black, 3=white
// pixelValue: 0=white, 1=light gray, 2=dark gray, 3=black (XTC polarity)
grayValue = (3 - pixelValue) * 85; // 0->255, 1->170, 2->85, 3->0
// pixelValue: 0=white, 1=dark gray, 2=light gray, 3=black —
// same semantics as the cover's kXthToBmp mapping above
static constexpr uint8_t kXthToGray[4] = {255, 85, 170, 0};
grayValue = kXthToGray[pixelValue];
}
}
} else {