#include "Quest.hh" #include #include #include #include #include #include #include #include #include #include #include "CommandFormats.hh" #include "Compression.hh" #include "Loggers.hh" #include "PSOEncryption.hh" #include "QuestScript.hh" #include "SaveFileFormats.hh" #include "Text.hh" using namespace std; QuestCategoryIndex::Category::Category(uint32_t category_id, const phosg::JSON& json) : category_id(category_id) { this->enabled_flags = json.get_int(0); this->directory_name = json.get_string(1); this->name = json.get_string(2); this->description = json.get_string(3); } QuestCategoryIndex::QuestCategoryIndex(const phosg::JSON& json) { uint32_t next_category_id = 1; for (const auto& it : json.as_list()) { this->categories.emplace_back(make_shared(next_category_id++, *it)); } } shared_ptr QuestCategoryIndex::at(uint32_t category_id) const { return this->categories.at(category_id - 1); } // GCI decoding logic template struct PSOMemCardDLQFileEncryptedHeaderT { U32T round2_seed; // To compute checksum, set checksum to zero, then compute the CRC32 of the // entire data section, including this header struct (but not the unencrypted // header struct). U32T checksum; le_uint32_t decompressed_size; le_uint32_t round3_seed; // Data follows here. } __packed__; using PSOVMSDLQFileEncryptedHeader = PSOMemCardDLQFileEncryptedHeaderT; using PSOGCIDLQFileEncryptedHeader = PSOMemCardDLQFileEncryptedHeaderT; check_struct_size(PSOVMSDLQFileEncryptedHeader, 0x10); check_struct_size(PSOGCIDLQFileEncryptedHeader, 0x10); template string decrypt_download_quest_data_section( const void* data_section, size_t size, uint32_t seed, bool skip_checksum = false, bool is_ep3_trial = false) { string decrypted = decrypt_data_section(data_section, size, seed); size_t orig_size = decrypted.size(); decrypted.resize((decrypted.size() + 3) & (~3)); // Note: Other PSO save files have the round 2 seed at the end of the data, // not at the beginning. Presumably they did this because the system, // character, and Guild Card files are a constant size, but download quest // files can vary in size. using HeaderT = PSOMemCardDLQFileEncryptedHeaderT; auto* header = reinterpret_cast(decrypted.data()); PSOV2Encryption round2_crypt(header->round2_seed); round2_crypt.encrypt_t( decrypted.data() + 4, (decrypted.size() - 4)); if (is_ep3_trial) { phosg::StringReader r(decrypted); r.skip(16); if (r.readx(15) != "SONICTEAM,SEGA.") { throw runtime_error("Episode 3 GCI file is not a quest"); } r.skip(9); // Some Ep3 trial download quests don't have a stop opcode in the PRS // stream; it seems the client just automatically stops when the correct // amount of data has been produced. To handle this, we allow the PRS stream // to be unterminated here. size_t decompressed_size = prs_decompress_size( r.getv(r.remaining(), false), r.remaining(), sizeof(Episode3::MapDefinitionTrial), true); if (decompressed_size < sizeof(Episode3::MapDefinitionTrial)) { throw runtime_error(phosg::string_printf( "decompressed size (%zu) does not match expected size (%zu)", decompressed_size, sizeof(Episode3::MapDefinitionTrial))); } return decrypted.substr(0x28); } else { if (header->decompressed_size & 0xFFF00000) { throw runtime_error(phosg::string_printf( "decompressed_size too large (%08" PRIX32 ")", header->decompressed_size.load())); } if (!skip_checksum) { uint32_t expected_crc = header->checksum; header->checksum = 0; uint32_t actual_crc = phosg::crc32(decrypted.data(), orig_size); header->checksum = expected_crc; if (expected_crc != actual_crc && expected_crc != phosg::bswap32(actual_crc)) { throw runtime_error(phosg::string_printf( "incorrect decrypted data section checksum: expected %08" PRIX32 "; received %08" PRIX32, expected_crc, actual_crc)); } } // Unlike the above rounds, round 3 is always little-endian (it corresponds to // the round of encryption done on the server before sending the file to the // client in the first place) PSOV2Encryption(header->round3_seed).decrypt(decrypted.data() + sizeof(HeaderT), decrypted.size() - sizeof(HeaderT)); decrypted.resize(orig_size); // Some download quest GCI files have decompressed_size fields that are 8 // bytes smaller than the actual decompressed size of the data. They seem to // work fine, so we accept both cases as correct. size_t decompressed_size = prs_decompress_size( decrypted.data() + sizeof(HeaderT), decrypted.size() - sizeof(HeaderT)); size_t expected_decompressed_size = header->decompressed_size.load(); if ((decompressed_size != expected_decompressed_size) && (decompressed_size != expected_decompressed_size - 8)) { throw runtime_error(phosg::string_printf( "decompressed size (%zu) does not match expected size (%zu)", decompressed_size, expected_decompressed_size)); } return decrypted.substr(sizeof(HeaderT)); } } string decrypt_vms_v1_data_section(const void* data_section, size_t size) { phosg::StringReader r(data_section, size); uint32_t expected_decompressed_size = r.get_u32l(); uint32_t seed = r.get_u32l(); string data = r.read(r.remaining()); size_t orig_size = data.size(); data.resize((orig_size + 3) & (~3)); PSOV2Encryption(seed).decrypt(data.data(), data.size()); data.resize(orig_size); size_t actual_decompressed_size = prs_decompress_size(data); if (actual_decompressed_size != expected_decompressed_size) { throw runtime_error(phosg::string_printf( "decompressed size (%zu) does not match size in header (%" PRId32 ")", actual_decompressed_size, expected_decompressed_size)); } return data; } template string find_seed_and_decrypt_download_quest_data_section( const void* data_section, size_t size, bool skip_checksum, bool is_ep3_trial, size_t num_threads) { mutex result_lock; string result; uint64_t result_seed = phosg::parallel_range_blocks([&](uint64_t seed, size_t) { try { string ret = decrypt_download_quest_data_section( data_section, size, seed, skip_checksum, is_ep3_trial); lock_guard g(result_lock); result = std::move(ret); return true; } catch (const runtime_error& e) { return false; } }, 0, 0x100000000, 0x1000, num_threads); if (!result.empty() && (result_seed < 0x100000000)) { static_game_data_log.info("Found seed %08" PRIX64, result_seed); return result; } else { throw runtime_error("no seed found"); } } struct PSODownloadQuestHeader { le_uint32_t size; le_uint32_t encryption_seed; } __packed_ws__(PSODownloadQuestHeader, 8); VersionedQuest::VersionedQuest( uint32_t quest_number, uint32_t category_id, Version version, uint8_t language, std::shared_ptr bin_contents, std::shared_ptr dat_contents, std::shared_ptr map_file, std::shared_ptr pvr_contents, std::shared_ptr battle_rules, ssize_t challenge_template_index, uint8_t description_flag, std::shared_ptr available_expression, std::shared_ptr enabled_expression, bool allow_start_from_chat_command, bool force_joinable, int16_t lock_status_register) : quest_number(quest_number), category_id(category_id), episode(Episode::NONE), allow_start_from_chat_command(allow_start_from_chat_command), joinable(force_joinable), lock_status_register(lock_status_register), version(version), language(language), is_dlq_encoded(false), bin_contents(bin_contents), dat_contents(dat_contents), map_file(map_file), pvr_contents(pvr_contents), battle_rules(battle_rules), challenge_template_index(challenge_template_index), description_flag(description_flag), available_expression(available_expression), enabled_expression(enabled_expression) { auto bin_decompressed = prs_decompress(*this->bin_contents); switch (this->version) { case Version::DC_NTE: { if (bin_decompressed.size() < sizeof(PSOQuestHeaderDCNTE)) { throw invalid_argument("file is too small for header"); } auto* header = reinterpret_cast(bin_decompressed.data()); this->episode = Episode::EP1; if (this->quest_number == 0xFFFFFFFF) { this->quest_number = phosg::fnv1a32(header, sizeof(header)) & 0xFFFF; } this->name = header->name.decode(this->language); break; } case Version::DC_V1_11_2000_PROTOTYPE: case Version::DC_V1: case Version::DC_V2: { if (bin_decompressed.size() < sizeof(PSOQuestHeaderDC)) { throw invalid_argument("file is too small for header"); } auto* header = reinterpret_cast(bin_decompressed.data()); this->episode = Episode::EP1; if (this->quest_number == 0xFFFFFFFF) { this->quest_number = header->quest_number; } this->name = header->name.decode(this->language); this->short_description = header->short_description.decode(this->language); this->long_description = header->long_description.decode(this->language); break; } case Version::PC_NTE: case Version::PC_V2: { if (bin_decompressed.size() < sizeof(PSOQuestHeaderPC)) { throw invalid_argument("file is too small for header"); } auto* header = reinterpret_cast(bin_decompressed.data()); this->episode = Episode::EP1; if (this->quest_number == 0xFFFFFFFF) { this->quest_number = header->quest_number; } this->name = header->name.decode(this->language); this->short_description = header->short_description.decode(this->language); this->long_description = header->long_description.decode(this->language); break; } case Version::GC_EP3_NTE: case Version::GC_EP3: { // Note: This codepath handles Episode 3 download quests, which are not // the same as Episode 3 quest scripts. The latter are only used offline // in story mode, but can be disassembled with disassemble_quest_script. // It's unfortunate that Version::GC_EP3 is used here for Episode 3 // download quests (maps) and there for offline story mode scripts, but // it's probably not worth refactoring this logic, at least right now. if (bin_decompressed.size() != sizeof(Episode3::MapDefinition)) { throw invalid_argument("file is incorrect size"); } auto* map = reinterpret_cast(bin_decompressed.data()); this->episode = Episode::EP3; if (this->quest_number == 0xFFFFFFFF) { this->quest_number = map->map_number; } this->name = map->name.decode(this->language); this->short_description = map->quest_name.decode(this->language); this->long_description = map->description.decode(this->language); break; } case Version::XB_V3: case Version::GC_NTE: case Version::GC_V3: { if (bin_decompressed.size() < sizeof(PSOQuestHeaderGC)) { throw invalid_argument("file is too small for header"); } auto* header = reinterpret_cast(bin_decompressed.data()); this->episode = find_quest_episode_from_script(bin_decompressed.data(), bin_decompressed.size(), this->version); if (this->quest_number == 0xFFFFFFFF) { this->quest_number = header->quest_number; } this->name = header->name.decode(this->language); this->short_description = header->short_description.decode(this->language); this->long_description = header->long_description.decode(this->language); break; } case Version::BB_V4: { 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; this->episode = find_quest_episode_from_script(bin_decompressed.data(), bin_decompressed.size(), this->version); if (this->quest_number == 0xFFFFFFFF) { this->quest_number = header->quest_number; } this->name = header->name.decode(this->language); this->short_description = header->short_description.decode(this->language); this->long_description = header->long_description.decode(this->language); break; } default: throw logic_error("invalid quest game version"); } } string VersionedQuest::bin_filename() const { if (this->episode == Episode::EP3) { return phosg::string_printf("m%06" PRIu32 "p_e.bin", this->quest_number); } else { return phosg::string_printf("quest%" PRIu32 ".bin", this->quest_number); } } string VersionedQuest::dat_filename() const { if (this->episode == Episode::EP3) { throw logic_error("Episode 3 quests do not have .dat files"); } else { return phosg::string_printf("quest%" PRIu32 ".dat", this->quest_number); } } string VersionedQuest::pvr_filename() const { if (this->episode == Episode::EP3) { throw logic_error("Episode 3 quests do not have .pvr files"); } else { return phosg::string_printf("quest%" PRIu32 ".pvr", this->quest_number); } } string VersionedQuest::xb_filename() const { if (this->episode == Episode::EP3) { throw logic_error("Episode 3 quests do not have Xbox filenames"); } else { return phosg::string_printf("quest%" PRIu32 "_%c.dat", this->quest_number, tolower(char_for_language_code(this->language))); } } string VersionedQuest::encode_qst() const { unordered_map> files; files.emplace(phosg::string_printf("quest%" PRIu32 ".bin", this->quest_number), this->bin_contents); files.emplace(phosg::string_printf("quest%" PRIu32 ".dat", this->quest_number), this->dat_contents); if (this->pvr_contents) { files.emplace(phosg::string_printf("quest%" PRIu32 ".pvr", this->quest_number), this->pvr_contents); } string xb_filename = phosg::string_printf("quest%" PRIu32 "_%c.dat", quest_number, tolower(char_for_language_code(language))); return encode_qst_file(files, this->name, this->quest_number, xb_filename, this->version, this->is_dlq_encoded); } Quest::Quest(shared_ptr initial_version) : quest_number(initial_version->quest_number), category_id(initial_version->category_id), episode(initial_version->episode), allow_start_from_chat_command(initial_version->allow_start_from_chat_command), joinable(initial_version->joinable), lock_status_register(initial_version->lock_status_register), name(initial_version->name), supermap(nullptr), battle_rules(initial_version->battle_rules), challenge_template_index(initial_version->challenge_template_index), description_flag(initial_version->description_flag), available_expression(initial_version->available_expression), enabled_expression(initial_version->enabled_expression) { this->add_version(initial_version); } uint32_t Quest::versions_key(Version v, uint8_t language) { return (static_cast(v) << 8) | language; } void Quest::add_version(shared_ptr vq) { if (this->quest_number != vq->quest_number) { throw logic_error("incorrect versioned quest number"); } if (this->category_id != vq->category_id) { throw runtime_error("quest version is in a different category"); } if (this->episode != vq->episode) { throw runtime_error("quest version is in a different episode"); } if (this->allow_start_from_chat_command != vq->allow_start_from_chat_command) { throw runtime_error("quest version has a different allow_start_from_chat_command state"); } if (this->joinable != vq->joinable) { throw runtime_error("quest version has a different joinability state"); } if (this->lock_status_register != vq->lock_status_register) { throw runtime_error("quest version has a different lock status register"); } if (!this->battle_rules != !vq->battle_rules) { throw runtime_error("quest version has a different battle rules presence state"); } if (this->battle_rules && (*this->battle_rules != *vq->battle_rules)) { throw runtime_error("quest version has different battle rules"); } if (this->challenge_template_index != vq->challenge_template_index) { throw runtime_error("quest version has different challenge template index"); } if (this->description_flag != vq->description_flag) { throw runtime_error("quest version has different description flag"); } if (!this->available_expression != !vq->available_expression) { throw runtime_error("quest version has available expression but root quest does not, or vice versa"); } if (this->available_expression && *this->available_expression != *vq->available_expression) { throw runtime_error("quest version has a different available expression"); } if (!this->enabled_expression != !vq->enabled_expression) { throw runtime_error("quest version has enabled expression but root quest does not, or vice versa"); } if (this->enabled_expression && *this->enabled_expression != *vq->enabled_expression) { throw runtime_error("quest version has a different enabled expression"); } this->versions.emplace(this->versions_key(vq->version, vq->language), vq); } std::shared_ptr Quest::get_supermap(int64_t random_seed) const { if (this->supermap) { return this->supermap; } bool save_to_cache = true; bool any_map_file_present = false; array, NUM_VERSIONS> map_files; for (Version v : ALL_ARPG_SEMANTIC_VERSIONS) { auto vq = this->version(v, 1); if (vq && vq->map_file) { auto map_file = vq->map_file; if (map_file->has_random_sections()) { if (random_seed < 0) { return nullptr; } save_to_cache = false; map_file = map_file->materialize_random_sections(random_seed); } map_files.at(static_cast(v)) = map_file; any_map_file_present = true; } } if (!any_map_file_present) { return nullptr; } auto supermap = make_shared(this->episode, map_files); if (save_to_cache) { this->supermap = supermap; } static_game_data_log.info("Constructed %s supermap for quest %" PRIu32 " (%s)", save_to_cache ? "cacheable" : "temporary", this->quest_number, this->name.c_str()); return supermap; } bool Quest::has_version(Version v, uint8_t language) const { return this->versions.count(this->versions_key(v, language)); } bool Quest::has_version_any_language(Version v) const { uint32_t k = this->versions_key(v, 0); auto it = this->versions.lower_bound(k); return ((it != this->versions.end()) && ((it->first & 0xFF00) == k)); } shared_ptr Quest::version(Version v, uint8_t language) const { // Return the requested version, if it exists try { return this->versions.at(this->versions_key(v, language)); } catch (const out_of_range&) { } // Return the English version, if it exists try { return this->versions.at(this->versions_key(v, 1)); } catch (const out_of_range&) { } // Return the first language, if it exists auto it = this->versions.lower_bound(this->versions_key(v, 0)); if ((it == this->versions.end()) || ((it->first & 0xFF00) != this->versions_key(v, 0))) { return nullptr; } return it->second; } QuestIndex::QuestIndex( const string& directory, shared_ptr category_index, bool is_ep3) : directory(directory), category_index(category_index) { struct FileData { string filename; shared_ptr data; }; struct DATFileData { string filename; shared_ptr data; shared_ptr map_file; }; map bin_files; map dat_files; map pvr_files; map json_files; map categories; for (const auto& cat : this->category_index->categories) { // Don't index Ep3 download categories for non-Ep3 quest indexing, and vice // versa if (is_ep3 != cat->check_flag(QuestMenuType::EP3_DOWNLOAD)) { continue; } auto add_file = [&](map& files, const string& basename, const string& filename, string&& value, bool check_chunk_size) { if (categories.emplace(basename, cat->category_id).first->second != cat->category_id) { throw runtime_error("file " + basename + " exists in multiple categories"); } auto data_ptr = make_shared(std::move(value)); if (!files.emplace(basename, FileData{filename, data_ptr}).second) { throw runtime_error("file " + basename + " already exists"); } // There is a bug in the client that prevents quests from loading properly // if any file's size is a multiple of 0x400. See the comments on the 13 // command in CommandFormats.hh for more details. if (check_chunk_size && !(data_ptr->size() & 0x3FF)) { data_ptr->push_back(0x00); } }; auto add_dat_file = [&](const string& basename, const string& filename, string&& value) { if (categories.emplace(basename, cat->category_id).first->second != cat->category_id) { throw runtime_error("file " + basename + " exists in multiple categories"); } auto data_ptr = make_shared(std::move(value)); auto map_file = make_shared(make_shared(prs_decompress(*data_ptr))); if (!dat_files.emplace(basename, DATFileData{filename, data_ptr, map_file}).second) { throw runtime_error("file " + basename + " already exists"); } // There is a bug in the client that prevents quests from loading properly // if any file's size is a multiple of 0x400. See the comments on the 13 // command in CommandFormats.hh for more details. if (!(data_ptr->size() & 0x3FF)) { data_ptr->push_back(0x00); } }; string cat_path = directory + "/" + cat->directory_name; if (!phosg::isdir(cat_path)) { static_game_data_log.warning("Quest category directory %s is missing; skipping it", cat_path.c_str()); continue; } for (string filename : phosg::list_directory_sorted(cat_path)) { if (filename == ".DS_Store") { continue; } string file_path = cat_path + "/" + filename; try { string orig_filename = filename; string file_data; if (phosg::ends_with(filename, ".gci")) { file_data = decode_gci_data(phosg::load_file(file_path)); filename.resize(filename.size() - 4); } else if (phosg::ends_with(filename, ".vms")) { file_data = decode_vms_data(phosg::load_file(file_path)); filename.resize(filename.size() - 4); } else if (phosg::ends_with(filename, ".dlq")) { file_data = decode_dlq_data(phosg::load_file(file_path)); filename.resize(filename.size() - 4); } else if (phosg::ends_with(filename, ".txt")) { string include_dir = phosg::dirname(file_path); file_data = assemble_quest_script(phosg::load_file(file_path), include_dir); filename.resize(filename.size() - 4); if (phosg::ends_with(filename, ".bin")) { filename.push_back('d'); } } else { file_data = phosg::load_file(file_path); } size_t dot_pos = filename.rfind('.'); string file_basename; string extension; if (dot_pos != string::npos) { file_basename = phosg::tolower(filename.substr(0, dot_pos)); extension = phosg::tolower(filename.substr(dot_pos + 1)); } else { file_basename = phosg::tolower(filename); } if (extension == "json") { add_file(json_files, file_basename, orig_filename, std::move(file_data), false); } else if (extension == "bin" || extension == "mnm") { add_file(bin_files, file_basename, orig_filename, std::move(file_data), true); } else if (extension == "bind" || extension == "mnmd") { add_file(bin_files, file_basename, orig_filename, prs_compress_optimal(file_data), true); } else if (extension == "dat") { add_dat_file(file_basename, orig_filename, std::move(file_data)); } else if (extension == "datd") { add_dat_file(file_basename, orig_filename, prs_compress_optimal(file_data)); } else if (extension == "pvr") { add_file(pvr_files, file_basename, orig_filename, std::move(file_data), true); } else if (extension == "qst") { auto files = decode_qst_data(file_data); for (auto& it : files) { if (phosg::ends_with(it.first, ".bin")) { add_file(bin_files, file_basename, orig_filename, std::move(it.second), true); } else if (phosg::ends_with(it.first, ".dat")) { add_dat_file(file_basename, orig_filename, std::move(it.second)); } else if (phosg::ends_with(it.first, ".pvr")) { add_file(pvr_files, file_basename, orig_filename, std::move(it.second), true); } else { throw runtime_error("qst file contains unsupported file type: " + it.first); } } } } catch (const exception& e) { static_game_data_log.warning("(%s) Failed to load quest file: (%s)", filename.c_str(), e.what()); } } } // All quests have a bin file (even in Episode 3, though its format is // different), so we use bin_files as the primary list of all quests that // should be indexed for (auto& bin_it : bin_files) { const string& basename = bin_it.first; const auto* bin_filedata = &bin_it.second; try { // Quest .bin filenames are like K###-VERS-LANG.EXT, where: // K can be any character (usually it's q) // # = quest number (does not have to match the internal quest number) // VERS = PSO version that the quest is for (dc, pc, gc, etc.) // LANG = client language (j, e, g, f, s) // EXT = file type (bin, bind, bin.dlq, qst, etc.) // EXT has already been stripped off by the time we get here, so we just // parse the remaining fields. string quest_number_token, version_token, language_token; { vector filename_tokens = phosg::split(basename, '-'); if (filename_tokens.size() != 3) { throw invalid_argument("incorrect filename format"); } quest_number_token = std::move(filename_tokens[0]); version_token = std::move(filename_tokens[1]); language_token = std::move(filename_tokens[2]); } uint32_t category_id = categories.at(basename); // Get the number from the first token if (quest_number_token.empty()) { throw runtime_error("quest number token is missing"); } uint32_t quest_number = strtoull(quest_number_token.c_str() + 1, nullptr, 10); // Get the version from the second token static const unordered_map name_to_version({ {"dn", Version::DC_NTE}, {"dp", Version::DC_V1_11_2000_PROTOTYPE}, {"d1", Version::DC_V1}, {"dc", Version::DC_V2}, {"pcn", Version::PC_NTE}, {"pc", Version::PC_V2}, {"gcn", Version::GC_NTE}, {"gc", Version::GC_V3}, {"gc3t", Version::GC_EP3_NTE}, {"gc3", Version::GC_EP3}, {"xb", Version::XB_V3}, {"bb", Version::BB_V4}, }); auto version = name_to_version.at(version_token); // Get the language from the last token if (language_token.size() != 1) { throw runtime_error("language token is not a single character"); } uint8_t language = language_code_for_char(language_token[0]); // Find the corresponding dat and pvr files const DATFileData* dat_filedata = nullptr; const FileData* pvr_filedata = nullptr; if (!::is_ep3(version)) { // Look for dat and pvr files with the same basename as the bin file; if // not found, look for them without the language suffix try { dat_filedata = &dat_files.at(basename); } catch (const out_of_range&) { try { dat_filedata = &dat_files.at(quest_number_token + "-" + version_token); } catch (const out_of_range&) { throw runtime_error("no dat file found for bin file " + basename); } } try { pvr_filedata = &pvr_files.at(basename); } catch (const out_of_range&) { try { pvr_filedata = &pvr_files.at(quest_number_token + "-" + version_token); } catch (const out_of_range&) { // pvr files aren't required (and most quests do not have them), so // don't fail if it's missing } } } // Load the quest's metadata phosg::JSON file, if it exists const FileData* json_filedata = nullptr; shared_ptr battle_rules; ssize_t challenge_template_index = -1; uint8_t description_flag = 0; shared_ptr available_expression; shared_ptr enabled_expression; bool allow_start_from_chat_command = false; bool force_joinable = false; int16_t lock_status_register = -1; try { json_filedata = &json_files.at(basename); } catch (const out_of_range&) { try { json_filedata = &json_files.at(quest_number_token + "-" + version_token); } catch (const out_of_range&) { try { json_filedata = &json_files.at(quest_number_token); } catch (const out_of_range&) { } } } if (json_filedata) { auto metadata_json = phosg::JSON::parse(*json_filedata->data); try { battle_rules = make_shared(metadata_json.at("BattleRules")); } catch (const out_of_range&) { } try { challenge_template_index = metadata_json.at("ChallengeTemplateIndex").as_int(); } catch (const out_of_range&) { } try { description_flag = metadata_json.at("DescriptionFlag").as_int(); } catch (const out_of_range&) { } try { available_expression = make_shared(metadata_json.get_string("AvailableIf")); } catch (const out_of_range&) { } try { enabled_expression = make_shared(metadata_json.get_string("EnabledIf")); } catch (const out_of_range&) { } try { allow_start_from_chat_command = metadata_json.get_bool("AllowStartFromChatCommand"); } catch (const out_of_range&) { } try { force_joinable = metadata_json.get_bool("Joinable"); } catch (const out_of_range&) { } try { lock_status_register = metadata_json.get_int("LockStatusRegister"); } catch (const out_of_range&) { } } auto vq = make_shared( quest_number, category_id, version, language, bin_filedata->data, dat_filedata ? dat_filedata->data : nullptr, dat_filedata ? dat_filedata->map_file : nullptr, pvr_filedata ? pvr_filedata->data : nullptr, battle_rules, challenge_template_index, description_flag, available_expression, enabled_expression, allow_start_from_chat_command, force_joinable, lock_status_register); auto category_name = this->category_index->at(vq->category_id)->name; string filenames_str = bin_filedata->filename; if (dat_filedata) { filenames_str += phosg::string_printf("/%s", dat_filedata->filename.c_str()); } if (pvr_filedata) { filenames_str += phosg::string_printf("/%s", pvr_filedata->filename.c_str()); } if (json_filedata) { filenames_str += phosg::string_printf("/%s", json_filedata->filename.c_str()); } auto q_it = this->quests_by_number.find(vq->quest_number); if (q_it != this->quests_by_number.end()) { q_it->second->add_version(vq); static_game_data_log.info("(%s) Added %s %c version of quest %" PRIu32 " (%s)", filenames_str.c_str(), phosg::name_for_enum(vq->version), char_for_language_code(vq->language), vq->quest_number, vq->name.c_str()); } else { auto q = make_shared(vq); this->quests_by_number.emplace(vq->quest_number, q); this->quests_by_name.emplace(vq->name, q); this->quests_by_category_id_and_number[q->category_id].emplace(vq->quest_number, q); static_game_data_log.info("(%s) Created %s %c quest %" PRIu32 " (%s) (%s, %s (%" PRIu32 "), %s)", filenames_str.c_str(), phosg::name_for_enum(vq->version), char_for_language_code(vq->language), vq->quest_number, vq->name.c_str(), name_for_episode(vq->episode), category_name.c_str(), vq->category_id, vq->joinable ? "joinable" : "not joinable"); } } catch (const exception& e) { static_game_data_log.warning("(%s) Failed to index quest file: (%s)", basename.c_str(), e.what()); } } // Create supermaps for all quests that need them (all non-Ep3 quests) for (const auto& it : this->quests_by_number) { it.second->get_supermap(-1); } } shared_ptr QuestIndex::get(uint32_t quest_number) const { try { return this->quests_by_number.at(quest_number); } catch (const out_of_range&) { return nullptr; } } shared_ptr QuestIndex::get(const std::string& name) const { try { return this->quests_by_name.at(name); } catch (const out_of_range&) { return nullptr; } } vector> QuestIndex::categories( QuestMenuType menu_type, Episode episode, uint16_t version_flags, IncludeCondition include_condition) const { vector> ret; for (const auto& cat : this->category_index->categories) { if (cat->check_flag(menu_type) && !this->filter(episode, version_flags, cat->category_id, include_condition, 1).empty()) { ret.emplace_back(cat); } } return ret; } vector>> QuestIndex::filter( Episode episode, uint16_t version_flags, uint32_t category_id, IncludeCondition include_condition, size_t limit) const { auto cat = this->category_index->at(category_id); Episode effective_episode = cat->enable_episode_filter() ? episode : Episode::NONE; vector>> ret; auto category_it = this->quests_by_category_id_and_number.find(category_id); if (category_it == this->quests_by_category_id_and_number.end()) { return ret; } for (auto it : category_it->second) { if ((effective_episode != Episode::NONE) && (it.second->episode != effective_episode)) { continue; } bool all_required_versions_present = true; for (size_t v_s = 0; v_s < NUM_VERSIONS; v_s++) { if ((version_flags & (1 << v_s)) && !it.second->has_version_any_language(static_cast(v_s))) { all_required_versions_present = false; break; } } if (!all_required_versions_present) { continue; } IncludeState state = include_condition ? include_condition(it.second) : IncludeState::AVAILABLE; if (state == IncludeState::HIDDEN) { continue; } ret.emplace_back(make_pair(state, it.second)); if (limit && (ret.size() >= limit)) { break; } } return ret; } string encode_download_quest_data(const string& compressed_data, size_t decompressed_size, uint32_t encryption_seed) { // Download quest files are like normal (PRS-compressed) quest files, but they // are encrypted with PSO V2 encryption (even on V3 / PSO GC), and a small // header (PSODownloadQuestHeader) is prepended to the encrypted data. if (encryption_seed == 0) { encryption_seed = phosg::random_object(); } if (decompressed_size == 0) { decompressed_size = prs_decompress_size(compressed_data); } string data(8, '\0'); auto* header = reinterpret_cast(data.data()); header->size = decompressed_size; header->encryption_seed = encryption_seed; data += compressed_data; // Add temporary extra bytes if necessary so encryption won't fail - the data // size must be a multiple of 4 for PSO V2 encryption. size_t original_size = data.size(); data.resize((data.size() + 3) & (~3)); PSOV2Encryption encr(encryption_seed); encr.encrypt(data.data() + sizeof(PSODownloadQuestHeader), data.size() - sizeof(PSODownloadQuestHeader)); data.resize(original_size); return data; } shared_ptr VersionedQuest::create_download_quest(uint8_t override_language) const { // The download flag needs to be set in the bin header, or else the client // will ignore it when scanning for download quests in an offline game. To set // this flag, we need to decompress the quest's .bin file, set the flag, then // recompress it again. // This function should not be used for Episode 3 quests (they should be sent // to the client as-is, without any encryption or other preprocessing) if (this->episode == Episode::EP3 || is_ep3(this->version)) { throw logic_error("Episode 3 quests cannot be converted to download quests"); } string decompressed_bin = prs_decompress(*this->bin_contents); void* data_ptr = decompressed_bin.data(); switch (this->version) { case Version::DC_NTE: if (decompressed_bin.size() < sizeof(PSOQuestHeaderDCNTE)) { throw runtime_error("bin file is too small for header"); } // There's no known language field in this version, so we don't write // anything here break; case Version::DC_V1_11_2000_PROTOTYPE: case Version::DC_V1: case Version::DC_V2: if (decompressed_bin.size() < sizeof(PSOQuestHeaderDC)) { throw runtime_error("bin file is too small for header"); } if (override_language != 0xFF) { reinterpret_cast(data_ptr)->language = override_language; } break; case Version::PC_NTE: case Version::PC_V2: if (decompressed_bin.size() < sizeof(PSOQuestHeaderPC)) { throw runtime_error("bin file is too small for header"); } if (override_language != 0xFF) { reinterpret_cast(data_ptr)->language = override_language; } break; case Version::GC_NTE: case Version::GC_V3: case Version::XB_V3: if (decompressed_bin.size() < sizeof(PSOQuestHeaderGC)) { throw runtime_error("bin file is too small for header"); } if (override_language != 0xFF) { reinterpret_cast(data_ptr)->language = override_language; } break; case Version::BB_V4: throw invalid_argument("PSOBB does not support download quests"); default: throw invalid_argument("unknown game version"); } string compressed_bin = prs_compress(decompressed_bin); // Return a new VersionedQuest object with appropriately-processed .bin and // .dat file contents auto dlq = make_shared(*this); dlq->bin_contents = make_shared(encode_download_quest_data(compressed_bin, decompressed_bin.size())); dlq->dat_contents = make_shared(encode_download_quest_data(*this->dat_contents)); dlq->pvr_contents = this->pvr_contents; dlq->is_dlq_encoded = true; return dlq; } string decode_gci_data( const string& data, ssize_t find_seed_num_threads, int64_t known_seed, bool skip_checksum) { phosg::StringReader r(data); const auto& header = r.get(); header.check(); if (header.is_ep12()) { const auto& dlq_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 (dlq_header.round2_seed || dlq_header.checksum || dlq_header.round3_seed) { if (known_seed >= 0) { return decrypt_download_quest_data_section( r.getv(header.data_size), header.data_size, known_seed, skip_checksum, false); } else if (header.embedded_seed != 0) { return decrypt_download_quest_data_section( r.getv(header.data_size), header.data_size, header.embedded_seed, skip_checksum, false); } else { if (find_seed_num_threads < 0) { throw runtime_error("file is encrypted"); } if (find_seed_num_threads == 0) { find_seed_num_threads = thread::hardware_concurrency(); } return find_seed_and_decrypt_download_quest_data_section( r.getv(header.data_size), header.data_size, skip_checksum, false, find_seed_num_threads); } } else { // Unencrypted GCI format r.skip(sizeof(PSOGCIDLQFileEncryptedHeader)); string compressed_data = r.readx(header.data_size - sizeof(PSOGCIDLQFileEncryptedHeader)); size_t decompressed_bytes = prs_decompress_size(compressed_data); size_t expected_decompressed_bytes = dlq_header.decompressed_size - 8; if (decompressed_bytes < expected_decompressed_bytes) { throw runtime_error(phosg::string_printf( "GCI decompressed data is smaller than expected size (have 0x%zX bytes, expected 0x%zX bytes)", decompressed_bytes, expected_decompressed_bytes)); } return compressed_data; } } else if (header.is_ep3()) { if (header.is_nte()) { if (known_seed >= 0) { return decrypt_download_quest_data_section( r.getv(header.data_size), header.data_size, known_seed, true, true); } else { if (find_seed_num_threads < 0) { throw runtime_error("file is encrypted"); } if (find_seed_num_threads == 0) { find_seed_num_threads = thread::hardware_concurrency(); } return find_seed_and_decrypt_download_quest_data_section( r.getv(header.data_size), header.data_size, true, true, find_seed_num_threads); } } else { // 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 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); string decrypted = 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()); decrypt_trivial_gci_data(decrypted.data(), unscramble_size, 0); size_t decompressed_size = prs_decompress_size(decrypted); if (decompressed_size != sizeof(Episode3::MapDefinition)) { throw runtime_error(phosg::string_printf( "decompressed quest is 0x%zX bytes; expected 0x%zX bytes", decompressed_size, sizeof(Episode3::MapDefinition))); } return decrypted; } } else { throw runtime_error("unknown game name in GCI header"); } } string decode_vms_data( const string& data, ssize_t find_seed_num_threads, int64_t known_seed, bool skip_checksum) { phosg::StringReader r(data); const auto& header = r.get(); if (!header.checksum_correct()) { throw runtime_error("VMS file unencrypted header checksum is incorrect"); } r.skip(header.num_icons * 0x200); const void* data_section = r.getv(header.data_size); try { return decrypt_vms_v1_data_section(data_section, header.data_size); } catch (const exception& e) { } if (known_seed >= 0) { return decrypt_download_quest_data_section( data_section, header.data_size, known_seed); } else { if (find_seed_num_threads < 0) { throw runtime_error("file is encrypted"); } if (find_seed_num_threads == 0) { find_seed_num_threads = thread::hardware_concurrency(); } return find_seed_and_decrypt_download_quest_data_section( data_section, header.data_size, skip_checksum, 0, find_seed_num_threads); } } string decode_dlq_data(const string& data) { phosg::StringReader r(data); uint32_t decompressed_size = r.get_u32l(); uint32_t key = r.get_u32l(); // The compressed data size does not need to be a multiple of 4, but the V2 // encryption (which is used for all download quests, even in V3) requires the // data size to be a multiple of 4. We'll just temporarily stick a few bytes // on the end, then throw them away later if needed. string decrypted = r.read(r.remaining()); PSOV2Encryption encr(key); size_t original_size = data.size(); decrypted.resize((decrypted.size() + 3) & (~3)); encr.decrypt(decrypted); decrypted.resize(original_size); if (prs_decompress_size(decrypted) != decompressed_size) { throw runtime_error("decompressed size does not match size in header"); } return decrypted; } template static unordered_map decode_qst_data_t(const string& data) { phosg::StringReader r(data); unordered_map files; unordered_map file_remaining_bytes; QuestFileFormat subformat = QuestFileFormat::QST; // Stand-in for unknown while (!r.eof()) { // Handle BB's implicit 8-byte command alignment static constexpr size_t alignment = sizeof(HeaderT); size_t next_command_offset = (r.where() + (alignment - 1)) & ~(alignment - 1); r.go(next_command_offset); if (r.eof()) { break; } const auto& header = r.get(); if (header.command == 0x44 || header.command == 0x13) { if (subformat == QuestFileFormat::QST) { subformat = QuestFileFormat::BIN_DAT; } else if (subformat != QuestFileFormat::BIN_DAT) { throw runtime_error("QST file contains mixed download and non-download commands"); } } else if (header.command == 0xA6 || header.command == 0xA7) { if (subformat == QuestFileFormat::QST) { subformat = QuestFileFormat::BIN_DAT_DLQ; } else if (subformat != QuestFileFormat::BIN_DAT_DLQ) { throw runtime_error("QST file contains mixed download and non-download commands"); } } if (header.command == 0x44 || header.command == 0xA6) { if (header.size != sizeof(HeaderT) + sizeof(OpenFileT)) { throw runtime_error("qst open file command has incorrect size"); } const auto& cmd = r.get(); string internal_filename = cmd.filename.decode(); if (!files.emplace(internal_filename, "").second) { throw runtime_error("qst opens the same file multiple times: " + internal_filename); } if (!file_remaining_bytes.emplace(internal_filename, cmd.file_size).second) { throw runtime_error("qst opens the same file multiple times: " + internal_filename); } } else if (header.command == 0x13 || header.command == 0xA7) { // We have to allow larger commands here, because it seems some tools // encoded QST files with BB's extra 4 padding bytes included in the // command size. if (header.size < sizeof(HeaderT) + sizeof(S_WriteFile_13_A7)) { throw runtime_error("qst write file command has incorrect size"); } const auto& cmd = r.get(); if (cmd.data_size > 0x400) { throw runtime_error("qst contains invalid write command"); } string filename = cmd.filename.decode(); string& file_data = files.at(filename); size_t& remaining_bytes = file_remaining_bytes.at(filename); if (file_data.size() & 0x3FF) { throw runtime_error("qst contains uneven chunks out of order"); } if (header.flag != file_data.size() / 0x400) { throw runtime_error("qst contains chunks out of order"); } file_data.append(reinterpret_cast(cmd.data.data()), cmd.data_size); remaining_bytes -= cmd.data_size; } else { throw runtime_error("invalid command in qst file"); } } for (const auto& it : file_remaining_bytes) { if (it.second) { throw runtime_error(phosg::string_printf("expected %zu (0x%zX) more bytes for file %s", it.second, it.second, it.first.c_str())); } } if (subformat == QuestFileFormat::BIN_DAT_DLQ) { for (auto& it : files) { it.second = decode_dlq_data(it.second); } } return files; } unordered_map decode_qst_data(const string& data) { // QST files start with an open file command, but the format differs depending // on the PSO version that the qst file is for. We can detect the format from // the first 4 bytes in the file: // - BB: 58 00 44 00 or 58 00 A6 00 // - PC: 3C 00 44 ?? or 3C 00 A6 ?? // - DC/GC: 44 ?? 3C 00 or A6 ?? 3C 00 // - XB: 44 ?? 54 00 or A6 ?? 54 00 phosg::StringReader r(data); uint32_t signature = r.get_u32b(); if ((signature == 0x58004400) || (signature == 0x5800A600)) { return decode_qst_data_t(data); } else if (((signature & 0xFFFFFF00) == 0x3C004400) || ((signature & 0xFFFFFF00) == 0x3C00A600)) { return decode_qst_data_t(data); } else if (((signature & 0xFF00FFFF) == 0x44003C00) || ((signature & 0xFF00FFFF) == 0xA6003C00)) { // In PSO DC, the type field is only one byte, but in V3 it's two bytes and // the filename was shifted over by one byte. To detect this, we check if // the V3 type field has a reasonable value, and if not, we assume the file // is for PSO DC. if (r.pget_u16l(sizeof(PSOCommandHeaderDCV3) + offsetof(S_OpenFile_PC_GC_44_A6, type)) > 3) { return decode_qst_data_t(data); } else { return decode_qst_data_t(data); } } else if (((signature & 0xFF00FFFF) == 0x44005400) || ((signature & 0xFF00FFFF) == 0xA6005400)) { return decode_qst_data_t(data); } else { throw runtime_error("invalid qst file format"); } } template void add_command_header(phosg::StringWriter& w, uint8_t command, uint8_t flag, uint16_t size) { HeaderT header; header.command = command; header.flag = flag; header.size = sizeof(HeaderT) + size; w.put(header); } template void add_open_file_command_t( phosg::StringWriter& w, const std::string& name, const std::string& filename, const std::string&, uint32_t, size_t file_size, bool is_download) { add_command_header(w, is_download ? 0xA6 : 0x44, 0x00, sizeof(CmdT)); CmdT cmd; cmd.name.assign_raw("PSO/" + name); cmd.filename.encode(filename); cmd.type = 0; cmd.file_size = file_size; // TODO: It'd be nice to have something like w.emplace(...) to avoid copying // the command structs into the StringWriter. w.put(cmd); } template <> void add_open_file_command_t( phosg::StringWriter& w, const std::string& name, const std::string& filename, const std::string& xb_filename, uint32_t quest_number, size_t file_size, bool is_download) { add_command_header(w, is_download ? 0xA6 : 0x44, 0x00, sizeof(S_OpenFile_XB_44_A6)); S_OpenFile_XB_44_A6 cmd; cmd.name.assign_raw("PSO/" + name); cmd.filename.encode(filename); cmd.type = 0; cmd.file_size = file_size; cmd.xb_filename.encode(xb_filename); cmd.content_meta = 0x30000000 | quest_number; w.put(cmd); } template void add_write_file_commands_t( phosg::StringWriter& w, const string& filename, const string& data, bool is_download, bool bb_alignment) { for (size_t z = 0; z < data.size(); z += 0x400) { size_t chunk_size = min(data.size() - z, 0x400); add_command_header(w, is_download ? 0xA7 : 0x13, z >> 10, sizeof(S_WriteFile_13_A7)); S_WriteFile_13_A7 cmd; cmd.filename.encode(filename); memcpy(cmd.data.data(), &data[z], chunk_size); cmd.data_size = chunk_size; w.put(cmd); // On BB, the write file command size is a multiple of 4 but not a multiple // of 8; in QST format the implicit extra 4 bytes are apparently stored in // the file. if (bb_alignment) { w.put_u32(0); } } } string encode_qst_file( const unordered_map>& files, const string& name, uint32_t quest_number, const string& xb_filename, Version version, bool is_dlq_encoded) { phosg::StringWriter w; // Some tools expect both open file commands at the beginning, hence this // unfortunate abstraction-breaking. switch (version) { case Version::DC_NTE: // DC NTE doesn't support quests, but we support encoding QST files anyway case Version::DC_V1_11_2000_PROTOTYPE: case Version::DC_V1: case Version::DC_V2: for (const auto& it : files) { add_open_file_command_t(w, name, it.first, xb_filename, quest_number, it.second->size(), is_dlq_encoded); } for (const auto& it : files) { add_write_file_commands_t(w, it.first, *it.second, is_dlq_encoded, false); } break; case Version::PC_NTE: case Version::PC_V2: for (const auto& it : files) { add_open_file_command_t(w, name, it.first, xb_filename, quest_number, it.second->size(), is_dlq_encoded); } for (const auto& it : files) { add_write_file_commands_t(w, it.first, *it.second, is_dlq_encoded, false); } break; case Version::GC_NTE: case Version::GC_V3: case Version::GC_EP3_NTE: case Version::GC_EP3: for (const auto& it : files) { add_open_file_command_t(w, name, it.first, xb_filename, quest_number, it.second->size(), is_dlq_encoded); } for (const auto& it : files) { add_write_file_commands_t(w, it.first, *it.second, is_dlq_encoded, false); } break; case Version::XB_V3: for (const auto& it : files) { add_open_file_command_t(w, name, it.first, xb_filename, quest_number, it.second->size(), is_dlq_encoded); } for (const auto& it : files) { add_write_file_commands_t(w, it.first, *it.second, is_dlq_encoded, false); } break; case Version::BB_V4: for (const auto& it : files) { add_open_file_command_t(w, name, it.first, xb_filename, quest_number, it.second->size(), is_dlq_encoded); } for (const auto& it : files) { add_write_file_commands_t(w, it.first, *it.second, is_dlq_encoded, true); } break; default: throw logic_error("invalid game version"); } return std::move(w.str()); }