diff --git a/README.md b/README.md index 6c38eea6..1f9b1c0f 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ newserv automatically finds quests in the system/quests/ directory. To install y Standard quest files should be named like `q###-CATEGORY-VERSION.EXT`, battle quests should be named like `b###-VERSION.EXT`, challenge quests should be named like `c###-VERSION.EXT` for Episode 1 or `d###-VERSION.EXT` for Episode 2, and Episode 3 download quests should be named like `e###-gc3.EXT`. The fields in each filename are: - `###`: quest number (this doesn't really matter; it should just be unique across the PSO version) - `CATEGORY`: ret = Retrieval, ext = Extermination, evt = Events, shp = Shops, vr = VR, twr = Tower, gv1/gv2/gv4 = Government (BB only), dl = Download (these don't appear during online play), 1p = Solo (BB only) -- `VERSION`: d1 = Dreamcast v1, dc = Dreamcast v2, pc = PC, gc = GameCube Episodes 1 & 2, gc3 = Episode 3, bb = Blue Burst +- `VERSION`: dn = Dreamcast NTE, d1 = Dreamcast v1, dc = Dreamcast v2, pc = PC, gcn = GameCube Trial Edition, gc = GameCube Episodes 1 & 2, gc3 = Episode 3, xb = Xbox, bb = Blue Burst - `EXT`: file extension (see table below) For example, the GameCube version of Lost HEAT SWORD is in two files named `q058-ret-gc.bin` and `q058-ret-gc.dat`. newserv knows these files are quests because they're in the system/quests/ directory, it knows they're for PSO GC because the filenames contain `-gc`, and it puts them in the Retrieval category because the filenames contain `-ret`. diff --git a/src/Client.cc b/src/Client.cc index 1f880ee3..9b919d8d 100644 --- a/src/Client.cc +++ b/src/Client.cc @@ -98,6 +98,35 @@ Client::~Client() { this->log.info("Deleted"); } +QuestScriptVersion Client::quest_version() const { + switch (this->version()) { + case GameVersion::DC: + if (this->flags & Flag::IS_TRIAL_EDITION) { + return QuestScriptVersion::DC_NTE; + } else if (this->flags & Flag::IS_DC_V1) { + return QuestScriptVersion::DC_V1; + } else { + return QuestScriptVersion::DC_V2; + } + case GameVersion::PC: + return QuestScriptVersion::PC_V2; + case GameVersion::GC: + if (this->flags & Flag::IS_TRIAL_EDITION) { + return QuestScriptVersion::GC_NTE; + } else if (this->flags & Flag::IS_EPISODE_3) { + return QuestScriptVersion::GC_EP3; + } else { + return QuestScriptVersion::GC_V3; + } + case GameVersion::XB: + return QuestScriptVersion::XB_V3; + case GameVersion::BB: + return QuestScriptVersion::BB_V4; + default: + throw logic_error("client\'s game version does not have a quest version"); + } +} + void Client::set_license(shared_ptr l) { this->license = l; this->game_data.guild_card_number = this->license->serial_number; diff --git a/src/Client.hh b/src/Client.hh index 8d212e55..15953bb8 100644 --- a/src/Client.hh +++ b/src/Client.hh @@ -15,6 +15,7 @@ #include "PSOProtocol.hh" #include "PatchFileIndex.hh" #include "Player.hh" +#include "QuestScript.hh" #include "Text.hh" extern const uint64_t CLIENT_CONFIG_MAGIC; @@ -171,6 +172,7 @@ struct Client { inline GameVersion version() const { return this->channel.version; } + QuestScriptVersion quest_version() const; void set_license(std::shared_ptr l); diff --git a/src/Quest.cc b/src/Quest.cc index 6a8dc611..19b4c1db 100644 --- a/src/Quest.cc +++ b/src/Quest.cc @@ -236,7 +236,6 @@ Quest::Quest(const string& bin_filename, shared_ptr ca menu_item_id(0), category_id(0), episode(Episode::NONE), - is_dcv1(false), joinable(false), file_format(FileFormat::BIN_DAT), has_mnm_extension(false), @@ -278,12 +277,6 @@ Quest::Quest(const string& bin_filename, shared_ptr ca } } - // Quest filenames are like: - // b###-VV.bin for battle mode - // c###-VV.bin for challenge mode - // e###-gc3.mnm (or .bin) for episode 3 - // q###-CAT-VV.bin for normal quests - if (basename.empty()) { throw invalid_argument("empty filename"); } @@ -305,14 +298,16 @@ Quest::Quest(const string& bin_filename, shared_ptr ca this->internal_id = strtoull(tokens[0].c_str() + 1, nullptr, 10); // Get the version from the second (or previously third) token - static const unordered_map name_to_version({ - {"d1", GameVersion::DC}, - {"dc", GameVersion::DC}, - {"pc", GameVersion::PC}, - {"gc", GameVersion::GC}, - {"gc3", GameVersion::GC}, - {"xb", GameVersion::XB}, - {"bb", GameVersion::BB}, + static const unordered_map name_to_version({ + {"dn", QuestScriptVersion::DC_NTE}, + {"d1", QuestScriptVersion::DC_V1}, + {"dc", QuestScriptVersion::DC_V2}, + {"pc", QuestScriptVersion::PC_V2}, + {"gcn", QuestScriptVersion::GC_NTE}, + {"gc", QuestScriptVersion::GC_V3}, + {"gc3", QuestScriptVersion::GC_EP3}, + {"xb", QuestScriptVersion::XB_V3}, + {"bb", QuestScriptVersion::BB_V4}, }); this->version = name_to_version.at(tokens[1]); @@ -323,11 +318,9 @@ Quest::Quest(const string& bin_filename, shared_ptr ca 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: { + case QuestScriptVersion::DC_NTE: + case QuestScriptVersion::DC_V1: + case QuestScriptVersion::DC_V2: { if (bin_decompressed.size() < sizeof(PSOQuestHeaderDC)) { throw invalid_argument("file is too small for header"); } @@ -337,11 +330,10 @@ Quest::Quest(const string& bin_filename, shared_ptr ca 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"); break; } - case GameVersion::PC: { + case QuestScriptVersion::PC_V2: { if (bin_decompressed.size() < sizeof(PSOQuestHeaderPC)) { throw invalid_argument("file is too small for header"); } @@ -354,33 +346,42 @@ Quest::Quest(const string& bin_filename, shared_ptr ca break; } - case GameVersion::XB: - case GameVersion::GC: { - if (category.flags & QuestCategoryIndex::Category::Flag::EP3_DOWNLOAD) { - if (bin_decompressed.size() != sizeof(Episode3::MapDefinition)) { - throw invalid_argument("file is incorrect size"); - } - auto* header = reinterpret_cast(bin_decompressed.data()); - this->joinable = false; - this->episode = Episode::EP3; - this->name = decode_sjis(header->name); - this->short_description = decode_sjis(header->quest_name); - 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) ? Episode::EP2 : Episode::EP1; - this->name = decode_sjis(header->name); - this->short_description = decode_sjis(header->short_description); - this->long_description = decode_sjis(header->long_description); + case QuestScriptVersion::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 the QuestScriptVersion::GC_EP3 value 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* header = reinterpret_cast(bin_decompressed.data()); + this->joinable = false; + this->episode = Episode::EP3; + this->name = decode_sjis(header->name); + this->short_description = decode_sjis(header->quest_name); + this->long_description = decode_sjis(header->description); break; } - case GameVersion::BB: { + case QuestScriptVersion::XB_V3: + case QuestScriptVersion::GC_NTE: + case QuestScriptVersion::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->joinable = false; + this->episode = (header->episode == 1) ? Episode::EP2 : Episode::EP1; + this->name = decode_sjis(header->name); + this->short_description = decode_sjis(header->short_description); + this->long_description = decode_sjis(header->long_description); + break; + } + + case QuestScriptVersion::BB_V4: { if (bin_decompressed.size() < sizeof(PSOQuestHeaderBB)) { throw invalid_argument("file is too small for header"); } @@ -827,7 +828,7 @@ void add_write_file_commands( } } -string Quest::export_qst(GameVersion version) const { +string Quest::export_qst() const { bool is_ep3 = this->episode == Episode::EP3; if (is_ep3 && !this->is_dlq_encoded) { throw runtime_error("Episode 3 quests can only be encoded in download QST format"); @@ -837,8 +838,10 @@ string Quest::export_qst(GameVersion version) const { // Some tools expect both open file commands at the beginning, hence this // unfortunate abstraction-breaking. - switch (version) { - case GameVersion::DC: + switch (this->version) { + case QuestScriptVersion::DC_NTE: + case QuestScriptVersion::DC_V1: + case QuestScriptVersion::DC_V2: add_open_file_command(w, *this, true); add_open_file_command(w, *this, false); add_write_file_commands( @@ -846,7 +849,7 @@ string Quest::export_qst(GameVersion version) const { add_write_file_commands( w, this->file_basename + ".dat", *this->dat_contents(), this->is_dlq_encoded); break; - case GameVersion::PC: + case QuestScriptVersion::PC_V2: add_open_file_command(w, *this, true); add_open_file_command(w, *this, false); add_write_file_commands( @@ -854,20 +857,22 @@ string Quest::export_qst(GameVersion version) const { add_write_file_commands( w, this->file_basename + ".dat", *this->dat_contents(), this->is_dlq_encoded); break; - case GameVersion::GC: - case GameVersion::XB: + case QuestScriptVersion::GC_NTE: + case QuestScriptVersion::GC_V3: + case QuestScriptVersion::XB_V3: add_open_file_command(w, *this, true); - if (!is_ep3) { - add_open_file_command(w, *this, false); - } + add_open_file_command(w, *this, false); add_write_file_commands( w, this->file_basename + ".bin", *this->bin_contents(), this->is_dlq_encoded); - if (!is_ep3) { - add_write_file_commands( - w, this->file_basename + ".dat", *this->dat_contents(), this->is_dlq_encoded); - } + add_write_file_commands( + w, this->file_basename + ".dat", *this->dat_contents(), this->is_dlq_encoded); break; - case GameVersion::BB: + case QuestScriptVersion::GC_EP3: + add_open_file_command(w, *this, true); + add_write_file_commands( + w, this->file_basename + ".bin", *this->bin_contents(), this->is_dlq_encoded); + break; + case QuestScriptVersion::BB_V4: add_open_file_command(w, *this, true); add_open_file_command(w, *this, false); add_write_file_commands( @@ -916,17 +921,16 @@ QuestIndex::QuestIndex( throw logic_error("duplicate quest menu item id"); } auto category_name = encode_sjis(this->category_index->at(q->category_id).name); - static_game_data_log.info("Indexed quest %s (%s => %s-%" PRId64 " (%" PRIu32 "), %s, %s (%" PRIu32 "), joinable=%s, dcv1=%s)", + static_game_data_log.info("Indexed quest %s (%s => %s-%" PRId64 " (%" PRIu32 "), %s, %s (%" PRIu32 "), joinable=%s)", ascii_name.c_str(), filename.c_str(), - name_for_version(q->version), + name_for_enum(q->version), q->internal_id, q->menu_item_id, name_for_episode(q->episode), category_name.c_str(), q->category_id, - q->joinable ? "true" : "false", - q->is_dcv1 ? "true" : "false"); + q->joinable ? "true" : "false"); } catch (const exception& e) { static_game_data_log.warning("Failed to index quest file %s (%s)", filename.c_str(), e.what()); } @@ -934,8 +938,8 @@ QuestIndex::QuestIndex( } } -shared_ptr QuestIndex::get(GameVersion version, - uint32_t menu_item_id) const { +shared_ptr QuestIndex::get( + QuestScriptVersion version, uint32_t menu_item_id) const { return this->version_menu_item_id_to_quest.at(make_pair(version, menu_item_id)); } @@ -944,17 +948,15 @@ shared_ptr QuestIndex::get_gba(const string& name) const { } vector> QuestIndex::filter( - GameVersion version, bool is_dcv1, uint32_t category_id) const { + QuestScriptVersion version, uint32_t category_id) const { auto it = this->version_menu_item_id_to_quest.lower_bound(make_pair(version, 0)); auto end_it = this->version_menu_item_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_id != category_id)) { - continue; + if (it->second->category_id == category_id) { + ret.emplace_back(it->second); } - ret.emplace_back(q); } return ret; @@ -997,7 +999,7 @@ shared_ptr Quest::create_download_quest() const { // 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) { + if (this->episode == Episode::EP3 || this->version == QuestScriptVersion::GC_EP3) { throw logic_error("Episode 3 quests cannot be converted to download quests"); } @@ -1005,27 +1007,32 @@ shared_ptr Quest::create_download_quest() const { void* data_ptr = decompressed_bin.data(); switch (this->version) { - case GameVersion::DC: + case QuestScriptVersion::DC_NTE: + case QuestScriptVersion::DC_V1: + case QuestScriptVersion::DC_V2: if (decompressed_bin.size() < sizeof(PSOQuestHeaderDC)) { throw runtime_error("bin file is too small for header"); } reinterpret_cast(data_ptr)->is_download = 0x01; break; - case GameVersion::PC: + case QuestScriptVersion::PC_V2: if (decompressed_bin.size() < sizeof(PSOQuestHeaderPC)) { throw runtime_error("bin file is too small for header"); } reinterpret_cast(data_ptr)->is_download = 0x01; break; - case GameVersion::XB: - case GameVersion::GC: + case QuestScriptVersion::GC_NTE: + case QuestScriptVersion::GC_V3: + case QuestScriptVersion::XB_V3: if (decompressed_bin.size() < sizeof(PSOQuestHeaderGC)) { throw runtime_error("bin file is too small for header"); } reinterpret_cast(data_ptr)->is_download = 0x01; break; - case GameVersion::BB: + case QuestScriptVersion::BB_V4: throw invalid_argument("PSOBB does not support download quests"); + case QuestScriptVersion::GC_EP3: + throw logic_error("Episode 3 quests cannot be converted to download quests"); default: throw invalid_argument("unknown game version"); } diff --git a/src/Quest.hh b/src/Quest.hh index 7151780c..b2ba26aa 100644 --- a/src/Quest.hh +++ b/src/Quest.hh @@ -7,8 +7,8 @@ #include #include +#include "QuestScript.hh" #include "StaticGameData.hh" -#include "Version.hh" struct QuestCategoryIndex { struct Category { @@ -64,9 +64,8 @@ public: uint32_t menu_item_id; uint32_t category_id; Episode episode; - bool is_dcv1; bool joinable; - GameVersion version; + QuestScriptVersion version; std::string file_basename; // we append -. when reading FileFormat file_format; bool has_mnm_extension; @@ -101,7 +100,7 @@ public: static std::string decode_dlq_data(const std::string& filename); static std::pair decode_qst_file(const std::string& filename); - std::string export_qst(GameVersion version) const; + std::string export_qst() const; private: // these are populated when requested @@ -113,7 +112,7 @@ struct QuestIndex { std::string directory; std::shared_ptr category_index; - std::map, std::shared_ptr> version_menu_item_id_to_quest; + std::map, std::shared_ptr> version_menu_item_id_to_quest; std::map>> category_to_quests; @@ -121,8 +120,8 @@ struct QuestIndex { QuestIndex(const std::string& directory, std::shared_ptr category_index); - std::shared_ptr get(GameVersion version, uint32_t id) const; + std::shared_ptr get(QuestScriptVersion version, uint32_t id) const; std::shared_ptr get_gba(const std::string& name) const; - std::vector> filter(GameVersion version, - bool is_dcv1, uint32_t category_id) const; + std::vector> filter( + QuestScriptVersion version, uint32_t category_id) const; }; diff --git a/src/QuestScript.cc b/src/QuestScript.cc index d7ab5653..883d1763 100644 --- a/src/QuestScript.cc +++ b/src/QuestScript.cc @@ -18,6 +18,32 @@ using namespace std; +template <> +const char* name_for_enum(QuestScriptVersion v) { + switch (v) { + case QuestScriptVersion::DC_NTE: + return "DC_NTE"; + case QuestScriptVersion::DC_V1: + return "DC_V1"; + case QuestScriptVersion::DC_V2: + return "DC_V2"; + case QuestScriptVersion::PC_V2: + return "PC_V2"; + case QuestScriptVersion::GC_NTE: + return "GC_NTE"; + case QuestScriptVersion::GC_V3: + return "GC_V3"; + case QuestScriptVersion::XB_V3: + return "XB_V3"; + case QuestScriptVersion::GC_EP3: + return "GC_EP3"; + case QuestScriptVersion::BB_V4: + return "BB_V4"; + default: + return "__UNKNOWN__"; + } +} + // bit_cast isn't in the standard place on macOS (it is apparently implicitly // included by resource_dasm, but newserv can be built without resource_dasm) // and I'm too lazy to go find the right header to include diff --git a/src/QuestScript.hh b/src/QuestScript.hh index 4fb5428c..04a9529f 100644 --- a/src/QuestScript.hh +++ b/src/QuestScript.hh @@ -3,6 +3,7 @@ #include #include +#include #include "Text.hh" #include "Version.hh" @@ -19,6 +20,9 @@ enum class QuestScriptVersion { BB_V4 = 8, }; +template <> +const char* name_for_enum(QuestScriptVersion v); + struct PSOQuestHeaderDC { // Same format for DC v1 and v2 le_uint32_t code_offset; le_uint32_t function_table_offset; diff --git a/src/ReceiveCommands.cc b/src/ReceiveCommands.cc index 56ab60aa..baa41c5c 100644 --- a/src/ReceiveCommands.cc +++ b/src/ReceiveCommands.cc @@ -1444,7 +1444,7 @@ static void on_09(shared_ptr s, shared_ptr c, if (!s->quest_index) { send_quest_info(c, u"$C6Quests are not available.", !c->lobby_id); } else { - auto q = s->quest_index->get(c->version(), cmd.item_id); + auto q = s->quest_index->get(c->quest_version(), cmd.item_id); if (!q) { send_quest_info(c, u"$C4Quest does not\nexist.", !c->lobby_id); } else { @@ -1674,8 +1674,7 @@ static void on_10(shared_ptr s, shared_ptr c, vector> quests; for (const auto& category : s->quest_category_index->categories) { if (category.flags & QuestCategoryIndex::Category::Flag::EP3_DOWNLOAD) { - quests = s->quest_index->filter( - c->version(), c->flags & Client::Flag::IS_DC_V1, category.category_id); + quests = s->quest_index->filter(c->quest_version(), category.category_id); break; } } @@ -1916,8 +1915,7 @@ static void on_10(shared_ptr s, shared_ptr c, break; } shared_ptr l = c->lobby_id ? s->find_lobby(c->lobby_id) : nullptr; - auto quests = s->quest_index->filter( - c->version(), c->flags & Client::Flag::IS_DC_V1, item_id); + auto quests = s->quest_index->filter(c->quest_version(), item_id); // Hack: Assume the menu to be sent is the download quest menu if the // client is not in any lobby @@ -1930,7 +1928,7 @@ static void on_10(shared_ptr s, shared_ptr c, send_lobby_message_box(c, u"$C6Quests are not available."); break; } - auto q = s->quest_index->get(c->version(), item_id); + auto q = s->quest_index->get(c->quest_version(), item_id); if (!q) { send_lobby_message_box(c, u"$C6Quest does not exist."); break;