From 08481e3906a0726d2fc6fa47f030789a9a4b2806 Mon Sep 17 00:00:00 2001 From: Andreas Lierschaft Date: Tue, 18 Aug 2026 00:03:48 +0200 Subject: [PATCH] Add standalone pack tooling (PackList/PackMake/PackVerify) Automates the .eix/.epk repacking step that was previously done via the EterNexus.exe GUI, using the real CEterPack class so output is guaranteed read-compatible with the game client. PackList inspects an existing pack's entries, PackMake rebuilds a pack from a source folder, PackVerify byte-compares a pack's contents against source to confirm round-trip correctness. --- source/PackTool/PackList.cpp | 53 +++++++++++++++++ source/PackTool/PackMake.cpp | 105 +++++++++++++++++++++++++++++++++ source/PackTool/PackVerify.cpp | 96 ++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 source/PackTool/PackList.cpp create mode 100644 source/PackTool/PackMake.cpp create mode 100644 source/PackTool/PackVerify.cpp diff --git a/source/PackTool/PackList.cpp b/source/PackTool/PackList.cpp new file mode 100644 index 0000000..09921b5 --- /dev/null +++ b/source/PackTool/PackList.cpp @@ -0,0 +1,53 @@ +// Read-only pack inspector: opens an existing .eix/.epk pair and prints every +// entry's stored filename, so we can learn the exact relative-path naming +// convention a given pack uses before writing anything back. +// +// Usage: PackList.exe +// e.g. PackList.exe "Y:\Metin2Dev\Exec_Client\pack" root + +#include +#include +#include +#include + +#include "../EterPack/EterPack.h" + +int main(int argc, char** argv) +{ + if (argc < 3) + { + fprintf(stderr, "Usage: %s \n", argv[0]); + return 1; + } + + const char* pathName = argv[1]; + const char* dbname = argv[2]; + + if (!SetCurrentDirectoryA(pathName)) + { + fprintf(stderr, "FAILED to chdir to '%s'\n", pathName); + return 4; + } + + CEterFileDict dict; + CEterPack pack; + + if (!pack.Create(dict, dbname, "", true)) + { + fprintf(stderr, "FAILED to open pack '%s' in '%s'\n", dbname, pathName); + return 2; + } + + std::vector names; + if (!pack.GetNames(&names)) + { + fprintf(stderr, "FAILED GetNames\n"); + return 3; + } + + printf("Entries: %zu\n", names.size()); + for (auto& n : names) + printf("%s\n", n.c_str()); + + return 0; +} diff --git a/source/PackTool/PackMake.cpp b/source/PackTool/PackMake.cpp new file mode 100644 index 0000000..a5631a4 --- /dev/null +++ b/source/PackTool/PackMake.cpp @@ -0,0 +1,105 @@ +// Write-mode pack builder: recursively packs every file under a source folder +// into .eix/.epk in an output folder, using the same CEterPack +// class the game client itself uses to read packs (so the result is guaranteed +// compatible). Always rebuilds the pack from scratch (deletes any existing +// .eix/.epk in the output folder first) so removed source files don't +// linger as stale entries. +// +// Usage: PackMake.exe +// e.g. PackMake.exe "Y:\Metin2Dev\Exec_Client\Eternexus\root" "Y:\scratch\pack" root + +#include +#include +#include +#include + +#include "../EterPack/EterPack.h" +#include "../EterBase/lzo.h" + +namespace fs = std::filesystem; + +int main(int argc, char** argv) +{ + // CLZO is a CSingleton that registers itself via ctor side effect (no + // lazy init) - EncryptIndexFile() calls CLZO::Instance() internally, so an + // instance must exist before that or Instance() dereferences a null pointer. + CLZO lzoInstance; + + if (argc < 4) + { + fprintf(stderr, "Usage: %s \n", argv[0]); + return 1; + } + + const std::string sourceFolder = argv[1]; + const std::string outputFolder = argv[2]; + const char* dbname = argv[3]; + + if (!fs::is_directory(sourceFolder)) + { + fprintf(stderr, "Source folder does not exist: %s\n", sourceFolder.c_str()); + return 2; + } + + if (!fs::is_directory(outputFolder)) + { + fprintf(stderr, "Output pack folder does not exist: %s\n", outputFolder.c_str()); + return 2; + } + + if (!SetCurrentDirectoryA(outputFolder.c_str())) + { + fprintf(stderr, "FAILED to chdir to '%s'\n", outputFolder.c_str()); + return 4; + } + + std::string eixPath = std::string(dbname) + ".eix"; + std::string epkPath = std::string(dbname) + ".epk"; + + if (fs::exists(eixPath) || fs::exists(epkPath)) + { + printf("Removing existing %s / %s in output folder for a clean rebuild\n", eixPath.c_str(), epkPath.c_str()); + fs::remove(eixPath); + fs::remove(epkPath); + } + + CEterFileDict dict; + CEterPack pack; + + if (!pack.Create(dict, dbname, "", /*bReadOnly=*/false)) + { + fprintf(stderr, "FAILED to create pack '%s' in '%s'\n", dbname, outputFolder.c_str()); + return 3; + } + + long fileCount = 0; + long failCount = 0; + + for (auto it = fs::recursive_directory_iterator(sourceFolder); it != fs::recursive_directory_iterator(); ++it) + { + if (!it->is_regular_file()) + continue; + + std::string relPath = fs::relative(it->path(), sourceFolder).generic_string(); + std::string srcPath = it->path().string(); + + if (!pack.Put(relPath.c_str(), srcPath.c_str(), COMPRESSED_TYPE_NONE, "")) + { + fprintf(stderr, "FAILED to put '%s' (source: %s)\n", relPath.c_str(), srcPath.c_str()); + ++failCount; + continue; + } + + ++fileCount; + } + + if (!pack.EncryptIndexFile()) + { + fprintf(stderr, "FAILED to encrypt index for pack '%s'\n", dbname); + return 5; + } + + printf("Packed %ld files (%ld failures) into %s / %s\n", fileCount, failCount, eixPath.c_str(), epkPath.c_str()); + + return failCount > 0 ? 6 : 0; +} diff --git a/source/PackTool/PackVerify.cpp b/source/PackTool/PackVerify.cpp new file mode 100644 index 0000000..a162a9b --- /dev/null +++ b/source/PackTool/PackVerify.cpp @@ -0,0 +1,96 @@ +// Round-trip verification tool: opens a pack read-only, reads every entry via +// the same CEterPack::Get() path the game engine uses, and byte-compares it +// against the corresponding file under a source folder. +// +// Usage: PackVerify.exe +// e.g. PackVerify.exe "Y:\scratch\pack" root "Y:\Metin2Dev\Exec_Client\Eternexus\root" + +#include +#include +#include +#include +#include + +#include "../EterPack/EterPack.h" + +int main(int argc, char** argv) +{ + if (argc < 4) + { + fprintf(stderr, "Usage: %s \n", argv[0]); + return 1; + } + + const char* pathName = argv[1]; + const char* dbname = argv[2]; + std::string sourceFolder = argv[3]; + + if (!SetCurrentDirectoryA(pathName)) + { + fprintf(stderr, "FAILED to chdir to '%s'\n", pathName); + return 4; + } + + CEterFileDict dict; + CEterPack pack; + + if (!pack.Create(dict, dbname, "", true)) + { + fprintf(stderr, "FAILED to open pack '%s' in '%s'\n", dbname, pathName); + return 2; + } + + std::vector names; + if (!pack.GetNames(&names)) + { + fprintf(stderr, "FAILED GetNames\n"); + return 3; + } + + long okCount = 0, mismatchCount = 0, missingSrcCount = 0, getFailCount = 0; + + for (auto& name : names) + { + std::string srcPath = sourceFolder + "\\" + name; + for (auto& c : srcPath) if (c == '/') c = '\\'; + + std::ifstream srcFile(srcPath, std::ios::binary | std::ios::ate); + if (!srcFile) + { + fprintf(stderr, "MISSING SOURCE: %s (expected at %s)\n", name.c_str(), srcPath.c_str()); + ++missingSrcCount; + continue; + } + + std::streamsize srcSize = srcFile.tellg(); + srcFile.seekg(0); + std::vector srcData(srcSize); + srcFile.read(srcData.data(), srcSize); + + CMappedFile mappedFile; + LPCVOID packData = NULL; + + if (!pack.Get(mappedFile, name.c_str(), &packData)) + { + fprintf(stderr, "Get() FAILED: %s\n", name.c_str()); + ++getFailCount; + continue; + } + + DWORD packSize = mappedFile.Size(); + + if ((std::streamsize)packSize != srcSize || memcmp(packData, srcData.data(), srcSize) != 0) + { + fprintf(stderr, "MISMATCH: %s (src %lld bytes, pack %lu bytes)\n", name.c_str(), (long long)srcSize, packSize); + ++mismatchCount; + continue; + } + + ++okCount; + } + + printf("Verified %ld entries: %ld OK, %ld mismatched, %ld missing-source, %ld get-failed\n", + (long)names.size(), okCount, mismatchCount, missingSrcCount, getFailCount); + + return (mismatchCount > 0 || getFailCount > 0) ? 5 : 0; +}