From cfb52ee239ee930ec4c92260910db084399447fd Mon Sep 17 00:00:00 2001 From: Andrettin <6322423+Andrettin@users.noreply.github.com> Date: Sun, 10 Aug 2025 23:03:27 +0200 Subject: [PATCH] Added support for mods to add unique monsters without replacing any data (#8092) --- Source/CMakeLists.txt | 2 + Source/loadsave.cpp | 84 +++++++++++++++++++++++++------ Source/lua/lua_global.cpp | 6 +++ Source/lua/modules/monsters.cpp | 77 ++++++++++++++++++++++++++++ Source/lua/modules/monsters.hpp | 9 ++++ Source/monstdat.cpp | 21 ++++++-- Source/monstdat.h | 6 +++ Source/monsters/validation.cpp | 23 +++++++++ Source/monsters/validation.hpp | 3 ++ assets/lua/devilutionx/events.lua | 4 ++ 10 files changed, 216 insertions(+), 19 deletions(-) create mode 100644 Source/lua/modules/monsters.cpp create mode 100644 Source/lua/modules/monsters.hpp diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 8cd758c61..ed72d6168 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -116,6 +116,7 @@ set(libdevilutionx_SRCS lua/modules/i18n.cpp lua/modules/items.cpp lua/modules/log.cpp + lua/modules/monsters.cpp lua/modules/player.cpp lua/modules/render.cpp lua/modules/towners.cpp @@ -525,6 +526,7 @@ add_devilutionx_object_library(libdevilutionx_monster target_link_dependencies(libdevilutionx_monster PUBLIC DevilutionX::SDL + sol2::sol2 tl libdevilutionx_game_mode libdevilutionx_headless_mode diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index c750c3518..ca2654315 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -32,6 +32,7 @@ #include "menu.h" #include "missiles.h" #include "monster.h" +#include "monsters/validation.hpp" #include "mpq/mpq_common.hpp" #include "pfile.h" #include "playerdat.hpp" @@ -629,7 +630,7 @@ void LoadPlayer(LoadHelper &file, Player &player) bool gbSkipSync = false; -void LoadMonster(LoadHelper *file, Monster &monster, MonsterConversionData *monsterConversionData = nullptr) +[[nodiscard]] bool LoadMonster(LoadHelper *file, Monster &monster, MonsterConversionData *monsterConversionData = nullptr) { monster.levelType = file->NextLE(); monster.mode = static_cast(file->NextLE()); @@ -736,6 +737,54 @@ void LoadMonster(LoadHelper *file, Monster &monster, MonsterConversionData *mons if (monster.mode == MonsterMode::Petrified) monster.animInfo.isPetrified = true; + + if (monster.isUnique()) { + // check if the unique monster is still valid (it could no longer be valid e.g. because the loaded mods changed and the unique monsters changed as a consequence) + const bool valid = IsUniqueMonsterValid(monster); + if (!valid) { + LogWarn("Unique monster no longer valid, skipping it."); + return false; + } + } + + return true; +} + +void LoadMonsters(LoadHelper &file, ankerl::unordered_dense::set &removedMonsterIds, const bool applyLight, LevelConversionData *levelConversionData) +{ + for (unsigned &monsterId : ActiveMonsters) + monsterId = file.NextBE(); + + for (size_t i = 0; i < ActiveMonsterCount;) { + Monster &monster = Monsters[ActiveMonsters[i]]; + MonsterConversionData *monsterConversionData = nullptr; + if (levelConversionData != nullptr) + monsterConversionData = &levelConversionData->monsterConversionData[ActiveMonsters[i]]; + const bool valid = LoadMonster(&file, monster, monsterConversionData); + if (!valid) { + Monsters[ActiveMonsters[i]] = {}; + removedMonsterIds.insert(ActiveMonsters[i]); + for (size_t j = i + 1; j < ActiveMonsterCount; j++) { + ActiveMonsters[j - 1] = ActiveMonsters[j]; + } + --ActiveMonsterCount; + continue; + } + + if (applyLight && monster.isUnique() && monster.lightId != NO_LIGHT) + Lights[monster.lightId].isInvalid = false; + + i++; + } + + for (const unsigned removedMonsterId : removedMonsterIds) { + for (size_t i = 0; i < ActiveMonsterCount; i++) { + Monster &activeMonster = Monsters[ActiveMonsters[i]]; + if ((activeMonster.flags & MFLAG_TARGETS_MONSTER) != 0 && activeMonster.enemy == removedMonsterId) { + activeMonster.flags |= MFLAG_NO_ENEMY; + } + } + } } /** @@ -1944,18 +1993,11 @@ tl::expected LoadLevel(LevelConversionData *levelConversionDa auto savedItemCount = file.NextBE(); ActiveObjectCount = file.NextBE(); + ankerl::unordered_dense::set removedMonsterIds; + if (leveltype != DTYPE_TOWN) { - for (unsigned &monsterId : ActiveMonsters) - monsterId = file.NextBE(); - for (size_t i = 0; i < ActiveMonsterCount; i++) { - Monster &monster = Monsters[ActiveMonsters[i]]; - MonsterConversionData *monsterConversionData = nullptr; - if (levelConversionData != nullptr) - monsterConversionData = &levelConversionData->monsterConversionData[ActiveMonsters[i]]; - LoadMonster(&file, monster, monsterConversionData); - if (monster.isUnique() && monster.lightId != NO_LIGHT) - Lights[monster.lightId].isInvalid = false; - } + LoadMonsters(file, removedMonsterIds, true, levelConversionData); + if (!gbSkipSync) { for (size_t i = 0; i < ActiveMonsterCount; i++) RETURN_IF_ERROR(SyncMonsterAnim(Monsters[ActiveMonsters[i]])); @@ -1985,7 +2027,12 @@ tl::expected LoadLevel(LevelConversionData *levelConversionDa if (leveltype != DTYPE_TOWN) { for (int j = 0; j < MAXDUNY; j++) { for (int i = 0; i < MAXDUNX; i++) // NOLINT(modernize-loop-convert) + { dMonster[i][j] = file.NextBE(); + if (dMonster[i][j] > 0 && removedMonsterIds.contains(std::abs(dMonster[i][j]) - 1)) { + dMonster[i][j] = 0; + } + } } for (int j = 0; j < MAXDUNY; j++) { for (int i = 0; i < MAXDUNX; i++) // NOLINT(modernize-loop-convert) @@ -2463,13 +2510,13 @@ tl::expected LoadGame(bool firstflag) for (int &monstkill : MonsterKillCounts) monstkill = file.NextBE(); + ankerl::unordered_dense::set removedMonsterIds; + // skip ahead for vanilla save compatibility (Related to bugfix where MonsterKillCounts[MaxMonsters] was changed to MonsterKillCounts[NUM_MTYPES] file.Skip(4 * (MaxMonsters - NUM_MTYPES)); if (leveltype != DTYPE_TOWN) { - for (unsigned &monsterId : ActiveMonsters) - monsterId = file.NextBE(); - for (size_t i = 0; i < ActiveMonsterCount; i++) - LoadMonster(&file, Monsters[ActiveMonsters[i]]); + LoadMonsters(file, removedMonsterIds, false, nullptr); + for (size_t i = 0; i < ActiveMonsterCount; i++) SyncPackSize(Monsters[ActiveMonsters[i]]); // Skip ActiveMissiles @@ -2530,7 +2577,12 @@ tl::expected LoadGame(bool firstflag) if (leveltype != DTYPE_TOWN) { for (int j = 0; j < MAXDUNY; j++) { for (int i = 0; i < MAXDUNX; i++) // NOLINT(modernize-loop-convert) + { dMonster[i][j] = file.NextBE(); + if (dMonster[i][j] > 0 && removedMonsterIds.contains(std::abs(dMonster[i][j]) - 1)) { + dMonster[i][j] = 0; + } + } } for (int j = 0; j < MAXDUNY; j++) { for (int i = 0; i < MAXDUNX; i++) // NOLINT(modernize-loop-convert) diff --git a/Source/lua/lua_global.cpp b/Source/lua/lua_global.cpp index 767147836..b21b01cae 100644 --- a/Source/lua/lua_global.cpp +++ b/Source/lua/lua_global.cpp @@ -16,6 +16,7 @@ #include "lua/modules/i18n.hpp" #include "lua/modules/items.hpp" #include "lua/modules/log.hpp" +#include "lua/modules/monsters.hpp" #include "lua/modules/player.hpp" #include "lua/modules/render.hpp" #include "lua/modules/towners.hpp" @@ -267,6 +268,7 @@ void LuaInitialize() "devilutionx.items", LuaItemModule(lua), "devilutionx.log", LuaLogModule(lua), "devilutionx.audio", LuaAudioModule(lua), + "devilutionx.monsters", LuaMonstersModule(lua), "devilutionx.player", LuaPlayerModule(lua), "devilutionx.render", LuaRenderModule(lua), "devilutionx.towners", LuaTownersModule(lua), @@ -295,6 +297,10 @@ void LuaShutdown() void LuaEvent(std::string_view name) { + if (!CurrentLuaState.has_value()) { + return; + } + const auto trigger = CurrentLuaState->events.traverse_get>(name, "trigger"); if (!trigger.has_value() || !trigger->is()) { LogError("events.{}.trigger is not a function", name); diff --git a/Source/lua/modules/monsters.cpp b/Source/lua/modules/monsters.cpp new file mode 100644 index 000000000..c12593c2c --- /dev/null +++ b/Source/lua/modules/monsters.cpp @@ -0,0 +1,77 @@ +#include "lua/modules/monsters.hpp" + +#include + +#include +#include + +#include "lua/metadoc.hpp" +#include "monstdat.h" +#include "utils/language.h" +#include "utils/str_split.hpp" + +namespace devilution { + +namespace { + +void AddUniqueMonsterData(const std::string_view type, const std::string_view name, const std::string_view trn, const uint8_t level, const uint16_t maxHp, const std::string_view ai, const uint8_t intelligence, const uint8_t minDamage, const uint8_t maxDamage, const std::string_view resistance, const std::string_view monsterPack, const std::optional customToHit, const std::optional customArmorClass) +{ + UniqueMonsterData monster; + + const auto monsterTypeResult = ParseMonsterId(type); + if (!monsterTypeResult.has_value()) { + DisplayFatalErrorAndExit(_("Adding Unique Monster Failed"), fmt::format(fmt::runtime(_("Failed to parse monster type ID \"{}\": {}")), type, monsterTypeResult.error())); + } + + monster.mtype = monsterTypeResult.value(); + monster.mName = name; + monster.mTrnName = trn; + monster.mlevel = level; + monster.mmaxhp = maxHp; + + const auto monsterAiResult = ParseAiId(ai); + if (!monsterAiResult.has_value()) { + DisplayFatalErrorAndExit(_("Adding Unique Monster Failed"), fmt::format(fmt::runtime(_("Failed to parse monster AI ID \"{}\": {}")), ai, monsterAiResult.error())); + } + + monster.mAi = monsterAiResult.value(); + monster.mint = intelligence; + monster.mMinDamage = minDamage; + monster.mMaxDamage = maxDamage; + + monster.mMagicRes = {}; + + if (!resistance.empty()) { + for (const std::string_view resistancePart : SplitByChar(resistance, ',')) { + const auto monsterResistanceResult = ParseMonsterResistance(resistancePart); + if (!monsterResistanceResult.has_value()) { + DisplayFatalErrorAndExit(_("Adding Unique Monster Failed"), fmt::format(fmt::runtime(_("Failed to parse monster resistance \"{}\": {}")), resistance, monsterResistanceResult.error())); + } + + monster.mMagicRes |= monsterResistanceResult.value(); + } + } + + const auto monsterPackResult = ParseUniqueMonsterPack(monsterPack); + if (!monsterPackResult.has_value()) { + DisplayFatalErrorAndExit(_("Adding Unique Monster Failed"), fmt::format(fmt::runtime(_("Failed to parse unique monster pack \"{}\": {}")), monsterPack, monsterPackResult.error())); + } + + monster.monsterPack = monsterPackResult.value(); + monster.customToHit = customToHit.value_or(0); + monster.customArmorClass = customArmorClass.value_or(0); + monster.mtalkmsg = TEXT_NONE; + + UniqueMonstersData.push_back(std::move(monster)); +} + +} // namespace + +sol::table LuaMonstersModule(sol::state_view &lua) +{ + sol::table table = lua.create_table(); + LuaSetDocFn(table, "addUniqueMonsterData", "(type: string, name: string, trn: string, level: number, maxHp: number, ai: string, intelligence: number, minDamage: number, maxDamage: number, resistance: string, monsterPack: string, customToHit: number = nil, customArmorClass: number = nil)", AddUniqueMonsterData); + return table; +} + +} // namespace devilution diff --git a/Source/lua/modules/monsters.hpp b/Source/lua/modules/monsters.hpp new file mode 100644 index 000000000..62b3655e7 --- /dev/null +++ b/Source/lua/modules/monsters.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace devilution { + +sol::table LuaMonstersModule(sol::state_view &lua); + +} // namespace devilution diff --git a/Source/monstdat.cpp b/Source/monstdat.cpp index 3f4d97460..58a65767d 100644 --- a/Source/monstdat.cpp +++ b/Source/monstdat.cpp @@ -8,12 +8,12 @@ #include #include -#include #include "cursor.h" #include "data/file.hpp" #include "data/record_reader.hpp" #include "items.h" +#include "lua/lua_global.hpp" #include "monster.h" #include "textdat.h" #include "utils/language.h" @@ -206,8 +206,6 @@ const _monster_id MonstConvTbl[] = { MT_LRDSAYTR, }; -namespace { - tl::expected<_monster_id, std::string> ParseMonsterId(std::string_view value) { if (value == "MT_NZOMBIE") return MT_NZOMBIE; @@ -370,6 +368,8 @@ tl::expected<_monster_id, std::string> ParseMonsterId(std::string_view value) return tl::make_unexpected("Unknown enum value"); } +namespace { + tl::expected ParseMonsterAvailability(std::string_view value) { if (value == "Always") return MonsterAvailability::Always; @@ -378,6 +378,8 @@ tl::expected ParseMonsterAvailability(std::str return tl::make_unexpected("Expected one of: Always, Never, or Retail"); } +} // namespace + tl::expected ParseAiId(std::string_view value) { if (value == "Zombie") return MonsterAIID::Zombie; @@ -423,6 +425,8 @@ tl::expected ParseAiId(std::string_view value) return tl::make_unexpected("Unknown enum value"); } +namespace { + tl::expected ParseMonsterFlag(std::string_view value) { if (value == "HIDDEN") return MFLAG_HIDDEN; @@ -448,6 +452,8 @@ tl::expected ParseMonsterClass(std::string_view value return tl::make_unexpected("Unknown enum value"); } +} // namespace + tl::expected ParseMonsterResistance(std::string_view value) { if (value == "RESIST_MAGIC") return RESIST_MAGIC; @@ -460,6 +466,8 @@ tl::expected ParseMonsterResistance(std::string return tl::make_unexpected("Unknown enum value"); } +namespace { + tl::expected ParseSelectionRegion(std::string_view value) { if (value.empty()) return SelectionRegion::None; @@ -469,6 +477,8 @@ tl::expected ParseSelectionRegion(std::string_view return tl::make_unexpected("Unknown enum value"); } +} // namespace + tl::expected ParseUniqueMonsterPack(std::string_view value) { if (value == "None") return UniqueMonsterPack::None; @@ -477,6 +487,8 @@ tl::expected ParseUniqueMonsterPack(std::string_ return tl::make_unexpected("Unknown enum value"); } +namespace { + void LoadMonstDat() { const std::string_view filename = "txtdata\\monsters\\monstdat.tsv"; @@ -583,7 +595,10 @@ void LoadUniqueMonstDat() return tl::make_unexpected("Invalid value. NOTE: Parser is incomplete"); }); } + UniqueMonstersData.shrink_to_fit(); + + LuaEvent("UniqueMonsterDataLoaded"); } } // namespace diff --git a/Source/monstdat.h b/Source/monstdat.h index 4aa2f839a..06f3bd01a 100644 --- a/Source/monstdat.h +++ b/Source/monstdat.h @@ -10,6 +10,8 @@ #include #include +#include + #include "cursor.h" #include "textdat.h" @@ -334,6 +336,10 @@ extern std::vector MonstersData; extern const _monster_id MonstConvTbl[]; extern std::vector UniqueMonstersData; +tl::expected<_monster_id, std::string> ParseMonsterId(std::string_view value); +tl::expected ParseAiId(std::string_view value); +tl::expected ParseMonsterResistance(std::string_view value); +tl::expected ParseUniqueMonsterPack(std::string_view value); void LoadMonsterData(); /** diff --git a/Source/monsters/validation.cpp b/Source/monsters/validation.cpp index e7e043497..427ecbbe6 100644 --- a/Source/monsters/validation.cpp +++ b/Source/monsters/validation.cpp @@ -39,4 +39,27 @@ bool IsEnemyValid(size_t monsterId, size_t enemyId) return IsEnemyValid(enemyId, true); } +bool IsUniqueMonsterValid(const Monster &monster) +{ + assert(monster.isUnique()); + + const size_t uniqueMonsterIndex = static_cast(monster.uniqueType); + if (uniqueMonsterIndex >= UniqueMonstersData.size()) { + return false; + } + + const CMonster &monsterType = LevelMonsterTypes[monster.levelType]; + const _monster_id monsterId = monsterType.type; + const UniqueMonsterData &uniqueMonsterData = UniqueMonstersData.at(uniqueMonsterIndex); + if (monsterId != uniqueMonsterData.mtype) { + return false; + } + + if (uniqueMonsterData.mlevel != 0 && uniqueMonsterData.mlevel != currlevel) { + return false; + } + + return true; +} + } // namespace devilution diff --git a/Source/monsters/validation.hpp b/Source/monsters/validation.hpp index 30bf9c116..75ca9d26a 100644 --- a/Source/monsters/validation.hpp +++ b/Source/monsters/validation.hpp @@ -9,7 +9,10 @@ namespace devilution { +struct Monster; + bool IsEnemyIdValid(size_t enemyId); bool IsEnemyValid(size_t monsterId, size_t enemyId); +bool IsUniqueMonsterValid(const Monster &monster); } // namespace devilution diff --git a/assets/lua/devilutionx/events.lua b/assets/lua/devilutionx/events.lua index 3847171aa..70d0b7de9 100644 --- a/assets/lua/devilutionx/events.lua +++ b/assets/lua/devilutionx/events.lua @@ -44,6 +44,10 @@ local events = { LoadModsComplete = CreateEvent(), __doc_LoadModsComplete = "Called after all mods have been loaded.", + ---Called after the unique monster data TSV file has been loaded. + UniqueMonsterDataLoaded = CreateEvent(), + __doc_UniqueMonsterDataLoaded = "Called after the unique monster data TSV file has been loaded.", + ---Called every time a new game is started. GameStart = CreateEvent(), __doc_GameStart = "Called every time a new game is started.",