Add main code for v1.0.

This commit is contained in:
njohnson 2026-01-20 14:45:53 -05:00
parent c52a4df53d
commit e5859aa79e
4 changed files with 664 additions and 0 deletions

270
coalesced.cpp Normal file
View File

@ -0,0 +1,270 @@
#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;
// Parse the coalesced format
const char* ptr = data.constData();
const char* end = ptr + data.size();
// Read version (big-endian)
if (ptr + 4 > end) {
setError("Unexpected end of file reading header");
return false;
}
m_version = qFromBigEndian<quint32>(reinterpret_cast<const uchar*>(ptr));
ptr += 4;
// Read entries until EOF
while (ptr + 4 <= end) {
quint32 filenameLen = qFromBigEndian<quint32>(reinterpret_cast<const uchar*>(ptr));
ptr += 4;
// Sanity check filename length - 0 or very large means we've hit end of entries
if (filenameLen == 0 || filenameLen > 1024) {
break;
}
// Read filename
if (ptr + filenameLen > end) {
break;
}
// Filename includes null terminator
QString filename = QString::fromLatin1(ptr, filenameLen > 0 ? filenameLen - 1 : 0);
ptr += filenameLen;
// Read content length
if (ptr + 4 > end) {
break;
}
quint32 contentLen = qFromBigEndian<quint32>(reinterpret_cast<const uchar*>(ptr));
ptr += 4;
// Read content
if (ptr + contentLen > end) {
break;
}
QByteArray content(ptr, contentLen);
ptr += contentLen;
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
stream << m_version;
// Write each entry
for (const IniEntry& entry : m_entries) {
// 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());
// Write content length
stream << static_cast<quint32>(entry.content.size());
// Write content
stream.writeRawData(entry.content.constData(), entry.content.size());
}
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;
// Find all .ini files recursively
QDirIterator it(dirPath, QStringList() << "*.ini", QDir::Files, QDirIterator::Subdirectories);
while (it.hasNext()) {
QString filePath = it.next();
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()) {
setError("No .ini files found in directory");
return false;
}
m_isCoalesced = true;
return true;
}
void CoalescedFile::setError(const QString& error)
{
m_lastError = error;
}

66
coalesced.h Normal file
View File

@ -0,0 +1,66 @@
#ifndef COALESCED_H
#define COALESCED_H
#include <QString>
#include <QByteArray>
#include <QList>
struct IniEntry {
QString filename; // Relative path stored in file
QByteArray content; // Raw INI content
};
class CoalescedFile {
public:
CoalescedFile();
// Load and parse a coalesced file
bool load(const QString& path);
// Write back to coalesced format
bool save(const QString& path) const;
// Get list of contained files
QList<IniEntry> entries() const;
// Get entry by index
const IniEntry* entry(int index) const;
// Get entry by filename
const IniEntry* entry(const QString& filename) const;
// Check if loaded file is coalesced format
bool isCoalesced() const;
// Get number of entries
int count() const;
// Get total content size
qint64 totalSize() const;
// Get the path of the loaded file
QString loadedPath() const;
// Clear loaded data
void clear();
// Get last error message
QString lastError() const;
// Static check if file is coalesced format without full parse
static bool isCoalescedFile(const QString& path);
// Pack a directory of INI files into this object
bool packDirectory(const QString& dirPath, const QString& basePath = QString());
private:
QList<IniEntry> m_entries;
QString m_loadedPath;
QString m_lastError;
bool m_isCoalesced;
quint32 m_version; // Header value (often 0x1e for UE3)
void setError(const QString& error);
};
#endif // COALESCED_H

313
main.cpp Normal file
View File

@ -0,0 +1,313 @@
#include <QCoreApplication>
#include <QTextStream>
#include <QFile>
#include <QDir>
#include <QFileInfo>
#include "coalesced.h"
static QTextStream cout(stdout);
static QTextStream cerr(stderr);
void printError(const QString& message)
{
cerr << "Error: " << message << "\n";
cerr.flush();
}
void showUsage()
{
cout << "UDK Config Extractor v1.0\n";
cout << "Extract and pack UDK/UE3 coalesced INI files\n\n";
cout << "Usage:\n";
cout << " udk-config-extractor <command> [arguments]\n\n";
cout << "Commands:\n";
cout << " list <file> List files in archive\n";
cout << " info <file> Show archive metadata\n";
cout << " unpack <file> [output_dir] Extract all INI files\n";
cout << " pack <input_dir> <output_file> Pack directory into coalesced file\n";
cout << " extract <file> <index|name> [output] Extract single file\n\n";
cout << "Options:\n";
cout << " -h, --help Show this help message\n";
cout << " -v, --version Show version\n\n";
cout << "Drag & Drop:\n";
cout << " Drag a coalesced file onto exe Extracts to ./unpacked\n";
cout << " Drag a directory onto exe Packs to ./coalesced.ini\n";
cout.flush();
}
int cmdList(const QStringList& args)
{
if (args.isEmpty()) {
printError("Usage: udk-config-extractor list <file>");
return 1;
}
CoalescedFile coalesced;
if (!coalesced.load(args[0])) {
printError(coalesced.lastError());
return 1;
}
cout << QString("%1 %2 %3\n")
.arg("Index", 5)
.arg("Size", 8)
.arg("Filename");
cout << QString("%1 %2 %3\n")
.arg(QString("-").repeated(5), 5)
.arg(QString("-").repeated(8), 8)
.arg(QString("-").repeated(50));
const QList<IniEntry>& entries = coalesced.entries();
for (int i = 0; i < entries.count(); ++i) {
const IniEntry& entry = entries[i];
cout << QString("%1 %2 %3\n")
.arg(i, 5)
.arg(entry.content.size(), 8)
.arg(entry.filename);
}
cout << QString("\nTotal: %1 files, %2 bytes\n")
.arg(coalesced.count())
.arg(coalesced.totalSize());
cout.flush();
return 0;
}
int cmdInfo(const QStringList& args)
{
if (args.isEmpty()) {
printError("Usage: udk-config-extractor info <file>");
return 1;
}
CoalescedFile coalesced;
if (!coalesced.load(args[0])) {
printError(coalesced.lastError());
return 1;
}
cout << "Archive Information:\n";
cout << QString(" File: %1\n").arg(coalesced.loadedPath());
cout << QString(" Entries: %1\n").arg(coalesced.count());
cout << QString(" Total content size: %1 bytes\n").arg(coalesced.totalSize());
qint64 minSize = LLONG_MAX;
qint64 maxSize = 0;
QString minFile, maxFile;
for (const IniEntry& entry : coalesced.entries()) {
if (entry.content.size() < minSize) {
minSize = entry.content.size();
minFile = entry.filename;
}
if (entry.content.size() > maxSize) {
maxSize = entry.content.size();
maxFile = entry.filename;
}
}
if (coalesced.count() > 0) {
cout << QString(" Smallest: %1 bytes (%2)\n").arg(minSize).arg(minFile);
cout << QString(" Largest: %1 bytes (%2)\n").arg(maxSize).arg(maxFile);
cout << QString(" Average: %1 bytes\n").arg(coalesced.totalSize() / coalesced.count());
}
cout.flush();
return 0;
}
int cmdUnpack(const QStringList& args)
{
if (args.isEmpty()) {
printError("Usage: udk-config-extractor unpack <file> [output_dir]");
return 1;
}
CoalescedFile coalesced;
if (!coalesced.load(args[0])) {
printError(coalesced.lastError());
return 1;
}
QString outputDir = args.size() > 1 ? args[1] : ".";
QDir dir(outputDir);
if (!dir.exists() && !dir.mkpath(".")) {
printError(QString("Cannot create output directory: %1").arg(outputDir));
return 1;
}
int extracted = 0;
qint64 totalBytes = 0;
for (const IniEntry& entry : coalesced.entries()) {
QString localPath = entry.filename;
// Remove leading ".." components for safety
while (localPath.startsWith("..\\") || localPath.startsWith("../")) {
localPath = localPath.mid(3);
}
localPath.replace('\\', '/');
QString fullPath = dir.filePath(localPath);
QFileInfo fi(fullPath);
if (!fi.dir().exists() && !QDir().mkpath(fi.path())) {
printError(QString("Cannot create directory: %1").arg(fi.path()));
continue;
}
QFile file(fullPath);
if (!file.open(QIODevice::WriteOnly)) {
printError(QString("Cannot write file: %1").arg(fullPath));
continue;
}
file.write(entry.content);
file.close();
extracted++;
totalBytes += entry.content.size();
}
cout << QString("Extracted %1 files (%2 bytes) to %3\n")
.arg(extracted)
.arg(totalBytes)
.arg(QDir(outputDir).absolutePath());
cout.flush();
return 0;
}
int cmdPack(const QStringList& args)
{
if (args.size() < 2) {
printError("Usage: udk-config-extractor pack <input_dir> <output_file>");
return 1;
}
CoalescedFile packer;
if (!packer.packDirectory(args[0])) {
printError(packer.lastError());
return 1;
}
if (!packer.save(args[1])) {
printError(QString("Cannot write output file: %1").arg(args[1]));
return 1;
}
cout << QString("Packed %1 files (%2 bytes) to %3\n")
.arg(packer.count())
.arg(packer.totalSize())
.arg(args[1]);
cout.flush();
return 0;
}
int cmdExtract(const QStringList& args)
{
if (args.size() < 2) {
printError("Usage: udk-config-extractor extract <file> <index|name> [output]");
return 1;
}
CoalescedFile coalesced;
if (!coalesced.load(args[0])) {
printError(coalesced.lastError());
return 1;
}
QString target = args[1];
const IniEntry* entry = nullptr;
bool ok;
int index = target.toInt(&ok);
if (ok) {
entry = coalesced.entry(index);
if (!entry) {
printError(QString("Index %1 out of range (0-%2)").arg(index).arg(coalesced.count() - 1));
return 1;
}
} else {
entry = coalesced.entry(target);
if (!entry) {
printError(QString("File not found: %1").arg(target));
return 1;
}
}
QString outputPath;
if (args.size() > 2) {
outputPath = args[2];
} else {
QFileInfo fi(entry->filename);
outputPath = fi.fileName();
}
QFile file(outputPath);
if (!file.open(QIODevice::WriteOnly)) {
printError(QString("Cannot write file: %1").arg(outputPath));
return 1;
}
file.write(entry->content);
file.close();
cout << QString("Extracted '%1' to '%2' (%3 bytes)\n")
.arg(entry->filename)
.arg(outputPath)
.arg(entry->content.size());
cout.flush();
return 0;
}
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
QCoreApplication::setApplicationName("udk-config-extractor");
QCoreApplication::setApplicationVersion("1.0");
QStringList args = app.arguments();
args.removeFirst(); // Remove program name
if (args.isEmpty()) {
showUsage();
return 1;
}
QString cmd = args.takeFirst().toLower();
if (cmd == "-h" || cmd == "--help" || cmd == "help") {
showUsage();
return 0;
}
if (cmd == "-v" || cmd == "--version" || cmd == "version") {
cout << QString("udk-config-extractor %1\n").arg(QCoreApplication::applicationVersion());
cout.flush();
return 0;
}
if (cmd == "list") {
return cmdList(args);
} else if (cmd == "info") {
return cmdInfo(args);
} else if (cmd == "unpack") {
return cmdUnpack(args);
} else if (cmd == "pack") {
return cmdPack(args);
} else if (cmd == "extract") {
return cmdExtract(args);
} else if (QFileInfo(cmd).isDir()) {
// Directory dragged onto exe - pack to coalesced.ini
return cmdPack(QStringList() << cmd << "coalesced.ini");
} else if (QFile::exists(cmd)) {
// File dragged onto exe - unpack to ./unpacked
return cmdUnpack(QStringList() << cmd << "unpacked");
} else {
printError(QString("Unknown command: %1").arg(cmd));
showUsage();
return 1;
}
}

15
udk-config-extractor.pro Normal file
View File

@ -0,0 +1,15 @@
QT += core
QT -= gui
TEMPLATE = app
CONFIG += console c++17
CONFIG -= app_bundle
TARGET = udk-config-extractor
SOURCES += \
main.cpp \
coalesced.cpp
HEADERS += \
coalesced.h