add ability to decrypt Ep3 GCI files

This commit is contained in:
Martin Michelsen
2022-10-02 22:56:09 -07:00
parent 5ec90db9eb
commit 73278fe9ab
4 changed files with 92 additions and 51 deletions
+4
View File
@@ -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 | | Format | Extension | Supported online? | Offline decode option |
|---------------------------|-------------------|-------------------|---------------------------| |---------------------------|-------------------|-------------------|---------------------------|
| Compressed | .bin/.dat | Yes | None (1) | | Compressed | .bin/.dat | Yes | None (1) |
| Compressed Ep3 | .bin | Download only | None (1) |
| Uncompressed | .bind/.datd | Yes | --compress-data (2) | | 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 | | Unencrypted GCI | .bin.gci/.dat.gci | Yes | --decode-gci=FILENAME |
| Encrypted GCI with key | .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) | | 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 | | 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 | | QST | .qst | Yes | --decode-qst=FILENAME |
*Notes:* *Notes:*
+1 -1
View File
@@ -237,7 +237,7 @@ struct Ep3CompressedMapHeader { // .mnm file format
// Compressed data immediately follows (which decompresses to an Ep3Map) // Compressed data immediately follows (which decompresses to an Ep3Map)
} __attribute__((packed)); } __attribute__((packed));
struct Ep3Map { // .mnmd format struct Ep3Map { // .mnmd format; also the format of (decompressed) Ep3 quests
/* 0000 */ be_uint32_t unknown_a1; /* 0000 */ be_uint32_t unknown_a1;
/* 0004 */ be_uint32_t map_number; /* 0004 */ be_uint32_t map_number;
/* 0008 */ uint8_t width; /* 0008 */ uint8_t width;
+60 -17
View File
@@ -70,8 +70,10 @@ struct ShuffleTables {
}; };
struct PSOGCIFileHeader { struct PSOGCIFileHeader {
parray<uint8_t, 0x40> gci_header; parray<char, 4> game_id; // 'GPOE', 'GPSP', etc.
ptext<char, 0x1C> game_name; // e.g. "PSO EPISODE I & II" parray<char, 2> developer_id; // '8P' for Sega
parray<uint8_t, 0x3A> remaining_gci_header; // There is a structure for this but we don't use it
ptext<char, 0x1C> 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 be_uint32_t embedded_seed; // Used in some of Ralf's quest packs
ptext<char, 0x20> quest_name; ptext<char, 0x20> quest_name;
parray<uint8_t, 0x2000> banner_and_icon; parray<uint8_t, 0x2000> banner_and_icon;
@@ -294,18 +296,6 @@ struct PSOQuestHeaderGC {
ptext<char, 0x120> long_description; ptext<char, 0x120> long_description;
} __attribute__((packed)); } __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<uint8_t, 0x1DF0> unknown_a1;
ptext<char, 0x14> name;
ptext<char, 0x14> location;
ptext<char, 0x3C> location2;
ptext<char, 0x190> description;
parray<uint8_t, 0x3A34> unknown_a2;
} __attribute__((packed));
struct PSOQuestHeaderBB { struct PSOQuestHeaderBB {
uint32_t start_offset; uint32_t start_offset;
uint32_t unknown_offset1; uint32_t unknown_offset1;
@@ -464,14 +454,14 @@ Quest::Quest(const string& bin_filename)
case GameVersion::GC: { case GameVersion::GC: {
if (this->category == QuestCategory::EPISODE_3) { if (this->category == QuestCategory::EPISODE_3) {
// these all appear to be the same size // 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"); throw invalid_argument("file is incorrect size");
} }
auto* header = reinterpret_cast<const PSOQuestHeaderGCEpisode3*>(bin_decompressed.data()); auto* header = reinterpret_cast<const Ep3Map*>(bin_decompressed.data());
this->joinable = false; this->joinable = false;
this->episode = 0xFF; this->episode = 0xFF;
this->name = decode_sjis(header->name); 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); this->long_description = decode_sjis(header->description);
} else { } else {
if (bin_decompressed.size() < sizeof(PSOQuestHeaderGC)) { if (bin_decompressed.size() < sizeof(PSOQuestHeaderGC)) {
@@ -596,6 +586,17 @@ string Quest::decode_gci(
throw runtime_error("GCI file unencrypted header checksum is incorrect"); throw runtime_error("GCI file unencrypted header checksum is incorrect");
} }
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");
}
if (header.game_id[2] == 'O') { // Episodes 1&2 (GPO*)
const auto& encrypted_header = r.get<PSOGCIFileEncryptedHeader>(false); const auto& encrypted_header = r.get<PSOGCIFileEncryptedHeader>(false);
// Unencrypted GCI files appear to always have zeroes in these fields. // Unencrypted GCI files appear to always have zeroes in these fields.
// Encrypted GCI files are highly unlikely to have zeroes in ALL of these // Encrypted GCI files are highly unlikely to have zeroes in ALL of these
@@ -634,6 +635,48 @@ string Quest::decode_gci(
return compressed_data; return 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);
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<size_t>(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(
"decompressed quest is 0x%zX bytes; expected 0x%zX bytes",
decompressed_size, sizeof(Ep3Map)));
}
return data;
} else {
throw runtime_error("unknown game name in GCI header");
}
} }
string Quest::decode_dlq(const string& filename) { string Quest::decode_dlq(const string& filename) {
-6
View File
@@ -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 // (1a) Conversion functions
// These return the number of characters written, including the terminating null // These return the number of characters written, including the terminating null