From 73278fe9ab38cacf977ccf2042534d318241459c Mon Sep 17 00:00:00 2001 From: Martin Michelsen Date: Sun, 2 Oct 2022 22:56:09 -0700 Subject: [PATCH] add ability to decrypt Ep3 GCI files --- README.md | 4 ++ src/Episode3.hh | 2 +- src/Quest.cc | 131 ++++++++++++++++++++++++++++++++---------------- src/Text.hh | 6 --- 4 files changed, 92 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index d45f0f09..9f5774db 100644 --- a/README.md +++ b/README.md @@ -102,11 +102,15 @@ There are multiple PSO quest formats out there; newserv supports most of them. I | Format | Extension | Supported online? | Offline decode option | |---------------------------|-------------------|-------------------|---------------------------| | Compressed | .bin/.dat | Yes | None (1) | +| Compressed Ep3 | .bin | Download only | None (1) | | Uncompressed | .bind/.datd | Yes | --compress-data (2) | +| Uncompressed Ep3 | .bind | Download only | --compress-data (2) | | Unencrypted GCI | .bin.gci/.dat.gci | Yes | --decode-gci=FILENAME | | Encrypted GCI with key | .bin.gci/.dat.gci | Yes | --decode-gci=FILENAME | | Encrypted GCI without key | .bin.gci/.dat.gci | No | --decode-gci=FILENAME (3) | +| Ep3 GCI | .bin.gci | Download only | --decode-gci=FILENAME | | Encrypted DLQ | .bin.dlq/.dat.dlq | Yes | --decode-dlq=FILENAME | +| Ep3 DLQ | .bin.dlq | Download only | --decode-dlq=FILENAME | | QST | .qst | Yes | --decode-qst=FILENAME | *Notes:* diff --git a/src/Episode3.hh b/src/Episode3.hh index 7e82a1ab..752c67d1 100644 --- a/src/Episode3.hh +++ b/src/Episode3.hh @@ -237,7 +237,7 @@ struct Ep3CompressedMapHeader { // .mnm file format // Compressed data immediately follows (which decompresses to an Ep3Map) } __attribute__((packed)); -struct Ep3Map { // .mnmd format +struct Ep3Map { // .mnmd format; also the format of (decompressed) Ep3 quests /* 0000 */ be_uint32_t unknown_a1; /* 0004 */ be_uint32_t map_number; /* 0008 */ uint8_t width; diff --git a/src/Quest.cc b/src/Quest.cc index 955238a0..19b78232 100644 --- a/src/Quest.cc +++ b/src/Quest.cc @@ -70,8 +70,10 @@ struct ShuffleTables { }; struct PSOGCIFileHeader { - parray gci_header; - ptext game_name; // e.g. "PSO EPISODE I & II" + parray game_id; // 'GPOE', 'GPSP', etc. + parray developer_id; // '8P' for Sega + parray remaining_gci_header; // There is a structure for this but we don't use it + ptext game_name; // e.g. "PSO EPISODE I & II" or "PSO EPISODE III" be_uint32_t embedded_seed; // Used in some of Ralf's quest packs ptext quest_name; parray banner_and_icon; @@ -294,18 +296,6 @@ struct PSOQuestHeaderGC { ptext long_description; } __attribute__((packed)); -struct PSOQuestHeaderGCEpisode3 { - // there's actually a lot of other important stuff in here but I'm lazy. it - // looks like map data, cutscene data, and maybe special cards used during - // the quest - parray unknown_a1; - ptext name; - ptext location; - ptext location2; - ptext description; - parray unknown_a2; -} __attribute__((packed)); - struct PSOQuestHeaderBB { uint32_t start_offset; uint32_t unknown_offset1; @@ -464,14 +454,14 @@ Quest::Quest(const string& bin_filename) case GameVersion::GC: { if (this->category == QuestCategory::EPISODE_3) { // these all appear to be the same size - if (bin_decompressed.size() != sizeof(PSOQuestHeaderGCEpisode3)) { + if (bin_decompressed.size() != sizeof(Ep3Map)) { throw invalid_argument("file is incorrect size"); } - auto* header = reinterpret_cast(bin_decompressed.data()); + auto* header = reinterpret_cast(bin_decompressed.data()); this->joinable = false; this->episode = 0xFF; this->name = decode_sjis(header->name); - this->short_description = decode_sjis(header->location2); + this->short_description = decode_sjis(header->quest_name); this->long_description = decode_sjis(header->description); } else { if (bin_decompressed.size() < sizeof(PSOQuestHeaderGC)) { @@ -596,43 +586,96 @@ string Quest::decode_gci( throw runtime_error("GCI file unencrypted header checksum is incorrect"); } - const auto& encrypted_header = r.get(false); - // Unencrypted GCI files appear to always have zeroes in these fields. - // Encrypted GCI files are highly unlikely to have zeroes in ALL of these - // fields, so assume it's encrypted if any of them are nonzero. - if (encrypted_header.round2_seed || encrypted_header.checksum || encrypted_header.round3_seed) { - if (known_seed >= 0) { - return decrypt_gci_data_section( - r.getv(header.data_size), header.data_size, known_seed); + if (header.developer_id[0] != '8' || header.developer_id[1] != 'P') { + throw runtime_error("GCI file is not for a Sega game"); + } + if (header.game_id[0] != 'G') { + throw runtime_error("GCI file is not for a GameCube game"); + } + if (header.game_id[1] != 'P') { + throw runtime_error("GCI file is not for Phantasy Star Online"); + } - } else if (header.embedded_seed != 0) { - return decrypt_gci_data_section( - r.getv(header.data_size), header.data_size, header.embedded_seed); + if (header.game_id[2] == 'O') { // Episodes 1&2 (GPO*) + const auto& encrypted_header = r.get(false); + // Unencrypted GCI files appear to always have zeroes in these fields. + // Encrypted GCI files are highly unlikely to have zeroes in ALL of these + // fields, so assume it's encrypted if any of them are nonzero. + if (encrypted_header.round2_seed || encrypted_header.checksum || encrypted_header.round3_seed) { + if (known_seed >= 0) { + return decrypt_gci_data_section( + r.getv(header.data_size), header.data_size, known_seed); - } else { - if (find_seed_num_threads < 0) { - throw runtime_error("GCI file appears to be encrypted"); + } else if (header.embedded_seed != 0) { + return decrypt_gci_data_section( + r.getv(header.data_size), header.data_size, header.embedded_seed); + + } else { + if (find_seed_num_threads < 0) { + throw runtime_error("GCI file appears to be encrypted"); + } + if (find_seed_num_threads == 0) { + find_seed_num_threads = thread::hardware_concurrency(); + } + return find_seed_and_decrypt_gci_data_section( + r.getv(header.data_size), header.data_size, find_seed_num_threads); } - if (find_seed_num_threads == 0) { - find_seed_num_threads = thread::hardware_concurrency(); + + } else { // Unencrypted GCI format + r.skip(sizeof(PSOGCIFileEncryptedHeader)); + string compressed_data = r.readx(header.data_size - sizeof(PSOGCIFileEncryptedHeader)); + size_t decompressed_bytes = prs_decompress_size(compressed_data); + + size_t expected_decompressed_bytes = encrypted_header.decompressed_size - 8; + if (decompressed_bytes < expected_decompressed_bytes) { + throw runtime_error(string_printf( + "GCI decompressed data is smaller than expected size (have 0x%zX bytes, expected 0x%zX bytes)", + decompressed_bytes, expected_decompressed_bytes)); } - return find_seed_and_decrypt_gci_data_section( - r.getv(header.data_size), header.data_size, find_seed_num_threads); + + return compressed_data; } - } else { // Unencrypted GCI format - r.skip(sizeof(PSOGCIFileEncryptedHeader)); - string compressed_data = r.readx(header.data_size - sizeof(PSOGCIFileEncryptedHeader)); - size_t decompressed_bytes = prs_decompress_size(compressed_data); + } else if (header.game_id[2] == 'S') { // Episode 3 + // The first 0x10 bytes in the data segment appear to be unused. In most + // files I've seen, the last half of it (8 bytes) are duplicates of the + // first 8 bytes of the unscrambled, compressed data, though this is likely + // the result of an uninitialized memory bug when the client encodes the + // file and not an actual constraint on what should be in these 8 bytes. + r.skip(16); + // The game treats this field as a 16-byte string (including the \0). The 8 + // bytes after it appear to be completely unused. + if (r.readx(15) != "SONICTEAM,SEGA.") { + throw runtime_error("Episode 3 GCI file is not a quest"); + } + r.skip(9); - size_t expected_decompressed_bytes = encrypted_header.decompressed_size - 8; - if (decompressed_bytes < expected_decompressed_bytes) { + data = r.readx(header.data_size - 40); + + // For some reason, Sega decided not to encrypt Episode 3 quest files in the + // same way as Episodes 1&2 quest files (see above). Instead, they just + // wrote a fairly trivial XOR loop over the first 0x100 bytes, leaving the + // remaining bytes completely unencrypted (but still compressed). + size_t unscramble_size = min(0x100, data.size()); + { + uint8_t key = 0x80; // Technically basis + 0x80, but basis is zero + for (size_t z = 0; z < unscramble_size; z++) { + key = (key * 5) + 1; + data[z] ^= key; + } + } + + size_t decompressed_size = prs_decompress_size(data); + if (decompressed_size != sizeof(Ep3Map)) { throw runtime_error(string_printf( - "GCI decompressed data is smaller than expected size (have 0x%zX bytes, expected 0x%zX bytes)", - decompressed_bytes, expected_decompressed_bytes)); + "decompressed quest is 0x%zX bytes; expected 0x%zX bytes", + decompressed_size, sizeof(Ep3Map))); } - return compressed_data; + return data; + + } else { + throw runtime_error("unknown game name in GCI header"); } } diff --git a/src/Text.hh b/src/Text.hh index d573a760..1ef92983 100644 --- a/src/Text.hh +++ b/src/Text.hh @@ -11,12 +11,6 @@ -// TODO: delete these if not needed -// int char16ncmp(const char16_t* s1, const char16_t* s2, size_t count); -// size_t char16len(const char16_t* s); - - - // (1a) Conversion functions // These return the number of characters written, including the terminating null