2026-01-20 14:45:53 -05:00
|
|
|
#include "coalesced.h"
|
|
|
|
|
|
|
|
|
|
#include <QFile>
|
|
|
|
|
#include <QDir>
|
|
|
|
|
#include <QDirIterator>
|
|
|
|
|
#include <QFileInfo>
|
|
|
|
|
#include <QDataStream>
|
|
|
|
|
#include <QtEndian>
|
|
|
|
|
|
|
|
|
|
CoalescedFile::CoalescedFile()
|
|
|
|
|
: m_isCoalesced(false)
|
|
|
|
|
, m_version(0x1e) // Default UE3 version
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool CoalescedFile::load(const QString& path)
|
|
|
|
|
{
|
|
|
|
|
clear();
|
|
|
|
|
|
|
|
|
|
QFile file(path);
|
|
|
|
|
if (!file.exists()) {
|
|
|
|
|
setError(QString("File not found: %1").arg(path));
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
|
|
|
setError(QString("Cannot open file: %1").arg(file.errorString()));
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QByteArray data = file.readAll();
|
|
|
|
|
file.close();
|
|
|
|
|
|
|
|
|
|
if (data.size() < 4) {
|
|
|
|
|
setError("File too small to be a valid coalesced file");
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if this is a coalesced file or a normal INI
|
|
|
|
|
// Normal INI files start with '[' (0x5B)
|
|
|
|
|
// Coalesced files start with a version (big-endian), first byte typically 0x00
|
|
|
|
|
if (static_cast<unsigned char>(data[0]) == '[') {
|
|
|
|
|
setError("This appears to be a normal INI file, not a coalesced file");
|
|
|
|
|
m_isCoalesced = false;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m_isCoalesced = true;
|
|
|
|
|
m_loadedPath = path;
|
|
|
|
|
|
2026-01-21 00:47:04 -05:00
|
|
|
QDataStream stream(data);
|
|
|
|
|
stream.setByteOrder(QDataStream::BigEndian);
|
2026-01-20 14:45:53 -05:00
|
|
|
|
2026-01-21 00:47:04 -05:00
|
|
|
quint32 entryCount;
|
|
|
|
|
stream >> entryCount;
|
2026-01-20 14:45:53 -05:00
|
|
|
|
2026-01-21 00:47:04 -05:00
|
|
|
entryCount /= 2;
|
2026-01-20 14:45:53 -05:00
|
|
|
|
2026-01-21 00:47:04 -05:00
|
|
|
for (uint i = 0; i < entryCount; i++)
|
|
|
|
|
{
|
|
|
|
|
quint32 filenameLen;
|
|
|
|
|
stream >> filenameLen;
|
2026-01-20 14:45:53 -05:00
|
|
|
if (filenameLen == 0 || filenameLen > 1024) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 00:47:04 -05:00
|
|
|
QByteArray filename(filenameLen - 1, Qt::Uninitialized);
|
|
|
|
|
stream.readRawData(filename.data(), filenameLen - 1);
|
|
|
|
|
stream.skipRawData(1);
|
2026-01-20 14:45:53 -05:00
|
|
|
|
2026-01-21 00:47:04 -05:00
|
|
|
quint32 contentLen;
|
|
|
|
|
stream >> contentLen;
|
2026-01-20 14:45:53 -05:00
|
|
|
|
2026-01-21 00:47:04 -05:00
|
|
|
QByteArray content(contentLen - 1, Qt::Uninitialized);
|
|
|
|
|
stream.readRawData(content.data(), contentLen - 1);
|
|
|
|
|
stream.skipRawData(1);
|
2026-01-20 14:45:53 -05:00
|
|
|
|
|
|
|
|
IniEntry entry;
|
|
|
|
|
entry.filename = filename;
|
|
|
|
|
entry.content = content;
|
|
|
|
|
m_entries.append(entry);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool CoalescedFile::save(const QString& path) const
|
|
|
|
|
{
|
|
|
|
|
QFile file(path);
|
|
|
|
|
if (!file.open(QIODevice::WriteOnly)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QDataStream stream(&file);
|
|
|
|
|
stream.setByteOrder(QDataStream::BigEndian);
|
|
|
|
|
|
|
|
|
|
// Write version
|
2026-01-21 00:47:04 -05:00
|
|
|
stream << (quint32)m_entries.size() * 2;
|
2026-01-20 14:45:53 -05:00
|
|
|
|
|
|
|
|
// Write each entry
|
2026-01-21 00:47:04 -05:00
|
|
|
for (uint i = 0; i < m_entries.size(); i++) {
|
|
|
|
|
const IniEntry& entry = m_entries.at(i);
|
2026-01-20 14:45:53 -05:00
|
|
|
// Convert filename to Latin1 with null terminator
|
|
|
|
|
QByteArray filenameBytes = entry.filename.toLatin1();
|
|
|
|
|
filenameBytes.append('\0');
|
|
|
|
|
|
|
|
|
|
// Write filename length (including null terminator)
|
|
|
|
|
stream << static_cast<quint32>(filenameBytes.size());
|
|
|
|
|
|
|
|
|
|
// Write filename
|
|
|
|
|
stream.writeRawData(filenameBytes.constData(), filenameBytes.size());
|
|
|
|
|
|
2026-01-21 00:47:04 -05:00
|
|
|
QByteArray contentBytes = entry.content;
|
|
|
|
|
contentBytes.append('\0');
|
|
|
|
|
|
2026-01-20 14:45:53 -05:00
|
|
|
// Write content length
|
2026-01-21 00:47:04 -05:00
|
|
|
stream << static_cast<quint32>(contentBytes.size());
|
2026-01-20 14:45:53 -05:00
|
|
|
|
|
|
|
|
// Write content
|
2026-01-21 00:47:04 -05:00
|
|
|
stream.writeRawData(contentBytes.constData(), contentBytes.size());
|
2026-01-20 14:45:53 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
file.close();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QList<IniEntry> CoalescedFile::entries() const
|
|
|
|
|
{
|
|
|
|
|
return m_entries;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const IniEntry* CoalescedFile::entry(int index) const
|
|
|
|
|
{
|
|
|
|
|
if (index < 0 || index >= m_entries.count()) {
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
return &m_entries.at(index);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const IniEntry* CoalescedFile::entry(const QString& filename) const
|
|
|
|
|
{
|
|
|
|
|
for (const IniEntry& e : m_entries) {
|
|
|
|
|
if (e.filename.compare(filename, Qt::CaseInsensitive) == 0) {
|
|
|
|
|
return &e;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool CoalescedFile::isCoalesced() const
|
|
|
|
|
{
|
|
|
|
|
return m_isCoalesced;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int CoalescedFile::count() const
|
|
|
|
|
{
|
|
|
|
|
return m_entries.count();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
qint64 CoalescedFile::totalSize() const
|
|
|
|
|
{
|
|
|
|
|
qint64 total = 0;
|
|
|
|
|
for (const IniEntry& entry : m_entries) {
|
|
|
|
|
total += entry.content.size();
|
|
|
|
|
}
|
|
|
|
|
return total;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QString CoalescedFile::loadedPath() const
|
|
|
|
|
{
|
|
|
|
|
return m_loadedPath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void CoalescedFile::clear()
|
|
|
|
|
{
|
|
|
|
|
m_entries.clear();
|
|
|
|
|
m_loadedPath.clear();
|
|
|
|
|
m_lastError.clear();
|
|
|
|
|
m_isCoalesced = false;
|
|
|
|
|
m_version = 0x1e; // Default UE3 version
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QString CoalescedFile::lastError() const
|
|
|
|
|
{
|
|
|
|
|
return m_lastError;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool CoalescedFile::isCoalescedFile(const QString& path)
|
|
|
|
|
{
|
|
|
|
|
QFile file(path);
|
|
|
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
char firstByte;
|
|
|
|
|
if (file.read(&firstByte, 1) != 1) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If first byte is '[', it's a normal INI file
|
|
|
|
|
// Coalesced files start with a version (big-endian), first byte typically 0x00
|
|
|
|
|
return firstByte != '[';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool CoalescedFile::packDirectory(const QString& dirPath, const QString& basePath)
|
|
|
|
|
{
|
|
|
|
|
clear();
|
|
|
|
|
|
|
|
|
|
QDir dir(dirPath);
|
|
|
|
|
if (!dir.exists()) {
|
|
|
|
|
setError(QString("Directory not found: %1").arg(dirPath));
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QString effectiveBasePath = basePath.isEmpty() ? dirPath : basePath;
|
|
|
|
|
|
2026-01-20 15:57:20 -05:00
|
|
|
// Find all config and localization files recursively
|
|
|
|
|
// Supports: .ini, .int, .jpn, .deu, .esn, .fra, .ita, .kor, .pol, .rus, .cze, .hun, etc.
|
|
|
|
|
QStringList filters;
|
|
|
|
|
filters << "*.ini" << "*.int" << "*.jpn" << "*.deu" << "*.esn"
|
|
|
|
|
<< "*.fra" << "*.ita" << "*.kor" << "*.pol" << "*.rus"
|
|
|
|
|
<< "*.cze" << "*.hun" << "*.esm" << "*.ptb";
|
2026-01-20 14:45:53 -05:00
|
|
|
|
2026-01-21 00:47:04 -05:00
|
|
|
int entryCount = 0;
|
|
|
|
|
QDirIterator entryIt(dirPath, filters, QDir::Files, QDirIterator::Subdirectories);
|
|
|
|
|
while (entryIt.hasNext())
|
|
|
|
|
{
|
|
|
|
|
entryIt.next();
|
|
|
|
|
entryCount++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Read temp file for entry records
|
|
|
|
|
QFile tempFile(dirPath + "/temp");
|
|
|
|
|
if (!tempFile.open(QIODevice::ReadOnly))
|
|
|
|
|
{
|
|
|
|
|
setError(QString("Failed to open temp file: %1").arg(tempFile.errorString()));
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse filenames
|
|
|
|
|
QStringList entryStrings;
|
|
|
|
|
for (int i = 0; i < entryCount; i++)
|
|
|
|
|
{
|
|
|
|
|
QByteArray entryStr(64, Qt::Uninitialized);
|
|
|
|
|
qint64 lineLen = tempFile.readLine(entryStr.data(), 64);
|
|
|
|
|
|
|
|
|
|
entryStrings.push_back(entryStr.mid(0, lineLen - 1));
|
|
|
|
|
}
|
|
|
|
|
tempFile.close();
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < entryStrings.size(); i++)
|
|
|
|
|
{
|
|
|
|
|
QString entryString = entryStrings.at(i);
|
|
|
|
|
const QString& filePath = dirPath + entryString.replace("..", "");
|
2026-01-20 14:45:53 -05:00
|
|
|
QFile file(filePath);
|
|
|
|
|
|
|
|
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
|
|
|
setError(QString("Cannot open file: %1").arg(filePath));
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
IniEntry entry;
|
|
|
|
|
|
|
|
|
|
// Calculate relative path
|
|
|
|
|
QString relativePath = QDir(effectiveBasePath).relativeFilePath(filePath);
|
|
|
|
|
// Convert to the format used in coalesced files (with ..\)
|
|
|
|
|
entry.filename = "..\\" + relativePath.replace('/', '\\');
|
|
|
|
|
entry.content = file.readAll();
|
|
|
|
|
|
|
|
|
|
m_entries.append(entry);
|
|
|
|
|
file.close();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (m_entries.isEmpty()) {
|
2026-01-20 15:57:20 -05:00
|
|
|
setError("No config/localization files found in directory");
|
2026-01-20 14:45:53 -05:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m_isCoalesced = true;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 15:57:20 -05:00
|
|
|
QString CoalescedFile::detectExtension() const
|
|
|
|
|
{
|
|
|
|
|
// Look at entries to detect the language/type
|
|
|
|
|
// Check paths for language folder indicators (e.g., \INT\, \JPN\, \DEU\)
|
|
|
|
|
// or file extensions
|
|
|
|
|
|
|
|
|
|
for (const IniEntry& entry : m_entries) {
|
|
|
|
|
QString path = entry.filename.toUpper();
|
|
|
|
|
|
|
|
|
|
// Check for language folder pattern like \INT\, \JPN\, etc.
|
|
|
|
|
QStringList langs = {"INT", "JPN", "DEU", "ESN", "FRA", "ITA", "KOR", "POL", "RUS", "CZE", "HUN", "ESM", "PTB"};
|
|
|
|
|
for (const QString& lang : langs) {
|
|
|
|
|
if (path.contains("\\" + lang + "\\")) {
|
|
|
|
|
return lang.toLower();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall back to file extension
|
|
|
|
|
QFileInfo fi(entry.filename);
|
|
|
|
|
QString ext = fi.suffix().toLower();
|
|
|
|
|
if (!ext.isEmpty() && ext != "ini") {
|
|
|
|
|
return ext;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "ini"; // Default
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 14:45:53 -05:00
|
|
|
void CoalescedFile::setError(const QString& error)
|
|
|
|
|
{
|
|
|
|
|
m_lastError = error;
|
|
|
|
|
}
|