From 0c3fe1345cab72586dfcf1d9bcffb863142afb0c Mon Sep 17 00:00:00 2001 From: Andrettin <6322423+Andrettin@users.noreply.github.com> Date: Thu, 21 Aug 2025 22:52:34 +0200 Subject: [PATCH] Parse Quest Data from TSV --- CMake/Assets.cmake | 1 + Source/diablo.cpp | 1 + Source/levels/gendung.cpp | 10 +++++ Source/levels/gendung.h | 1 + Source/lua/lua_global.cpp | 1 + Source/monstdat.cpp | 14 +----- Source/quests.cpp | 68 +++++++++++++++++------------- Source/quests.h | 6 ++- Source/textdat.cpp | 22 ++++++++++ Source/textdat.h | 6 +++ assets/txtdata/quests/questdat.tsv | 25 +++++++++++ test/drlg_l2_test.cpp | 3 ++ test/drlg_test.hpp | 3 ++ tools/extract_translation_data.py | 9 ++++ 14 files changed, 126 insertions(+), 44 deletions(-) create mode 100644 assets/txtdata/quests/questdat.tsv diff --git a/CMake/Assets.cmake b/CMake/Assets.cmake index a371d1e7c..e1b888ba6 100644 --- a/CMake/Assets.cmake +++ b/CMake/Assets.cmake @@ -173,6 +173,7 @@ set(devilutionx_assets txtdata/monsters/monstdat.tsv txtdata/monsters/unique_monstdat.tsv txtdata/objects/objdat.tsv + txtdata/quests/questdat.tsv txtdata/sound/effects.tsv txtdata/spells/spelldat.tsv ui_art/diablo.pal diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 1af81882f..0871ec2c1 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -2675,6 +2675,7 @@ int DiabloMain(int argc, char **argv) LoadMonsterData(); LoadItemData(); LoadObjectData(); + LoadQuestData(); DiabloInit(); #ifdef __UWP__ diff --git a/Source/levels/gendung.cpp b/Source/levels/gendung.cpp index 67894c38b..39dd6057a 100644 --- a/Source/levels/gendung.cpp +++ b/Source/levels/gendung.cpp @@ -10,6 +10,7 @@ #include #include +#include #include "engine/clx_sprite.hpp" #include "engine/load_file.hpp" @@ -844,4 +845,13 @@ tl::expected ParseDungeonType(std::string_view value) return tl::make_unexpected("Unknown enum value"); } +tl::expected<_setlevels, std::string> ParseSetLevel(std::string_view value) +{ + const std::optional<_setlevels> enumValueOpt = magic_enum::enum_cast<_setlevels>(value); + if (enumValueOpt.has_value()) { + return enumValueOpt.value(); + } + return tl::make_unexpected("Unknown enum value"); +} + } // namespace devilution diff --git a/Source/levels/gendung.h b/Source/levels/gendung.h index 910ed19ee..090c14fa3 100644 --- a/Source/levels/gendung.h +++ b/Source/levels/gendung.h @@ -59,6 +59,7 @@ inline bool IsArenaLevel(_setlevels setLevel) } tl::expected ParseDungeonType(std::string_view value); +tl::expected<_setlevels, std::string> ParseSetLevel(std::string_view value); enum class DungeonFlag : uint8_t { // clang-format off diff --git a/Source/lua/lua_global.cpp b/Source/lua/lua_global.cpp index b0c187015..69daaf301 100644 --- a/Source/lua/lua_global.cpp +++ b/Source/lua/lua_global.cpp @@ -236,6 +236,7 @@ void LuaReloadActiveMods() LoadMonsterData(); LoadItemData(); LoadObjectData(); + LoadQuestData(); LuaEvent("LoadModsComplete"); } diff --git a/Source/monstdat.cpp b/Source/monstdat.cpp index 6355b5e47..6ee169f63 100644 --- a/Source/monstdat.cpp +++ b/Source/monstdat.cpp @@ -448,19 +448,7 @@ void LoadUniqueMonstDatFromFile(DataFile &dataFile, std::string_view filename) reader.read("monsterPack", monster.monsterPack, ParseUniqueMonsterPack); reader.readInt("customToHit", monster.customToHit); reader.readInt("customArmorClass", monster.customArmorClass); - - // talkMessage - // TODO: Replace this hack with proper parsing once messages have been migrated to data files. - reader.read("talkMessage", monster.mtalkmsg, [](std::string_view value) -> tl::expected<_speech_id, std::string> { - if (value.empty()) return TEXT_NONE; - if (value == "TEXT_GARBUD1") return TEXT_GARBUD1; - if (value == "TEXT_ZHAR1") return TEXT_ZHAR1; - if (value == "TEXT_BANNER10") return TEXT_BANNER10; - if (value == "TEXT_VILE13") return TEXT_VILE13; - if (value == "TEXT_VEIL9") return TEXT_VEIL9; - if (value == "TEXT_WARLRD9") return TEXT_WARLRD9; - return tl::make_unexpected("Invalid value. NOTE: Parser is incomplete"); - }); + reader.read("talkMessage", monster.mtalkmsg, ParseSpeechId); } } diff --git a/Source/quests.cpp b/Source/quests.cpp index 1839d1144..f6676afd2 100644 --- a/Source/quests.cpp +++ b/Source/quests.cpp @@ -12,6 +12,8 @@ #include "DiabloUI/ui_flags.hpp" #include "control.h" #include "cursor.h" +#include "data/file.hpp" +#include "data/record_reader.hpp" #include "engine/load_file.hpp" #include "engine/random.hpp" #include "engine/render/clx_render.hpp" @@ -47,35 +49,7 @@ dungeon_type ReturnLevelType; int ReturnLevel; /** Contains the data related to each quest_id. */ -QuestData QuestsData[] = { - // clang-format off - // _qdlvl, _qdmultlvl, _qlvlt, bookOrder, _qdrnd, _qslvl, isSinglePlayerOnly, _qdmsg, _qlstr - { 5, -1, DTYPE_NONE, 5, 100, SL_NONE, true, TEXT_INFRA5, N_( /* TRANSLATORS: Quest Name Block */ "The Magic Rock") }, - { 9, -1, DTYPE_NONE, 10, 100, SL_NONE, true, TEXT_MUSH8, N_("Black Mushroom") }, - { 4, -1, DTYPE_NONE, 3, 100, SL_NONE, true, TEXT_GARBUD1, N_("Gharbad The Weak") }, - { 8, -1, DTYPE_NONE, 9, 100, SL_NONE, true, TEXT_ZHAR1, N_("Zhar the Mad") }, - { 14, -1, DTYPE_NONE, 21, 100, SL_NONE, true, TEXT_VEIL9, N_("Lachdanan") }, - { 15, -1, DTYPE_NONE, 23, 100, SL_NONE, false, TEXT_VILE3, N_("Diablo") }, - { 2, 2, DTYPE_NONE, 0, 100, SL_NONE, false, TEXT_BUTCH9, N_("The Butcher") }, - { 4, -1, DTYPE_NONE, 4, 100, SL_NONE, true, TEXT_BANNER2, N_("Ogden's Sign") }, - { 7, -1, DTYPE_NONE, 8, 100, SL_NONE, true, TEXT_BLINDING, N_("Halls of the Blind") }, - { 5, -1, DTYPE_NONE, 6, 100, SL_NONE, true, TEXT_BLOODY, N_("Valor") }, - { 10, -1, DTYPE_NONE, 11, 100, SL_NONE, true, TEXT_ANVIL5, N_("Anvil of Fury") }, - { 13, -1, DTYPE_NONE, 20, 100, SL_NONE, true, TEXT_BLOODWAR, N_("Warlord of Blood") }, - { 3, 3, DTYPE_CATHEDRAL, 2, 100, SL_SKELKING, false, TEXT_KING2, N_("The Curse of King Leoric") }, - { 2, -1, DTYPE_CAVES, 1, 100, SL_POISONWATER, true, TEXT_POISON3, N_("Poisoned Water Supply") }, - { 6, -1, DTYPE_CATACOMBS, 7, 100, SL_BONECHAMB, true, TEXT_BONER, N_("The Chamber of Bone") }, - { 15, 15, DTYPE_CATHEDRAL, 22, 100, SL_VILEBETRAYER, false, TEXT_VILE1, N_("Archbishop Lazarus") }, - { 17, 17, DTYPE_NONE, 17, 100, SL_NONE, false, TEXT_GRAVE7, N_("Grave Matters") }, - { 9, 9, DTYPE_NONE, 12, 100, SL_NONE, false, TEXT_FARMER1, N_("Farmer's Orchard") }, - { 17, -1, DTYPE_NONE, 14, 100, SL_NONE, true, TEXT_GIRL2, N_("Little Girl") }, - { 19, -1, DTYPE_NONE, 16, 100, SL_NONE, true, TEXT_TRADER, N_("Wandering Trader") }, - { 17, 17, DTYPE_NONE, 15, 100, SL_NONE, false, TEXT_DEFILER1, N_("The Defiler") }, - { 21, 21, DTYPE_NONE, 19, 100, SL_NONE, false, TEXT_NAKRUL1, N_("Na-Krul") }, - { 21, -1, DTYPE_NONE, 18, 100, SL_NONE, true, TEXT_CORNSTN, N_("Cornerstone of the World") }, - { 9, 9, DTYPE_NONE, 13, 100, SL_NONE, false, TEXT_JERSEY4, N_( /* TRANSLATORS: Quest Name Block end*/ "The Jersey's Jersey") }, - // clang-format on -}; +std::vector QuestsData; namespace { @@ -948,4 +922,40 @@ bool Quest::IsAvailable() const return true; } +namespace { + +void LoadQuestDatFromFile(DataFile &dataFile, std::string_view filename) +{ + dataFile.skipHeaderOrDie(filename); + + QuestsData.reserve(QuestsData.size() + dataFile.numRecords()); + + for (DataFileRecord record : dataFile) { + RecordReader reader { record, filename }; + QuestData &quest = QuestsData.emplace_back(); + reader.readInt("qdlvl", quest._qdlvl); + reader.readInt("qdmultlvl", quest._qdmultlvl); + reader.read("qlvlt", quest._qlvlt, ParseDungeonType); + reader.readInt("bookOrder", quest.questBookOrder); + reader.readInt("qdrnd", quest._qdrnd); + reader.read("qslvl", quest._qslvl, ParseSetLevel); + reader.readBool("isSinglePlayerOnly", quest.isSinglePlayerOnly); + reader.read("qdmsg", quest._qdmsg, ParseSpeechId); + reader.readString("qlstr", quest._qlstr); + } +} + +} // namespace + +void LoadQuestData() +{ + const std::string_view filename = "txtdata\\quests\\questdat.tsv"; + DataFile dataFile = DataFile::loadOrDie(filename); + + QuestsData.clear(); + LoadQuestDatFromFile(dataFile, filename); + + QuestsData.shrink_to_fit(); +} + } // namespace devilution diff --git a/Source/quests.h b/Source/quests.h index a0d2f935e..5a47bc8cc 100644 --- a/Source/quests.h +++ b/Source/quests.h @@ -103,7 +103,7 @@ struct QuestData { _setlevels _qslvl; bool isSinglePlayerOnly; _speech_id _qdmsg; - const char *_qlstr; + std::string _qlstr; }; extern bool QuestLogIsOpen; @@ -141,6 +141,8 @@ void SetMultiQuest(int q, quest_state s, bool log, int v1, int v2, int16_t qmsg) bool UseMultiplayerQuests(); /* rdata */ -extern QuestData QuestsData[]; +extern std::vector QuestsData; + +void LoadQuestData(); } // namespace devilution diff --git a/Source/textdat.cpp b/Source/textdat.cpp index 4275bf854..75ee842a0 100644 --- a/Source/textdat.cpp +++ b/Source/textdat.cpp @@ -4,8 +4,17 @@ * Implementation of all dialog texts. */ #include "textdat.h" + +#include + #include "utils/language.h" +template <> +struct magic_enum::customize::enum_range { + static constexpr int min = devilution::TEXT_NONE; + static constexpr int max = devilution::NUM_TEXT_IDS; +}; + namespace devilution { /* todo: move text out of struct */ @@ -919,4 +928,17 @@ const Speech Speeches[] = { const size_t SpeechCount = sizeof(Speeches) / sizeof(Speech); +tl::expected<_speech_id, std::string> ParseSpeechId(std::string_view value) +{ + if (value.empty()) { + return TEXT_NONE; + } + + const std::optional<_speech_id> enumValueOpt = magic_enum::enum_cast<_speech_id>(value); + if (enumValueOpt.has_value()) { + return enumValueOpt.value(); + } + return tl::make_unexpected("Invalid value."); +} + } // namespace devilution diff --git a/Source/textdat.h b/Source/textdat.h index 1a8264cf2..1dea62b96 100644 --- a/Source/textdat.h +++ b/Source/textdat.h @@ -7,6 +7,9 @@ #include #include +#include + +#include #include "sound_effect_enums.h" @@ -422,6 +425,7 @@ enum _speech_id : int16_t { TEXT_GRISWOLD36, TEXT_GRISWOLD37, */ + NUM_TEXT_IDS, TEXT_NONE = -1, }; @@ -434,4 +438,6 @@ struct Speech { extern const size_t SpeechCount; extern const Speech Speeches[]; +tl::expected<_speech_id, std::string> ParseSpeechId(std::string_view value); + } // namespace devilution diff --git a/assets/txtdata/quests/questdat.tsv b/assets/txtdata/quests/questdat.tsv new file mode 100644 index 000000000..0e499f91d --- /dev/null +++ b/assets/txtdata/quests/questdat.tsv @@ -0,0 +1,25 @@ +qdlvl qdmultlvl qlvlt bookOrder qdrnd qslvl isSinglePlayerOnly qdmsg qlstr +5 -1 5 100 SL_NONE true TEXT_INFRA5 The Magic Rock +9 -1 10 100 SL_NONE true TEXT_MUSH8 Black Mushroom +4 -1 3 100 SL_NONE true TEXT_GARBUD1 Gharbad The Weak +8 -1 9 100 SL_NONE true TEXT_ZHAR1 Zhar the Mad +14 -1 21 100 SL_NONE true TEXT_VEIL9 Lachdanan +15 -1 23 100 SL_NONE false TEXT_VILE3 Diablo +2 2 0 100 SL_NONE false TEXT_BUTCH9 The Butcher +4 -1 4 100 SL_NONE true TEXT_BANNER2 Ogden's Sign +7 -1 8 100 SL_NONE true TEXT_BLINDING Halls of the Blind +5 -1 6 100 SL_NONE true TEXT_BLOODY Valor +10 -1 11 100 SL_NONE true TEXT_ANVIL5 Anvil of Fury +13 -1 20 100 SL_NONE true TEXT_BLOODWAR Warlord of Blood +3 3 DTYPE_CATHEDRAL 2 100 SL_SKELKING false TEXT_KING2 The Curse of King Leoric +2 -1 DTYPE_CAVES 1 100 SL_POISONWATER true TEXT_POISON3 Poisoned Water Supply +6 -1 DTYPE_CATACOMBS 7 100 SL_BONECHAMB true TEXT_BONER The Chamber of Bone +15 15 DTYPE_CATHEDRAL 22 100 SL_VILEBETRAYER false TEXT_VILE1 Archbishop Lazarus +17 17 17 100 SL_NONE false TEXT_GRAVE7 Grave Matters +9 9 12 100 SL_NONE false TEXT_FARMER1 Farmer's Orchard +17 -1 14 100 SL_NONE true TEXT_GIRL2 Little Girl +19 -1 16 100 SL_NONE true TEXT_TRADER Wandering Trader +17 17 15 100 SL_NONE false TEXT_DEFILER1 The Defiler +21 21 19 100 SL_NONE false TEXT_NAKRUL1 Na-Krul +21 -1 18 100 SL_NONE true TEXT_CORNSTN Cornerstone of the World +9 9 13 100 SL_NONE false TEXT_JERSEY4 The Jersey's Jersey diff --git a/test/drlg_l2_test.cpp b/test/drlg_l2_test.cpp index a2a10786a..7bb4ef3d5 100644 --- a/test/drlg_l2_test.cpp +++ b/test/drlg_l2_test.cpp @@ -12,6 +12,9 @@ TEST(Drlg_l2, CreateL2Dungeon_diablo_5_1677631846) { LoadExpectedLevelData("diablo/5-1677631846.dun"); + LoadCoreArchives(); + LoadQuestData(); + InitQuests(); Quests[Q_BLOOD]._qactive = QUEST_NOTAVAIL; diff --git a/test/drlg_test.hpp b/test/drlg_test.hpp index 15b493406..247089aeb 100644 --- a/test/drlg_test.hpp +++ b/test/drlg_test.hpp @@ -60,6 +60,9 @@ void TestInitGame(bool fullQuests = true, bool originalCathedral = true, bool he sgGameInitInfo.fullQuests = fullQuests ? 1 : 0; gbIsMultiplayer = !fullQuests; + LoadCoreArchives(); + LoadQuestData(); + UnloadModArchives(); if (hellfire) { LoadModArchives({ { "Hellfire" } }); diff --git a/tools/extract_translation_data.py b/tools/extract_translation_data.py index 90629ac2d..46f28382f 100755 --- a/tools/extract_translation_data.py +++ b/tools/extract_translation_data.py @@ -12,6 +12,7 @@ base_paths = { "unique_itemdat": root.joinpath("assets/txtdata/items/unique_itemdat.tsv"), "item_prefixes": root.joinpath("assets/txtdata/items/item_prefixes.tsv"), "item_suffixes": root.joinpath("assets/txtdata/items/item_suffixes.tsv"), + "questdat": root.joinpath("assets/txtdata/quests/questdat.tsv"), "spelldat": root.joinpath("assets/txtdata/spells/spelldat.tsv"), } @@ -81,6 +82,14 @@ def process_files(paths, temp_source): for i, row in enumerate(reader): write_entry(temp_source, f'ITEM_SUFFIX_{i}_NAME', "default", row['name'], False) + # Quests + if "questdat" in paths: + with open(paths["questdat"], 'r') as tsv: + reader = csv.DictReader(tsv, delimiter='\t') + for i, row in enumerate(reader): + var_name = 'QUEST_' + row['qlstr'].upper().replace(' ', '_').replace('-', '_') + write_entry(temp_source, f'{var_name}_NAME', "default", row['qlstr'], False) + # Spells with open(paths["spelldat"], 'r') as tsv: reader = csv.DictReader(tsv, delimiter='\t')