diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d75c60e..9cfbb07b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -107,6 +107,7 @@ set(SOURCES src/PatchServer.cc src/PlayerFilesManager.cc src/PlayerSubordinates.cc + src/PPKArchive.cc src/ProxyCommands.cc src/ProxyServer.cc src/PSOEncryption.cc diff --git a/README.md b/README.md index eb25faaf..ce3e894b 100644 --- a/README.md +++ b/README.md @@ -742,6 +742,7 @@ The data formats that newserv can convert to/from are: | Quest map (.dat) | None | `disassemble-quest-map` | | AFS archive (.afs) | None | `extract-afs` | | BML archive (.bml) | None | `extract-bml` | +| PPK archive (.ppk) | None | `extract-ppk` | | GSL archive (.gsl) | `generate-gsl` | `extract-gsl` | | GVM texture (.gvm) | `encode-gvm` | None | | Bitmap font (.fon) | `encode-bitmap-font` | `decode-bitmap-font` | diff --git a/src/Main.cc b/src/Main.cc index 693e95ab..3de70050 100644 --- a/src/Main.cc +++ b/src/Main.cc @@ -34,6 +34,7 @@ #include "ImageEncoder.hh" #include "Loggers.hh" #include "NetworkAddresses.hh" +#include "PPKArchive.hh" #include "PSOGCObjectGraph.hh" #include "PSOProtocol.hh" #include "PatchServer.hh" @@ -1688,7 +1689,7 @@ void a_extract_archive_fn(phosg::Arguments& args) { for (const auto& entry_it : arch.all_entries()) { auto e = arch.get(entry_it.first); string out_file = output_prefix + entry_it.first; - phosg::save_file(out_file.c_str(), e.first, e.second); + phosg::save_file(out_file, e.first, e.second); fprintf(stderr, "... %s\n", out_file.c_str()); } } else if (args.get(0) == "extract-bml") { @@ -1710,6 +1711,13 @@ void a_extract_archive_fn(phosg::Arguments& args) { fprintf(stderr, "... %s\n", out_file.c_str()); } } + } else if (args.get(0) == "extract-ppk") { + auto files = decode_ppk_file(*data_shared, args.get("password", true)); + for (const auto& [filename, data] : files) { + string out_file = output_prefix + filename; + phosg::save_file(out_file, data); + fprintf(stderr, "... %s\n", out_file.c_str()); + } } else { throw logic_error("unimplemented archive type"); } @@ -1717,16 +1725,18 @@ void a_extract_archive_fn(phosg::Arguments& args) { Action a_extract_afs("extract-afs", nullptr, a_extract_archive_fn); Action a_extract_gsl("extract-gsl", nullptr, a_extract_archive_fn); -Action a_extract_bml("extract-bml", "\ +Action a_extract_bml("extract-bml", nullptr, a_extract_archive_fn); +Action a_extract_ppk("extract-ppk", "\ extract-afs [INPUT-FILENAME] [--big-endian]\n\ extract-gsl [INPUT-FILENAME] [--big-endian]\n\ extract-bml [INPUT-FILENAME] [--big-endian]\n\ - Extract all files from an AFS, GSL, or BML archive into the current\n\ + extract-ppk [INPUT-FILENAME] [--big-endian]\n\ + Extract all files from an AFS, GSL, BML, or PPK archive into the current\n\ directory. input-filename may be specified. If output-filename is\n\ specified, then it is treated as a prefix which is prepended to the\n\ filename of each file contained in the archive. If --big-endian is given,\n\ the archive header is read in GameCube format; otherwise it is read in\n\ - PC/BB format.\n", + PC/BB format. For PPK archives, the --password= option is required.\n", a_extract_archive_fn); Action a_encode_sjis( diff --git a/src/PPKArchive.cc b/src/PPKArchive.cc new file mode 100644 index 00000000..b7c08418 --- /dev/null +++ b/src/PPKArchive.cc @@ -0,0 +1,76 @@ +#include "AFSArchive.hh" + +#include +#include + +#include +#include +#include + +#include "Compression.hh" +#include "Text.hh" + +using namespace std; + +struct Entry { + pstring filename; + le_uint32_t unknown_a1; + le_uint32_t decompressed_size; + le_uint32_t compressed_size; + le_uint32_t checksum; + // Data follows immediately here + // Trailer: le_uint32_t entry_size; // +}; + +static void decrypt_ppk_data(std::string& data, const std::string& filename, const std::string& password) { + if (password.size() > 0xFF) { + throw runtime_error("password is too long"); + } + + uint8_t key[0x100]; + for (size_t z = 0; z < 0x100; z++) { + key[z] = z ^ filename[z % filename.size()]; + } + for (size_t z = 0; z < password.size(); z++) { + key[z + 1] ^= password[z]; + } + for (size_t z = 0; z < 0xFC; z++) { + key[z + 4] ^= key[z]; + } + for (size_t z = 0; z < data.size(); z++) { + data[z] ^= key[z & 0xFF]; + } +} + +std::unordered_map decode_ppk_file(const std::string& data, const std::string& password) { + phosg::StringReader r(data); + + uint32_t signature = r.get_u32b(); + if (signature != 0x50503130 && signature != 0x4D5A5000) { // 'PP10' or 'MZP\0' + throw runtime_error("file is not a ppk archive"); + } + + unordered_map ret; + for (size_t offset = r.size() - 4; offset >= 4;) { + uint32_t size = r.pget_u32l(offset) ^ 0x12345678; + uint32_t entry_offset = offset - size; + const auto& entry = r.pget(entry_offset); + string data = r.pread(entry_offset + sizeof(Entry), entry.compressed_size); + string filename = entry.filename.decode(); + decrypt_ppk_data(data, phosg::tolower(filename), password); + uint32_t checksum = phosg::crc32(data.data(), data.size()); + if (checksum != entry.checksum) { + throw runtime_error(phosg::string_printf( + "incorrect checksum for file %s (expected %08" PRIX32 "; received %08" PRIX32 ")", + filename.c_str(), entry.checksum.load(), checksum)); + } + if (entry.compressed_size < entry.decompressed_size) { + data = prs_decompress(data); + } + if (!ret.emplace(filename, data).second) { + throw runtime_error(phosg::string_printf("archive contains multiple files with the same name (%s)", filename.c_str())); + } + offset = entry_offset - 4; + } + return ret; +} diff --git a/src/PPKArchive.hh b/src/PPKArchive.hh new file mode 100644 index 00000000..b7e71585 --- /dev/null +++ b/src/PPKArchive.hh @@ -0,0 +1,6 @@ +#pragma once + +#include +#include + +std::unordered_map decode_ppk_file(const std::string& data, const std::string& password);