#include "Quest.hh" #include #include #include #include #include #include #include "Compression.hh" #include "PSOEncryption.hh" #include "Text.hh" using namespace std; bool category_is_mode(QuestCategory category) { return (category == QuestCategory::Battle) || (category == QuestCategory::Challenge) || (category == QuestCategory::Episode3); } const char* name_for_category(QuestCategory category) { switch (category) { case QuestCategory::Retrieval: return "Retrieval"; case QuestCategory::Extermination: return "Extermination"; case QuestCategory::Event: return "Event"; case QuestCategory::Shop: return "Shop"; case QuestCategory::VR: return "VR"; case QuestCategory::Tower: return "Tower"; case QuestCategory::GovernmentEpisode1: return "GovernmentEpisode1"; case QuestCategory::GovernmentEpisode2: return "GovernmentEpisode2"; case QuestCategory::GovernmentEpisode4: return "GovernmentEpisode4"; case QuestCategory::Download: return "Download"; case QuestCategory::Battle: return "Battle"; case QuestCategory::Challenge: return "Challenge"; case QuestCategory::Solo: return "Solo"; case QuestCategory::Episode3: return "Episode3"; default: return "Unknown"; } } struct PSOQuestHeaderDC { // same for dc v1 and v2, thankfully uint32_t start_offset; uint32_t unknown_offset1; uint32_t size; uint32_t unused; uint8_t is_download; uint8_t unknown1; uint16_t quest_number; // 0xFFFF for challenge quests char name[0x20]; char short_description[0x80]; char long_description[0x120]; }; struct PSOQuestHeaderPC { uint32_t start_offset; uint32_t unknown_offset1; uint32_t size; uint32_t unused; uint8_t is_download; uint8_t unknown1; uint16_t quest_number; // 0xFFFF for challenge quests char16_t name[0x20]; char16_t short_description[0x80]; char16_t long_description[0x120]; }; struct PSOQuestHeaderGC { uint32_t start_offset; uint32_t unknown_offset1; uint32_t size; uint32_t unused; uint8_t is_download; uint8_t unknown1; uint8_t quest_number; uint8_t episode; // 1 = ep2. apparently some quests have 0xFF here, which means ep1 (?) char name[0x20]; char short_description[0x80]; char long_description[0x120]; }; 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 uint8_t unused[0x1DF0]; char name[0x14]; char location[0x14]; char location2[0x3C]; char description[0x190]; uint8_t unused2[0x3A34]; }; struct PSOQuestHeaderBB { uint32_t start_offset; uint32_t unknown_offset1; uint32_t size; uint32_t unused; uint16_t quest_number; // 0xFFFF for challenge quests uint16_t unused2; uint8_t episode; // 0 = ep1, 1 = ep2, 2 = ep4 uint8_t max_players; uint8_t joinable_in_progress; uint8_t unknown; char16_t name[0x20]; char16_t short_description[0x80]; char16_t long_description[0x120]; }; Quest::Quest(const string& bin_filename) : quest_id(-1), category(QuestCategory::Unknown), episode(0), is_dcv1(false), joinable(false), file_basename(bin_filename.substr(0, bin_filename.size() - 4)) { string bin_basename; { size_t slash_pos = bin_filename.rfind('/'); if (slash_pos != string::npos) { bin_basename = bin_filename.substr(slash_pos + 1); } else { bin_basename = bin_filename; } } // quest filenames are like: // b###-VV.bin for battle mode // c###-VV.bin for challenge mode // e###-gc3.bin for episode 3 // q###-CAT-VV.bin for normal quests if (bin_basename.empty()) { throw invalid_argument("empty filename"); } if (bin_basename[0] == 'b') { this->category = QuestCategory::Battle; } else if (bin_basename[0] == 'c') { this->category = QuestCategory::Challenge; } else if (bin_basename[0] == 'e') { this->category = QuestCategory::Episode3; } else if (bin_basename[0] != 'q') { throw invalid_argument("filename does not indicate mode"); } // if the quest category is still unknown, expect 3 tokens (one of them will // tell us the category) vector tokens = split(bin_basename, '-'); if (tokens.size() != (2 + (this->category == QuestCategory::Unknown))) { throw invalid_argument("incorrect filename format"); } // parse the number out of the first token this->quest_id = strtoull(tokens[0].c_str() + 1, NULL, 10); // get the category from the second token if needed if (this->category == QuestCategory::Unknown) { if (tokens[1] == "gov") { if (this->episode == 0) { this->category = QuestCategory::GovernmentEpisode1; } else if (this->episode == 1) { this->category = QuestCategory::GovernmentEpisode2; } else if (this->episode == 2) { this->category = QuestCategory::GovernmentEpisode4; } else { throw invalid_argument("government quest has incorrect episode"); } } else { static const unordered_map name_to_category({ {"ret", QuestCategory::Retrieval}, {"ext", QuestCategory::Extermination}, {"evt", QuestCategory::Event}, {"shp", QuestCategory::Shop}, {"vr", QuestCategory::VR}, {"twr", QuestCategory::Tower}, {"dl", QuestCategory::Download}, {"1p", QuestCategory::Solo}, }); this->category = name_to_category.at(tokens[1]); } tokens.erase(tokens.begin() + 1); } static const unordered_map name_to_version({ {"d1.bin", GameVersion::DC}, {"dc.bin", GameVersion::DC}, {"pc.bin", GameVersion::PC}, {"gc.bin", GameVersion::GC}, {"gc3.bin", GameVersion::GC}, {"bb.bin", GameVersion::BB}, }); this->version = name_to_version.at(tokens[1]); // the rest of the information needs to be fetched from the .bin file's // contents auto bin_compressed = this->bin_contents(); auto bin_decompressed = prs_decompress(*bin_compressed); switch (this->version) { case GameVersion::Patch: throw invalid_argument("patch server quests are not valid"); break; case GameVersion::DC: { if (bin_decompressed.size() < sizeof(PSOQuestHeaderDC)) { throw invalid_argument("file is too small for header"); } auto* header = reinterpret_cast(bin_decompressed.data()); this->joinable = false; this->episode = 0; this->name = decode_sjis(header->name); this->short_description = decode_sjis(header->short_description); this->long_description = decode_sjis(header->long_description); this->is_dcv1 = (tokens[1] == "d1.bin"); break; } case GameVersion::PC: { if (bin_decompressed.size() < sizeof(PSOQuestHeaderPC)) { throw invalid_argument("file is too small for header"); } auto* header = reinterpret_cast(bin_decompressed.data()); this->joinable = false; this->episode = 0; this->name = header->name; this->short_description = header->short_description; this->long_description = header->long_description; break; } case GameVersion::GC: { if (this->category == QuestCategory::Episode3) { // these all appear to be the same size if (bin_decompressed.size() != sizeof(PSOQuestHeaderGCEpisode3)) { throw invalid_argument("file is incorrect size"); } 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->long_description = decode_sjis(header->description); } else { if (bin_decompressed.size() < sizeof(PSOQuestHeaderGC)) { throw invalid_argument("file is too small for header"); } auto* header = reinterpret_cast(bin_decompressed.data()); this->joinable = false; this->episode = (header->episode == 1); this->name = decode_sjis(header->name); this->short_description = decode_sjis(header->short_description); this->long_description = decode_sjis(header->long_description); } break; } case GameVersion::BB: { if (bin_decompressed.size() < sizeof(PSOQuestHeaderBB)) { throw invalid_argument("file is too small for header"); } auto* header = reinterpret_cast(bin_decompressed.data()); this->joinable = header->joinable_in_progress; this->episode = header->episode; this->name = header->name; this->short_description = header->short_description; this->long_description = header->long_description; break; } } } static string basename_for_filename(const std::string& filename) { size_t slash_pos = filename.rfind('/'); if (slash_pos != string::npos) { return filename.substr(slash_pos + 1); } return filename; } std::string Quest::bin_filename() const { return basename_for_filename(this->file_basename + ".bin"); } std::string Quest::dat_filename() const { return basename_for_filename(this->file_basename + ".dat"); } shared_ptr Quest::bin_contents() const { if (!this->bin_contents_ptr) { this->bin_contents_ptr.reset(new string(load_file(this->file_basename + ".bin"))); } return this->bin_contents_ptr; } shared_ptr Quest::dat_contents() const { if (!this->dat_contents_ptr) { this->dat_contents_ptr.reset(new string(load_file(this->file_basename + ".dat"))); } return this->dat_contents_ptr; } QuestIndex::QuestIndex(const char* directory) : directory(directory) { auto filename_set = list_directory(this->directory); vector filenames(filename_set.begin(), filename_set.end()); sort(filenames.begin(), filenames.end()); for (const auto& filename : filenames) { string full_path = this->directory + "/" + filename; if (ends_with(filename, ".gba")) { shared_ptr contents(new string(load_file(full_path))); this->gba_file_contents.emplace(make_pair(filename, contents)); continue; } if (ends_with(filename, ".bin")) { try { shared_ptr q(new Quest(full_path)); this->version_id_to_quest.emplace(make_pair(q->version, q->quest_id), q); this->version_name_to_quest.emplace(make_pair(q->version, q->name), q); string ascii_name = encode_sjis(q->name); log(INFO, "indexed quest %s (%s-%" PRId64 ", %s, episode=%hhu, joinable=%s, dcv1=%s)", ascii_name.c_str(), name_for_version(q->version), q->quest_id, name_for_category(q->category), q->episode, q->joinable ? "true" : "false", q->is_dcv1 ? "true" : "false"); } catch (const exception& e) { log(WARNING, "failed to parse quest file %s (%s)", filename.c_str(), e.what()); } } } } shared_ptr QuestIndex::get(GameVersion version, uint32_t id) const { return this->version_id_to_quest.at(make_pair(version, id)); } shared_ptr QuestIndex::get_gba(const string& name) const { return this->gba_file_contents.at(name); } vector> QuestIndex::filter(GameVersion version, bool is_dcv1, QuestCategory category, uint8_t episode) const { auto it = this->version_id_to_quest.lower_bound(make_pair(version, 0)); auto end_it = this->version_id_to_quest.upper_bound(make_pair(version, 0xFFFFFFFF)); vector> ret; for (; it != end_it; it++) { shared_ptr q = it->second; if ((q->is_dcv1 != is_dcv1) || (q->category != category)) { continue; } // only check episode and solo if the category isn't a mode (that is, ignore // episode if querying for battle/challange/solo quests) if (!category_is_mode(category) && ((q->episode != episode))) { continue; } ret.emplace_back(q); } return ret; } static string create_download_quest_file(const string& compressed_data, size_t decompressed_size) { struct PSODownloadQuestHeader { uint32_t decompressed_size; uint32_t encryption_seed; // note: use PC encryption, even for GC quests }; string data(8, '\0'); auto* header = reinterpret_cast(const_cast( compressed_data.data())); header->decompressed_size = decompressed_size + sizeof(PSODownloadQuestHeader); header->encryption_seed = random_object(); data += compressed_data; // add extra bytes if necessary so encryption won't fail data.resize((data.size() + 3) & (~3)); // TODO: for DC quests, do we use DC encryption? PSOPCEncryption encr(header->encryption_seed); encr.encrypt(const_cast(data.data() + sizeof(PSODownloadQuestHeader)), data.size() - sizeof(PSODownloadQuestHeader)); return data; } shared_ptr Quest::create_download_quest(const string& file_basename) const { if (this->category == QuestCategory::Download) { throw invalid_argument("quest is already a download quest"); } string decompressed_bin = prs_decompress(*this->bin_contents()); void* data_ptr = const_cast(decompressed_bin.data()); switch (this->version) { case GameVersion::DC: reinterpret_cast(data_ptr)->is_download = 0x01; break; case GameVersion::PC: reinterpret_cast(data_ptr)->is_download = 0x01; break; case GameVersion::GC: reinterpret_cast(data_ptr)->is_download = 0x01; break; case GameVersion::BB: throw invalid_argument("PSOBB does not support download quests"); default: throw invalid_argument("unknown game version"); } shared_ptr dlq(new Quest(file_basename)); dlq->quest_id = this->quest_id; dlq->category = QuestCategory::Download; dlq->episode = this->episode; dlq->is_dcv1 = this->is_dcv1; dlq->joinable = this->joinable; dlq->version = this->version; dlq->name = this->name; dlq->short_description = this->short_description; dlq->long_description = this->long_description; dlq->bin_contents_ptr.reset(new string(create_download_quest_file( prs_compress(decompressed_bin), decompressed_bin.size()))); auto dat_contents = this->dat_contents(); dlq->dat_contents_ptr.reset(new string(create_download_quest_file( *dat_contents, prs_decompress_size(*dat_contents)))); save_file(dlq->bin_filename(), *dlq->bin_contents_ptr); save_file(dlq->dat_filename(), *dlq->dat_contents_ptr); return dlq; }