implement quest item creation masks

This commit is contained in:
Martin Michelsen
2025-11-15 22:36:36 -08:00
parent 678c60dd14
commit 77d5436b15
16 changed files with 512 additions and 109 deletions
+24 -10
View File
@@ -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<uint8_t, 2> 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<uint8_t, 2> 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.
+3
View File
@@ -354,6 +354,9 @@ const string& Quest::name_for_language(Language language) const {
void Quest::add_version(shared_ptr<const VersionedQuest> 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<size_t>(vq->language);
+85
View File
@@ -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<uint32_t, uint32_t> QuestMetadata::parse_enemy_exp_overrides(const phosg::JSON& json) {
try {
std::unordered_map<uint32_t, uint32_t> ret;
+27
View File
@@ -44,6 +44,33 @@ struct QuestMetadata {
int16_t lock_status_register = -1;
std::unordered_map<uint32_t, uint32_t> 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<Range, 12> 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<CreateItemMask> create_item_mask_entries;
std::string name;
std::string short_description;
std::string long_description;
+123 -17
View File
@@ -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<uint8_t>(v), .max = static_cast<uint8_t>(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<uint8_t>(min), .max = static_cast<uint8_t>(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<PSOQuestHeaderBB>();
const auto& header = r.get<PSOQuestHeaderBBBase>();
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<PSOQuestHeaderBB>();
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<QuestMetadata::CreateItemMask> 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<PSOQuestHeaderBB>();
const auto& header = r.get<PSOQuestHeaderBBBase>();
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<PSOQuestHeaderBB>();
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;
+22 -2
View File
@@ -83,7 +83,21 @@ struct PSOQuestHeaderGC {
/* 01D4 */
} __packed_ws__(PSOQuestHeaderGC, 0x1D4);
struct PSOQuestHeaderBB {
struct CreateItemMaskEntry {
parray<le_int32_t, 12> 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<TextEncoding::UTF16, 0x80> short_description;
/* 0158 */ pstring<TextEncoding::UTF16, 0x120> long_description;
/* 0398 */
} __packed_ws__(PSOQuestHeaderBB, 0x398);
} __packed_ws__(PSOQuestHeaderBBBase, 0x0398);
struct PSOQuestHeaderBB : PSOQuestHeaderBBBase {
/* 0398 */ parray<uint8_t, 0x94> unknown_a5;
/* 042C */ parray<CreateItemMaskEntry, 0x40> create_item_mask_entries;
/* 122C */
} __packed_ws__(PSOQuestHeaderBB, 0x122C);
void check_opcode_definitions();
+99 -48
View File
@@ -4182,18 +4182,43 @@ static asio::awaitable<void> on_adjust_player_meseta_bb(shared_ptr<Client> c, Su
co_return;
}
static asio::awaitable<void> on_item_reward_request_bb(shared_ptr<Client> c, SubcommandMessage& msg) {
const auto& cmd = msg.check_size_t<G_ItemRewardRequest_BB_6xCA>();
static void assert_quest_item_create_allowed(shared_ptr<const Lobby> 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<void> on_quest_create_item_bb(shared_ptr<Client> c, SubcommandMessage& msg) {
const auto& cmd = msg.check_size_t<G_QuestCreateItem_BB_6xCA>();
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<void> on_quest_exchange_item_bb(shared_ptr<Client> c, Sub
throw runtime_error("6xD5 command sent during free play");
}
const auto& cmd = msg.check_size_t<G_ExchangeItemInQuest_BB_6xD5>();
const auto& cmd = msg.check_size_t<G_QuestExchangeItem_BB_6xD5>();
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<void> on_photon_drop_exchange_for_item_bb(shared_ptr<Clie
auto p = c->character_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<void> 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<void> on_secret_lottery_ticket_exchange_bb(shared_ptr<Cli
if (!l->check_flag(Lobby::Flag::QUEST_IN_PROGRESS) && !l->check_flag(Lobby::Flag::JOINABLE_QUEST_IN_PROGRESS)) {
throw runtime_error("6xDE command sent during free play");
}
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");
}
auto s = c->require_server_state();
// See notes about 6xDE in CommandFormats.hh about this weirdness
const auto& cmd = msg.check_size_t<G_ExchangeSecretLotteryTicket_Incomplete_BB_6xDE>(0x0C);
uint16_t failure_label;
if (msg.size >= 0x0C) {
const auto& cmd = msg.check_size_t<G_ExchangeSecretLotteryTicket_BB_6xDE>();
if (s->secret_lottery_results.empty()) {
throw runtime_error("no secret lottery results are defined");
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<void> on_secret_lottery_ticket_exchange_bb(shared_ptr<Cli
p->remove_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<void> on_momoka_item_exchange_bb(shared_ptr<Client> 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<void> on_momoka_item_exchange_bb(shared_ptr<Client> 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<void> on_upgrade_weapon_attribute_bb(shared_ptr<Client> 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<void> on_upgrade_weapon_attribute_bb(shared_ptr<Client> 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<SubcommandDefinition> 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<void> on_subcommand_multi(shared_ptr<Client> c, Channel::Message
offset += cmd_size;
}
}
// NOCOMMIT: Make BB item creation opcodes use the quests' create masks
-11
View File
@@ -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<uint32_t> {
unordered_set<uint32_t> ret;
-1
View File
@@ -239,7 +239,6 @@ struct ServerState : public std::enable_shared_from_this<ServerState> {
std::vector<std::pair<size_t, ItemData>> quest_F95F_results; // [(num_photon_tickets, item)]
std::vector<QuestF960Result> quest_F960_success_results;
QuestF960Result quest_F960_failure_results;
std::vector<ItemData> secret_lottery_results;
float bb_global_exp_multiplier = 1.0f;
float exp_share_multiplier = 0.5f;
float server_global_drop_rate_multiplier = 1.0f;
@@ -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
-10
View File
@@ -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
Binary file not shown.
+12
View File
@@ -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
Binary file not shown.
Binary file not shown.
-8
View File
@@ -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,