Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a398878aaa | ||
|
|
628d8a3c05 | ||
|
|
bfd5d68f5b | ||
|
|
5cdd08e5d6 | ||
|
|
91b043929c | ||
|
|
5c455e9a45 | ||
|
|
279c7aa666 |
58
README.md
58
README.md
@ -1,3 +1,57 @@
|
||||
# udk-config-extractor
|
||||
# udk-manip
|
||||
|
||||
Config extractor for UDK/UE3 games. Packs and unpacks ini files from coalesced.ini files
|
||||
A command-line tool for manipulating UDK/UE3 coalesced config and localization files.
|
||||
|
||||
Tested with Army of Two: The 40th Day (Xbox 360).
|
||||
|
||||
## Features
|
||||
|
||||
- Extract all files from coalesced archives
|
||||
- Repack directories back into coalesced format
|
||||
- List contents of an archive with file sizes
|
||||
- Extract individual files by index or name
|
||||
- Drag & drop support for quick extraction/packing
|
||||
- Auto-detect language extension when repacking (int, jpn, deu, fra, ita, etc.)
|
||||
|
||||
## Supported Formats
|
||||
|
||||
- `coalesced.ini` - Config files
|
||||
- `coalesced.int` - English localization
|
||||
- `coalesced.jpn` - Japanese localization
|
||||
- `coalesced.deu` - German localization
|
||||
- `coalesced.fra` - French localization
|
||||
- `coalesced.ita` - Italian localization
|
||||
- `coalesced.esn` - Spanish localization
|
||||
- And other UE3 localization formats
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
udk-manip list <file> - List archive contents
|
||||
udk-manip unpack <file> [output_dir] - Extract all files
|
||||
udk-manip pack <input_dir> <output_file> - Create coalesced file
|
||||
udk-manip info <file> - Show archive info
|
||||
udk-manip extract <file> <index> [output] - Extract single file
|
||||
```
|
||||
|
||||
### Drag & Drop
|
||||
|
||||
- Drag a coalesced file onto the exe to extract to `./<name>_<ext>/`
|
||||
- Example: `coalesced.ini` extracts to `coalesced_ini/`
|
||||
- Drag a folder onto the exe to pack into `./<name>.<ext>`
|
||||
- Example: `coalesced_ini/` packs to `coalesced.ini`
|
||||
|
||||
## Building
|
||||
|
||||
Requires Qt 6 and a C++17 compiler.
|
||||
|
||||
```bash
|
||||
qmake udk-manip.pro
|
||||
make
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Preserves original file format for game compatibility
|
||||
- Maintains directory structure when extracting
|
||||
- Big-endian binary format (Xbox 360 / PS3)
|
||||
|
||||
133
coalesced.cpp
133
coalesced.cpp
@ -48,53 +48,32 @@ bool CoalescedFile::load(const QString& path)
|
||||
m_isCoalesced = true;
|
||||
m_loadedPath = path;
|
||||
|
||||
// Parse the coalesced format
|
||||
const char* ptr = data.constData();
|
||||
const char* end = ptr + data.size();
|
||||
QDataStream stream(data);
|
||||
stream.setByteOrder(QDataStream::BigEndian);
|
||||
|
||||
// Read version (big-endian)
|
||||
if (ptr + 4 > end) {
|
||||
setError("Unexpected end of file reading header");
|
||||
return false;
|
||||
}
|
||||
quint32 entryCount;
|
||||
stream >> entryCount;
|
||||
|
||||
m_version = qFromBigEndian<quint32>(reinterpret_cast<const uchar*>(ptr));
|
||||
ptr += 4;
|
||||
entryCount /= 2;
|
||||
|
||||
// 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
|
||||
for (uint i = 0; i < entryCount; i++)
|
||||
{
|
||||
quint32 filenameLen;
|
||||
stream >> filenameLen;
|
||||
if (filenameLen == 0 || filenameLen > 1024) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Read filename
|
||||
if (ptr + filenameLen > end) {
|
||||
break;
|
||||
}
|
||||
QByteArray filename(filenameLen - 1, Qt::Uninitialized);
|
||||
stream.readRawData(filename.data(), filenameLen - 1);
|
||||
stream.skipRawData(1);
|
||||
|
||||
// Filename includes null terminator
|
||||
QString filename = QString::fromLatin1(ptr, filenameLen > 0 ? filenameLen - 1 : 0);
|
||||
ptr += filenameLen;
|
||||
quint32 contentLen;
|
||||
stream >> contentLen;
|
||||
|
||||
// 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;
|
||||
QByteArray content(contentLen - 1, Qt::Uninitialized);
|
||||
stream.readRawData(content.data(), contentLen - 1);
|
||||
stream.skipRawData(1);
|
||||
|
||||
IniEntry entry;
|
||||
entry.filename = filename;
|
||||
@ -116,10 +95,11 @@ bool CoalescedFile::save(const QString& path) const
|
||||
stream.setByteOrder(QDataStream::BigEndian);
|
||||
|
||||
// Write version
|
||||
stream << m_version;
|
||||
stream << (quint32)m_entries.size() * 2;
|
||||
|
||||
// Write each entry
|
||||
for (const IniEntry& entry : m_entries) {
|
||||
for (uint i = 0; i < m_entries.size(); i++) {
|
||||
const IniEntry& entry = m_entries.at(i);
|
||||
// Convert filename to Latin1 with null terminator
|
||||
QByteArray filenameBytes = entry.filename.toLatin1();
|
||||
filenameBytes.append('\0');
|
||||
@ -131,7 +111,7 @@ bool CoalescedFile::save(const QString& path) const
|
||||
stream.writeRawData(filenameBytes.constData(), filenameBytes.size());
|
||||
|
||||
// Write content length
|
||||
stream << static_cast<quint32>(entry.content.size());
|
||||
stream << static_cast<quint32>(entry.content.size() + 1);
|
||||
|
||||
// Write content
|
||||
stream.writeRawData(entry.content.constData(), entry.content.size());
|
||||
@ -231,11 +211,44 @@ bool CoalescedFile::packDirectory(const QString& dirPath, const QString& basePat
|
||||
|
||||
QString effectiveBasePath = basePath.isEmpty() ? dirPath : basePath;
|
||||
|
||||
// Find all .ini files recursively
|
||||
QDirIterator it(dirPath, QStringList() << "*.ini", QDir::Files, QDirIterator::Subdirectories);
|
||||
// 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";
|
||||
|
||||
while (it.hasNext()) {
|
||||
QString filePath = it.next();
|
||||
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("..", "");
|
||||
QFile file(filePath);
|
||||
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
@ -256,7 +269,7 @@ bool CoalescedFile::packDirectory(const QString& dirPath, const QString& basePat
|
||||
}
|
||||
|
||||
if (m_entries.isEmpty()) {
|
||||
setError("No .ini files found in directory");
|
||||
setError("No config/localization files found in directory");
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -264,6 +277,34 @@ bool CoalescedFile::packDirectory(const QString& dirPath, const QString& basePat
|
||||
return true;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
void CoalescedFile::setError(const QString& error)
|
||||
{
|
||||
m_lastError = error;
|
||||
|
||||
@ -50,9 +50,12 @@ public:
|
||||
// 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
|
||||
// Pack a directory of INI/localization files into this object
|
||||
bool packDirectory(const QString& dirPath, const QString& basePath = QString());
|
||||
|
||||
// Detect language extension from entries (e.g., "int", "jpn", "ini")
|
||||
QString detectExtension() const;
|
||||
|
||||
private:
|
||||
QList<IniEntry> m_entries;
|
||||
QString m_loadedPath;
|
||||
|
||||
122
main.cpp
122
main.cpp
@ -17,29 +17,30 @@ void printError(const QString& message)
|
||||
|
||||
void showUsage()
|
||||
{
|
||||
cout << "UDK Config Extractor v1.0\n";
|
||||
cout << "Extract and pack UDK/UE3 coalesced INI files\n\n";
|
||||
cout << QString("UDK Config/Localization Manipulator %1\n").arg(QCoreApplication::applicationVersion());
|
||||
cout << "Extract and pack UDK/UE3 coalesced Config/Localization files\n\n";
|
||||
cout << "Usage:\n";
|
||||
cout << " udk-config-extractor <command> [arguments]\n\n";
|
||||
cout << QString(" %1 <command> [arguments]\n\n").arg(QCoreApplication::applicationName());
|
||||
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 << " 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 << " -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 << " Drag a coalesced file onto exe Extracts to ./<name>_<ext>/\n";
|
||||
cout << " Drag a folder onto exe Packs to ./<name>.<ext>\n";
|
||||
cout << " (folder coalesced_ini -> coalesced.ini)\n";
|
||||
cout.flush();
|
||||
}
|
||||
|
||||
int cmdList(const QStringList& args)
|
||||
{
|
||||
if (args.isEmpty()) {
|
||||
printError("Usage: udk-config-extractor list <file>");
|
||||
printError(QString("Usage: %1 list <file>").arg(QCoreApplication::applicationName()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@ -77,7 +78,7 @@ int cmdList(const QStringList& args)
|
||||
int cmdInfo(const QStringList& args)
|
||||
{
|
||||
if (args.isEmpty()) {
|
||||
printError("Usage: udk-config-extractor info <file>");
|
||||
printError(QString("Usage: %1 info <file>").arg(QCoreApplication::applicationName()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@ -119,21 +120,34 @@ int cmdInfo(const QStringList& args)
|
||||
|
||||
int cmdUnpack(const QStringList& args)
|
||||
{
|
||||
if (args.isEmpty()) {
|
||||
printError("Usage: udk-config-extractor unpack <file> [output_dir]");
|
||||
if (args.isEmpty())
|
||||
{
|
||||
printError(QString("Usage: %1 unpack <file> [output_dir]").arg(QCoreApplication::applicationName()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
CoalescedFile coalesced;
|
||||
if (!coalesced.load(args[0])) {
|
||||
if (!coalesced.load(args[0]))
|
||||
{
|
||||
printError(coalesced.lastError());
|
||||
return 1;
|
||||
}
|
||||
|
||||
QString outputDir = args.size() > 1 ? args[1] : ".";
|
||||
QString outputDir;
|
||||
if (args.size() > 1)
|
||||
{
|
||||
outputDir = args[1];
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default: use filename with _ instead of . (e.g., coalesced.ini -> coalesced_ini)
|
||||
QFileInfo fi(args[0]);
|
||||
outputDir = fi.completeBaseName() + "_" + fi.suffix();
|
||||
}
|
||||
|
||||
QDir dir(outputDir);
|
||||
if (!dir.exists() && !dir.mkpath(".")) {
|
||||
if (!dir.exists() && !dir.mkpath("."))
|
||||
{
|
||||
printError(QString("Cannot create output directory: %1").arg(outputDir));
|
||||
return 1;
|
||||
}
|
||||
@ -141,11 +155,28 @@ int cmdUnpack(const QStringList& args)
|
||||
int extracted = 0;
|
||||
qint64 totalBytes = 0;
|
||||
|
||||
for (const IniEntry& entry : coalesced.entries()) {
|
||||
QFile tempFile(outputDir + "/temp");
|
||||
if (!tempFile.open(QIODevice::WriteOnly))
|
||||
{
|
||||
printError(QString("Failed to open temp file: %1").arg(tempFile.errorString()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < coalesced.entries().size(); i++)
|
||||
{
|
||||
QString entryStr(coalesced.entries().at(i).filename + "\n");
|
||||
tempFile.write(entryStr.toUtf8());
|
||||
}
|
||||
tempFile.close();
|
||||
|
||||
for (int i = 0; i < coalesced.entries().size(); i++)
|
||||
{
|
||||
const IniEntry& entry = coalesced.entries().at(i);
|
||||
QString localPath = entry.filename;
|
||||
|
||||
// Remove leading ".." components for safety
|
||||
while (localPath.startsWith("..\\") || localPath.startsWith("../")) {
|
||||
while (localPath.startsWith("..\\") || localPath.startsWith("../"))
|
||||
{
|
||||
localPath = localPath.mid(3);
|
||||
}
|
||||
localPath.replace('\\', '/');
|
||||
@ -153,13 +184,15 @@ int cmdUnpack(const QStringList& args)
|
||||
QString fullPath = dir.filePath(localPath);
|
||||
|
||||
QFileInfo fi(fullPath);
|
||||
if (!fi.dir().exists() && !QDir().mkpath(fi.path())) {
|
||||
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)) {
|
||||
if (!file.open(QIODevice::WriteOnly))
|
||||
{
|
||||
printError(QString("Cannot write file: %1").arg(fullPath));
|
||||
continue;
|
||||
}
|
||||
@ -182,7 +215,7 @@ int cmdUnpack(const QStringList& args)
|
||||
int cmdPack(const QStringList& args)
|
||||
{
|
||||
if (args.size() < 2) {
|
||||
printError("Usage: udk-config-extractor pack <input_dir> <output_file>");
|
||||
printError(QString("Usage: %1 pack <input_dir> <output_file>").arg(QCoreApplication::applicationName()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@ -208,7 +241,7 @@ int cmdPack(const QStringList& args)
|
||||
int cmdExtract(const QStringList& args)
|
||||
{
|
||||
if (args.size() < 2) {
|
||||
printError("Usage: udk-config-extractor extract <file> <index|name> [output]");
|
||||
printError(QString("Usage: %1 extract <file> <index|name> [output]").arg(QCoreApplication::applicationName()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@ -265,8 +298,8 @@ int cmdExtract(const QStringList& args)
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
QCoreApplication::setApplicationName("udk-config-extractor");
|
||||
QCoreApplication::setApplicationVersion("1.0");
|
||||
QCoreApplication::setApplicationName("udk-manip");
|
||||
QCoreApplication::setApplicationVersion("1.2");
|
||||
|
||||
QStringList args = app.arguments();
|
||||
args.removeFirst(); // Remove program name
|
||||
@ -284,7 +317,7 @@ int main(int argc, char *argv[])
|
||||
}
|
||||
|
||||
if (cmd == "-v" || cmd == "--version" || cmd == "version") {
|
||||
cout << QString("udk-config-extractor %1\n").arg(QCoreApplication::applicationVersion());
|
||||
cout << QString("%1 %2\n").arg(QCoreApplication::applicationVersion()).arg(QCoreApplication::applicationName());
|
||||
cout.flush();
|
||||
return 0;
|
||||
}
|
||||
@ -300,11 +333,36 @@ int main(int argc, char *argv[])
|
||||
} 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");
|
||||
// Directory dragged onto exe - derive output filename from folder name
|
||||
// Folder "coalesced_ini" -> output "coalesced.ini"
|
||||
CoalescedFile packer;
|
||||
if (!packer.packDirectory(cmd)) {
|
||||
printError(packer.lastError());
|
||||
return 1;
|
||||
}
|
||||
// Get folder name and replace last _ with .
|
||||
QString folderName = QFileInfo(cmd).fileName();
|
||||
int lastUnderscore = folderName.lastIndexOf('_');
|
||||
QString outputFile;
|
||||
if (lastUnderscore > 0) {
|
||||
outputFile = folderName.left(lastUnderscore) + "." + folderName.mid(lastUnderscore + 1);
|
||||
} else {
|
||||
// Fallback: use detected extension
|
||||
outputFile = folderName + "." + packer.detectExtension();
|
||||
}
|
||||
if (!packer.save(outputFile)) {
|
||||
printError(QString("Cannot write output file: %1").arg(outputFile));
|
||||
return 1;
|
||||
}
|
||||
cout << QString("Packed %1 files (%2 bytes) to %3\n")
|
||||
.arg(packer.count())
|
||||
.arg(packer.totalSize())
|
||||
.arg(outputFile);
|
||||
cout.flush();
|
||||
return 0;
|
||||
} else if (QFile::exists(cmd)) {
|
||||
// File dragged onto exe - unpack to ./unpacked
|
||||
return cmdUnpack(QStringList() << cmd << "unpacked");
|
||||
// File dragged onto exe - unpack to folder named after file
|
||||
return cmdUnpack(QStringList() << cmd);
|
||||
} else {
|
||||
printError(QString("Unknown command: %1").arg(cmd));
|
||||
showUsage();
|
||||
|
||||
@ -5,7 +5,7 @@ TEMPLATE = app
|
||||
CONFIG += console c++17
|
||||
CONFIG -= app_bundle
|
||||
|
||||
TARGET = udk-config-extractor
|
||||
TARGET = udk-manip
|
||||
|
||||
SOURCES += \
|
||||
main.cpp \
|
||||
Loading…
x
Reference in New Issue
Block a user