From c596a18b3a55dbb8e7f072fea04e46ba68a7e21b Mon Sep 17 00:00:00 2001 From: Martin Michelsen Date: Wed, 26 Feb 2025 21:01:55 -0800 Subject: [PATCH] support .include in quest scripts --- notes/handler-tables.txt | 10 +- src/Main.cc | 5 +- src/Quest.cc | 6 +- src/QuestScript.cc | 295 ++++++++++++++-------- src/QuestScript.hh | 10 +- system/quests/retrieval/q058-gc-e.bin.txt | 8 + 6 files changed, 222 insertions(+), 112 deletions(-) diff --git a/notes/handler-tables.txt b/notes/handler-tables.txt index 006d56ad..536cc294 100644 --- a/notes/handler-tables.txt +++ b/notes/handler-tables.txt @@ -489,16 +489,18 @@ SUBCMD GCEp3NTE GCEp3USA 6xB5x47 80234FBC 8022A314 Quest opcode dispatch -3OE0 => 801F2CE0 -3OE1 => 801F2CE0 -3OE2 => 801F2E64 +3OJT => 80242F7C 3OJ2 => 801F287C 3OJ3 => 801F2E00 3OJ4 => 801F3568 3OJ5 => 801F2E10 +3OE0 => 801F2CE0 +3OE1 => 801F2CE0 +3OE2 => 801F2E64 3OP0 => 801F33DC -3SE0 => 80109F78 +3SJT => 8010D5D8 3SJ0 => 8010A138 +3SE0 => 80109F78 3SP0 => 8010A404 Quest opcode handlers (format: GET_ARGS EXEC_FUN) diff --git a/src/Main.cc b/src/Main.cc index b853d6fe..26ed203c 100644 --- a/src/Main.cc +++ b/src/Main.cc @@ -1558,7 +1558,10 @@ Action a_assemble_quest_script( ? phosg::dirname(input_filename) : "."; - string result = assemble_quest_script(text, {include_dir, "system/client-functions/System"}); + string result = assemble_quest_script( + text, + {include_dir, "system/quests/includes"}, + {include_dir, "system/quests/includes", "system/client-functions/System"}); bool compress = !args.get("decompressed"); if (compress) { if (args.get("optimal")) { diff --git a/src/Quest.cc b/src/Quest.cc index 32d7a316..abc978df 100644 --- a/src/Quest.cc +++ b/src/Quest.cc @@ -637,7 +637,11 @@ QuestIndex::QuestIndex( file_data = decode_dlq_data(phosg::load_file(file_path)); filename.resize(filename.size() - 4); } else if (phosg::ends_with(filename, ".bin.txt")) { - file_data = assemble_quest_script(phosg::load_file(file_path), {phosg::dirname(file_path), "system/client-functions/System"}); + string include_dir = phosg::dirname(file_path); + file_data = assemble_quest_script( + phosg::load_file(file_path), + {include_dir, "system/quests/includes"}, + {include_dir, "system/quests/includes", "system/client-functions/System"}); filename.resize(filename.size() - 4); if (phosg::ends_with(filename, ".bin")) { filename.push_back('d'); diff --git a/src/QuestScript.cc b/src/QuestScript.cc index 9e2fc55d..eb374539 100644 --- a/src/QuestScript.cc +++ b/src/QuestScript.cc @@ -396,7 +396,8 @@ static const QuestScriptOpcodeDefinition opcode_defs[] = { // Sets regA to the memory address of regB {0x0C, "leta", nullptr, {REG, REG}, F_V3_V4}, - // Sets regA to the address of labelB + // Sets regA to the address of the offset of labelB in the function table + // (to get the offset, use read4 after this) {0x0D, "leto", nullptr, {REG, SCRIPT16}, F_V3_V4}, // Sets regA to 1 @@ -466,9 +467,12 @@ static const QuestScriptOpcodeDefinition opcode_defs[] = { {0x25, "xori", nullptr, {REG, INT32}, F_V0_V4}, // regA %= regB + // Note: This does signed division, so if the value is negative, you might + // get unexpected results. {0x26, "mod", nullptr, {REG, REG}, F_V3_V4}, // regA %= valueB + // Note: Unlike mod, this does unsigned division. {0x27, "modi", nullptr, {REG, INT32}, F_V3_V4}, // Jumps to labelA @@ -1235,9 +1239,11 @@ static const QuestScriptOpcodeDefinition opcode_defs[] = { {0xE8, "set_eventflag2", nullptr, {INT32, REG}, F_V1_V4 | F_ARGS}, // regA %= regB + // This is exactly the same as the mod opcode (including its quirk). {0xE9, "mod2", "res", {REG, REG}, F_V1_V4}, // regA %= valueB + // This is exactly the same as the modi opcode (including its quirk). {0xEA, "modi2", "unknownEA", {REG, INT32}, F_V1_V4}, // Changes the background music. create_bgmctrl must be run before doing @@ -2598,7 +2604,9 @@ static const QuestScriptOpcodeDefinition opcode_defs[] = { {0xF93D, "get_lang_setting", "get_lang_setting?", {REG}, F_V3_V4 | F_ARGS}, // Sets some values to be sent to the server with send_statistic. - // valueA = stat_id (used in send_statistic) + // valueA = stat_id (used in send_statistic); this is set to the quest + // number from the header when the quest starts, but that is overwritten + // by prepare_statistic // labelB = label1 (used in send_statistic) // labelC = label2 (used in send_statistic) {0xF93E, "prepare_statistic", "prepare_statistic?", {INT32, LABEL32, LABEL32}, F_V3_V4 | F_ARGS}, @@ -3989,26 +3997,101 @@ struct RegisterAssigner { array, 0x100> numbered_regs; }; -std::string assemble_quest_script(const std::string& text, const vector& include_directories) { - auto lines = phosg::split(text, '\n'); +std::string assemble_quest_script( + const std::string& text, + const vector& script_include_directories, + const vector& native_include_directories) { - // Strip comments and whitespace - for (auto& line : lines) { - size_t comment_start = line.find("/*"); - while (comment_start != string::npos) { - size_t comment_end = line.find("*/", comment_start + 2); - if (comment_end == string::npos) { - throw runtime_error("unterminated inline comment"); + struct Line { + string filename; // Empty if this is the main file + size_t number; // 1-based (there is no line 0) + string text; + ssize_t parent_index; // -1 if it's from the root file + }; + + auto wrap_exceptions_with_line_ref = [](const Line& line, auto fn) -> void { + try { + fn(); + } catch (const exception& e) { + if (line.filename.empty()) { + throw runtime_error(phosg::string_printf("(__main__:%zu) %s", line.number, e.what())); + } else { + throw runtime_error(phosg::string_printf("(%s:%zu) %s", line.filename.c_str(), line.number, e.what())); } - line.erase(comment_start, comment_end + 2 - comment_start); - comment_start = line.find("/*"); } - comment_start = line.find("//"); - if (comment_start != string::npos) { - line.resize(comment_start); + }; + + std::vector lines; + auto include_file = [&](const std::string& filename, const std::string& text, ssize_t parent_index) { + // Inserts the new lines after the parent line and preprocesses them. The + // parent line is not modified or deleted. + vector new_lines; + auto text_lines = phosg::split(text, '\n'); + for (size_t z = 0; z < text_lines.size(); z++) { + auto& line = new_lines.emplace_back(); + line.filename = filename; + line.number = z + 1; + line.text = std::move(text_lines[z]); + line.parent_index = parent_index; + + // Strip comments and whitespace + size_t comment_start = line.text.find("/*"); + while (comment_start != string::npos) { + size_t comment_end = line.text.find("*/", comment_start + 2); + if (comment_end == string::npos) { + throw runtime_error("unterminated inline comment"); + } + line.text.erase(comment_start, comment_end + 2 - comment_start); + comment_start = line.text.find("/*"); + } + comment_start = line.text.find("//"); + if (comment_start != string::npos) { + line.text.resize(comment_start); + } + phosg::strip_trailing_whitespace(line.text); + phosg::strip_leading_whitespace(line.text); + } + + if (parent_index < 0) { // Root file + lines = std::move(new_lines); + } else { + lines.insert( + lines.begin() + (parent_index + 1), + std::make_move_iterator(new_lines.begin()), + std::make_move_iterator(new_lines.end())); + } + }; + include_file("", text, -1); + + // Process all includes + for (size_t z = 0; z < lines.size(); z++) { + if (phosg::starts_with(lines[z].text, ".include ")) { + string filename = lines[z].text.substr(9); + phosg::strip_leading_whitespace(filename); + + // Make sure there's not a cycle + unordered_set seen_filenames; + for (ssize_t index = lines[z].parent_index; index >= 0; index = lines[index].parent_index) { + if (!seen_filenames.emplace(lines.at(index).filename).second) { + throw runtime_error(phosg::string_printf("detected cycle while including %s", filename.c_str())); + } + } + + bool found = false; + for (const auto& include_dir : script_include_directories) { + string include_path = include_dir + "/" + filename; + if (phosg::isfile(include_path)) { + found = true; + include_file(filename, phosg::load_file(filename), z); + break; + } + } + if (!found) { + throw runtime_error(phosg::string_printf("included file %s not found in any include directory", filename.c_str())); + } + + // We leave the .include line there; it will be ignored in the logic below } - phosg::strip_trailing_whitespace(line); - phosg::strip_leading_whitespace(line); } // Collect metadata directives @@ -4022,31 +4105,36 @@ std::string assemble_quest_script(const std::string& text, const vector& uint8_t quest_max_players = 4; bool quest_joinable = false; for (const auto& line : lines) { - if (line.empty()) { + if (line.text.empty()) { continue; } - if (line[0] == '.') { - if (phosg::starts_with(line, ".version ")) { - string name = line.substr(9); - quest_version = phosg::enum_for_name(name.c_str()); - } else if (phosg::starts_with(line, ".name ")) { - quest_name = phosg::parse_data_string(line.substr(6)); - } else if (phosg::starts_with(line, ".short_desc ")) { - quest_short_desc = phosg::parse_data_string(line.substr(12)); - } else if (phosg::starts_with(line, ".long_desc ")) { - quest_long_desc = phosg::parse_data_string(line.substr(11)); - } else if (phosg::starts_with(line, ".quest_num ")) { - quest_num = stoul(line.substr(11), nullptr, 0); - } else if (phosg::starts_with(line, ".language ")) { - quest_language = stoul(line.substr(10), nullptr, 0); - } else if (phosg::starts_with(line, ".episode ")) { - quest_episode = episode_for_token_name(line.substr(9)); - } else if (phosg::starts_with(line, ".max_players ")) { - quest_max_players = stoul(line.substr(12), nullptr, 0); - } else if (phosg::starts_with(line, ".joinable ")) { - quest_joinable = true; + wrap_exceptions_with_line_ref(line, [&]() -> void { + if (line.text[0] == '.') { + if (phosg::starts_with(line.text, ".include ")) { + // Nothing to do + } else if (phosg::starts_with(line.text, ".version ")) { + string name = line.text.substr(9); + phosg::strip_leading_whitespace(name); + quest_version = phosg::enum_for_name(name.c_str()); + } else if (phosg::starts_with(line.text, ".name ")) { + quest_name = phosg::parse_data_string(line.text.substr(6)); + } else if (phosg::starts_with(line.text, ".short_desc ")) { + quest_short_desc = phosg::parse_data_string(line.text.substr(12)); + } else if (phosg::starts_with(line.text, ".long_desc ")) { + quest_long_desc = phosg::parse_data_string(line.text.substr(11)); + } else if (phosg::starts_with(line.text, ".quest_num ")) { + quest_num = stoul(line.text.substr(11), nullptr, 0); + } else if (phosg::starts_with(line.text, ".language ")) { + quest_language = stoul(line.text.substr(10), nullptr, 0); + } else if (phosg::starts_with(line.text, ".episode ")) { + quest_episode = episode_for_token_name(line.text.substr(9)); + } else if (phosg::starts_with(line.text, ".max_players ")) { + quest_max_players = stoul(line.text.substr(12), nullptr, 0); + } else if (phosg::starts_with(line.text, ".joinable ")) { + quest_joinable = true; + } } - } + }); } if (quest_version == Version::PC_PATCH || quest_version == Version::BB_PATCH || quest_version == Version::UNKNOWN) { throw runtime_error(".version directive is missing or invalid"); @@ -4066,35 +4154,38 @@ std::string assemble_quest_script(const std::string& text, const vector& }; map> labels_by_name; map> labels_by_index; - for (size_t line_num = 1; line_num <= lines.size(); line_num++) { - const auto& line = lines[line_num - 1]; - if (phosg::ends_with(line, ":")) { - auto label = make_shared