From e02a006b6077693b1fe5808a7f2d44d4744f6712 Mon Sep 17 00:00:00 2001 From: Martin Michelsen Date: Fri, 28 Nov 2025 12:40:14 -0800 Subject: [PATCH] add support for cross-episode quests --- src/ItemCreator.cc | 517 +++++++++++++++++++------------------- src/ItemCreator.hh | 90 +++---- src/Lobby.cc | 3 +- src/ProxySession.cc | 1 - src/Quest.cc | 50 ++-- src/QuestMetadata.cc | 41 ++- src/QuestMetadata.hh | 20 +- src/QuestScript.cc | 102 +++++--- src/QuestScript.hh | 16 +- src/ReceiveSubcommands.cc | 58 ++--- src/StaticGameData.cc | 12 + src/StaticGameData.hh | 1 + 12 files changed, 504 insertions(+), 407 deletions(-) diff --git a/src/ItemCreator.cc b/src/ItemCreator.cc index 90f743cb..8ac70e95 100644 --- a/src/ItemCreator.cc +++ b/src/ItemCreator.cc @@ -30,16 +30,14 @@ ItemCreator::ItemCreator( shared_ptr tekker_adjustment_set, shared_ptr item_parameter_table, std::shared_ptr stack_limits, - Episode episode, GameMode mode, Difficulty difficulty, uint8_t section_id, std::shared_ptr rand_crypt, shared_ptr restrictions) - : log(std::format("[ItemCreator:{}/{}/{}/{}/{}] ", phosg::name_for_enum(stack_limits->version), abbreviation_for_episode(episode), abbreviation_for_mode(mode), abbreviation_for_difficulty(difficulty), section_id), lobby_log.min_level), + : log(std::format("[ItemCreator:{}/{}/{}/{}] ", phosg::name_for_enum(stack_limits->version), abbreviation_for_mode(mode), abbreviation_for_difficulty(difficulty), section_id), lobby_log.min_level), logic_version(stack_limits->version), stack_limits(stack_limits), - episode(episode), mode(mode), difficulty(difficulty), section_id(section_id), @@ -50,7 +48,6 @@ ItemCreator::ItemCreator( tekker_adjustment_set(tekker_adjustment_set), item_parameter_table(item_parameter_table), common_item_set(common_item_set), - pt(common_item_set->get_table(this->episode, this->mode, this->difficulty, this->section_id)), restrictions(restrictions), rand_crypt(rand_crypt) { this->generate_unit_stars_tables(); @@ -59,13 +56,11 @@ ItemCreator::ItemCreator( void ItemCreator::set_section_id(uint8_t new_section_id) { if (this->section_id != new_section_id) { this->section_id = new_section_id; - this->log.prefix = std::format("[ItemCreator:{}/{}/{}/{}/{}] ", + this->log.prefix = std::format("[ItemCreator:{}/{}/{}/{}] ", phosg::name_for_enum(stack_limits->version), - abbreviation_for_episode(episode), abbreviation_for_mode(mode), abbreviation_for_difficulty(difficulty), this->section_id); - this->pt = common_item_set->get_table(this->episode, this->mode, this->difficulty, this->section_id); } } @@ -80,71 +75,114 @@ bool ItemCreator::are_rare_drops_allowed() const { return (this->mode != GameMode::CHALLENGE); } -uint8_t ItemCreator::normalize_area_number(uint8_t area) const { +uint8_t ItemCreator::table_index_for_area(uint8_t area) const { if (this->restrictions && (this->restrictions->box_drop_area != 0)) { return this->restrictions->box_drop_area - 1; - } else { - switch (this->episode) { - case Episode::EP1: - if (area >= 0x11) { - throw runtime_error("invalid Episode 1 area number"); - } - switch (area) { - case 0x0B: // Dragon -> Cave 1 - return 2; - case 0x0C: // De Rol Le -> Mine 1 - return 5; - case 0x0D: // Vol Opt -> Ruins 1 - return 7; - case 0x0E: // Dark Falz -> Ruins 3 - case 0x10: // Palace -> Ruins 3 - case 0x11: // Spaceship -> Ruins 3 - return 9; - case 0x0F: // Lobby - throw runtime_error("visual lobby does not have item drop tables"); - default: - return area - 1; - } - throw logic_error("this should be impossible"); - case Episode::EP2: { - static const array area_subs = { - 0x00, // 13 (VR Temple Alpha) - 0x01, // 14 (VR Temple Beta) - 0x02, // 15 (VR Spaceship Alpha) - 0x03, // 16 (VR Spaceship Beta) - 0x07, // 17 (Central Control Area) - 0x04, // 18 (Jungle North) - 0x05, // 19 (Jungle South) - 0x06, // 1A (Mountain) - 0x07, // 1B (Seaside) - 0x08, // 1C (Seabed Upper) - 0x09, // 1D (Seabed Lower) - 0x08, // 1E (Gal Gryphon) - 0x09, // 1F (Olga Flow) - 0x02, // 20 (Barba Ray) - 0x04, // 21 (Gol Dragon) - 0x07, // 22 (Seaside Night) - 0x09, // 23 (Tower) - }; - if ((area >= 0x13) && (area < 0x24)) { - return area_subs.at(area - 0x13); - } - throw runtime_error("invalid Episode 2 area number"); - } - case Episode::EP4: - if (area >= 0x24 && area < 0x2D) { - return area - 0x23; - } - throw runtime_error("invalid Episode 4 area number"); - default: - throw logic_error("invalid episode number"); - } } + static constexpr std::array data = { + // Episode 1 + 0xFF, // 00 => Pioneer 2 (no drops) + 0x00, // 01 => Forest 1 + 0x01, // 02 => Forest 2 + 0x02, // 03 => Cave 1 + 0x03, // 04 => Cave 2 + 0x04, // 05 => Cave 3 + 0x05, // 06 => Mine 1 + 0x06, // 07 => Mine 2 + 0x07, // 08 => Ruins 1 + 0x08, // 09 => Ruins 2 + 0x09, // 0A => Ruins 3 + 0x02, // 0B => Dragon -> Cave 1 + 0x05, // 0C => De Rol Le -> Mine 1 + 0x07, // 0D => Vol Opt -> Ruins 1 + 0x09, // 0E => Dark Falz -> Ruins 3 + 0xFF, // 0F => Lobby (no drops) + 0x09, // 10 => Palace -> Ruins 3 + 0x09, // 11 => Spaceship -> Ruins 3 + // Episode 2 + 0xFF, // 12 => Lab (no drops) + 0x00, // 13 => VR Temple Alpha + 0x01, // 14 => VR Temple Beta + 0x02, // 15 => VR Spaceship Alpha + 0x03, // 16 => VR Spaceship Beta + 0x07, // 17 => Central Control Area -> Seaside + 0x04, // 18 => Jungle North + 0x05, // 19 => Jungle South + 0x06, // 1A => Mountain + 0x07, // 1B => Seaside + 0x08, // 1C => Seabed Upper + 0x09, // 1D => Seabed Lower + 0x08, // 1E => Gal Gryphon -> Seabed Upper + 0x09, // 1F => Olga Flow -> Seabed Lower + 0x02, // 20 => Barba Ray -> VR Spaceship Alpha + 0x04, // 21 => Gol Dragon -> Jungle North + 0x07, // 22 => Seaside Night -> Seaside + 0x09, // 23 => Tower -> Seabed Lower + // Episode 4 + 0x01, // 24 => Crater East + 0x02, // 25 => Crater West + 0x03, // 26 => Crater South + 0x04, // 27 => Crater North + 0x05, // 28 => Crater Interior + 0x06, // 29 => Subterranean Desert 1 + 0x07, // 2A => Subterranean Desert 2 + 0x08, // 2B => Subterranean Desert 3 + 0x09, // 2C => Saint-Milion + 0xFF, // 2D => Pioneer 2 (no drops) + 0xFF, // 2E => Test area (no drops) + }; + if (area >= data.size() || data[area] == 0xFF) { + throw std::runtime_error("invalid area number"); + } + return data[area]; } ItemCreator::DropResult ItemCreator::on_box_item_drop(uint8_t area) { try { - return this->on_box_item_drop_with_area_norm(this->normalize_area_number(area)); + uint8_t table_index = this->table_index_for_area(area); + this->log.info_f("Box drop checks for area {:02X} (table index {:02X})", area, table_index); + + DropResult res; + res.item = this->check_rare_specs_and_create_rare_box_item(area); + if (!res.item.empty()) { + res.is_from_rare_table = true; + } else { + uint8_t item_class = this->get_rand_from_weighted_tables_2d_vertical( + this->pt(area)->box_item_class_prob_table, table_index); + this->log.info_f("Item class is {:02X}", item_class); + switch (item_class) { + case 0: // Weapon + res.item.data1[0] = 0; + break; + case 1: // Armor + res.item.data1[0] = 1; + res.item.data1[1] = 1; + break; + case 2: // Shield + res.item.data1[0] = 1; + res.item.data1[1] = 2; + break; + case 3: // Unit + res.item.data1[0] = 1; + res.item.data1[1] = 3; + break; + case 4: // Tool + res.item.data1[0] = 3; + break; + case 5: // Meseta + res.item.data1[0] = 4; + break; + case 6: // Nothing + break; + default: + throw logic_error("this should be impossible"); + } + if (item_class < 6) { + this->generate_common_item_variances(res.item, area); + } + } + return res; + } catch (const exception& e) { this->log.error_f("Exception in item creation: {}", e.what()); return DropResult(); @@ -153,143 +191,99 @@ ItemCreator::DropResult ItemCreator::on_box_item_drop(uint8_t area) { ItemCreator::DropResult ItemCreator::on_monster_item_drop(uint32_t enemy_type, uint8_t area) { try { - return this->on_monster_item_drop_with_area_norm(enemy_type, this->normalize_area_number(area)); + // Note: The original GC implementation uses (enemy_type > 0x58) here; we + // extend it to the full array size for BB + if (enemy_type >= 0x64) { + this->log.warning_f("Invalid enemy type: {:X}", enemy_type); + return DropResult(); + } + this->log.info_f("Enemy type: {:X}", enemy_type); + + auto pt = this->pt(area); + uint8_t type_drop_prob = pt->enemy_type_drop_probs.at(enemy_type); + uint8_t drop_sample = this->rand_int(100); + if (drop_sample >= type_drop_prob) { + this->log.info_f("Drop not chosen ({} >= {})", drop_sample, type_drop_prob); + return DropResult(); + } else { + this->log.info_f("Drop chosen ({} < {})", drop_sample, type_drop_prob); + } + + DropResult res; + res.item = this->check_rare_spec_and_create_rare_enemy_item(enemy_type, area); + if (!res.item.empty()) { + res.is_from_rare_table = true; + } else { + uint32_t item_class_determinant = + this->should_allow_meseta_drops() + ? this->rand_int(3) + : (this->rand_int(2) + 1); + + uint32_t item_class; + switch (item_class_determinant) { + case 0: + item_class = 5; + break; + case 1: + item_class = 4; + break; + case 2: + item_class = pt->enemy_item_classes.at(enemy_type); + break; + default: + throw logic_error("invalid item class determinant"); + } + + this->log.info_f("Rare drop not chosen; item class determinant is {}; item class is {}", item_class_determinant, item_class); + + switch (item_class) { + case 0: // Weapon + res.item.data1[0] = 0x00; + break; + case 1: // Armor + res.item.data1w[0] = 0x0101; + break; + case 2: // Shield + res.item.data1w[0] = 0x0201; + break; + case 3: // Unit + res.item.data1w[0] = 0x0301; + break; + case 4: // Tool + res.item.data1[0] = 0x03; + break; + case 5: // Meseta + res.item.data1[0] = 0x04; + res.item.data2d = this->choose_meseta_amount(pt->enemy_meseta_ranges, enemy_type) & 0xFFFF; + break; + default: + return res; + } + + if (res.item.data1[0] != 0x04) { + this->generate_common_item_variances(res.item, area); + } + } + + return res; + } catch (const exception& e) { this->log.error_f("Exception in item creation: {}", e.what()); return DropResult(); } } -ItemCreator::DropResult ItemCreator::on_box_item_drop_with_area_norm(uint8_t area_norm) { - this->log.info_f("Box drop checks for area_norm {:02X}", area_norm); - DropResult res; - res.item = this->check_rare_specs_and_create_rare_box_item(area_norm); - if (!res.item.empty()) { - res.is_from_rare_table = true; - } else { - uint8_t item_class = this->get_rand_from_weighted_tables_2d_vertical(this->pt->box_item_class_prob_table, area_norm); - this->log.info_f("Item class is {:02X}", item_class); - switch (item_class) { - case 0: // Weapon - res.item.data1[0] = 0; - break; - case 1: // Armor - res.item.data1[0] = 1; - res.item.data1[1] = 1; - break; - case 2: // Shield - res.item.data1[0] = 1; - res.item.data1[1] = 2; - break; - case 3: // Unit - res.item.data1[0] = 1; - res.item.data1[1] = 3; - break; - case 4: // Tool - res.item.data1[0] = 3; - break; - case 5: // Meseta - res.item.data1[0] = 4; - break; - case 6: // Nothing - break; - default: - throw logic_error("this should be impossible"); - } - if (item_class < 6) { - this->generate_common_item_variances(area_norm, res.item); - } - } - return res; -} - -ItemCreator::DropResult ItemCreator::on_monster_item_drop_with_area_norm(uint32_t enemy_type, uint8_t area_norm) { - // Note: The original GC implementation uses (enemy_type > 0x58) here; we - // extend it to the full array size for BB - if (enemy_type >= 0x64) { - this->log.warning_f("Invalid enemy type: {:X}", enemy_type); - return DropResult(); - } - this->log.info_f("Enemy type: {:X}", enemy_type); - - uint8_t type_drop_prob = this->pt->enemy_type_drop_probs.at(enemy_type); - uint8_t drop_sample = this->rand_int(100); - if (drop_sample >= type_drop_prob) { - this->log.info_f("Drop not chosen ({} >= {})", drop_sample, type_drop_prob); - return DropResult(); - } else { - this->log.info_f("Drop chosen ({} < {})", drop_sample, type_drop_prob); - } - - DropResult res; - res.item = this->check_rare_spec_and_create_rare_enemy_item(enemy_type, area_norm); - if (!res.item.empty()) { - res.is_from_rare_table = true; - } else { - uint32_t item_class_determinant = - this->should_allow_meseta_drops() - ? this->rand_int(3) - : (this->rand_int(2) + 1); - - uint32_t item_class; - switch (item_class_determinant) { - case 0: - item_class = 5; - break; - case 1: - item_class = 4; - break; - case 2: - item_class = this->pt->enemy_item_classes.at(enemy_type); - break; - default: - throw logic_error("invalid item class determinant"); - } - - this->log.info_f("Rare drop not chosen; item class determinant is {}; item class is {}", item_class_determinant, item_class); - - switch (item_class) { - case 0: // Weapon - res.item.data1[0] = 0x00; - break; - case 1: // Armor - res.item.data1w[0] = 0x0101; - break; - case 2: // Shield - res.item.data1w[0] = 0x0201; - break; - case 3: // Unit - res.item.data1w[0] = 0x0301; - break; - case 4: // Tool - res.item.data1[0] = 0x03; - break; - case 5: // Meseta - res.item.data1[0] = 0x04; - res.item.data2d = this->choose_meseta_amount(this->pt->enemy_meseta_ranges, enemy_type) & 0xFFFF; - break; - default: - return res; - } - - if (res.item.data1[0] != 0x04) { - this->generate_common_item_variances(area_norm, res.item); - } - } - - return res; -} - -ItemData ItemCreator::check_rare_specs_and_create_rare_box_item(uint8_t area_norm) { +ItemData ItemCreator::check_rare_specs_and_create_rare_box_item(uint8_t area) { ItemData item; if (!this->are_rare_drops_allowed()) { return item; } - auto rare_specs = this->rare_item_set->get_box_specs( - this->mode, this->episode, this->difficulty, this->section_id, area_norm); + uint8_t table_index = this->table_index_for_area(area); + Episode episode = episode_for_area(area); + auto rare_specs = this->rare_item_set->get_box_specs(this->mode, episode, this->difficulty, this->section_id, table_index); for (const auto& spec : rare_specs) { - item = this->check_rate_and_create_rare_item(spec, area_norm); + item = this->check_rate_and_create_rare_item(spec, area); if (!item.empty()) { if (this->log.should_log(phosg::LogLevel::L_INFO)) { auto hex = spec.data.hex(); @@ -340,17 +334,17 @@ bool ItemCreator::should_allow_meseta_drops() const { return (this->mode != GameMode::CHALLENGE); } -ItemData ItemCreator::check_rare_spec_and_create_rare_enemy_item(uint32_t enemy_type, uint8_t area_norm) { +ItemData ItemCreator::check_rare_spec_and_create_rare_enemy_item(uint32_t enemy_type, uint8_t area) { ItemData item; if (this->are_rare_drops_allowed() && (enemy_type > 0) && (enemy_type < 0x64)) { // Note: In the original implementation, enemies can only have one possible // rare drop. In our implementation, they can have multiple rare drops if // JSONRareItemSet is used (the other RareItemSet implementations never // return multiple drops for an enemy type). - auto rare_specs = this->rare_item_set->get_enemy_specs( - this->mode, this->episode, this->difficulty, this->section_id, enemy_type); + Episode episode = episode_for_area(area); + auto rare_specs = this->rare_item_set->get_enemy_specs(this->mode, episode, this->difficulty, this->section_id, enemy_type); for (const auto& spec : rare_specs) { - item = this->check_rate_and_create_rare_item(spec, area_norm); + item = this->check_rate_and_create_rare_item(spec, area); if (!item.empty()) { if (this->log.should_log(phosg::LogLevel::L_INFO)) { auto hex = spec.data.hex(); @@ -367,7 +361,7 @@ ItemData ItemCreator::check_rare_spec_and_create_rare_enemy_item(uint32_t enemy_ return item; } -ItemData ItemCreator::check_rate_and_create_rare_item(const RareItemSet::ExpandedDrop& drop, uint8_t area_norm) { +ItemData ItemCreator::check_rate_and_create_rare_item(const RareItemSet::ExpandedDrop& drop, uint8_t area) { if (drop.probability == 0) { return ItemData(); } @@ -382,15 +376,15 @@ ItemData ItemCreator::check_rate_and_create_rare_item(const RareItemSet::Expande if (item.can_be_encoded_in_rel_rare_table()) { switch (item.data1[0]) { case 0: - if (this->pt->has_rare_bonus_value_prob_table) { - this->generate_rare_weapon_bonuses(item, this->rand_int(10)); + if (this->pt(area)->has_rare_bonus_value_prob_table) { + this->generate_rare_weapon_bonuses(item, episode_for_area(area), this->rand_int(10)); } else { - this->generate_common_weapon_bonuses(item, area_norm); + this->generate_common_weapon_bonuses(item, area); } this->set_item_unidentified_flag_if_not_challenge(item); break; case 1: - this->generate_common_armor_slots_and_bonuses(item); + this->generate_common_armor_slots_and_bonuses(item, episode_for_area(area)); break; case 2: this->generate_common_mag_variances(item); @@ -411,18 +405,19 @@ ItemData ItemCreator::check_rate_and_create_rare_item(const RareItemSet::Expande return item; } -void ItemCreator::generate_rare_weapon_bonuses(ItemData& item, uint32_t random_sample) { +void ItemCreator::generate_rare_weapon_bonuses(ItemData& item, Episode episode, uint32_t table_index) { if (item.data1[0] != 0) { return; } - if (!this->pt->has_rare_bonus_value_prob_table) { + auto pt = this->pt(episode); + if (!pt->has_rare_bonus_value_prob_table) { throw logic_error("generate_rare_weapon_bonuses called for common item table without rare bonus value probability table"); } for (size_t z = 0; z < 6; z += 2) { - uint8_t bonus_type = this->get_rand_from_weighted_tables_2d_vertical(this->pt->bonus_type_prob_table, random_sample); - int16_t bonus_value = this->get_rand_from_weighted_tables_2d_vertical(this->pt->bonus_value_prob_table, 5); + uint8_t bonus_type = this->get_rand_from_weighted_tables_2d_vertical(pt->bonus_type_prob_table, table_index); + int16_t bonus_value = this->get_rand_from_weighted_tables_2d_vertical(pt->bonus_value_prob_table, 5); item.data1[z + 6] = bonus_type; item.data1[z + 7] = bonus_value * 5 - 10; // Note: The original code has a special case here, which divides @@ -434,20 +429,22 @@ void ItemCreator::generate_rare_weapon_bonuses(ItemData& item, uint32_t random_s this->deduplicate_weapon_bonuses(item); } -void ItemCreator::generate_common_weapon_bonuses(ItemData& item, uint8_t area_norm) { +void ItemCreator::generate_common_weapon_bonuses(ItemData& item, uint8_t area) { if (item.data1[0] != 0) { return; } + auto pt = this->pt(area); + uint8_t table_index = this->table_index_for_area(area); for (size_t row = 0; row < 3; row++) { - uint8_t spec = this->pt->nonrare_bonus_prob_spec.at(row).at(area_norm); + uint8_t spec = pt->nonrare_bonus_prob_spec.at(row).at(table_index); if (spec == 0xFF) { this->log.info_f("Bonus {} is forbidden", row); } else { - item.data1[(row * 2) + 6] = this->get_rand_from_weighted_tables_2d_vertical(this->pt->bonus_type_prob_table, area_norm); - int16_t amount = this->get_rand_from_weighted_tables_2d_vertical(this->pt->bonus_value_prob_table, spec); + item.data1[(row * 2) + 6] = this->get_rand_from_weighted_tables_2d_vertical(pt->bonus_type_prob_table, table_index); + int16_t amount = this->get_rand_from_weighted_tables_2d_vertical(pt->bonus_value_prob_table, spec); item.data1[(row * 2) + 7] = amount * 5 - 10; - this->log.info_f("Bonus {} generated as {:02X} {:02X} from area_norm {:02X} and spec {:02X}", row, item.data1[(row * 2) + 6], item.data1[(row * 2) + 7], area_norm, spec); + this->log.info_f("Bonus {} generated as {:02X} {:02X} from table index {:02X} and spec {:02X}", row, item.data1[(row * 2) + 6], item.data1[(row * 2) + 7], table_index, spec); } // Note: The original code has a special case here, which divides // item.data1[z + 7] by 5 and multiplies it by 5 again if bonus_type is 5 @@ -605,14 +602,14 @@ void ItemCreator::clear_item_if_restricted(ItemData& item) const { } } -void ItemCreator::generate_common_item_variances(uint32_t area_norm, ItemData& item) { +void ItemCreator::generate_common_item_variances(ItemData& item, uint8_t area) { switch (item.data1[0]) { case 0: - this->generate_common_weapon_variances(area_norm, item); + this->generate_common_weapon_variances(item, area); break; case 1: if (item.data1[1] == 3) { - float f1 = 1.0 + this->pt->unit_max_stars_table.at(area_norm); + float f1 = 1.0 + this->pt(area)->unit_max_stars_table.at(this->table_index_for_area(area)); float f2 = this->rand_float_0_1_from_crypt(); uint8_t stars = static_cast(f1 * f2) & 0xFF; this->log.info_f("Unit stars: {:g} * {:g} = {}", f1, f2, stars); @@ -622,17 +619,17 @@ void ItemCreator::generate_common_item_variances(uint32_t area_norm, ItemData& i item.clear(); } } else { - this->generate_common_armor_or_shield_type_and_variances(area_norm, item); + this->generate_common_armor_or_shield_type_and_variances(item, area); } break; case 2: this->generate_common_mag_variances(item); break; case 3: - this->generate_common_tool_variances(area_norm, item); + this->generate_common_tool_variances(item, area); break; case 4: - item.data2d = this->choose_meseta_amount(this->pt->box_meseta_ranges, area_norm) & 0xFFFF; + item.data2d = this->choose_meseta_amount(this->pt(area)->box_meseta_ranges, this->table_index_for_area(area)) & 0xFFFF; break; default: // Note: The original code does the following here: @@ -645,27 +642,30 @@ void ItemCreator::generate_common_item_variances(uint32_t area_norm, ItemData& i this->set_item_kill_count_if_unsealable(item); } -void ItemCreator::generate_common_armor_or_shield_type_and_variances(char area_norm, ItemData& item) { - this->generate_common_armor_slots_and_bonuses(item); +void ItemCreator::generate_common_armor_or_shield_type_and_variances(ItemData& item, uint8_t area) { + this->generate_common_armor_slots_and_bonuses(item, episode_for_area(area)); - uint8_t type = this->get_rand_from_weighted_tables_1d(this->pt->armor_shield_type_index_prob_table); - item.data1[2] = area_norm + type + this->pt->armor_or_shield_type_bias; + auto pt = this->pt(area); + uint8_t table_index = this->table_index_for_area(area); + + uint8_t type = this->get_rand_from_weighted_tables_1d(pt->armor_shield_type_index_prob_table); + item.data1[2] = table_index + type + pt->armor_or_shield_type_bias; if (item.data1[2] < 3) { item.data1[2] = 0; } else { item.data1[2] -= 3; } this->log.info_f("Armor/shield type: max({:02X} + {:02X} + {:02X} - 3, 0) = {:02X}", - area_norm, type, this->pt->armor_or_shield_type_bias, item.data1[2]); + table_index, type, pt->armor_or_shield_type_bias, item.data1[2]); } -void ItemCreator::generate_common_armor_slots_and_bonuses(ItemData& item) { +void ItemCreator::generate_common_armor_slots_and_bonuses(ItemData& item, Episode episode) { if ((item.data1[0] != 0x01) || (item.data1[1] < 1) || (item.data1[1] > 2)) { return; } if (item.data1[1] == 1) { - this->generate_common_armor_slot_count(item); + this->generate_common_armor_slot_count(item, episode); } const auto& def = this->item_parameter_table->get_armor_or_shield(item.data1[1], item.data1[2]); @@ -673,14 +673,17 @@ void ItemCreator::generate_common_armor_slots_and_bonuses(ItemData& item) { item.set_common_armor_evasion_bonus(def.evp_range * this->rand_float_0_1_from_crypt()); } -void ItemCreator::generate_common_armor_slot_count(ItemData& item) { - item.data1[5] = this->get_rand_from_weighted_tables_1d(this->pt->armor_slot_count_prob_table); +void ItemCreator::generate_common_armor_slot_count(ItemData& item, Episode episode) { + item.data1[5] = this->get_rand_from_weighted_tables_1d(this->pt(episode)->armor_slot_count_prob_table); } -void ItemCreator::generate_common_tool_variances(uint32_t area_norm, ItemData& item) { +void ItemCreator::generate_common_tool_variances(ItemData& item, uint8_t area) { item.clear(); - uint8_t tool_class = this->get_rand_from_weighted_tables_2d_vertical(this->pt->tool_class_prob_table, area_norm); + auto pt = this->pt(area); + uint8_t table_index = this->table_index_for_area(area); + + uint8_t tool_class = this->get_rand_from_weighted_tables_2d_vertical(pt->tool_class_prob_table, table_index); if ((!is_v1_or_v2(this->logic_version) || (this->logic_version == Version::GC_NTE)) && (tool_class == 0x1A)) { tool_class = 0x73; } @@ -705,15 +708,15 @@ void ItemCreator::generate_common_tool_variances(uint32_t area_norm, ItemData& i } if (item.data1[1] == 0x02) { // Tech disk - item.data1[4] = this->get_rand_from_weighted_tables_2d_vertical(this->pt->technique_index_prob_table, area_norm); - item.data1[2] = this->generate_tech_disk_level(item.data1[4], area_norm); + item.data1[4] = this->get_rand_from_weighted_tables_2d_vertical(pt->technique_index_prob_table, table_index); + item.data1[2] = this->generate_tech_disk_level(item.data1[4], area); this->clear_tool_item_if_invalid(item); } this->set_tool_item_amount_to_1(item); } -uint8_t ItemCreator::generate_tech_disk_level(uint32_t tech_num, uint32_t area_norm) { - const auto& range = this->pt->technique_level_ranges.at(tech_num).at(area_norm); +uint8_t ItemCreator::generate_tech_disk_level(uint32_t tech_num, uint8_t area) { + const auto& range = this->pt(area)->technique_level_ranges.at(tech_num).at(this->table_index_for_area(area)); if (((range.min == 0xFF) || (range.max == 0xFF)) || (range.max < range.min)) { return 0xFF; } else if (range.min != range.max) { @@ -739,20 +742,20 @@ void ItemCreator::generate_common_mag_variances(ItemData& item) { } } -void ItemCreator::generate_common_weapon_variances(uint8_t area_norm, ItemData& item) { +void ItemCreator::generate_common_weapon_variances(ItemData& item, uint8_t area) { item.clear(); item.data1[0] = 0x00; + auto pt = this->pt(area); + uint8_t table_index = this->table_index_for_area(area); + parray weapon_type_prob_table; weapon_type_prob_table[0] = 0; - memmove( - weapon_type_prob_table.data() + 1, - this->pt->base_weapon_type_prob_table.data(), - 0x0C); + memmove(weapon_type_prob_table.data() + 1, pt->base_weapon_type_prob_table.data(), 0x0C); for (size_t z = 1; z < 13; z++) { // Technically this should be `if (... < 0)`, but whatever - if ((area_norm + this->pt->subtype_base_table.at(z - 1)) & 0x80) { + if ((table_index + pt->subtype_base_table.at(z - 1)) & 0x80) { weapon_type_prob_table[z] = 0; } } @@ -768,33 +771,35 @@ void ItemCreator::generate_common_weapon_variances(uint8_t area_norm, ItemData& this->log.info_f("00 chosen from subtype table; skipping item"); item.clear(); } else { - int8_t subtype_base = this->pt->subtype_base_table.at(item.data1[1] - 1); - uint8_t area_length = this->pt->subtype_area_length_table.at(item.data1[1] - 1); + int8_t subtype_base = pt->subtype_base_table.at(item.data1[1] - 1); + uint8_t area_length = pt->subtype_area_length_table.at(item.data1[1] - 1); this->log.info_f("Subtype table yielded {:02X}; subtype base is {} with area length {}", item.data1[1], subtype_base, area_length); if (subtype_base < 0) { - item.data1[2] = (area_norm + subtype_base) / area_length; - this->log.info_f("Resulting subtype: ({:02X} + {:02X}) / {:02X} = {:02X}", area_norm, subtype_base, area_length, item.data1[2]); - this->generate_common_weapon_grind(item, (area_norm + subtype_base) - (item.data1[2] * area_length)); + item.data1[2] = (table_index + subtype_base) / area_length; + this->log.info_f("Resulting subtype: ({:02X} + {:02X}) / {:02X} = {:02X}", table_index, subtype_base, area_length, item.data1[2]); + this->generate_common_weapon_grind(item, area, (table_index + subtype_base) - (item.data1[2] * area_length)); } else { - item.data1[2] = subtype_base + (area_norm / area_length); - this->log.info_f("Resulting subtype: {:02X} + ({:02X} / {:02X}) = {:02X}", subtype_base, area_norm, area_length, item.data1[2]); - this->generate_common_weapon_grind(item, area_norm - (area_norm / area_length) * area_length); + item.data1[2] = subtype_base + (table_index / area_length); + this->log.info_f("Resulting subtype: {:02X} + ({:02X} / {:02X}) = {:02X}", subtype_base, table_index, area_length, item.data1[2]); + this->generate_common_weapon_grind(item, area, table_index - (table_index / area_length) * area_length); } - this->generate_common_weapon_bonuses(item, area_norm); - this->generate_common_weapon_special(item, area_norm); + this->generate_common_weapon_bonuses(item, area); + this->generate_common_weapon_special(item, area); this->set_item_unidentified_flag_if_not_challenge(item); } } -void ItemCreator::generate_common_weapon_grind(ItemData& item, uint8_t offset_within_subtype_range) { +void ItemCreator::generate_common_weapon_grind(ItemData& item, uint8_t area, uint8_t offset_within_subtype_range) { if (item.data1[0] == 0) { uint8_t offset = clamp(offset_within_subtype_range, 0, 3); - item.data1[3] = this->get_rand_from_weighted_tables_2d_vertical(this->pt->grind_prob_table, offset); + item.data1[3] = this->get_rand_from_weighted_tables_2d_vertical(this->pt(area)->grind_prob_table, offset); this->log.info_f("Generated grind {:02X} from offset within subtype range {:02X}", item.data1[3], offset_within_subtype_range); } } -void ItemCreator::generate_common_weapon_special(ItemData& item, uint8_t area_norm) { +void ItemCreator::generate_common_weapon_special(ItemData& item, uint8_t area) { + auto pt = this->pt(area); + uint8_t table_index = this->table_index_for_area(area); if (item.data1[0] != 0) { return; } @@ -802,13 +807,13 @@ void ItemCreator::generate_common_weapon_special(ItemData& item, uint8_t area_no this->log.info_f("Item is rare; skipping special generation"); return; } - uint8_t special_mult = this->pt->special_mult.at(area_norm); + uint8_t special_mult = pt->special_mult.at(table_index); if (special_mult == 0) { - this->log.info_f("Special multiplier is zero for area_norm {:02X}; skipping special generation", area_norm); + this->log.info_f("Special multiplier is zero for table index {:02X}; skipping special generation", table_index); return; } uint8_t det = this->rand_int(100); - uint8_t prob = this->pt->special_percent.at(area_norm); + uint8_t prob = pt->special_percent.at(table_index); if (det >= prob) { this->log.info_f("Special not chosen ({:02X} > {:02X})", det, prob); return; @@ -963,9 +968,9 @@ IntT ItemCreator::get_rand_from_weighted_tables_2d_vertical(const parray(tables[0].data(), offset, Y, X); } -vector ItemCreator::generate_armor_shop_contents(size_t player_level) { +vector ItemCreator::generate_armor_shop_contents(Episode episode, size_t player_level) { vector shop; - this->generate_armor_shop_armors(shop, player_level); + this->generate_armor_shop_armors(shop, episode, player_level); this->generate_armor_shop_shields(shop, player_level); this->generate_armor_shop_units(shop, player_level); return shop; @@ -1044,7 +1049,7 @@ bool ItemCreator::shop_does_not_contain_duplicate_item_by_data1_0_1_2( return true; } -void ItemCreator::generate_armor_shop_armors(vector& shop, size_t player_level) { +void ItemCreator::generate_armor_shop_armors(vector& shop, Episode episode, size_t player_level) { size_t num_items; if (player_level < 11) { num_items = 4; @@ -1080,7 +1085,7 @@ void ItemCreator::generate_armor_shop_armors(vector& shop, size_t play } } - this->generate_common_armor_slot_count(item); + this->generate_common_armor_slot_count(item, episode); if (this->shop_does_not_contain_duplicate_armor(shop, item)) { shop.emplace_back(std::move(item)); items_generated++; @@ -1711,7 +1716,7 @@ ItemCreator::DropResult ItemCreator::on_specialized_box_item_drop( uint16_t type = res.item.data1w[0]; res.item.clear(); res.item.data1w[0] = type; - this->generate_common_item_variances(this->normalize_area_number(area), res.item); + this->generate_common_item_variances(res.item, area); } return res; } diff --git a/src/ItemCreator.hh b/src/ItemCreator.hh index b1a8bcac..034c2d05 100644 --- a/src/ItemCreator.hh +++ b/src/ItemCreator.hh @@ -9,6 +9,11 @@ #include "RareItemSet.hh" #include "StaticGameData.hh" +// This file and ItemCreator.cc are essentially a direct reverse-engineering of the item creation algorithm in PSO GC. +// Only minor changes have been made to support BB (as described in the comments in the implementation) and to support +// cross-episode quests. The latter consists mostly of delaying table_index lookups and ItemPT table lookups until much +// later than in the original implementation; the actual logic for generating item data is the same. + class ItemCreator { public: ItemCreator( @@ -20,7 +25,6 @@ public: std::shared_ptr tekker_adjustment_set, std::shared_ptr item_parameter_table, std::shared_ptr stack_limits, - Episode episode, GameMode mode, Difficulty difficulty, uint8_t section_id, @@ -39,7 +43,7 @@ public: DropResult on_specialized_box_item_drop(uint8_t area, float param3, uint32_t param4, uint32_t param5, uint32_t param6); ItemData base_item_for_specialized_box(uint32_t param4, uint32_t param5, uint32_t param6) const; - std::vector generate_armor_shop_contents(size_t player_level); + std::vector generate_armor_shop_contents(Episode episode, size_t player_level); std::vector generate_tool_shop_contents(size_t player_level); std::vector generate_weapon_shop_contents(size_t player_level); @@ -56,10 +60,16 @@ public: void set_section_id(uint8_t new_section_id); private: + inline std::shared_ptr pt(Episode episode) const { + return this->common_item_set->get_table(episode, this->mode, this->difficulty, this->section_id); + } + inline std::shared_ptr pt(uint8_t area) const { + return this->pt(episode_for_area(area)); + } + phosg::PrefixedLogger log; Version logic_version; std::shared_ptr stack_limits; - Episode episode; GameMode mode; Difficulty difficulty; uint8_t section_id; @@ -70,7 +80,6 @@ private: std::shared_ptr tekker_adjustment_set; std::shared_ptr item_parameter_table; std::shared_ptr common_item_set; - std::shared_ptr pt; std::shared_ptr restrictions; struct UnitResult { @@ -102,42 +111,37 @@ private: std::shared_ptr rand_crypt; bool are_rare_drops_allowed() const; - uint8_t normalize_area_number(uint8_t area) const; - - DropResult on_monster_item_drop_with_area_norm(uint32_t enemy_type, uint8_t area_norm); - DropResult on_box_item_drop_with_area_norm(uint8_t area_norm); + uint8_t table_index_for_area(uint8_t area) const; uint32_t rand_int(uint64_t max); float rand_float_0_1_from_crypt(); template - uint32_t choose_meseta_amount( - const parray, NumRanges> ranges, - size_t table_index); + uint32_t choose_meseta_amount(const parray, NumRanges> ranges, size_t table_index); bool should_allow_meseta_drops() const; - ItemData check_rare_spec_and_create_rare_enemy_item(uint32_t enemy_type, uint8_t area_norm); - ItemData check_rare_specs_and_create_rare_box_item(uint8_t area_norm); - ItemData check_rate_and_create_rare_item(const RareItemSet::ExpandedDrop& drop, uint8_t area_norm); + ItemData check_rare_spec_and_create_rare_enemy_item(uint32_t enemy_type, uint8_t area); + ItemData check_rare_specs_and_create_rare_box_item(uint8_t area); + ItemData check_rate_and_create_rare_item(const RareItemSet::ExpandedDrop& drop, uint8_t area); - void generate_rare_weapon_bonuses(ItemData& item, uint32_t random_sample); + void generate_rare_weapon_bonuses(ItemData& item, Episode episode, uint32_t random_sample); void deduplicate_weapon_bonuses(ItemData& item) const; void set_item_kill_count_if_unsealable(ItemData& item) const; void set_item_unidentified_flag_if_not_challenge(ItemData& item) const; void set_tool_item_amount_to_1(ItemData& item) const; - void generate_common_item_variances(uint32_t area_norm, ItemData& item); - void generate_common_armor_slots_and_bonuses(ItemData& item); - void generate_common_armor_slot_count(ItemData& item); - void generate_common_armor_or_shield_type_and_variances(char area_norm, ItemData& item); - void generate_common_tool_variances(uint32_t area_norm, ItemData& item); - uint8_t generate_tech_disk_level(uint32_t tech_num, uint32_t area_norm); + void generate_common_item_variances(ItemData& item, uint8_t area); + void generate_common_armor_slots_and_bonuses(ItemData& item, Episode episode); + void generate_common_armor_slot_count(ItemData& item, Episode episode); + void generate_common_armor_or_shield_type_and_variances(ItemData& item, uint8_t area); + void generate_common_tool_variances(ItemData& item, uint8_t area); + uint8_t generate_tech_disk_level(uint32_t tech_num, uint8_t area); void generate_common_mag_variances(ItemData& item); - void generate_common_weapon_variances(uint8_t area_norm, ItemData& item); - void generate_common_weapon_grind(ItemData& item, uint8_t offset_within_subtype_range); - void generate_common_weapon_bonuses(ItemData& item, uint8_t area_norm); - void generate_common_weapon_special(ItemData& item, uint8_t area_norm); + void generate_common_weapon_variances(ItemData& item, uint8_t area); + void generate_common_weapon_grind(ItemData& item, uint8_t area, uint8_t offset_within_subtype_range); + void generate_common_weapon_bonuses(ItemData& item, uint8_t area); + void generate_common_weapon_special(ItemData& item, uint8_t area); uint8_t choose_weapon_special(uint8_t det); void generate_unit_stars_tables(); void generate_common_unit_variances(uint8_t stars, ItemData& item); @@ -146,28 +150,18 @@ private: void clear_item_if_restricted(ItemData& item) const; static size_t get_table_index_for_armor_shop(size_t player_level); - static bool shop_does_not_contain_duplicate_armor( - const std::vector& shop, const ItemData& item); - static bool shop_does_not_contain_duplicate_tech_disk( - const std::vector& shop, const ItemData& item); - static bool shop_does_not_contain_duplicate_or_too_many_similar_weapons( - const std::vector& shop, const ItemData& item); - static bool shop_does_not_contain_duplicate_item_by_data1_0_1_2( - const std::vector& shop, const ItemData& item); - void generate_armor_shop_armors( - std::vector& shop, size_t player_level); - void generate_armor_shop_shields( - std::vector& shop, size_t player_level); - void generate_armor_shop_units( - std::vector& shop, size_t player_level); + static bool shop_does_not_contain_duplicate_armor(const std::vector& shop, const ItemData& item); + static bool shop_does_not_contain_duplicate_tech_disk(const std::vector& shop, const ItemData& item); + static bool shop_does_not_contain_duplicate_or_too_many_similar_weapons(const std::vector& shop, const ItemData& item); + static bool shop_does_not_contain_duplicate_item_by_data1_0_1_2(const std::vector& shop, const ItemData& item); + void generate_armor_shop_armors(std::vector& shop, Episode episode, size_t player_level); + void generate_armor_shop_shields(std::vector& shop, size_t player_level); + void generate_armor_shop_units(std::vector& shop, size_t player_level); static size_t get_table_index_for_tool_shop(size_t player_level); - void generate_common_tool_shop_recovery_items( - std::vector& shop, size_t player_level); - void generate_rare_tool_shop_recovery_items( - std::vector& shop, size_t player_level); - void generate_tool_shop_tech_disks( - std::vector& shop, size_t player_level); + void generate_common_tool_shop_recovery_items(std::vector& shop, size_t player_level); + void generate_rare_tool_shop_recovery_items(std::vector& shop, size_t player_level); + void generate_tool_shop_tech_disks(std::vector& shop, size_t player_level); void generate_weapon_shop_item_grind(ItemData& item, size_t player_level); void generate_weapon_shop_item_special(ItemData& item, size_t player_level); @@ -175,11 +169,9 @@ private: void generate_weapon_shop_item_bonus2(ItemData& item, size_t player_level); template - IntT get_rand_from_weighted_tables( - const IntT* tables, size_t offset, size_t num_values, size_t stride); + IntT get_rand_from_weighted_tables(const IntT* tables, size_t offset, size_t num_values, size_t stride); template IntT get_rand_from_weighted_tables_1d(const parray& tables); template - IntT get_rand_from_weighted_tables_2d_vertical( - const parray, Y>& tables, size_t offset); + IntT get_rand_from_weighted_tables_2d_vertical(const parray, Y>& tables, size_t offset); }; diff --git a/src/Lobby.cc b/src/Lobby.cc index 61864218..40a57bd3 100644 --- a/src/Lobby.cc +++ b/src/Lobby.cc @@ -171,7 +171,7 @@ void Lobby::reset_next_item_ids() { uint8_t Lobby::area_for_floor(Version version, uint8_t floor) const { if (this->quest) { - return this->quest->meta.area_for_floor.at(floor); + return this->quest->meta.floor_assignments.at(floor).area; } auto sdt = this->require_server_state()->set_data_table(version, this->episode, this->mode, this->difficulty); return sdt->default_area_for_floor(this->episode, floor); @@ -224,7 +224,6 @@ void Lobby::create_item_creator(Version logic_version) { s->tekker_adjustment_set, s->item_parameter_table(logic_version), s->item_stack_limits(logic_version), - this->episode, (this->mode == GameMode::SOLO) ? GameMode::NORMAL : this->mode, this->difficulty, effective_section_id, diff --git a/src/ProxySession.cc b/src/ProxySession.cc index b9bf589e..446c7cc2 100644 --- a/src/ProxySession.cc +++ b/src/ProxySession.cc @@ -35,7 +35,6 @@ void ProxySession::set_drop_mode( s->tekker_adjustment_set, s->item_parameter_table(version), s->item_stack_limits(version), - this->lobby_episode, (this->lobby_mode == GameMode::SOLO) ? GameMode::NORMAL : this->lobby_mode, this->lobby_difficulty, this->lobby_section_id, diff --git a/src/Quest.cc b/src/Quest.cc index dcdeff47..cd356150 100644 --- a/src/Quest.cc +++ b/src/Quest.cc @@ -206,52 +206,56 @@ void VersionedQuest::assert_valid() const { if (this->meta.language == Language::UNKNOWN) { throw runtime_error("language is not set"); } + + uint8_t num_floors = is_v1(this->meta.version) ? 0x10 : 0x12; + uint8_t num_areas; + if (is_v1(this->meta.version)) { + num_areas = 0x10; + } else if (is_v2(this->meta.version) && (this->meta.version != Version::GC_NTE)) { + num_areas = 0x12; + } else if (is_v3(this->meta.version) || (this->meta.version == Version::GC_NTE)) { + num_areas = 0x24; + } else if (is_ep3(this->meta.version)) { + num_areas = 0x00; // Floor overrides not allowed on Ep3 + } else if (is_v4(this->meta.version)) { + num_areas = 0x2F; + } else { + throw std::logic_error("unhandled quest version"); + } + for (size_t floor = 0; floor < num_floors; floor++) { + const auto& fa = this->meta.floor_assignments[floor]; + if (fa.floor != floor) { + throw logic_error("floor assignment is inconsistent"); + } + if ((fa.area != 0xFF) && (fa.area > num_areas)) { + throw runtime_error(std::format("floor assignment 0x{:02X} specifies invalid area 0x{:02X}", floor, fa.area)); + } + } + switch (this->meta.episode) { case Episode::EP1: - for (size_t floor = 0; floor < this->meta.area_for_floor.size(); floor++) { - uint8_t area = this->meta.area_for_floor[floor]; - if (area >= 0x12) { - throw runtime_error("Episode 1 quest specifies invalid area"); - } - } break; case Episode::EP2: if (is_v1_or_v2(this->meta.version)) { throw runtime_error("v1 or v2 quest specifies Episode 2"); } - for (size_t floor = 0; floor < this->meta.area_for_floor.size(); floor++) { - uint8_t area = this->meta.area_for_floor[floor]; - if ((area < 0x12) || (area >= 0x24)) { - throw runtime_error("Episode 2 quest specifies invalid area"); - } - } break; case Episode::EP3: if (!is_ep3(this->meta.version)) { throw runtime_error("non-Ep3 quest specifies Episode 3"); } - for (size_t floor = 0; floor < this->meta.area_for_floor.size(); floor++) { - if (this->meta.area_for_floor[floor] != 0xFF) { - throw runtime_error("Episode 3 quest specifies floor overrides"); - } - } break; case Episode::EP4: if (!is_v4(this->meta.version)) { throw runtime_error("non-v4 quest specifies Episode 4"); } - for (size_t floor = 0; floor < this->meta.area_for_floor.size(); floor++) { - uint8_t area = this->meta.area_for_floor[floor]; - if (area != 0xFF && (area < 0x24 || area >= 0x2F)) { - throw runtime_error("Episode 4 quest specifies invalid floor"); - } - } break; case Episode::NONE: throw runtime_error("episode is not set"); default: throw runtime_error("episode is not valid"); } + if (!this->bin_contents) { throw runtime_error("bin file is missing"); } diff --git a/src/QuestMetadata.cc b/src/QuestMetadata.cc index 761ea434..ec5ad6f2 100644 --- a/src/QuestMetadata.cc +++ b/src/QuestMetadata.cc @@ -2,6 +2,21 @@ using namespace std; +phosg::JSON QuestMetadata::FloorAssignment::json() const { + return phosg::JSON::dict({ + {"Floor", this->floor}, + {"Area", this->area}, + {"Type", this->type}, + {"LayoutVariation", this->layout_var}, + {"EntitiesVariation", this->entities_var}, + }); +} + +std::string QuestMetadata::FloorAssignment::str() const { + return std::format("FloorAssignment(floor=0x{:02X}, area=0x{:02X}, type=0x{:02X}, layout_var=0x{:02X}, entities_var=0x{:02X})", + this->floor, this->area, this->type, this->layout_var, this->entities_var); +} + void QuestMetadata::apply_json_overrides(const phosg::JSON& json) { try { this->description_flag = json.at("DescriptionFlag").as_int(); @@ -49,9 +64,14 @@ void QuestMetadata::apply_json_overrides(const phosg::JSON& json) { } } -void QuestMetadata::assign_default_areas(Version version, Episode episode) { +void QuestMetadata::assign_default_floors() { for (size_t z = 0; z < 0x12; z++) { - this->area_for_floor[z] = SetDataTableBase::default_area_for_floor(version, episode, z); + auto& fa = this->floor_assignments[z]; + fa.floor = z; + fa.area = SetDataTableBase::default_area_for_floor(this->version, this->episode, z); + fa.type = 0; + fa.layout_var = 0; + fa.entities_var = 0; } } @@ -141,13 +161,12 @@ void QuestMetadata::assert_compatible(const QuestMetadata& other) const { "quest version has different challenge difficulty (existing: {}, new: {})", name_for_difficulty(this->challenge_difficulty), name_for_difficulty(other.challenge_difficulty))); } - for (size_t z = 0; z < this->area_for_floor.size(); z++) { - const auto& this_fa = this->area_for_floor[z]; - const auto& other_fa = other.area_for_floor[z]; - if (this_fa != other_fa) { + for (size_t z = 0; z < this->floor_assignments.size(); z++) { + const auto& this_fa = this->floor_assignments[z]; + const auto& other_fa = other.floor_assignments[z]; + if (this_fa.area != other_fa.area) { throw runtime_error(std::format( - "quest version has different area on floor 0x{:02X} (existing: {}, new: {})", - z, phosg::format_data_string(this->area_for_floor.data(), 0x12), phosg::format_data_string(other.area_for_floor.data(), 0x12))); + "quest version has different area on floor 0x{:02X} (existing: {}, new: {})", z, this_fa.str(), other_fa.str())); } } if (this->description_flag != other.description_flag) { @@ -201,8 +220,8 @@ void QuestMetadata::assert_compatible(const QuestMetadata& other) const { phosg::JSON QuestMetadata::json() const { auto floors_json = phosg::JSON::list(); - for (const auto& fa : this->area_for_floor) { - floors_json.emplace_back(fa); + for (const auto& fa : this->floor_assignments) { + floors_json.emplace_back(fa.json()); } auto enemy_exp_overrides_json = phosg::JSON::dict(); for (const auto& [key, exp_override] : this->enemy_exp_overrides) { @@ -220,7 +239,7 @@ phosg::JSON QuestMetadata::json() const { return phosg::JSON::dict({ {"CategoryID", this->category_id}, - {"Number", this->quest_number}, + {"QuestNumber", this->quest_number}, {"Episode", name_for_episode(this->episode)}, {"FloorAssignments", std::move(floors_json)}, {"Joinable", this->joinable}, diff --git a/src/QuestMetadata.hh b/src/QuestMetadata.hh index 51d410dc..e557bf5d 100644 --- a/src/QuestMetadata.hh +++ b/src/QuestMetadata.hh @@ -26,10 +26,23 @@ struct QuestMetadata { Language language; // Fields that must match across all quest versions + struct FloorAssignment { + uint8_t floor = 0xFF; + uint8_t area = 0xFF; + uint8_t type = 0xFF; + uint8_t layout_var = 0xFF; + uint8_t entities_var = 0xFF; + + bool operator==(const FloorAssignment& other) const = default; + bool operator!=(const FloorAssignment& other) const = default; + + phosg::JSON json() const; + std::string str() const; + }; uint32_t category_id = 0xFFFFFFFF; uint32_t quest_number = 0xFFFFFFFF; Episode episode = Episode::NONE; - std::array area_for_floor; + std::array floor_assignments; bool joinable = false; uint8_t max_players = 4; std::shared_ptr battle_rules; @@ -72,6 +85,7 @@ struct QuestMetadata { bool match(const ItemData& item) const; uint32_t primary_identifier() const; // Raises if any of data1[0-2] are ambiguous }; + std::vector bb_map_designate_opcodes; std::vector create_item_mask_entries; // Unknown header fields. These are not used by the client, so they are not required to match across quest versions; @@ -83,7 +97,7 @@ struct QuestMetadata { uint16_t header_unknown_a6 = 0; // BB only int16_t header_episode = -1; // -1 = unspecified; BB only; newserv uses script analysis instead int16_t header_language = -1; // -1 = unspecified; DCv1 and later; newserv uses the filename instead - std::shared_ptr> header_unknown_a5; // BB only; null for non-BB quests + std::shared_ptr> header_unknown_a5; // BB only; null for non-BB quests // Fields that may be different across quest versions (and are only used on VersionedQuest, not Quest) std::string name; @@ -99,7 +113,7 @@ struct QuestMetadata { void apply_json_overrides(const phosg::JSON& json); - void assign_default_areas(Version version, Episode episode); + void assign_default_floors(); void assert_compatible(const QuestMetadata& other) const; phosg::JSON json() const; std::string areas_str() const; diff --git a/src/QuestScript.cc b/src/QuestScript.cc index 741be5ed..609c6f32 100644 --- a/src/QuestScript.cc +++ b/src/QuestScript.cc @@ -2773,7 +2773,10 @@ static const QuestScriptOpcodeDefinition opcode_defs[] = { {0xF950, "bb_p2_menu", "BB_p2_menu", {I32}, F_V4 | F_ARGS}, // Behaves exactly the same as map_designate_ex, but the arguments are - // specified as immediate values and not via registers or arg_push. + // specified as immediate values and not via registers or arg_push. Sega + // probably added this opcode so their quest authoring tools could easily + // generate the necessary header fields without doing any fancy script + // analysis. // valueA = floor number // valueB = area number // valueC = type (0: use layout, 1: use offline template, 2: use online @@ -3202,7 +3205,7 @@ std::string disassemble_quest_script( } } if (fs.enemy_sets) { - uint8_t area = meta.area_for_floor.at(floor); + uint8_t area = meta.floor_assignments.at(floor).area; for (size_t z = 0; z < fs.enemy_set_count; z++) { // Only NPCs use script labels; no other enemies do const auto& ene_set = fs.enemy_sets[z]; @@ -4313,11 +4316,11 @@ AssembledQuestScript assemble_quest_script( ret.meta.header_unknown_a6 = stoul(line.text.substr(19), nullptr, 0); } else if (line.text.starts_with(".header_unknown_a5 ")) { std::string data = phosg::parse_data_string(line.text.substr(19)); - if (data.size() != 0x94) { - throw std::runtime_error(".header_unknown_a5 directive must specify 0x94 bytes of data"); + if (data.size() != 0x14) { + throw std::runtime_error(".header_unknown_a5 directive must specify 0x14 bytes of data"); } - ret.meta.header_unknown_a5 = std::make_shared>(); - for (size_t z = 0; z < 0x94; z++) { + ret.meta.header_unknown_a5 = std::make_shared>(); + for (size_t z = 0; z < 0x14; z++) { ret.meta.header_unknown_a5->at(z) = static_cast(data[z]); } } @@ -4491,6 +4494,7 @@ AssembledQuestScript assemble_quest_script( bool version_has_args = F_HAS_ARGS & v_flag(ret.meta.version); const auto& opcodes = opcodes_by_name_for_version(ret.meta.version); phosg::StringWriter code_w; + std::vector bb_map_designate_args_offsets; for (const auto& line : lines) { wrap_exceptions_with_line_ref(line, [&]() -> void { if (line.text.empty()) { @@ -4569,6 +4573,15 @@ AssembledQuestScript assemble_quest_script( } } + // Hack: Collect bb_map_designate offsets during assembly, to generate + // the necessary server-side header field afterward + if (opcode_def->opcode == 0xF951) { + if (bb_map_designate_args_offsets.size() >= 0x10) { + throw std::runtime_error("bb_map_designate was used too many times; up to 16 uses are allowed"); + } + bb_map_designate_args_offsets.emplace_back(code_w.size()); + } + if (opcode_def->args.empty()) { if (line_tokens.size() > 1) { throw runtime_error(std::format("arguments not allowed for {}", opcode_def->name)); @@ -4968,6 +4981,17 @@ AssembledQuestScript assemble_quest_script( } else { header.unknown_a5.clear(0); } + phosg::StringReader code_r(code_w.str()); + for (size_t z = 0; z < bb_map_designate_args_offsets.size(); z++) { + code_r.go(bb_map_designate_args_offsets[z]); + auto& fa = header.floor_assignments[z]; + fa.floor = code_r.get_u8(); + fa.area = code_r.get_u8(); + fa.type = code_r.get_u8(); + fa.layout_var = code_r.get_u8(); + fa.entities_var = code_r.get_u8(); + fa.unused.clear(0); + } for (size_t z = 0; z < ret.meta.create_item_mask_entries.size(); z++) { header.create_item_mask_entries[z] = ret.meta.create_item_mask_entries[z]; } @@ -4986,10 +5010,11 @@ AssembledQuestScript assemble_quest_script( void populate_quest_metadata_from_script( QuestMetadata& meta, const void* data, size_t size, Version version, Language language) { + meta.version = version; meta.language = language; phosg::StringReader r(data, size); - switch (version) { + switch (meta.version) { case Version::DC_NTE: { const auto& header = r.get(); meta.header_unknown_a1 = header.unknown_a1; @@ -5109,11 +5134,10 @@ void populate_quest_metadata_from_script( // 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.text_offset >= sizeof(PSOQuestHeaderBB)) && - (header.label_table_offset >= sizeof(PSOQuestHeaderBB))) { + if ((header.text_offset >= sizeof(PSOQuestHeaderBB)) && (header.label_table_offset >= sizeof(PSOQuestHeaderBB))) { r.go(0); const auto& header = r.get(); - meta.header_unknown_a5 = std::make_shared>(header.unknown_a5); + meta.header_unknown_a5 = std::make_shared>(header.unknown_a5); 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()) { @@ -5130,8 +5154,8 @@ void populate_quest_metadata_from_script( throw logic_error("invalid quest version"); } - const auto& opcodes = opcodes_for_version(version); - bool version_has_args = F_HAS_ARGS & v_flag(version); + const auto& opcodes = opcodes_for_version(meta.version); + bool version_has_args = F_HAS_ARGS & v_flag(meta.version); struct RegisterFile { // All registers are initially zero @@ -5200,7 +5224,7 @@ void populate_quest_metadata_from_script( deque pending_fn_offsets{get_label_offset(0)}; unordered_set done_fn_offsets; shared_ptr battle_rules; - meta.assign_default_areas(version, meta.episode); + meta.assign_default_floors(); while (!pending_fn_offsets.empty()) { uint32_t start_offset = pending_fn_offsets.front(); pending_fn_offsets.pop_front(); @@ -5276,7 +5300,7 @@ void populate_quest_metadata_from_script( } case 0x000A: { // leta (v1/v2); letb (v3/v4) uint8_t a = r.get_u8(); - if (is_v1_or_v2(version)) { // leta + if (is_v1_or_v2(meta.version)) { // leta regs.invalidate(a); r.skip(1); } else { // letb @@ -5387,7 +5411,7 @@ void populate_quest_metadata_from_script( break; case 0x004E: // arg_pushs args_list.emplace_back(r.where()); - if (uses_utf16(version)) { + if (uses_utf16(meta.version)) { while (r.get_u16l()) { } } else { @@ -5412,9 +5436,14 @@ void populate_quest_metadata_from_script( } case 0x00C4: { // map_designate - uint32_t floor = regs.get(r.get_u8()); - if (floor < meta.area_for_floor.size()) { - meta.area_for_floor[floor] = floor; + uint8_t base_reg = r.get_u8(); + uint32_t floor = regs.get(base_reg); + if (floor < meta.floor_assignments.size()) { + auto& fa = meta.floor_assignments[floor]; + fa.area = floor; + fa.type = regs.get(base_reg + 1); + fa.layout_var = regs.get(base_reg + 2); + fa.entities_var = 0; } // phosg::fwrite_fmt(stderr, ">>> Trace: map_designate fa[{}]={}\n", floor, floor); break; @@ -5423,9 +5452,12 @@ void populate_quest_metadata_from_script( case 0xF80D: { // map_designate_ex uint8_t base_reg = r.get_u8(); uint32_t floor = regs.get(base_reg); - uint32_t area = regs.get(base_reg + 1); - if (floor < meta.area_for_floor.size()) { - meta.area_for_floor[floor] = area; + if (floor < meta.floor_assignments.size()) { + auto& fa = meta.floor_assignments[floor]; + fa.area = regs.get(base_reg + 1); + fa.type = regs.get(base_reg + 2); + fa.layout_var = regs.get(base_reg + 3); + fa.entities_var = regs.get(base_reg + 4); } // phosg::fwrite_fmt(stderr, ">>> Trace: map_designate_ex fa[{}]={}\n", floor, area); break; @@ -5626,9 +5658,9 @@ void populate_quest_metadata_from_script( break; case 0xF88C: // get_game_version - if (is_v1_or_v2(version)) { + if (is_v1_or_v2(meta.version)) { regs.set(r.get_u8(), 2); - } else if (is_gc(version)) { + } else if (is_gc(meta.version)) { regs.set(r.get_u8(), 3); } else { regs.set(r.get_u8(), 4); @@ -5652,7 +5684,7 @@ void populate_quest_metadata_from_script( meta.episode = Episode::EP2; break; case 2: - if (!is_v4(version)) { + if (!is_v4(meta.version)) { throw runtime_error("invalid argument to set_episode"); } meta.episode = Episode::EP4; @@ -5660,8 +5692,7 @@ void populate_quest_metadata_from_script( default: throw runtime_error("invalid argument to set_episode"); } - meta.assign_default_areas(version, meta.episode); - // phosg::fwrite_fmt(stderr, ">>> Trace: meta.episode = {}\n", name_for_episode(meta.episode)); + meta.assign_default_floors(); break; case 0xF932: // set_episode2 @@ -5670,13 +5701,20 @@ void populate_quest_metadata_from_script( case 0xF951: { // bb_map_designate uint8_t floor = r.get_u8(); - if (floor < meta.area_for_floor.size()) { - meta.area_for_floor.at(floor) = r.get_u8(); - r.skip(3); // entities_list_type, vars.layout, vars.entities + if (floor < meta.floor_assignments.size()) { + auto& fa = meta.floor_assignments[floor]; + if (fa.floor != floor) { + throw std::logic_error("FloorAssignment\'s floor field is incorrect"); + } + fa.area = r.get_u8(); + fa.type = r.get_u8(); + fa.layout_var = r.get_u8(); + fa.entities_var = r.get_u8(); + meta.bb_map_designate_opcodes.emplace_back(fa); // phosg::fwrite_fmt(stderr, ">>> Trace: bb_map_designate fa[{}]={}\n", floor, meta.area_for_floor.at(floor)); } else { - r.skip(4); // area, entities_list_type, vars.layout, vars.entities - // phosg::fwrite_fmt(stderr, ">>> Trace: bb_map_designate fa[{}]=(ignored)\n", floor); + // Maybe we should throw in this case? + r.skip(4); } break; } @@ -5735,7 +5773,7 @@ void populate_quest_metadata_from_script( break; case Type::CSTRING: if (!use_args) { - if (uses_utf16(version)) { + if (uses_utf16(meta.version)) { while (r.get_u16l()) { } } else { diff --git a/src/QuestScript.hh b/src/QuestScript.hh index def2caf3..ae5ade72 100644 --- a/src/QuestScript.hh +++ b/src/QuestScript.hh @@ -114,12 +114,24 @@ struct PSOQuestHeaderBBBase { } __packed_ws__(PSOQuestHeaderBBBase, 0x0398); struct PSOQuestHeaderBB : PSOQuestHeaderBBBase { - /* 0398 */ parray unknown_a5; + struct FloorAssignment { + // These fields match the bb_map_designate arguments (see QuestScript.cc). + // Unused AreaAssignment structures should have all fields set to 0xFF. + uint8_t floor = 0xFF; + uint8_t area = 0xFF; + uint8_t type = 0xFF; + uint8_t layout_var = 0xFF; + uint8_t entities_var = 0xFF; + parray unused = 0xFF; + } __packed_ws__(FloorAssignment, 8); + + /* 0398 */ parray unknown_a5; + /* 03AC */ parray floor_assignments; /* 042C */ parray create_item_mask_entries; /* 122C */ } __packed_ws__(PSOQuestHeaderBB, 0x122C); -void check_opcode_definitions(); +void check_quest_opcode_definitions(); Episode episode_for_quest_episode_number(uint8_t episode_number); diff --git a/src/ReceiveSubcommands.cc b/src/ReceiveSubcommands.cc index d9504370..8022b78b 100644 --- a/src/ReceiveSubcommands.cc +++ b/src/ReceiveSubcommands.cc @@ -1805,8 +1805,7 @@ static asio::awaitable on_switch_state_changed(shared_ptr c, Subco const auto& obj_st = l->map_state->object_state_for_index( c->version(), cmd.switch_flag_floor, cmd.header.entity_id - 0x4000); auto s = c->require_server_state(); - auto sdt = s->set_data_table(c->version(), l->episode, l->mode, l->difficulty); - uint8_t area = sdt->default_area_for_floor(l->episode, c->floor); + uint8_t area = l->area_for_floor(c->version(), c->floor); auto type_name = obj_st->type_name(c->version(), area); send_text_message_fmt(c, "$C5K-{:03X} A {}", obj_st->k_id, type_name); } @@ -2534,9 +2533,11 @@ static asio::awaitable on_open_shop_bb_or_ep3_battle_subs(shared_ptrbb_shop_contents[1] = l->item_creator->generate_weapon_shop_contents(level); break; - case 2: - c->bb_shop_contents[2] = l->item_creator->generate_armor_shop_contents(level); + case 2: { + Episode episode = episode_for_area(l->area_for_floor(c->version(), 0)); + c->bb_shop_contents[2] = l->item_creator->generate_armor_shop_contents(episode, level); break; + } default: throw runtime_error("invalid shop type"); } @@ -2947,7 +2948,8 @@ static asio::awaitable on_entity_drop_item_request(shared_ptr c, S // mode, so that we can correctly mark enemies and objects as having dropped // their items in persistent games. G_SpecializableItemDropRequest_6xA2 cmd = normalize_drop_request(msg.data, msg.size); - auto rec = reconcile_drop_request_with_map(c, cmd, l->episode, l->difficulty, l->event, l->map_state, true); + Episode episode = episode_for_area(l->area_for_floor(c->version(), cmd.floor)); + auto rec = reconcile_drop_request_with_map(c, cmd, episode, l->difficulty, l->event, l->map_state, true); ServerDropMode drop_mode = l->drop_mode; switch (drop_mode) { @@ -3131,9 +3133,8 @@ static asio::awaitable on_set_quest_flag(shared_ptr c, SubcommandM if (l->drop_mode != ServerDropMode::DISABLED) { EnemyType boss_enemy_type = EnemyType::NONE; - bool is_ep2 = (l->episode == Episode::EP2); uint8_t area = l->area_for_floor(c->version(), c->floor); - if ((l->episode == Episode::EP1) && (area == 0x0E)) { + if (area == 0x0E) { // On Normal, Dark Falz does not have a third phase, so send the drop // request after the end of the second phase. On all other difficulty // levels, send it after the third phase. @@ -3142,21 +3143,19 @@ static asio::awaitable on_set_quest_flag(shared_ptr c, SubcommandM } else if ((difficulty != Difficulty::NORMAL) && (flag_num == 0x0037)) { boss_enemy_type = EnemyType::DARK_FALZ_3; } - } else if (is_ep2 && (flag_num == 0x0057) && (area == 0x0D)) { + } else if ((flag_num == 0x0057) && (area == 0x1F)) { boss_enemy_type = EnemyType::OLGA_FLOW_2; } if (boss_enemy_type != EnemyType::NONE) { l->log.info_f("Creating item from final boss ({})", phosg::name_for_enum(boss_enemy_type)); uint16_t enemy_index = 0xFFFF; - uint8_t enemy_floor = 0xFF; try { auto ene_st = l->map_state->enemy_state_for_floor_type(c->version(), c->floor, boss_enemy_type); if (ene_st->alias_target_ene_st) { ene_st = ene_st->alias_target_ene_st; } enemy_index = l->map_state->index_for_enemy_state(c->version(), ene_st); - enemy_floor = ene_st->super_ene->floor; if (c->floor != ene_st->super_ene->floor) { l->log.warning_f("Floor {:02X} from client does not match entity\'s expected floor {:02X}", c->floor, ene_st->super_ene->floor); @@ -3184,7 +3183,6 @@ static asio::awaitable on_set_quest_flag(shared_ptr c, SubcommandM } auto s = c->require_server_state(); - auto sdt = s->set_data_table(c->version(), l->episode, l->mode, l->difficulty); G_StandardDropItemRequest_PC_V3_BB_6x60 drop_req = { { {0x60, 0x06, 0x0000}, @@ -3195,8 +3193,7 @@ static asio::awaitable on_set_quest_flag(shared_ptr c, SubcommandM 2, 0, }, - sdt->default_area_for_floor(l->episode, enemy_floor), - {}}; + area, {}}; SubcommandMessage drop_msg{0x62, l->leader_id, &drop_req, sizeof(drop_req)}; co_await on_entity_drop_item_request(c, drop_msg); } @@ -3768,9 +3765,6 @@ static asio::awaitable on_set_entity_pos_and_angle_6x17(shared_ptr // 6x17 is used to transport players to the other part of the Vol Opt boss // arena, so phase 2 can begin. We only allow 6x17 in the Monitor Room (Vol // Opt arena). - if (l->episode != Episode::EP1) { - throw runtime_error("client sent 6x17 command in non-Ep1 game"); - } if (l->area_for_floor(c->version(), c->floor) != 0x0D) { throw runtime_error("client sent 6x17 command in area other than Vol Opt"); } @@ -3961,7 +3955,7 @@ static uint32_t base_exp_for_enemy_type( episode_order[2] = Episode::EP4; } else if (current_episode == Episode::EP4) { uint8_t area = quest - ? quest->meta.area_for_floor.at(floor) + ? quest->meta.floor_assignments.at(floor).area : SetDataTableBase::default_area_for_floor(Version::BB_V4, Episode::EP4, floor); if (area <= 0x28) { // Crater episode_order[1] = Episode::EP1; @@ -4034,15 +4028,16 @@ static asio::awaitable on_steal_exp_bb(shared_ptr c, SubcommandMes co_return; } - auto type = ene_st->type(c->version(), l->episode, l->difficulty, l->event); + auto episode = episode_for_area(l->area_for_floor(c->version(), ene_st->super_ene->floor)); + auto type = ene_st->type(c->version(), episode, l->difficulty, l->event); uint32_t enemy_exp = base_exp_for_enemy_type( - s->battle_params, l->quest, type, l->episode, l->difficulty, ene_st->super_ene->floor, l->mode == GameMode::SOLO); + s->battle_params, l->quest, type, episode, l->difficulty, ene_st->super_ene->floor, l->mode == GameMode::SOLO); // Note: The original code checks if special.type is 9, 10, or 11, and skips // applying the android bonus if so. We don't do anything for those special // types, so we don't check for that here. float percent = special.amount + ((l->difficulty == Difficulty::ULTIMATE) && char_class_is_android(p->disp.visual.char_class) ? 30 : 0); - float ep2_factor = (l->episode == Episode::EP2) ? 1.3 : 1.0; + float ep2_factor = (episode == Episode::EP2) ? 1.3 : 1.0; uint32_t stolen_exp = max(min((enemy_exp * percent * ep2_factor) / 100.0f, (static_cast(l->difficulty) + 1) * 20), 1); if (c->check_flag(Client::Flag::DEBUG_ENABLED)) { c->log.info_f("Stolen EXP from E-{:03X} with enemy_exp={} percent={:g} stolen_exp={}", @@ -4084,9 +4079,10 @@ static asio::awaitable on_enemy_exp_request_bb(shared_ptr c, Subco } ene_st->server_flags |= MapState::EnemyState::Flag::EXP_GIVEN; - auto type = ene_st->type(c->version(), l->episode, l->difficulty, l->event); + auto episode = episode_for_area(l->area_for_floor(c->version(), ene_st->super_ene->floor)); + auto type = ene_st->type(c->version(), episode, l->difficulty, l->event); double base_exp = base_exp_for_enemy_type( - s->battle_params, l->quest, type, l->episode, l->difficulty, ene_st->super_ene->floor, l->mode == GameMode::SOLO); + s->battle_params, l->quest, type, episode, l->difficulty, ene_st->super_ene->floor, l->mode == GameMode::SOLO); l->log.info_f("Base EXP for this enemy ({}) is {:g}", phosg::name_for_enum(type), base_exp); for (size_t client_id = 0; client_id < 4; client_id++) { @@ -4130,12 +4126,11 @@ static asio::awaitable on_enemy_exp_request_bb(shared_ptr c, Subco // something far lazier instead: they just stuck an if statement in the // client's EXP request function. We, unfortunately, have to do the // same thing here. - bool is_ep2 = (l->episode == Episode::EP2); uint32_t player_exp = base_exp * rate_factor * l->base_exp_multiplier * l->challenge_exp_multiplier * - (is_ep2 ? 1.3 : 1.0); + ((episode == Episode::EP2) ? 1.3 : 1.0); l->log.info_f("Client in slot {} receives {} EXP", client_id, player_exp); if (lc->check_flag(Client::Flag::DEBUG_ENABLED)) { send_text_message_fmt(lc, "$C5+{} E-{:03X} {}", player_exp, ene_st->e_id, phosg::name_for_enum(type)); @@ -4183,16 +4178,23 @@ static asio::awaitable on_adjust_player_meseta_bb(shared_ptr c, Su } 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. + // We always enforce these restrictions if the quest has any restrictions + // defined, 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"); } + if (l->quest->meta.create_item_mask_entries.empty()) { + l->log.warning_f("Player created quest item {}, but the loaded quest ({}) has no item creation masks", item.hex(), l->quest->meta.name); + return; + } + for (const auto& mask : l->quest->meta.create_item_mask_entries) { if (mask.match(item)) { + l->log.info_f("Player created quest item {} which matches create item mask {}", item.hex(), mask.str()); return; } } diff --git a/src/StaticGameData.cc b/src/StaticGameData.cc index 964fe130..6fa798e9 100644 --- a/src/StaticGameData.cc +++ b/src/StaticGameData.cc @@ -58,6 +58,18 @@ Episode episode_for_token_name(const string& name) { throw runtime_error("unknown episode"); } +Episode episode_for_area(uint8_t area) { + if (area < 0x12) { + return Episode::EP1; + } else if (area < 0x24) { + return Episode::EP2; + } else if (area < 0x2F) { + return Episode::EP4; + } else { + throw std::runtime_error("invalid area number"); + } +} + const char* abbreviation_for_episode(Episode ep) { switch (ep) { case Episode::NONE: diff --git a/src/StaticGameData.hh b/src/StaticGameData.hh index cd88d8f9..fc431f92 100644 --- a/src/StaticGameData.hh +++ b/src/StaticGameData.hh @@ -42,6 +42,7 @@ const char* name_for_episode(Episode ep); const char* token_name_for_episode(Episode ep); const char* abbreviation_for_episode(Episode ep); Episode episode_for_token_name(const std::string& name); +Episode episode_for_area(uint8_t area); enum class GameMode { NORMAL = 0,