XPlor/libs/dsl/recompiler.cpp
njohnson 3e3883cfeb Add DSL journaling infrastructure for field editing and write-back
Introduce operation journaling to track how values are read from the
binary stream, enabling future write-back of edited fields.

New components:
- OperationJournal: Records read operations with stream offsets, sizes,
  byte order, and original values during parsing
- ReverseEval: Analyzes expressions for invertibility (e.g., x+5 can be
  reversed to compute the source field when editing the result)
- Recompiler: Infrastructure for recompiling modified values back to
  binary format

Interpreter enhancements:
- Add runTypeInternalWithJournal() for journaled parsing
- Journal scalar reads, byte reads, strings, skip/seek operations
- Add deadrising_lzx() built-in for Dead Rising 2 LZX decompression
- Support _deadrising_lzx_stem context variable for nested archives

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 20:53:20 -05:00

566 lines
21 KiB
C++

#include "recompiler.h"
#include <QBuffer>
#include <QtEndian>
#include <stdexcept>
#include <cstring>
Recompiler::Recompiler(QObject* parent)
: QObject(parent)
{
}
bool Recompiler::canRecompile(int journalId) const {
const OperationJournal* journal = JournalManager::instance().getJournal(journalId);
if (!journal) {
return false;
}
return journal->isExportable();
}
QString Recompiler::whyNotRecompilable(int journalId) const {
const OperationJournal* journal = JournalManager::instance().getJournal(journalId);
if (!journal) {
return "Journal not found";
}
return journal->whyNotExportable();
}
QByteArray Recompiler::recompile(int journalId, const QVariantMap& values) {
QStringList errors;
QByteArray result = recompileWithErrors(journalId, values, errors);
if (!errors.isEmpty()) {
throw std::runtime_error(errors.join("; ").toStdString());
}
return result;
}
QByteArray Recompiler::recompileWithErrors(int journalId, const QVariantMap& values, QStringList& outErrors) {
outErrors.clear();
m_visitedJournals.clear(); // Clear recursion tracking
OperationJournal* journal = JournalManager::instance().getJournal(journalId);
if (!journal) {
outErrors << "Journal not found";
return QByteArray();
}
if (!journal->isExportable()) {
outErrors << journal->whyNotExportable();
return QByteArray();
}
emit progressChanged(0, "Starting recompilation");
// Create output buffer
QByteArray output;
QBuffer buffer(&output);
buffer.open(QIODevice::WriteOnly);
QDataStream out(&buffer);
const int totalEntries = journal->entries.size();
int processedEntries = 0;
// Walk journal entries in order and write each one
for (const JournalEntry& entry : journal->entries) {
try {
switch (entry.type) {
case OpType::ReadScalar: {
QVariant value = getValueForField(values, entry);
writeScalar(out, entry.scalarType, entry.byteOrder, value);
break;
}
case OpType::ReadBytes: {
// For raw bytes, check if we have modified bytes in values
QVariant value = getValueForField(values, entry);
if (value.typeId() == QMetaType::QByteArray) {
writeBytes(out, value.toByteArray());
} else if (!entry.originalBytes.isEmpty()) {
writeBytes(out, entry.originalBytes);
} else {
// Write zeros if we have no data
QByteArray zeros(entry.fieldSize, '\0');
writeBytes(out, zeros);
}
break;
}
case OpType::ReadCString: {
QVariant value = getValueForField(values, entry);
QString str = value.toString();
writeCString(out, str);
break;
}
case OpType::ReadWString: {
QVariant value = getValueForField(values, entry);
QString str = value.toString();
writeWString(out, str, entry.byteOrder);
break;
}
case OpType::Skip: {
// Write original padding bytes if available, otherwise zeros
writeSkip(out, entry.fieldSize, entry.originalBytes);
break;
}
case OpType::Align: {
// Write original alignment padding if available, otherwise zeros
writeSkip(out, entry.fieldSize, entry.originalBytes);
break;
}
case OpType::Seek: {
// Seek operations are tricky - we need to pad to the target position
// The target position is stored in fieldSize
qint64 currentPos = buffer.pos();
qint64 targetPos = entry.fieldSize;
if (targetPos > currentPos) {
// Need to pad to reach target position
qint64 padding = targetPos - currentPos;
QByteArray zeros(padding, '\0');
writeBytes(out, zeros);
} else if (targetPos < currentPos) {
// Seeking backwards - this is problematic for linear recompilation
// For now, we'll log a warning but continue
outErrors << QString("Warning: Backward seek from %1 to %2 in recompilation")
.arg(currentPos).arg(targetPos);
}
break;
}
case OpType::Computed: {
// Computed values don't directly contribute bytes to output
// They are derived from other fields
break;
}
case OpType::Decompress: {
// Compression write-back: need to recompress nested content
// The nested journal contains the decompressed content that may have been modified
if (entry.nestedJournalId >= 0) {
// Check if compression type is supported
if (!isCompressionSupported(entry.compressionType)) {
outErrors << QString("Unsupported compression type for write-back: %1")
.arg(entry.compressionType);
// Fall back to original compressed data
if (!entry.originalBytes.isEmpty()) {
writeBytes(out, entry.originalBytes);
}
break;
}
// Recompile the nested (decompressed) content
QByteArray uncompressedData = recompileNested(entry.nestedJournalId, values,
entry.fieldName, outErrors);
if (uncompressedData.isEmpty()) {
// Fallback to original compressed bytes
if (!entry.originalBytes.isEmpty()) {
writeBytes(out, entry.originalBytes);
}
break;
}
// Recompress the data
QByteArray compressedData = compress(uncompressedData, entry.compressionType,
entry.compressionParams);
if (compressedData.isEmpty()) {
outErrors << QString("Failed to recompress data with %1")
.arg(entry.compressionType);
// Fall back to original
if (!entry.originalBytes.isEmpty()) {
writeBytes(out, entry.originalBytes);
}
break;
}
writeBytes(out, compressedData);
} else {
// No nested journal - just use original bytes
if (!entry.originalBytes.isEmpty()) {
writeBytes(out, entry.originalBytes);
}
}
break;
}
case OpType::ParseNested:
case OpType::ParseHere: {
// Nested parsing - recursive recompilation
if (entry.nestedJournalId >= 0) {
QByteArray nestedBytes = recompileNested(entry.nestedJournalId, values,
entry.fieldName, outErrors);
if (!nestedBytes.isEmpty()) {
writeBytes(out, nestedBytes);
} else {
// Fallback to original bytes if recompilation failed
OperationJournal* nestedJournal = JournalManager::instance().getJournal(entry.nestedJournalId);
if (nestedJournal && !nestedJournal->originalBytes.isEmpty()) {
writeBytes(out, nestedJournal->originalBytes);
}
}
}
break;
}
case OpType::RandomAccess: {
// Random access entries don't contribute to output
// (and shouldn't exist in exportable journals)
break;
}
}
} catch (const std::exception& e) {
outErrors << QString("Error processing entry '%1': %2")
.arg(entry.fieldName).arg(e.what());
}
processedEntries++;
int percent = (processedEntries * 100) / qMax(1, totalEntries);
emit progressChanged(percent, QString("Processing %1").arg(entry.fieldName));
}
buffer.close();
emit progressChanged(100, "Recompilation complete");
return output;
}
QVariant Recompiler::getValueForField(const QVariantMap& values, const JournalEntry& entry) const {
// First check if we have a modified value in the values map
if (!entry.fieldName.isEmpty() && values.contains(entry.fieldName)) {
return values.value(entry.fieldName);
}
// Fall back to original value from journal
return entry.originalValue;
}
void Recompiler::writeScalar(QDataStream& out, ScalarType type, ByteOrder order, const QVariant& value) {
// Set byte order for this write
out.setByteOrder(order == ByteOrder::LE ? QDataStream::LittleEndian : QDataStream::BigEndian);
switch (type) {
case ScalarType::U8: {
quint8 v = static_cast<quint8>(value.toUInt());
out << v;
break;
}
case ScalarType::I8: {
qint8 v = static_cast<qint8>(value.toInt());
out << v;
break;
}
case ScalarType::U16: {
quint16 v = static_cast<quint16>(value.toUInt());
out << v;
break;
}
case ScalarType::I16: {
qint16 v = static_cast<qint16>(value.toInt());
out << v;
break;
}
case ScalarType::U32: {
quint32 v = static_cast<quint32>(value.toUInt());
out << v;
break;
}
case ScalarType::I32: {
qint32 v = static_cast<qint32>(value.toInt());
out << v;
break;
}
case ScalarType::U64: {
quint64 v = static_cast<quint64>(value.toULongLong());
out << v;
break;
}
case ScalarType::I64: {
qint64 v = static_cast<qint64>(value.toLongLong());
out << v;
break;
}
case ScalarType::F32: {
float f = value.toFloat();
quint32 bits;
memcpy(&bits, &f, 4);
out << bits;
break;
}
case ScalarType::F64: {
double d = value.toDouble();
quint64 bits;
memcpy(&bits, &d, 8);
out << bits;
break;
}
case ScalarType::Bool: {
quint8 v = value.toBool() ? 1 : 0;
out << v;
break;
}
}
}
void Recompiler::writeBytes(QDataStream& out, const QByteArray& data) {
out.writeRawData(data.constData(), data.size());
}
void Recompiler::writeCString(QDataStream& out, const QString& str) {
QByteArray utf8 = str.toUtf8();
out.writeRawData(utf8.constData(), utf8.size());
// Write null terminator
char null = '\0';
out.writeRawData(&null, 1);
}
void Recompiler::writeWString(QDataStream& out, const QString& str, ByteOrder order) {
// Write each character as UTF-16
for (const QChar& ch : str) {
quint16 code = ch.unicode();
if (order == ByteOrder::LE) {
code = qToLittleEndian(code);
} else {
code = qToBigEndian(code);
}
out.writeRawData(reinterpret_cast<const char*>(&code), 2);
}
// Write null terminator (2 bytes)
quint16 null = 0;
out.writeRawData(reinterpret_cast<const char*>(&null), 2);
}
void Recompiler::writeSkip(QDataStream& out, qint64 count, const QByteArray& originalBytes) {
if (!originalBytes.isEmpty() && originalBytes.size() >= count) {
// Use original padding bytes
out.writeRawData(originalBytes.constData(), count);
} else {
// Write zeros
QByteArray zeros(count, '\0');
out.writeRawData(zeros.constData(), count);
}
}
QByteArray Recompiler::recompileNested(int nestedJournalId, const QVariantMap& parentValues,
const QString& fieldName, QStringList& outErrors) {
// Check for circular reference
if (m_visitedJournals.contains(nestedJournalId)) {
outErrors << QString("Circular reference detected in nested journal %1").arg(nestedJournalId);
return QByteArray();
}
OperationJournal* nestedJournal = JournalManager::instance().getJournal(nestedJournalId);
if (!nestedJournal) {
outErrors << QString("Nested journal %1 not found").arg(nestedJournalId);
return QByteArray();
}
// Check exportability
if (!nestedJournal->isExportable()) {
// Non-exportable nested structure - return original bytes
return nestedJournal->originalBytes;
}
// Mark as visited
m_visitedJournals.insert(nestedJournalId);
// Extract values for the nested structure
QVariantMap nestedValues = extractNestedValues(parentValues, fieldName, nestedJournal);
// Create output buffer for nested structure
QByteArray output;
QBuffer buffer(&output);
buffer.open(QIODevice::WriteOnly);
QDataStream out(&buffer);
// Walk nested journal entries
for (const JournalEntry& entry : nestedJournal->entries) {
try {
switch (entry.type) {
case OpType::ReadScalar: {
QVariant value = getValueForField(nestedValues, entry);
writeScalar(out, entry.scalarType, entry.byteOrder, value);
break;
}
case OpType::ReadBytes: {
QVariant value = getValueForField(nestedValues, entry);
if (value.typeId() == QMetaType::QByteArray) {
writeBytes(out, value.toByteArray());
} else if (!entry.originalBytes.isEmpty()) {
writeBytes(out, entry.originalBytes);
} else {
QByteArray zeros(entry.fieldSize, '\0');
writeBytes(out, zeros);
}
break;
}
case OpType::ReadCString: {
QVariant value = getValueForField(nestedValues, entry);
writeCString(out, value.toString());
break;
}
case OpType::ReadWString: {
QVariant value = getValueForField(nestedValues, entry);
writeWString(out, value.toString(), entry.byteOrder);
break;
}
case OpType::Skip:
case OpType::Align: {
writeSkip(out, entry.fieldSize, entry.originalBytes);
break;
}
case OpType::Seek: {
qint64 currentPos = buffer.pos();
qint64 targetPos = entry.fieldSize;
if (targetPos > currentPos) {
qint64 padding = targetPos - currentPos;
QByteArray zeros(padding, '\0');
writeBytes(out, zeros);
}
break;
}
case OpType::Computed:
// Computed values don't contribute bytes
break;
case OpType::Decompress:
// Compression handled at outer level
break;
case OpType::ParseNested:
case OpType::ParseHere: {
// Recursively recompile deeper nested structures
if (entry.nestedJournalId >= 0) {
QByteArray deepNestedBytes = recompileNested(entry.nestedJournalId, nestedValues,
entry.fieldName, outErrors);
if (!deepNestedBytes.isEmpty()) {
writeBytes(out, deepNestedBytes);
} else {
OperationJournal* deepJournal = JournalManager::instance().getJournal(entry.nestedJournalId);
if (deepJournal && !deepJournal->originalBytes.isEmpty()) {
writeBytes(out, deepJournal->originalBytes);
}
}
}
break;
}
case OpType::RandomAccess:
// Should not be in exportable journal
break;
}
} catch (const std::exception& e) {
outErrors << QString("Error in nested '%1.%2': %3")
.arg(fieldName).arg(entry.fieldName).arg(e.what());
}
}
buffer.close();
// Remove from visited (allow reuse in other branches)
m_visitedJournals.remove(nestedJournalId);
return output;
}
QVariantMap Recompiler::extractNestedValues(const QVariantMap& parentValues, const QString& fieldName,
const OperationJournal* nestedJournal) const {
QVariantMap nestedValues;
// First, try to get nested values from parent values map
// Values may be stored as a nested QVariantMap under the field name
if (!fieldName.isEmpty() && parentValues.contains(fieldName)) {
QVariant fieldValue = parentValues.value(fieldName);
if (fieldValue.typeId() == QMetaType::QVariantMap) {
nestedValues = fieldValue.toMap();
}
}
// Also check for dotted keys like "nested.fieldname"
QString prefix = fieldName + ".";
for (auto it = parentValues.begin(); it != parentValues.end(); ++it) {
if (it.key().startsWith(prefix)) {
QString subKey = it.key().mid(prefix.length());
nestedValues[subKey] = it.value();
}
}
// Fill in missing values from journal's original values
if (nestedJournal) {
for (const JournalEntry& entry : nestedJournal->entries) {
if (!entry.fieldName.isEmpty() && !nestedValues.contains(entry.fieldName)) {
nestedValues[entry.fieldName] = entry.originalValue;
}
}
}
return nestedValues;
}
bool Recompiler::isCompressionSupported(const QString& compressionType) const {
// Supported compression types for write-back
static const QSet<QString> supported = {
"zlib", "zlib_auto", "deflate", "xmem", "oodle"
};
return supported.contains(compressionType.toLower());
}
QByteArray Recompiler::compress(const QByteArray& data, const QString& compressionType,
const QByteArray& params) const {
QString type = compressionType.toLower();
if (type == "zlib" || type == "zlib_auto") {
// Use settings from params if available
if (!params.isEmpty()) {
QDataStream paramsStream(params);
int level = Z_BEST_COMPRESSION;
int windowBits = MAX_WBITS;
int memLevel = 8;
int strategy = Z_DEFAULT_STRATEGY;
// Read parameters if available
if (params.size() >= 4) {
paramsStream >> level >> windowBits >> memLevel >> strategy;
}
return Compression::CompressZLIBWithSettings(data, level, windowBits, memLevel, strategy);
}
return Compression::CompressZLIB(data);
}
if (type == "deflate") {
// Use settings from params if available
if (!params.isEmpty()) {
QDataStream paramsStream(params);
int level = Z_BEST_COMPRESSION;
int windowBits = -MAX_WBITS; // Raw deflate
int memLevel = 8;
int strategy = Z_DEFAULT_STRATEGY;
if (params.size() >= 4) {
paramsStream >> level >> windowBits >> memLevel >> strategy;
}
return Compression::CompressDeflateWithSettings(data, level, windowBits, memLevel, strategy);
}
return Compression::CompressDeflate(data);
}
if (type == "xmem") {
return Compression::CompressXMem(data);
}
if (type == "oodle") {
return Compression::CompressOodle(data);
}
// Unsupported compression type
return QByteArray();
}