diff --git a/src/CommandFormats.hh b/src/CommandFormats.hh index 4cab9fbb..e6bf1e4f 100644 --- a/src/CommandFormats.hh +++ b/src/CommandFormats.hh @@ -6280,6 +6280,8 @@ struct G_AdjustPlayerMeseta_BB_6xC9 { } __packed_ws__(G_AdjustPlayerMeseta_BB_6xC9, 8); // 6xCA: Request item reward from quest (BB; handled by server) +// The server should create the item in the player's inventory using 6xBE if it +// matches at least one of the item creation masks in the quest's header. struct G_QuestCreateItem_BB_6xCA { G_UnusedHeader header; @@ -6365,6 +6367,10 @@ struct G_Unknown_BB_6xD4 { // 6xD5: Exchange item in quest (BB; handled by server) // The client sends this when it executes an F953 quest opcode. +// If any item matching find_item.data1[0-2] is present in the player's +// inventory, the server should destroy that item using 6x29, then create +// replace_item in the player's inventory using 6xBE if it matches at least one +// of the item creation masks in the quest's header. struct G_QuestExchangeItem_BB_6xD5 { G_ClientIDHeader header; @@ -6385,6 +6391,8 @@ struct G_WrapItem_BB_6xD6 { // 6xD7: Paganini Photon Drop exchange (BB; handled by server) // The client sends this when it executes an F955 quest opcode. +// The server should create the item in the player's inventory using 6xBE if it +// matches at least one of the item creation masks in the quest's header. struct G_PaganiniPhotonDropExchange_BB_6xD7 { G_ClientIDHeader header; @@ -6409,7 +6417,10 @@ struct G_AddSRankWeaponSpecial_BB_6xD8 { // The client sends this when it executes an F95B quest opcode. The client has // an unfortunate bug where it doesn't set the size field when generating this // command, so the size ends up as an uninitialized value and the client sends -// more (or less!) data than necessary. +// more (or less!) data than necessary. The MomokaItemExchangeFix patch fixes +// this bug. +// The server should create the item in the player's inventory using 6xBE if it +// matches at least one of the item creation masks in the quest's header. struct G_MomokaItemExchange_BB_6xD9 { /* 00 */ G_ClientIDHeader header; @@ -6470,18 +6481,21 @@ struct G_SetEXPMultiplier_BB_6xDD { // 6xDE: Exchange Secret Lottery Ticket (BB; handled by server) // The client sends this when it executes an F95C quest opcode. -// There appears to be a bug in the client here: it sets the subcommand size to -// 2 instead of 3, so the last relevant field (failure_label) is not sent to -// the server. +// There is a bug in the client here: it sets the subcommand size to 2 instead +// of 3, so the last relevant field (failure_label) is not sent to the server. +// This is fixed in the MomokaItemExchangeFix patch. -struct G_ExchangeSecretLotteryTicket_BB_6xDE { +struct G_ExchangeSecretLotteryTicket_Incomplete_BB_6xDE { G_ClientIDHeader header; - uint8_t index = 0; - uint8_t unknown_a1 = 0; + uint8_t index = 0; // 1-8 + uint8_t start_reg_num = 0; le_uint16_t success_label = 0; - // le_uint16_t failure_label = 0; - // parray unused; -} __packed_ws__(G_ExchangeSecretLotteryTicket_BB_6xDE, 8); +} __packed_ws__(G_ExchangeSecretLotteryTicket_Incomplete_BB_6xDE, 8); + +struct G_ExchangeSecretLotteryTicket_BB_6xDE : G_ExchangeSecretLotteryTicket_Incomplete_BB_6xDE { + le_uint16_t failure_label = 0; + parray unused; +} __packed_ws__(G_ExchangeSecretLotteryTicket_BB_6xDE, 0x0C); // 6xDF: Exchange Photon Crystals (BB; handled by server) // The client sends this when it executes an F95D quest opcode. diff --git a/src/Quest.cc b/src/Quest.cc index 538141d8..dba53de2 100644 --- a/src/Quest.cc +++ b/src/Quest.cc @@ -354,6 +354,9 @@ const string& Quest::name_for_language(Language language) const { void Quest::add_version(shared_ptr vq) { this->meta.assert_compatible(vq->meta); + if (this->meta.create_item_mask_entries.empty()) { + this->meta.create_item_mask_entries = vq->meta.create_item_mask_entries; + } this->versions.emplace(this->versions_key(vq->version, vq->language), vq); size_t lang_index = static_cast(vq->language); diff --git a/src/QuestMetadata.cc b/src/QuestMetadata.cc index 3a84be17..beded9ce 100644 --- a/src/QuestMetadata.cc +++ b/src/QuestMetadata.cc @@ -47,6 +47,25 @@ void QuestMetadata::assert_compatible(const QuestMetadata& other) const { if (this->enemy_exp_overrides != other.enemy_exp_overrides) { throw runtime_error("quest version has different enemy EXP overrides"); } + if (!this->create_item_mask_entries.empty() && + !other.create_item_mask_entries.empty() && + this->create_item_mask_entries != other.create_item_mask_entries) { + string this_str, other_str; + for (const auto& item : this->create_item_mask_entries) { + if (!this_str.empty()) { + this_str += ", "; + } + this_str += item.str(); + } + for (const auto& item : other.create_item_mask_entries) { + if (!other_str.empty()) { + other_str += ", "; + } + other_str += item.str(); + } + throw runtime_error(std::format( + "quest version has a different set of create item masks (existing: {}, new: {})", this_str, other_str)); + } if (!this->battle_rules != !other.battle_rules) { throw runtime_error(std::format( "quest version has a different battle rules presence state (existing: {}, new: {})", @@ -151,6 +170,12 @@ phosg::JSON QuestMetadata::json() const { auto key_str = std::format("{}:0x{:02X}:{}", name_for_difficulty(difficulty), floor, phosg::name_for_enum(enemy_type)); enemy_exp_overrides_json.emplace(key_str, exp_override); } + + auto create_item_mask_entries_json = phosg::JSON::dict(); + for (const auto& item : this->create_item_mask_entries) { + create_item_mask_entries_json.emplace_back(item.str()); + } + return phosg::JSON::dict({ {"CategoryID", this->category_id}, {"Number", this->quest_number}, @@ -172,9 +197,69 @@ phosg::JSON QuestMetadata::json() const { {"AllowStartFromChatCommand", this->allow_start_from_chat_command}, {"LockStatusRegister", (this->lock_status_register >= 0) ? this->lock_status_register : phosg::JSON(nullptr)}, {"EnemyEXPOverrides", std::move(enemy_exp_overrides_json)}, + {"CreateItemMasks", std::move(create_item_mask_entries_json)}, }); } +QuestMetadata::CreateItemMask::CreateItemMask(const std::string& s) { + phosg::StringReader r(s); + for (size_t z = 0; z < 12 && !r.eof(); z++) { + auto& range = this->data1_ranges[z]; + char c = r.get_s8(); + if (c == '[') { + c = r.get_s8(); + range.min = (phosg::value_for_hex_char(c) << 4) | phosg::value_for_hex_char(r.get_s8()); + if (r.get_s8() != '-') { + throw std::runtime_error("invalid range spec"); + } + c = r.get_s8(); + range.max = (phosg::value_for_hex_char(c) << 4) | phosg::value_for_hex_char(r.get_s8()); + if (r.get_s8() != ']') { + throw std::runtime_error("invalid range spec"); + } + } else { + range.min = (phosg::value_for_hex_char(c) << 4) | phosg::value_for_hex_char(r.get_s8()); + range.max = range.min; + } + } +} + +std::string QuestMetadata::CreateItemMask::str() const { + std::string ret; + for (size_t z = 0; z < 12; z++) { + const auto& r = this->data1_ranges[z]; + if (r.min == r.max) { + ret += std::format("{:02X}", r.min); + } else { + ret += std::format("[{:02X}-{:02X}]", r.min, r.max); + } + } + return ret; +} + +bool QuestMetadata::CreateItemMask::match(const ItemData& item) const { + for (size_t z = 0; z < 12; z++) { + const auto& r = this->data1_ranges[z]; + uint8_t v = item.data1[z]; + if (v < r.min || v > r.max) { + return false; + } + } + return true; +} + +uint32_t QuestMetadata::CreateItemMask::primary_identifier() const { + uint32_t ret = 0; + for (size_t z = 0; z < 3; z++) { + const auto& r = this->data1_ranges[z]; + if (r.min != r.max) { + throw std::runtime_error("create item mask is ambiguous; cannot compute primary identifier"); + } + ret = (ret << 8) | r.min; + } + return ret << 8; +} + std::unordered_map QuestMetadata::parse_enemy_exp_overrides(const phosg::JSON& json) { try { std::unordered_map ret; diff --git a/src/QuestMetadata.hh b/src/QuestMetadata.hh index b4f44284..3aa2384d 100644 --- a/src/QuestMetadata.hh +++ b/src/QuestMetadata.hh @@ -44,6 +44,33 @@ struct QuestMetadata { int16_t lock_status_register = -1; std::unordered_map enemy_exp_overrides; + // Item create allowances (only used on BB) + struct CreateItemMask { + struct Range { + uint8_t min = 0x00; + uint8_t max = 0x00; + + bool operator==(const Range& other) const = default; + bool operator!=(const Range& other) const = default; + }; + std::array data1_ranges; + + CreateItemMask() = default; + CreateItemMask(const CreateItemMask& other) = default; + CreateItemMask(CreateItemMask&& other) = default; + CreateItemMask& operator=(const CreateItemMask& other) = default; + CreateItemMask& operator=(CreateItemMask&& other) = default; + bool operator==(const CreateItemMask& other) const = default; + bool operator!=(const CreateItemMask& other) const = default; + + explicit CreateItemMask(const std::string& s); // Inverse of str() + std::string str() const; + + bool match(const ItemData& item) const; + uint32_t primary_identifier() const; // Raises if any of data1[0-2] are ambiguous + }; + std::vector create_item_mask_entries; + std::string name; std::string short_description; std::string long_description; diff --git a/src/QuestScript.cc b/src/QuestScript.cc index 9bedca32..e339d76d 100644 --- a/src/QuestScript.cc +++ b/src/QuestScript.cc @@ -969,7 +969,9 @@ static const QuestScriptOpcodeDefinition opcode_defs[] = { // Creates an item in the player's inventory. If the item is successfully // created, this opcode sends 6x2B on all versions except BB. On BB, this - // opcode sends 6xCA, and the server sends 6xBE to create the item. + // opcode sends 6xCA, and the server sends 6xBE to create the item. The + // requested item must match one of the item creation masks in the quest + // script's header. // regsA[0-2] = item.data1[0-2] // regB = returned item ID, or FFFFFFFF if item can't be created {0xB3, "item_create", nullptr, {{R_REG_SET_FIXED, 3}, W_REG}, F_V0_V4}, @@ -2764,6 +2766,9 @@ static const QuestScriptOpcodeDefinition opcode_defs[] = { // 5 = bank // 6 = tekker // 7 = government quest counter + // 8 = Momoka item exchange (this opens a menu displaying the items + // specified in the quest header, which sends 6xD9 when the player + // chooses an item) // valueA is not bounds-checked, so it could be used to write a byte with // the value 1 anywhere in memory. {0xF950, "bb_p2_menu", "BB_p2_menu", {I32}, F_V4 | F_ARGS}, @@ -2781,7 +2786,9 @@ static const QuestScriptOpcodeDefinition opcode_defs[] = { // Returns the number of items in the player's inventory. {0xF952, "bb_get_number_in_pack", "BB_get_number_in_pack", {W_REG}, F_V4}, - // Requests an item exchange in the player's inventory. Sends 6xD5. + // Requests an item exchange in the player's inventory. Sends 6xD5. The + // requested item must match one of the item creation masks in the quest + // script's header. // valueA/valueB/valueC = item.data1[0-2] to search for // valueD/valueE/valueF = item.data1[0-2] to replace it with // labelG = label to call on success @@ -2794,11 +2801,13 @@ static const QuestScriptOpcodeDefinition opcode_defs[] = { // 2 = item not found) {0xF954, "bb_check_wrap", "BB_check_wrap", {I32, W_REG}, F_V4 | F_ARGS}, - // Requests an item exchange for Photon Drops. Sends 6xD7. + // Requests an item exchange for Photon Drops. Sends 6xD7. The requested + // item must match one of the item creation masks in the quest script's + // header. // valueA/valueB/valueC = item.data1[0-2] for requested item // labelD = label to call on success // labelE = label to call on failure - {0xF955, "bb_exchange_pd_item", "BB_exchange_PD_item", {I32, I32, I32, LABEL16, LABEL16}, F_V4 | F_ARGS}, + {0xF955, "bb_exchange_pd_item", "BB_exchange_PD_item", {I32, I32, I32, SCRIPT16, SCRIPT16}, F_V4 | F_ARGS}, // Requests an S-rank special upgrade in exchange for Photon Drops. Sends // 6xD8. @@ -2807,7 +2816,7 @@ static const QuestScriptOpcodeDefinition opcode_defs[] = { // valueE = special type // labelF = label to call on success // labelG = label to call on failure - {0xF956, "bb_exchange_pd_srank", "BB_exchange_PD_srank", {I32, I32, I32, I32, I32, LABEL16, LABEL16}, F_V4 | F_ARGS}, + {0xF956, "bb_exchange_pd_srank", "BB_exchange_PD_srank", {I32, I32, I32, I32, I32, SCRIPT16, SCRIPT16}, F_V4 | F_ARGS}, // Requests a weapon attribute upgrade in exchange for Photon Drops. Sends // 6xDA. @@ -2817,12 +2826,12 @@ static const QuestScriptOpcodeDefinition opcode_defs[] = { // valueF = payment count (number of PDs) // labelG = label to call on success // labelH = label to call on failure - {0xF957, "bb_exchange_pd_percent", "BB_exchange_PD_special", {I32, I32, I32, I32, I32, I32, LABEL16, LABEL16}, F_V4 | F_ARGS}, + {0xF957, "bb_exchange_pd_percent", "BB_exchange_PD_special", {I32, I32, I32, I32, I32, I32, SCRIPT16, SCRIPT16}, F_V4 | F_ARGS}, // Requests a weapon attribute upgrade in exchange for Photon Spheres. // Sends 6xDA. Same arguments as bb_exchange_pd_percent, except Photon // Spheres are used instead. - {0xF958, "bb_exchange_ps_percent", "BB_exchange_PS_percent", {I32, I32, I32, I32, I32, I32, LABEL16, LABEL16}, F_V4 | F_ARGS}, + {0xF958, "bb_exchange_ps_percent", "BB_exchange_PS_percent", {I32, I32, I32, I32, I32, I32, SCRIPT16, SCRIPT16}, F_V4 | F_ARGS}, // Determines whether the Episode 4 boss can escape if undefeated after 20 // minutes. @@ -2833,24 +2842,28 @@ static const QuestScriptOpcodeDefinition opcode_defs[] = { // (even if the boss has already been defeated). {0xF95A, "bb_is_ep4_boss_dying", nullptr, {W_REG}, F_V4}, - // Requests an item exchange. Sends 6xD9. + // Requests an item exchange. Sends 6xD9. The requested item must match one + // of the item creation masks in the quest script's header. // valueA = find_item.data1[0-2] (low 3 bytes; high byte unused) // valueB = replace_item.data1[0-2] (low 3 bytes; high byte unused) // valueC = token1 (see 6xD9 in CommandFormats.hh) // valueD = token2 (see 6xD9 in CommandFormats.hh) // labelE = label to call on success // labelF = label to call on failure - {0xF95B, "bb_send_6xD9", nullptr, {I32, I32, I32, I32, LABEL16, LABEL16}, F_V4 | F_ARGS}, + {0xF95B, "bb_replace_item", "bb_send_6xD9", {I32, I32, I32, I32, SCRIPT16, SCRIPT16}, F_V4 | F_ARGS}, // Requests an exchange of Secret Lottery Tickets for items. Sends 6xDE. - // See SecretLotteryResultItems in config.json for the item pool used by - // this opcode. + // The pool of items that can be returned by this opcode is determined by + // the quest's header; newserv assembles/disassembles this field as + // .exchange_item directives. The first entry in the header is the currency + // item (which is to be deleted from the inventory); the others are the + // items the server randomly chooses from. // valueA = index // valueB = unknown_a1 // labelC = label to call on success // labelD = label to call on failure (unused because of a client bug; see // 6xDE description in CommandFormats.hh for details) - {0xF95C, "bb_exchange_slt", "BB_exchange_SLT", {I32, I32, LABEL32, LABEL32}, F_V4 | F_ARGS}, + {0xF95C, "bb_exchange_slt", "BB_exchange_SLT", {I32, I32, SCRIPT16, SCRIPT16}, F_V4 | F_ARGS}, // Removes a single Photon Crystal from the player's inventory, and // disables drops for the rest of the quest. Sends 6xDF. @@ -2959,6 +2972,52 @@ void check_opcode_definitions() { } } +CreateItemMaskEntry::CreateItemMaskEntry(const QuestMetadata::CreateItemMask& mask) { + for (size_t z = 0; z < 12; z++) { + auto& r = mask.data1_ranges[z]; + if (r.min == r.max) { + this->data1_fields[z] = r.min; + } else if (r.min == 0x00 && r.max == 0xFF) { + this->data1_fields[z] = -1; + } else { + this->data1_fields[z] = (r.min * 1000000) + (r.max * 1000); + } + } +} + +CreateItemMaskEntry::operator QuestMetadata::CreateItemMask() const { + using Range = QuestMetadata::CreateItemMask::Range; + + QuestMetadata::CreateItemMask ret; + for (size_t z = 0; z < 12; z++) { + int32_t v = this->data1_fields[z]; + + if (v < 0) { + // If v is negative, any value is allowed in this field + ret.data1_ranges[z] = Range{.min = 0x00, .max = 0xFF}; + + } else if (v < 0x100) { + // If v fits in an unsigned byte, this field must match exactly + ret.data1_ranges[z] = Range{.min = static_cast(v), .max = static_cast(v)}; + + } else if (v >= 1000000 && v <= 2000000) { + // Otherwise, the allowed range of values is encoded in decimal as + // 1MMMmmm (m = min, M = max) + uint32_t min = v % 1000; + uint32_t max = (v / 1000) % 1000; + if (min > 0xFF || max > 0xFF | min > max) { + throw std::runtime_error(std::format("invalid range spec {} (0x{:X})", v, v)); + } + ret.data1_ranges[z] = Range{.min = static_cast(min), .max = static_cast(max)}; + + } else { + throw std::runtime_error(std::format("invalid range spec {} (0x{:X})", v, v)); + } + } + + return ret; +} + std::string disassemble_quest_script( const void* data, size_t size, @@ -3047,7 +3106,7 @@ std::string disassemble_quest_script( } case Version::BB_V4: { use_wstrs = true; - const auto& header = r.get(); + const auto& header = r.get(); code_offset = header.code_offset; function_table_offset = header.function_table_offset; if (override_language != Language::UNKNOWN) { @@ -3061,9 +3120,25 @@ std::string disassemble_quest_script( if (header.joinable) { lines.emplace_back(".joinable"); } - lines.emplace_back(".name " + escape_string(header.name.decode(language))); - lines.emplace_back(".short_desc " + escape_string(header.short_description.decode(language))); - lines.emplace_back(".long_desc " + escape_string(header.long_description.decode(language))); + lines.emplace_back(std::format(".name {}", escape_string(header.name.decode(language)))); + lines.emplace_back(std::format(".short_desc {}", escape_string(header.short_description.decode(language)))); + lines.emplace_back(std::format(".long_desc {}", escape_string(header.long_description.decode(language)))); + // Quests saved with Qedit may not have the full header, so only parse + // the full header if the code and function table offsets don't point to + // space within it + if ((header.code_offset >= sizeof(PSOQuestHeaderBB)) && + (header.function_table_offset >= sizeof(PSOQuestHeaderBB))) { + r.go(0); + const auto& header = r.get(); + for (size_t z = 0; z < header.create_item_mask_entries.size(); z++) { + const auto& qh_mask = header.create_item_mask_entries[z]; + if (!qh_mask.is_valid()) { + break; + } + QuestMetadata::CreateItemMask qm_mask = qh_mask; + lines.emplace_back(std::format(".allow_create_item {}", qm_mask.str())); + } + } break; } default: @@ -4010,6 +4085,7 @@ AssembledQuestScript assemble_quest_script( Episode quest_episode = Episode::EP1; uint8_t quest_max_players = 4; bool quest_joinable = false; + std::vector create_item_mask_entries; for (const auto& line : lines) { if (line.text.empty()) { continue; @@ -4028,6 +4104,17 @@ AssembledQuestScript assemble_quest_script( quest_short_desc = phosg::parse_data_string(line.text.substr(12)); } else if (line.text.starts_with(".long_desc ")) { quest_long_desc = phosg::parse_data_string(line.text.substr(11)); + } else if (line.text.starts_with(".allow_create_item ")) { + if (create_item_mask_entries.size() >= 0x40) { + throw std::runtime_error("too many .allow_create_item directives; at most 64 are allowed"); + } + + string args_str = line.text.substr(19); + phosg::strip_whitespace(args_str); + + QuestMetadata::CreateItemMask mask(line.text.substr(19)); + create_item_mask_entries.emplace_back(mask); + } else if (line.text.starts_with(".quest_num ")) { quest_num = stoul(line.text.substr(11), nullptr, 0); } else if (line.text.starts_with(".language ")) { @@ -4646,6 +4733,10 @@ AssembledQuestScript assemble_quest_script( header.name.encode(quest_name, quest_language); header.short_description.encode(quest_short_desc, quest_language); header.long_description.encode(quest_long_desc, quest_language); + header.unknown_a5.clear(0xFF); + for (size_t z = 0; z < create_item_mask_entries.size(); z++) { + header.create_item_mask_entries[z] = create_item_mask_entries[z]; + } w.put(header); break; } @@ -4753,7 +4844,7 @@ void populate_quest_metadata_from_script( break; } case Version::BB_V4: { - const auto& header = r.get(); + const auto& header = r.get(); meta.episode = episode_for_quest_episode_number(header.episode); meta.joinable |= header.joinable; meta.max_players = 4; @@ -4763,6 +4854,21 @@ void populate_quest_metadata_from_script( meta.name = header.name.decode(language); meta.short_description = header.short_description.decode(language); meta.long_description = header.long_description.decode(language); + // Quests saved with Qedit may not have the full header, so only parse + // the full header if the code and function table offsets don't point to + // space within it + if ((header.code_offset >= sizeof(PSOQuestHeaderBB)) && + (header.function_table_offset >= sizeof(PSOQuestHeaderBB))) { + r.go(0); + const auto& header = r.get(); + for (size_t z = 0; z < header.create_item_mask_entries.size(); z++) { + const auto& item = header.create_item_mask_entries[z]; + if (!item.is_valid()) { + break; + } + meta.create_item_mask_entries.emplace_back(item); + } + } code_offset = header.code_offset; function_table_offset = header.function_table_offset; break; diff --git a/src/QuestScript.hh b/src/QuestScript.hh index 54fb22d4..a6611a19 100644 --- a/src/QuestScript.hh +++ b/src/QuestScript.hh @@ -83,7 +83,21 @@ struct PSOQuestHeaderGC { /* 01D4 */ } __packed_ws__(PSOQuestHeaderGC, 0x1D4); -struct PSOQuestHeaderBB { +struct CreateItemMaskEntry { + parray data1_fields; + le_uint32_t present = 0; + le_uint32_t unknown_a3 = 0; + + bool is_valid() const { + return (this->data1_fields[0] || this->data1_fields[1] || this->data1_fields[2]); + } + + CreateItemMaskEntry() = default; + CreateItemMaskEntry(const QuestMetadata::CreateItemMask& mask); + operator QuestMetadata::CreateItemMask() const; +} __packed_ws__(CreateItemMaskEntry, 0x38); + +struct PSOQuestHeaderBBBase { /* 0000 */ le_uint32_t code_offset = 0; /* 0004 */ le_uint32_t function_table_offset = 0; /* 0008 */ le_uint32_t size = 0; @@ -99,7 +113,13 @@ struct PSOQuestHeaderBB { /* 0058 */ pstring short_description; /* 0158 */ pstring long_description; /* 0398 */ -} __packed_ws__(PSOQuestHeaderBB, 0x398); +} __packed_ws__(PSOQuestHeaderBBBase, 0x0398); + +struct PSOQuestHeaderBB : PSOQuestHeaderBBBase { + /* 0398 */ parray unknown_a5; + /* 042C */ parray create_item_mask_entries; + /* 122C */ +} __packed_ws__(PSOQuestHeaderBB, 0x122C); void check_opcode_definitions(); diff --git a/src/ReceiveSubcommands.cc b/src/ReceiveSubcommands.cc index 037bb26b..c3c17022 100644 --- a/src/ReceiveSubcommands.cc +++ b/src/ReceiveSubcommands.cc @@ -4182,18 +4182,43 @@ static asio::awaitable on_adjust_player_meseta_bb(shared_ptr c, Su co_return; } -static asio::awaitable on_item_reward_request_bb(shared_ptr c, SubcommandMessage& msg) { - const auto& cmd = msg.check_size_t(); +static void assert_quest_item_create_allowed(shared_ptr l, const ItemData& item) { + // We always enforce these restrictions, even if the client has cheat mode + // enabled or has debug enabled. If the client can cheat, there are much + // easier ways to create items (e.g. the $item chat command) than spoofing + // these quest item creation commands, so they should just do that instead. + + if (!l->quest) { + throw std::runtime_error("cannot create quest reward item with no quest loaded"); + } + for (const auto& mask : l->quest->meta.create_item_mask_entries) { + if (mask.match(item)) { + return; + } + } + l->log.warning_f("Player attempted to create quest item {}, but it does not match any create item mask", item.hex()); + l->log.info_f("Quest has {} create item masks:", l->quest->meta.create_item_mask_entries.size()); + for (const auto& mask : l->quest->meta.create_item_mask_entries) { + l->log.info_f(" {}", mask.str()); + } + throw std::runtime_error("invalid item creation from quest"); +} + +static asio::awaitable on_quest_create_item_bb(shared_ptr c, SubcommandMessage& msg) { + const auto& cmd = msg.check_size_t(); auto s = c->require_server_state(); auto l = c->require_lobby(); const auto& limits = *s->item_stack_limits(c->version()); ItemData item; item = cmd.item_data; + // enforce_stack_size_limits must come after this assert since quests may + // attempt to create stackable items with a count of zero + assert_quest_item_create_allowed(l, item); item.enforce_stack_size_limits(limits); item.id = l->generate_item_id(c->lobby_client_id); - // The logic for the item_create and item_create2 opcodes (B3 and B4) + // The logic for the item_create and item_create2 quest opcodes (B3 and B4) // includes a precondition check to see if the player can actually add the // item to their inventory or not, and the entire command is skipped if not. // However, on BB, the implementation performs this check and sends a 6xCA @@ -4869,21 +4894,21 @@ static asio::awaitable on_quest_exchange_item_bb(shared_ptr c, Sub throw runtime_error("6xD5 command sent during free play"); } - const auto& cmd = msg.check_size_t(); + const auto& cmd = msg.check_size_t(); auto s = c->require_server_state(); try { auto p = c->character_file(); const auto& limits = *s->item_stack_limits(c->version()); + ItemData new_item = cmd.replace_item; + assert_quest_item_create_allowed(l, new_item); + new_item.enforce_stack_size_limits(limits); + size_t found_index = p->inventory.find_item_by_primary_identifier(cmd.find_item.primary_identifier()); auto found_item = p->remove_item(p->inventory.items[found_index].data.id, 1, limits); send_destroy_item_to_lobby(c, found_item.id, 1); - // TODO: We probably should use an allow-list here to prevent the client - // from creating arbitrary items if cheat mode is disabled. - ItemData new_item = cmd.replace_item; - new_item.enforce_stack_size_limits(limits); new_item.id = l->generate_item_id(c->lobby_client_id); p->add_item(new_item, limits); send_create_inventory_item_to_lobby(c, c->lobby_client_id, new_item); @@ -4934,14 +4959,14 @@ static asio::awaitable on_photon_drop_exchange_for_item_bb(shared_ptrcharacter_file(); const auto& limits = *s->item_stack_limits(c->version()); + ItemData new_item = cmd.new_item; + assert_quest_item_create_allowed(l, new_item); + new_item.enforce_stack_size_limits(limits); + size_t found_index = p->inventory.find_item_by_primary_identifier(0x03100000); auto found_item = p->remove_item(p->inventory.items[found_index].data.id, 0, limits); send_destroy_item_to_lobby(c, found_item.id, found_item.stack_size(limits)); - // TODO: We probably should use an allow-list here to prevent the client - // from creating arbitrary items if cheat mode is disabled. - ItemData new_item = cmd.new_item; - new_item.enforce_stack_size_limits(limits); new_item.id = l->generate_item_id(c->lobby_client_id); p->add_item(new_item, limits); send_create_inventory_item_to_lobby(c, c->lobby_client_id, new_item); @@ -4975,9 +5000,12 @@ static asio::awaitable on_photon_drop_exchange_for_s_rank_special_bb(share uint8_t cost = costs.at(cmd.special_type); size_t payment_item_index = p->inventory.find_item_by_primary_identifier(0x03100000); - // Ensure weapon exists before removing PDs, so inventory state will be - // consistent in case of error - p->inventory.find_item(cmd.item_id); + { + const auto& item = p->inventory.items[p->inventory.find_item(cmd.item_id)]; + if (!item.data.is_s_rank_weapon()) { + throw std::runtime_error("6xD8 cannot be used for non-ES weapons"); + } + } auto payment_item = p->remove_item(p->inventory.items[payment_item_index].data.id, cost, limits); send_destroy_item_to_lobby(c, payment_item.id, cost); @@ -5008,24 +5036,61 @@ static asio::awaitable on_secret_lottery_ticket_exchange_bb(shared_ptrcheck_flag(Lobby::Flag::QUEST_IN_PROGRESS) && !l->check_flag(Lobby::Flag::JOINABLE_QUEST_IN_PROGRESS)) { throw runtime_error("6xDE command sent during free play"); } - - auto s = c->require_server_state(); - const auto& cmd = msg.check_size_t(); - - if (s->secret_lottery_results.empty()) { - throw runtime_error("no secret lottery results are defined"); + if (!l->quest) { + throw runtime_error("6xDE command sent with no quest loaded"); + } + if (l->quest->meta.create_item_mask_entries.size() < 2) { + throw runtime_error("quest does not have enough create item mask entries"); } + // See notes about 6xDE in CommandFormats.hh about this weirdness + const auto& cmd = msg.check_size_t(0x0C); + uint16_t failure_label; + if (msg.size >= 0x0C) { + const auto& cmd = msg.check_size_t(); + failure_label = cmd.failure_label; + } else { + failure_label = cmd.success_label; + } + + // The last mask entry is the currency item (e.g. Secret Lottery Ticket) + const auto& currency_mask = l->quest->meta.create_item_mask_entries.back(); + uint32_t currency_primary_identifier = currency_mask.primary_identifier(); auto p = c->character_file(); - ssize_t slt_index = -1; + ssize_t currency_index = -1; try { - slt_index = p->inventory.find_item_by_primary_identifier(0x03100300); // Secret Lottery Ticket + currency_index = p->inventory.find_item_by_primary_identifier(currency_primary_identifier); + c->log.info_f("Currency item {:08X} found at index {}", currency_primary_identifier, currency_index); } catch (const out_of_range&) { + c->log.info_f("Currency item {:08X} not found in inventory", currency_primary_identifier); } - if (slt_index >= 0) { + S_ExchangeSecretLotteryTicketResult_BB_24 out_cmd; + out_cmd.start_reg_num = cmd.start_reg_num; + out_cmd.label = (currency_index >= 0) ? cmd.success_label.load() : failure_label; + for (size_t z = 0; z < out_cmd.reg_values.size(); z++) { + out_cmd.reg_values[z] = (l->rand_crypt->next() % (l->quest->meta.create_item_mask_entries.size() - 1)) + 1; + c->log.info_f("Mask index {} is {} ({})", z, out_cmd.reg_values[z] - 1, l->quest->meta.create_item_mask_entries[out_cmd.reg_values[z] - 1].str()); + } + + if (currency_index >= 0) { + size_t mask_index = out_cmd.reg_values[cmd.index - 1] - 1; + const auto& mask = l->quest->meta.create_item_mask_entries[mask_index]; + c->log.info_f("Chose mask {} ({})", mask_index, mask.str()); + + ItemData item; + for (size_t z = 0; z < 12; z++) { + const auto& r = mask.data1_ranges[z]; + if (r.min != r.max) { + throw std::runtime_error("invalid range for bb_exchange_slt"); + } + item.data1[z] = r.min; + } + auto s = c->require_server_state(); const auto& limits = *s->item_stack_limits(c->version()); - uint32_t slt_item_id = p->inventory.items[slt_index].data.id; + item.enforce_stack_size_limits(limits); + + uint32_t slt_item_id = p->inventory.items[currency_index].data.id; G_ExchangeItemInQuest_BB_6xDB exchange_cmd; exchange_cmd.header.subcommand = 0xDB; @@ -5038,28 +5103,12 @@ static asio::awaitable on_secret_lottery_ticket_exchange_bb(shared_ptrremove_item(slt_item_id, 1, limits); - ItemData item = (s->secret_lottery_results.size() == 1) - ? s->secret_lottery_results[0] - : s->secret_lottery_results[l->rand_crypt->next() % s->secret_lottery_results.size()]; - item.enforce_stack_size_limits(limits); item.id = l->generate_item_id(c->lobby_client_id); p->add_item(item, limits); send_create_inventory_item_to_lobby(c, c->lobby_client_id, item); } - S_ExchangeSecretLotteryTicketResult_BB_24 out_cmd; - out_cmd.start_reg_num = cmd.index; - out_cmd.label = cmd.success_label; - if (s->secret_lottery_results.empty()) { - out_cmd.reg_values.clear(0); - } else if (s->secret_lottery_results.size() == 1) { - out_cmd.reg_values.clear(1); - } else { - for (size_t z = 0; z < out_cmd.reg_values.size(); z++) { - out_cmd.reg_values[z] = l->rand_crypt->next() % s->secret_lottery_results.size(); - } - } - send_command_t(c, 0x24, (slt_index >= 0) ? 0 : 1, out_cmd); + send_command_t(c, 0x24, (currency_index >= 0) ? 0 : 1, out_cmd); co_return; } @@ -5284,6 +5333,11 @@ static asio::awaitable on_momoka_item_exchange_bb(shared_ptr c, Su auto p = c->character_file(); try { const auto& limits = *s->item_stack_limits(c->version()); + + ItemData new_item = cmd.replace_item; + assert_quest_item_create_allowed(l, new_item); + new_item.enforce_stack_size_limits(limits); + size_t found_index = p->inventory.find_item_by_primary_identifier(cmd.find_item.primary_identifier()); auto found_item = p->remove_item(p->inventory.items[found_index].data.id, 1, limits); @@ -5292,10 +5346,6 @@ static asio::awaitable on_momoka_item_exchange_bb(shared_ptr c, Su send_destroy_item_to_lobby(c, found_item.id, 1); - // TODO: We probably should use an allow-list here to prevent the client - // from creating arbitrary items if cheat mode is disabled. - ItemData new_item = cmd.replace_item; - new_item.enforce_stack_size_limits(limits); new_item.id = l->generate_item_id(c->lobby_client_id); p->add_item(new_item, limits); send_create_inventory_item_to_lobby(c, c->lobby_client_id, new_item); @@ -5326,6 +5376,9 @@ static asio::awaitable on_upgrade_weapon_attribute_bb(shared_ptr c try { size_t item_index = p->inventory.find_item(cmd.item_id); auto& item = p->inventory.items[item_index].data; + if (item.is_s_rank_weapon()) { + throw std::runtime_error("6xDA command sent for ES weapon"); + } uint32_t payment_primary_identifier = cmd.payment_type ? 0x03100100 : 0x03100000; size_t payment_index = p->inventory.find_item_by_primary_identifier(payment_primary_identifier); @@ -5348,7 +5401,7 @@ static asio::awaitable on_upgrade_weapon_attribute_bb(shared_ptr c } size_t attribute_index = 0; - for (size_t z = 6; z <= 10; z += 2) { + for (size_t z = 6; z <= (item.has_kill_count() ? 10 : 8); z += 2) { if ((item.data1[z] == 0) || (!(item.data1[z] & 0x80) && (item.data1[z] == cmd.attribute))) { attribute_index = z; break; @@ -5588,7 +5641,7 @@ const vector subcommand_definitions{ /* 6xC7 */ {NONE, NONE, 0xC7, on_charge_attack_bb}, /* 6xC8 */ {NONE, NONE, 0xC8, on_enemy_exp_request_bb}, /* 6xC9 */ {NONE, NONE, 0xC9, on_adjust_player_meseta_bb}, - /* 6xCA */ {NONE, NONE, 0xCA, on_item_reward_request_bb}, + /* 6xCA */ {NONE, NONE, 0xCA, on_quest_create_item_bb}, /* 6xCB */ {NONE, NONE, 0xCB, on_transfer_item_via_mail_message_bb}, /* 6xCC */ {NONE, NONE, 0xCC, on_exchange_item_for_team_points_bb}, /* 6xCD */ {NONE, NONE, 0xCD, forward_subcommand_m}, @@ -5682,5 +5735,3 @@ asio::awaitable on_subcommand_multi(shared_ptr c, Channel::Message offset += cmd_size; } } - -// NOCOMMIT: Make BB item creation opcodes use the quests' create masks diff --git a/src/ServerState.cc b/src/ServerState.cc index 05c1ff0e..11a3c253 100644 --- a/src/ServerState.cc +++ b/src/ServerState.cc @@ -1432,7 +1432,6 @@ void ServerState::load_config_late() { this->quest_F95F_results.clear(); this->quest_F960_success_results.clear(); this->quest_F960_failure_results = QuestF960Result(); - this->secret_lottery_results.clear(); if (this->item_name_index(Version::BB_V4)) { try { for (const auto& type_it : this->config_json->get_list("QuestF95EResultItems")) { @@ -1469,16 +1468,6 @@ void ServerState::load_config_late() { } } catch (const out_of_range&) { } - try { - for (const auto& it : this->config_json->get_list("SecretLotteryResultItems")) { - try { - this->secret_lottery_results.emplace_back(this->parse_item_description(Version::BB_V4, it->as_string())); - } catch (const exception& e) { - config_log.warning_f("Cannot parse item description \"{}\": {} (skipping entry)", it->as_string(), e.what()); - } - } - } catch (const out_of_range&) { - } auto parse_primary_identifier_list = [&](const char* key, Version v) -> unordered_set { unordered_set ret; diff --git a/src/ServerState.hh b/src/ServerState.hh index f6f6546b..d7599d9f 100644 --- a/src/ServerState.hh +++ b/src/ServerState.hh @@ -239,7 +239,6 @@ struct ServerState : public std::enable_shared_from_this { std::vector> quest_F95F_results; // [(num_photon_tickets, item)] std::vector quest_F960_success_results; QuestF960Result quest_F960_failure_results; - std::vector secret_lottery_results; float bb_global_exp_multiplier = 1.0f; float exp_share_multiplier = 0.5f; float server_global_drop_rate_multiplier = 1.0f; diff --git a/system/client-functions/BlueBurstExclusive/MomokaItemExchangeFix.59NL.patch.s b/system/client-functions/BlueBurstExclusive/MomokaItemExchangeFix.59NL.patch.s new file mode 100644 index 00000000..e0cfae90 --- /dev/null +++ b/system/client-functions/BlueBurstExclusive/MomokaItemExchangeFix.59NL.patch.s @@ -0,0 +1,115 @@ +.meta name="Item exch. fix" +.meta description="Fixes Momoka item exchange\nopcode" + +entry_ptr: +reloc0: + .offsetof start +start: + .include WriteCodeBlocksBB + + + + # Fix 6xDE failure label truncation + .data 0x006B90DE + .data 1 + .binary 03 + + + + # Fix send_6xD9 not setting size field + + .data 0x006CA540 + .deltaof send_6xD9_start, send_6xD9_end + .address 0x006CA540 +send_6xD9_start: # [std](void* this @ ecx) -> void + push ebx + mov ebx, ecx + push 0 # cmd.success_label, cmd.failure_label + mov eax, [0x00A9C4F4] # local_client_id + xor eax, 1 + push eax # cmd.token2 + mov ecx, [ebx + 0x2C] + call 0x00737D90 # [std](void* this @ ecx = *(this + 0x2C)) -> void* @ eax + mov edx, [ebx + 0x3C] + imul eax, eax, 0x14 + add edx, eax + mov eax, [edx + 0x10] + xor eax, [0x00A9C4F4] # local_client_id + push eax # cmd.token1 + push dword [edx + 0x10] # cmd.replace_item.data2d + push dword [edx + 0x0C] # cmd.replace_item.id + push dword [edx + 0x08] # cmd.replace_item.data1[8-11] + push dword [edx + 0x04] # cmd.replace_item.data1[4-7] + push dword [edx] # cmd.replace_item.data1[0-3] + push dword [ebx + 0x50] # cmd.find_item.data2d + push dword [ebx + 0x4C] # cmd.find_item.id + push dword [ebx + 0x48] # cmd.find_item.data1[8-11] + push dword [ebx + 0x44] # cmd.find_item.data1[4-7] + push dword [ebx + 0x40] # cmd.find_item.data1[0-3] + push 0x00000ED9 # cmd.header + + mov ecx, esp + call 0x008003E0 # send_and_handle_60[std](void* cmd @ ecx) -> void + add esp, 0x38 + + mov dword [ebx], 6 + push 0 + call 0x00859D2D # time[std](void* t @ [esp + 4] = nullptr) -> uint32_t @ eax + add esp, 4 + mov [ebx + 0x5C], eax + + pop ebx + ret +send_6xD9_end: + + + + # Same fix as above, but for quest_F95B_send_6xD9 + + .data 0x006B9018 + .deltaof quest_F95B_send_6xD9_start, quest_F95B_send_6xD9_end + .address 0x006B9018 +quest_F95B_send_6xD9_start: # [std]() -> void + mov edx, 0x00A954CC # quest_args_list + mov ax, [edx + 0x14] # quest_args_list[5] (failure_label) + shl eax, 0x10 + mov ax, [edx + 0x10] # quest_args_list[4] (success_label) + push eax # cmd.success_label, cmd.failure_label + mov ecx, [0x00A9C4F4] # local_client_id + mov eax, [edx + 0x0C] # quest_args_list[3] (token2) + xor eax, ecx + push eax # cmd.token2 + mov eax, [edx + 0x08] # quest_args_list[2] (token1) + xor eax, ecx + push eax # cmd.token1 + push 0x00000000 # cmd.replace_item.data2d + push 0xFFFFFFFF # cmd.replace_item.id + push 0x00000000 # cmd.replace_item.data1[8-11] + push 0x00000000 # cmd.replace_item.data1[4-7] + mov eax, [edx + 0x04] # quest_args_list[1] (data1[0-2] in low 3 bytes) + shl eax, 8 + bswap eax + push eax # cmd.replace_item.data1[0-3] + push 0x00000000 # cmd.find_item.data2d + push 0xFFFFFFFF # cmd.find_item.id + push 0x00000000 # cmd.find_item.data1[8-11] + push 0x00000000 # cmd.find_item.data1[4-7] + mov eax, [edx] # quest_args_list[0] (data1[0-2] in low 3 bytes) + shl eax, 8 + bswap eax + push eax # cmd.find_item.data1[0-3] + mov eax, 0xD90E0000 + mov ax, cx + bswap eax + push eax # cmd.header + + mov ecx, esp + call 0x008003E0 # send_and_handle_60[std](void* cmd @ ecx) -> void + add esp, 0x38 + ret +quest_F95B_send_6xD9_end: + + + + .data 0x00000000 + .data 0x00000000 diff --git a/system/config.example.json b/system/config.example.json index 03a1cf1c..126ff726 100644 --- a/system/config.example.json +++ b/system/config.example.json @@ -747,16 +747,6 @@ [15, "000A07"], [20, "010157"], ], - // Result item definitions for Secret Lottery Ticket exchange (quest opcode - // F95C, used in the Good Luck quest). - "SecretLotteryResultItems": [ - "000106", "000107", "000206", "000407", "000606", "000807", "000D01", - "001300", "002000", "002700", "002C00", "003400", "003900", "003C00", - "003E00", "004100", "004400", "004500", "004C00", "006A00", "008F07", - "009A00", "01011B", "01011C", "010129", "010129", "010130", "010131", - "010132", "010133", "010221", "010224", "010229", "01022B", "010235", - "031000", - ], // Result items for Coren (quest opcodes F960/F961). Indexed by prize_tier. // When a prize is requested, the server chooses a random number and checks it diff --git a/system/quests/events/q201-bb-j.bin b/system/quests/events/q201-bb-j.bin index 46973afb..f0c5303c 100644 Binary files a/system/quests/events/q201-bb-j.bin and b/system/quests/events/q201-bb-j.bin differ diff --git a/system/quests/retrieval/q058-gc-e.bin.txt b/system/quests/retrieval/q058-gc-e.bin.txt index 200ccd98..80fcfcfc 100644 --- a/system/quests/retrieval/q058-gc-e.bin.txt +++ b/system/quests/retrieval/q058-gc-e.bin.txt @@ -59,6 +59,18 @@ // capability. // .joinable +// On BB, quests that create items via the script must specify which items are +// allowed to be created. To do so, use this directive one or more times, which +// instructs the server to allow creation of that item. These masks can specify +// each byte of the item's data1 as ranges, to allow for parameters in the item +// data. For example, this directive allows the quest to create Trifluids +// (030102) with stack sizes of 1-10: +// .allow_create_item 0301020000[01-0A]000000000000 +// Another example: this directive allows the quest to create any weapon in the +// basic rifle series, not including the rares in that series, with a grind +// value of up to 5 and up to two bonuses between 0 and 30% each: +// .allow_create_item 0007[00-04][00-05]0000[00-05][00-1E][00-05][00-1E]0000 + // The quest script begins after the header directives. A quest script is a // sequence of opcodes, and labels denoting positions within that sequence that // can be jumped to or called like a function. All labels have names, and some diff --git a/system/quests/solo-extra/q035-bb-j.bin b/system/quests/solo-extra/q035-bb-j.bin index a6c434d2..7ee0042e 100644 Binary files a/system/quests/solo-extra/q035-bb-j.bin and b/system/quests/solo-extra/q035-bb-j.bin differ diff --git a/system/quests/solo-extra/q143-bb-j.bin b/system/quests/solo-extra/q143-bb-j.bin index 9e90da67..d4bc89a4 100644 Binary files a/system/quests/solo-extra/q143-bb-j.bin and b/system/quests/solo-extra/q143-bb-j.bin differ diff --git a/tests/config.json b/tests/config.json index c5174f4b..b3942dd2 100644 --- a/tests/config.json +++ b/tests/config.json @@ -354,14 +354,6 @@ "Friday": ["Monomate x1", "Dimate x1", "Trimate x1", "Monofluid x1", "Difluid x1", "Trifluid x1", "Sol Atomizer x1", "Moon Atomizer x1", "Antidote x1", "Antiparalysis x1", "Telepipe x1", "Trap Vision x1"], "Saturday": ["Monomate x1", "Dimate x1", "Trimate x1", "Monofluid x1", "Difluid x1", "Trifluid x1", "Sol Atomizer x1", "Moon Atomizer x1", "Antidote x1", "Antiparalysis x1", "Telepipe x1", "Trap Vision x1"], }, - "SecretLotteryResultItems": [ - "000106", "000107", "000206", "000407", "000606", "000807", "000D01", - "001300", "002000", "002700", "002C00", "003400", "003900", "003C00", - "003E00", "004100", "004400", "004500", "004C00", "006A00", "008F07", - "009A00", "01011B", "01011C", "010129", "010129", "010130", "010131", - "010132", "010133", "010221", "010224", "010229", "01022B", "010235", - "031000", - ], "BBGlobalEXPMultiplier": 1, "BBEXPShareMultiplier": 0.5, "ServerGlobalDropRateMultiplier": 1.0,