XPlor/app/imagepreviewwidget.cpp
njohnson be3ff9303b Add image preview widget with Xbox 360 texture support (WIP)
Add ImagePreviewWidget class for previewing texture assets:
- TGA image loading (uncompressed and RLE)
- Xbox 360 XBTX2D texture format support (work in progress)
- DXT1/DXT5 block decompression
- Xbox 360 texture untiling using SDK algorithms
- Scroll-to-zoom and drag-to-pan functionality
- Debug export to PNG for development

The Xbox 360 texture decoding is still a work in progress and
needs further research for proper implementation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 16:35:49 -05:00

706 lines
25 KiB
C++

#include "imagepreviewwidget.h"
#include <QImageReader>
#include <QBuffer>
#include <QFileInfo>
#include <QDir>
#include <QScrollBar>
#include <QWheelEvent>
#include <QEvent>
#include "statusbarmanager.h"
ImagePreviewWidget::ImagePreviewWidget(QWidget *parent)
: QWidget(parent)
, mImageLabel(new QLabel(this))
, mInfoLabel(new QLabel(this))
, mScrollArea(new QScrollArea(this))
, mDragging(false)
, mZoomFactor(1.0)
{
mImageLabel->setAlignment(Qt::AlignCenter);
mImageLabel->setBackgroundRole(QPalette::Base);
mImageLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored);
mImageLabel->setScaledContents(false);
mScrollArea->setWidget(mImageLabel);
mScrollArea->setWidgetResizable(false); // Don't auto-resize, allow scrolling
mScrollArea->setBackgroundRole(QPalette::Dark);
mScrollArea->setAlignment(Qt::AlignCenter);
mScrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
mScrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
mInfoLabel->setAlignment(Qt::AlignCenter);
mInfoLabel->setStyleSheet("QLabel { background-color: #333; color: #fff; padding: 4px; }");
QVBoxLayout *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
layout->addWidget(mInfoLabel);
layout->addWidget(mScrollArea, 1);
setLayout(layout);
// Enable mouse tracking for drag-to-pan
setMouseTracking(true);
mScrollArea->setMouseTracking(true);
mScrollArea->viewport()->installEventFilter(this);
mScrollArea->viewport()->setCursor(Qt::OpenHandCursor);
}
void ImagePreviewWidget::setFilename(const QString &filename)
{
mFilename = filename;
}
QSize ImagePreviewWidget::imageSize() const
{
return mImageSize;
}
bool ImagePreviewWidget::exportDebugImage(const QString &suffix)
{
QPixmap pixmap = mImageLabel->pixmap();
if (pixmap.isNull()) {
return false;
}
QDir().mkdir("exports");
QString filename = mFilename;
if (filename.isEmpty()) {
filename = "debug_image";
}
// Remove extension and add suffix
int dotPos = filename.lastIndexOf('.');
if (dotPos > 0) {
filename = filename.left(dotPos);
}
if (!suffix.isEmpty()) {
filename += "_" + suffix;
}
filename += "_DEBUG.png";
QString path = "exports/" + filename;
bool success = pixmap.save(path, "PNG");
if (success) {
StatusBarManager::instance().updateStatus(QString("Exported debug image: %1").arg(path), 2000);
}
return success;
}
bool ImagePreviewWidget::eventFilter(QObject *obj, QEvent *event)
{
if (obj == mScrollArea->viewport()) {
if (event->type() == QEvent::MouseButtonPress) {
QMouseEvent *me = static_cast<QMouseEvent*>(event);
if (me->button() == Qt::LeftButton) {
mDragging = true;
mLastDragPos = me->globalPosition().toPoint();
mScrollArea->viewport()->setCursor(Qt::ClosedHandCursor);
return true;
}
} else if (event->type() == QEvent::MouseMove) {
if (mDragging) {
QMouseEvent *me = static_cast<QMouseEvent*>(event);
QPoint globalPos = me->globalPosition().toPoint();
QPoint delta = globalPos - mLastDragPos;
mLastDragPos = globalPos;
QScrollBar *hBar = mScrollArea->horizontalScrollBar();
QScrollBar *vBar = mScrollArea->verticalScrollBar();
hBar->setValue(hBar->value() - delta.x());
vBar->setValue(vBar->value() - delta.y());
return true;
}
} else if (event->type() == QEvent::MouseButtonRelease) {
QMouseEvent *me = static_cast<QMouseEvent*>(event);
if (me->button() == Qt::LeftButton) {
mDragging = false;
mScrollArea->viewport()->setCursor(Qt::OpenHandCursor);
return true;
}
} else if (event->type() == QEvent::Wheel) {
QWheelEvent *we = static_cast<QWheelEvent*>(event);
int delta = we->angleDelta().y();
if (delta > 0) {
mZoomFactor *= 1.15;
} else if (delta < 0) {
mZoomFactor /= 1.15;
}
mZoomFactor = qBound(0.1, mZoomFactor, 10.0);
updateZoom();
return true;
}
}
return QWidget::eventFilter(obj, event);
}
void ImagePreviewWidget::wheelEvent(QWheelEvent *event)
{
// Forward to viewport event filter
int delta = event->angleDelta().y();
if (delta > 0) {
mZoomFactor *= 1.15;
} else if (delta < 0) {
mZoomFactor /= 1.15;
}
mZoomFactor = qBound(0.1, mZoomFactor, 10.0);
updateZoom();
event->accept();
}
void ImagePreviewWidget::updateZoom()
{
if (mOriginalPixmap.isNull()) return;
QSize newSize = mOriginalPixmap.size() * mZoomFactor;
QPixmap scaled = mOriginalPixmap.scaled(newSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
mImageLabel->setPixmap(scaled);
mImageLabel->adjustSize();
// Update info label with zoom level
mInfoLabel->setText(QString("%1 - %2x%3 (%.0f%%)")
.arg(mFilename)
.arg(mImageSize.width())
.arg(mImageSize.height())
.arg(mZoomFactor * 100));
}
bool ImagePreviewWidget::loadFromData(const QByteArray &data, const QString &format)
{
// Check for Xbox texture formats (XBTX2D, TX2D, etc.)
if (data.size() >= 8) {
QString magic = QString::fromLatin1(data.left(8));
if (magic.startsWith("XBTX2D") || magic.startsWith("TX2D") || magic.startsWith("TX1D") || magic.startsWith("TX3D")) {
// Try to decode Xbox 360 texture
QImage image = loadXBTX2D(data);
if (!image.isNull()) {
mImageSize = image.size();
mOriginalPixmap = QPixmap::fromImage(image);
mZoomFactor = 1.0;
mImageLabel->setPixmap(mOriginalPixmap);
mImageLabel->adjustSize();
mInfoLabel->setText(QString("%1 - %2x%3 (Xbox 360)").arg(mFilename).arg(image.width()).arg(image.height()));
return true;
}
// Fallback to info display if decode fails
mImageLabel->setText(QString("Xbox 360 Texture Format\n\nMagic: %1\nSize: %2 bytes\n\nCould not decode texture.")
.arg(magic.left(6).trimmed())
.arg(data.size()));
mInfoLabel->setText(QString("%1 - Xbox 360 Texture (%2 bytes)").arg(mFilename).arg(data.size()));
return true;
}
}
// Check for DDS format (starts with "DDS ")
if (data.size() >= 4 && data.left(4) == "DDS ") {
mImageLabel->setText(QString("DDS Texture Format\n\nSize: %1 bytes\n\nDDS preview not yet implemented.")
.arg(data.size()));
mInfoLabel->setText(QString("%1 - DDS Texture (%2 bytes)").arg(mFilename).arg(data.size()));
return true;
}
QImage image;
// Try standard Qt loading first
if (!format.isEmpty()) {
image.loadFromData(data, format.toUtf8().constData());
}
if (image.isNull()) {
image.loadFromData(data);
}
// If still null and looks like TGA, try manual loading
if (image.isNull() && (format.toLower() == "tga" ||
(data.size() > 18 && mFilename.toLower().endsWith(".tga")))) {
image = loadTGA(data);
}
if (image.isNull()) {
// Show hex dump of first bytes for debugging
QString hexDump;
for (int i = 0; i < qMin(16, data.size()); i++) {
hexDump += QString("%1 ").arg((unsigned char)data[i], 2, 16, QChar('0'));
}
mImageLabel->setText(QString("Failed to load image\n\nFirst bytes: %1\nSize: %2 bytes")
.arg(hexDump.trimmed())
.arg(data.size()));
mInfoLabel->setText(QString("%1 - Load failed").arg(mFilename));
return false;
}
mImageSize = image.size();
mOriginalPixmap = QPixmap::fromImage(image);
mZoomFactor = 1.0;
mImageLabel->setPixmap(mOriginalPixmap);
mImageLabel->adjustSize();
QString info = QString("%1 - %2x%3 - %4 bytes")
.arg(mFilename)
.arg(image.width())
.arg(image.height())
.arg(data.size());
mInfoLabel->setText(info);
return true;
}
bool ImagePreviewWidget::loadFromFile(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
return false;
}
mFilename = QFileInfo(path).fileName();
QByteArray data = file.readAll();
QString format;
if (path.toLower().endsWith(".tga")) format = "TGA";
else if (path.toLower().endsWith(".dds")) format = "DDS";
else if (path.toLower().endsWith(".png")) format = "PNG";
else if (path.toLower().endsWith(".jpg") || path.toLower().endsWith(".jpeg")) format = "JPG";
else if (path.toLower().endsWith(".bmp")) format = "BMP";
return loadFromData(data, format);
}
QImage ImagePreviewWidget::loadTGA(const QByteArray &data)
{
// Simple TGA loader for uncompressed RGB/RGBA images
if (data.size() < 18) return QImage();
const uchar *d = reinterpret_cast<const uchar*>(data.constData());
// TGA header
uchar idLength = d[0];
uchar colorMapType = d[1];
uchar imageType = d[2];
// Skip color map spec (5 bytes: 3-7)
// Image spec starts at byte 8
quint16 xOrigin = d[8] | (d[9] << 8);
quint16 yOrigin = d[10] | (d[11] << 8);
quint16 width = d[12] | (d[13] << 8);
quint16 height = d[14] | (d[15] << 8);
uchar bitsPerPixel = d[16];
uchar descriptor = d[17];
Q_UNUSED(xOrigin);
Q_UNUSED(yOrigin);
Q_UNUSED(colorMapType);
// Only support uncompressed true-color (type 2) or grayscale (type 3)
if (imageType != 2 && imageType != 3 && imageType != 10) {
return QImage();
}
int bytesPerPixel = bitsPerPixel / 8;
if (bytesPerPixel < 1 || bytesPerPixel > 4) {
return QImage();
}
int dataOffset = 18 + idLength;
int expectedSize = dataOffset + width * height * bytesPerPixel;
// For RLE images, we can't easily predict size, so just check minimum
if (imageType != 10 && data.size() < expectedSize) {
return QImage();
}
QImage::Format format = (bytesPerPixel == 4) ? QImage::Format_ARGB32 : QImage::Format_RGB32;
QImage image(width, height, format);
if (image.isNull()) return QImage();
const uchar *src = d + dataOffset;
bool flipVertical = !(descriptor & 0x20); // Bit 5: origin is top-left if set
if (imageType == 2 || imageType == 3) {
// Uncompressed
for (int y = 0; y < height; y++) {
int destY = flipVertical ? (height - 1 - y) : y;
QRgb *destLine = reinterpret_cast<QRgb*>(image.scanLine(destY));
for (int x = 0; x < width; x++) {
int idx = (y * width + x) * bytesPerPixel;
if (dataOffset + idx + bytesPerPixel > data.size()) break;
uchar b = src[idx];
uchar g = (bytesPerPixel > 1) ? src[idx + 1] : b;
uchar r = (bytesPerPixel > 2) ? src[idx + 2] : b;
uchar a = (bytesPerPixel > 3) ? src[idx + 3] : 255;
destLine[x] = qRgba(r, g, b, a);
}
}
} else if (imageType == 10) {
// RLE compressed
int pixelCount = width * height;
int currentPixel = 0;
int srcIdx = 0;
int maxSrcIdx = data.size() - dataOffset;
while (currentPixel < pixelCount && srcIdx < maxSrcIdx) {
uchar header = src[srcIdx++];
int count = (header & 0x7F) + 1;
if (header & 0x80) {
// RLE packet
if (srcIdx + bytesPerPixel > maxSrcIdx) break;
uchar b = src[srcIdx];
uchar g = (bytesPerPixel > 1) ? src[srcIdx + 1] : b;
uchar r = (bytesPerPixel > 2) ? src[srcIdx + 2] : b;
uchar a = (bytesPerPixel > 3) ? src[srcIdx + 3] : 255;
srcIdx += bytesPerPixel;
QRgb color = qRgba(r, g, b, a);
for (int i = 0; i < count && currentPixel < pixelCount; i++, currentPixel++) {
int x = currentPixel % width;
int y = currentPixel / width;
int destY = flipVertical ? (height - 1 - y) : y;
QRgb *destLine = reinterpret_cast<QRgb*>(image.scanLine(destY));
destLine[x] = color;
}
} else {
// Raw packet
for (int i = 0; i < count && currentPixel < pixelCount; i++, currentPixel++) {
if (srcIdx + bytesPerPixel > maxSrcIdx) break;
uchar b = src[srcIdx];
uchar g = (bytesPerPixel > 1) ? src[srcIdx + 1] : b;
uchar r = (bytesPerPixel > 2) ? src[srcIdx + 2] : b;
uchar a = (bytesPerPixel > 3) ? src[srcIdx + 3] : 255;
srcIdx += bytesPerPixel;
int x = currentPixel % width;
int y = currentPixel / width;
int destY = flipVertical ? (height - 1 - y) : y;
QRgb *destLine = reinterpret_cast<QRgb*>(image.scanLine(destY));
destLine[x] = qRgba(r, g, b, a);
}
}
}
}
return image;
}
// DXT1 block decoder - read colors as big-endian (Xbox 360 native)
static void decodeDXT1BlockStatic(const uchar *src, QRgb *dest, int destPitch)
{
// Read colors as big-endian (Xbox 360 native format)
quint16 c0 = (src[0] << 8) | src[1];
quint16 c1 = (src[2] << 8) | src[3];
// Extract RGB565 components: bits 15-11=R, 10-5=G, 4-0=B
// Xbox 360 stores as BGR565, so swap R and B
int b0 = ((c0 >> 11) & 0x1F) * 255 / 31;
int g0 = ((c0 >> 5) & 0x3F) * 255 / 63;
int r0 = (c0 & 0x1F) * 255 / 31;
int b1 = ((c1 >> 11) & 0x1F) * 255 / 31;
int g1 = ((c1 >> 5) & 0x3F) * 255 / 63;
int r1 = (c1 & 0x1F) * 255 / 31;
// Build color table
QRgb colors[4];
colors[0] = qRgb(r0, g0, b0);
colors[1] = qRgb(r1, g1, b1);
if (c0 > c1) {
colors[2] = qRgb((2 * r0 + r1) / 3, (2 * g0 + g1) / 3, (2 * b0 + b1) / 3);
colors[3] = qRgb((r0 + 2 * r1) / 3, (g0 + 2 * g1) / 3, (b0 + 2 * b1) / 3);
} else {
colors[2] = qRgb((r0 + r1) / 2, (g0 + g1) / 2, (b0 + b1) / 2);
colors[3] = qRgba(0, 0, 0, 0); // Transparent
}
// Read indices - standard order (no swap needed)
for (int y = 0; y < 4; y++) {
uchar rowByte = src[4 + y];
for (int x = 0; x < 4; x++) {
int idx = (rowByte >> (x * 2)) & 0x03;
dest[y * destPitch + x] = colors[idx];
}
}
}
// DXT5 block decoder - read as big-endian (Xbox 360 native)
static void decodeDXT5BlockStatic(const uchar *src, QRgb *dest, int destPitch)
{
// Alpha block (first 8 bytes) - standard order
uchar alpha0 = src[0];
uchar alpha1 = src[1];
// Build alpha table
uchar alphas[8];
alphas[0] = alpha0;
alphas[1] = alpha1;
if (alpha0 > alpha1) {
alphas[2] = (6 * alpha0 + 1 * alpha1) / 7;
alphas[3] = (5 * alpha0 + 2 * alpha1) / 7;
alphas[4] = (4 * alpha0 + 3 * alpha1) / 7;
alphas[5] = (3 * alpha0 + 4 * alpha1) / 7;
alphas[6] = (2 * alpha0 + 5 * alpha1) / 7;
alphas[7] = (1 * alpha0 + 6 * alpha1) / 7;
} else {
alphas[2] = (4 * alpha0 + 1 * alpha1) / 5;
alphas[3] = (3 * alpha0 + 2 * alpha1) / 5;
alphas[4] = (2 * alpha0 + 3 * alpha1) / 5;
alphas[5] = (1 * alpha0 + 4 * alpha1) / 5;
alphas[6] = 0;
alphas[7] = 255;
}
// Alpha indices (6 bytes, 48 bits = 16 pixels * 3 bits each) - standard order
quint64 alphaIndices = src[2] | ((quint64)src[3] << 8) | ((quint64)src[4] << 16) |
((quint64)src[5] << 24) | ((quint64)src[6] << 32) | ((quint64)src[7] << 40);
// Color block (bytes 8-15, same as DXT1) - big-endian
const uchar *colorSrc = src + 8;
quint16 c0 = (colorSrc[0] << 8) | colorSrc[1]; // Big-endian (Xbox 360)
quint16 c1 = (colorSrc[2] << 8) | colorSrc[3]; // Big-endian (Xbox 360)
// BGR565: bits 15-11=B, 10-5=G, 4-0=R (Xbox 360 swapped)
int b0 = ((c0 >> 11) & 0x1F) * 255 / 31;
int g0 = ((c0 >> 5) & 0x3F) * 255 / 63;
int r0 = (c0 & 0x1F) * 255 / 31;
int b1 = ((c1 >> 11) & 0x1F) * 255 / 31;
int g1 = ((c1 >> 5) & 0x3F) * 255 / 63;
int r1 = (c1 & 0x1F) * 255 / 31;
QRgb colors[4];
colors[0] = qRgb(r0, g0, b0);
colors[1] = qRgb(r1, g1, b1);
colors[2] = qRgb((2 * r0 + r1) / 3, (2 * g0 + g1) / 3, (2 * b0 + b1) / 3);
colors[3] = qRgb((r0 + 2 * r1) / 3, (g0 + 2 * g1) / 3, (b0 + 2 * b1) / 3);
// Decode 4x4 block - standard order
for (int y = 0; y < 4; y++) {
uchar colorRowByte = colorSrc[4 + y];
for (int x = 0; x < 4; x++) {
int pixelIdx = y * 4 + x;
int alphaIdx = (alphaIndices >> (pixelIdx * 3)) & 0x07;
int colorIdx = (colorRowByte >> (x * 2)) & 0x03;
QRgb color = colors[colorIdx];
dest[y * destPitch + x] = qRgba(qRed(color), qGreen(color), qBlue(color), alphas[alphaIdx]);
}
}
}
// Swap endianness in 16-bit chunks (required for Xbox 360)
static void swapEndian16(uchar *data, int size)
{
for (int i = 0; i < size - 1; i += 2) {
uchar tmp = data[i];
data[i] = data[i + 1];
data[i + 1] = tmp;
}
}
// Xbox 360 XGAddress2DTiledX - convert tiled offset to linear X
// From Noesis inc_xbox360_untile.py (credits: Banz99's Nier-Texture-Manager, GTA XTD editor)
static unsigned int xgAddress2DTiledX(unsigned int offset, unsigned int width, unsigned int texelPitch)
{
unsigned int alignedWidth = (width + 31) & ~31;
unsigned int logBpp = (texelPitch >> 2) + ((texelPitch >> 1) >> (texelPitch >> 2));
unsigned int offsetB = offset << logBpp;
unsigned int offsetT = ((offsetB & ~4095) >> 3) + ((offsetB & 1792) >> 2) + (offsetB & 63);
unsigned int offsetM = offsetT >> (7 + logBpp);
unsigned int macroX = ((offsetM % (alignedWidth >> 5)) << 2);
unsigned int tile = ((((offsetT >> (5 + logBpp)) & 2) + (offsetB >> 6)) & 3);
unsigned int macro = (macroX + tile) << 3;
unsigned int micro = ((((offsetT >> 1) & ~15) + (offsetT & 15)) & ((texelPitch << 3) - 1)) >> logBpp;
return macro + micro;
}
// Xbox 360 XGAddress2DTiledY - convert tiled offset to linear Y
static unsigned int xgAddress2DTiledY(unsigned int offset, unsigned int width, unsigned int texelPitch)
{
unsigned int alignedWidth = (width + 31) & ~31;
unsigned int logBpp = (texelPitch >> 2) + ((texelPitch >> 1) >> (texelPitch >> 2));
unsigned int offsetB = offset << logBpp;
unsigned int offsetT = ((offsetB & ~4095) >> 3) + ((offsetB & 1792) >> 2) + (offsetB & 63);
unsigned int offsetM = offsetT >> (7 + logBpp);
unsigned int macroY = ((offsetM / (alignedWidth >> 5)) << 2);
unsigned int tile = ((offsetT >> (6 + logBpp)) & 1) + (((offsetB & 2048) >> 10));
unsigned int macro = (macroY + tile) << 3;
unsigned int micro = ((((offsetT & (((texelPitch << 6) - 1) & ~31)) + ((offsetT & 15) << 1)) >> (3 + logBpp)) & ~1);
return macro + micro + ((offsetT & 16) >> 4);
}
// Xbox 360 texture untiling - iterate tiled source, write to linear dest
// Based on official Xbox 360 SDK XGAddress2DTiledX/Y functions
static void untileXbox360(const uchar *src, uchar *dest, int width, int height, int blockSize)
{
// For DXT, work in blocks (4x4 pixels per block)
unsigned int blocksWide = (width + 3) / 4;
unsigned int blocksHigh = (height + 3) / 4;
// Calculate aligned dimensions for iteration (SDK aligns internally, but we need to iterate aligned space)
unsigned int alignedBlocksWide = (blocksWide + 31) & ~31;
unsigned int alignedBlocksHigh = (blocksHigh + 31) & ~31;
unsigned int totalAlignedBlocks = alignedBlocksWide * alignedBlocksHigh;
// texelPitch is the block size (8 for DXT1, 16 for DXT5)
unsigned int texelPitch = blockSize;
// Iterate through aligned source blocks
for (unsigned int blockOffset = 0; blockOffset < totalAlignedBlocks; blockOffset++) {
// Pass the ACTUAL width in blocks - XGAddress functions align internally!
// This is the key fix: SDK expects real width, not pre-aligned
unsigned int x = xgAddress2DTiledX(blockOffset, blocksWide, texelPitch);
unsigned int y = xgAddress2DTiledY(blockOffset, blocksWide, texelPitch);
// Skip blocks outside actual texture bounds
if (x >= blocksWide || y >= blocksHigh) {
continue;
}
// Source is sequential tiled (aligned), dest is linear (actual size)
unsigned int srcOffset = blockOffset * texelPitch;
unsigned int destOffset = (y * blocksWide + x) * texelPitch;
memcpy(dest + destOffset, src + srcOffset, texelPitch);
}
}
// Helper to decode DXT data into an image
static QImage decodeDXTToImage(const uchar *srcData, int width, int height, int blockSize, bool isDXT5, int textureDataSize)
{
int blocksWide = (width + 3) / 4;
int blocksHigh = (height + 3) / 4;
QImage image(width, height, QImage::Format_ARGB32);
if (image.isNull()) return QImage();
image.fill(Qt::black);
for (int by = 0; by < blocksHigh; by++) {
for (int bx = 0; bx < blocksWide; bx++) {
int blockIdx = by * blocksWide + bx;
int blockOffset = blockIdx * blockSize;
if (blockOffset + blockSize > textureDataSize) break;
const uchar *blockData = srcData + blockOffset;
// Decode into temporary 4x4 block
QRgb block[16];
if (isDXT5) {
decodeDXT5BlockStatic(blockData, block, 4);
} else {
decodeDXT1BlockStatic(blockData, block, 4);
}
// Copy block to image
int px = bx * 4;
int py = by * 4;
for (int y = 0; y < 4 && py + y < height; y++) {
QRgb *destLine = reinterpret_cast<QRgb*>(image.scanLine(py + y));
for (int x = 0; x < 4 && px + x < width; x++) {
destLine[px + x] = block[y * 4 + x];
}
}
}
}
return image;
}
QImage ImagePreviewWidget::loadXBTX2D(const QByteArray &data)
{
if (data.size() < 0x60) return QImage();
const uchar *d = reinterpret_cast<const uchar*>(data.constData());
// XBTX2D header (big-endian) - actual format from hex analysis:
// 0x00-0x07: Magic "XBTX2D " (8 bytes)
// 0x08-0x0B: Version (4 bytes, BE)
// 0x0C-0x0F: Width (4 bytes, BE)
// 0x10-0x13: Height (4 bytes, BE)
// 0x14-0x17: Hash/ID
// 0x18-0x1B: Mip count (4 bytes, BE)
// 0x1C-0x1F: Size field 1
// 0x20-0x23: Size field 2 (base mip size for DXT1)
// 0x24-0x27: Format (3=DXT1?)
// 0x58: Data start
quint32 width = (d[0x0C] << 24) | (d[0x0D] << 16) | (d[0x0E] << 8) | d[0x0F];
quint32 height = (d[0x10] << 24) | (d[0x11] << 16) | (d[0x12] << 8) | d[0x13];
quint32 baseMipSize = (d[0x20] << 24) | (d[0x21] << 16) | (d[0x22] << 8) | d[0x23];
// Sanity checks
if (width == 0 || height == 0 || width > 8192 || height > 8192) {
return QImage();
}
// Determine format from base mip size
// DXT1: 8 bytes per 4x4 block = width*height/2
// DXT5: 16 bytes per 4x4 block = width*height
int blockSize;
int dataOffset = 0x58; // Fixed header size
bool isDXT5;
quint32 expectedDXT1Size = (width * height) / 2;
quint32 expectedDXT5Size = width * height;
if (baseMipSize == expectedDXT5Size) {
// DXT5 (or similar 16-byte block format)
blockSize = 16;
isDXT5 = true;
StatusBarManager::instance().updateStatus(
QString("XBTX2D: %1 - DXT5 %2x%3").arg(mFilename).arg(width).arg(height), 3000);
} else {
// DXT1 (or similar 8-byte block format)
blockSize = 8;
isDXT5 = false;
StatusBarManager::instance().updateStatus(
QString("XBTX2D: %1 - DXT1 %2x%3").arg(mFilename).arg(width).arg(height), 3000);
}
int blocksWide = (width + 3) / 4;
int blocksHigh = (height + 3) / 4;
int totalBlocks = blocksWide * blocksHigh;
int textureDataSize = totalBlocks * blockSize;
if (data.size() < dataOffset) return QImage();
int availableData = data.size() - dataOffset;
if (availableData < textureDataSize) {
textureDataSize = availableData;
}
const uchar *srcData = d + dataOffset;
// Xbox 360 DXT textures require:
// 1. Untiling (GPU memory layout to linear)
// 2. DXT decode with big-endian color reading
// Try both untiled and linear to compare
QByteArray untiled(textureDataSize, 0);
untileXbox360(srcData, reinterpret_cast<uchar*>(untiled.data()), width, height, blockSize);
// Decode both versions
QImage untiledResult = decodeDXTToImage(reinterpret_cast<const uchar*>(untiled.constData()),
width, height, blockSize, isDXT5, textureDataSize);
QImage linearResult = decodeDXTToImage(srcData, width, height, blockSize, isDXT5, textureDataSize);
// Export both for comparison
QDir().mkdir("exports");
QString debugName = mFilename.isEmpty() ? "xbtx2d_debug" : mFilename;
int dot = debugName.lastIndexOf('.');
if (dot > 0) debugName = debugName.left(dot);
if (!untiledResult.isNull()) {
untiledResult.save("exports/" + debugName + "_UNTILED.png", "PNG");
}
if (!linearResult.isNull()) {
linearResult.save("exports/" + debugName + "_LINEAR.png", "PNG");
}
// Return the untiled version
return untiledResult;
}