From 062cfd1e4aab590d23a591d9116352e1d3c49e2e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 12:22:50 -0700 Subject: [PATCH] feat: add SavedVariables persistence for Lua addons Addons can now persist data across sessions using the standard WoW SavedVariables pattern: 1. Declare in .toc: ## SavedVariables: MyAddonDB 2. Use the global in Lua: MyAddonDB = MyAddonDB or {default = true} 3. Data is automatically saved on logout and restored on next login Implementation: - TocFile::getSavedVariables() parses comma-separated variable names - LuaEngine::loadSavedVariables() executes saved .lua file to restore globals - LuaEngine::saveSavedVariables() serializes Lua tables/values to valid Lua - Serializer handles tables (nested), strings, numbers, booleans, nil - Save triggered on PLAYER_LEAVING_WORLD and AddonManager::shutdown() - Files stored as /.lua.saved Updated HelloWorld addon to track login count across sessions. --- .../AddOns/HelloWorld/HelloWorld.lua | 10 +- .../AddOns/HelloWorld/HelloWorld.toc | 1 + include/addons/addon_manager.hpp | 3 + include/addons/lua_engine.hpp | 4 + include/addons/toc_parser.hpp | 1 + src/addons/addon_manager.cpp | 24 +++- src/addons/lua_engine.cpp | 114 ++++++++++++++++++ src/addons/toc_parser.cpp | 21 ++++ src/core/application.cpp | 1 + 9 files changed, 177 insertions(+), 2 deletions(-) diff --git a/Data/interface/AddOns/HelloWorld/HelloWorld.lua b/Data/interface/AddOns/HelloWorld/HelloWorld.lua index bdddfc9f..5ee38fd6 100644 --- a/Data/interface/AddOns/HelloWorld/HelloWorld.lua +++ b/Data/interface/AddOns/HelloWorld/HelloWorld.lua @@ -1,5 +1,11 @@ -- HelloWorld addon — demonstrates the WoWee addon system +-- Initialize saved variables (persisted across sessions) +if not HelloWorldDB then + HelloWorldDB = { loginCount = 0 } +end +HelloWorldDB.loginCount = (HelloWorldDB.loginCount or 0) + 1 + -- Create a frame and register for events (standard WoW addon pattern) local f = CreateFrame("Frame", "HelloWorldFrame") f:RegisterEvent("PLAYER_ENTERING_WORLD") @@ -10,6 +16,7 @@ f:SetScript("OnEvent", function(self, event, ...) local name = UnitName("player") local level = UnitLevel("player") print("|cff00ff00[HelloWorld]|r Welcome, " .. name .. "! (Level " .. level .. ")") + print("|cff00ff00[HelloWorld]|r Login count: " .. HelloWorldDB.loginCount) elseif event == "CHAT_MSG_SAY" then local msg, sender = ... if msg and sender then @@ -23,6 +30,7 @@ SLASH_HELLOWORLD1 = "/hello" SLASH_HELLOWORLD2 = "/hw" SlashCmdList["HELLOWORLD"] = function(args) print("|cff00ff00[HelloWorld]|r Hello! " .. (args ~= "" and args or "Type /hello ")) + print("|cff00ff00[HelloWorld]|r Sessions: " .. HelloWorldDB.loginCount) end -print("|cff00ff00[HelloWorld]|r Addon loaded. Type /hello to test slash commands.") +print("|cff00ff00[HelloWorld]|r Addon loaded. Type /hello to test.") diff --git a/Data/interface/AddOns/HelloWorld/HelloWorld.toc b/Data/interface/AddOns/HelloWorld/HelloWorld.toc index 852994a1..f50ef105 100644 --- a/Data/interface/AddOns/HelloWorld/HelloWorld.toc +++ b/Data/interface/AddOns/HelloWorld/HelloWorld.toc @@ -1,4 +1,5 @@ ## Interface: 30300 ## Title: Hello World ## Notes: Test addon for the WoWee addon system +## SavedVariables: HelloWorldDB HelloWorld.lua diff --git a/include/addons/addon_manager.hpp b/include/addons/addon_manager.hpp index 7983749a..be4a6a89 100644 --- a/include/addons/addon_manager.hpp +++ b/include/addons/addon_manager.hpp @@ -25,11 +25,14 @@ public: LuaEngine* getLuaEngine() { return &luaEngine_; } bool isInitialized() const { return luaEngine_.isInitialized(); } + void saveAllSavedVariables(); + private: LuaEngine luaEngine_; std::vector addons_; bool loadAddon(const TocFile& addon); + std::string getSavedVariablesPath(const TocFile& addon) const; }; } // namespace wowee::addons diff --git a/include/addons/lua_engine.hpp b/include/addons/lua_engine.hpp index 2ee5954c..02f9ce54 100644 --- a/include/addons/lua_engine.hpp +++ b/include/addons/lua_engine.hpp @@ -35,6 +35,10 @@ public: // Call OnUpdate scripts on all frames that have one. void dispatchOnUpdate(float elapsed); + // SavedVariables: load globals from file, save globals to file + bool loadSavedVariables(const std::string& path); + bool saveSavedVariables(const std::string& path, const std::vector& varNames); + lua_State* getState() { return L_; } bool isInitialized() const { return L_ != nullptr; } diff --git a/include/addons/toc_parser.hpp b/include/addons/toc_parser.hpp index 09c7f164..7bfff469 100644 --- a/include/addons/toc_parser.hpp +++ b/include/addons/toc_parser.hpp @@ -17,6 +17,7 @@ struct TocFile { std::string getTitle() const; std::string getInterface() const; bool isLoadOnDemand() const; + std::vector getSavedVariables() const; }; std::optional parseTocFile(const std::string& tocPath); diff --git a/src/addons/addon_manager.cpp b/src/addons/addon_manager.cpp index ad71bcec..4f965b2a 100644 --- a/src/addons/addon_manager.cpp +++ b/src/addons/addon_manager.cpp @@ -61,10 +61,21 @@ void AddonManager::loadAllAddons() { (failed > 0 ? (", " + std::to_string(failed) + " failed") : "")); } +std::string AddonManager::getSavedVariablesPath(const TocFile& addon) const { + return addon.basePath + "/" + addon.addonName + ".lua.saved"; +} + bool AddonManager::loadAddon(const TocFile& addon) { + // Load SavedVariables before addon code (so globals are available at load time) + auto savedVars = addon.getSavedVariables(); + if (!savedVars.empty()) { + std::string svPath = getSavedVariablesPath(addon); + luaEngine_.loadSavedVariables(svPath); + LOG_DEBUG("AddonManager: loaded saved variables for '", addon.addonName, "'"); + } + bool success = true; for (const auto& filename : addon.files) { - // For Step 1: only load .lua files, skip .xml (frame system not yet implemented) std::string lower = filename; for (char& c : lower) c = static_cast(std::tolower(static_cast(c))); @@ -93,7 +104,18 @@ void AddonManager::update(float deltaTime) { luaEngine_.dispatchOnUpdate(deltaTime); } +void AddonManager::saveAllSavedVariables() { + for (const auto& addon : addons_) { + auto savedVars = addon.getSavedVariables(); + if (!savedVars.empty()) { + std::string svPath = getSavedVariablesPath(addon); + luaEngine_.saveSavedVariables(svPath, savedVars); + } + } +} + void AddonManager::shutdown() { + saveAllSavedVariables(); addons_.clear(); luaEngine_.shutdown(); } diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 634c9471..255695d0 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -3,6 +3,8 @@ #include "game/entity.hpp" #include "core/logger.hpp" #include +#include +#include extern "C" { #include @@ -1046,6 +1048,118 @@ bool LuaEngine::dispatchSlashCommand(const std::string& command, const std::stri return false; } +// ---- SavedVariables serialization ---- + +static void serializeLuaValue(lua_State* L, int idx, std::string& out, int indent); + +static void serializeLuaTable(lua_State* L, int idx, std::string& out, int indent) { + out += "{\n"; + std::string pad(indent + 2, ' '); + lua_pushnil(L); + while (lua_next(L, idx) != 0) { + out += pad; + // Key + if (lua_type(L, -2) == LUA_TSTRING) { + const char* k = lua_tostring(L, -2); + out += "[\""; + for (const char* p = k; *p; ++p) { + if (*p == '"' || *p == '\\') out += '\\'; + out += *p; + } + out += "\"] = "; + } else if (lua_type(L, -2) == LUA_TNUMBER) { + out += "[" + std::to_string(static_cast(lua_tonumber(L, -2))) + "] = "; + } else { + lua_pop(L, 1); + continue; + } + // Value + serializeLuaValue(L, lua_gettop(L), out, indent + 2); + out += ",\n"; + lua_pop(L, 1); + } + out += std::string(indent, ' ') + "}"; +} + +static void serializeLuaValue(lua_State* L, int idx, std::string& out, int indent) { + switch (lua_type(L, idx)) { + case LUA_TNIL: out += "nil"; break; + case LUA_TBOOLEAN: out += lua_toboolean(L, idx) ? "true" : "false"; break; + case LUA_TNUMBER: { + double v = lua_tonumber(L, idx); + char buf[64]; + snprintf(buf, sizeof(buf), "%.17g", v); + out += buf; + break; + } + case LUA_TSTRING: { + const char* s = lua_tostring(L, idx); + out += "\""; + for (const char* p = s; *p; ++p) { + if (*p == '"' || *p == '\\') out += '\\'; + else if (*p == '\n') { out += "\\n"; continue; } + else if (*p == '\r') continue; + out += *p; + } + out += "\""; + break; + } + case LUA_TTABLE: + serializeLuaTable(L, idx, out, indent); + break; + default: + out += "nil"; // Functions, userdata, etc. can't be serialized + break; + } +} + +bool LuaEngine::loadSavedVariables(const std::string& path) { + if (!L_) return false; + std::ifstream f(path); + if (!f.is_open()) return false; // No saved data yet — not an error + std::string content((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + if (content.empty()) return true; + int err = luaL_dostring(L_, content.c_str()); + if (err != 0) { + LOG_WARNING("LuaEngine: error loading saved variables from '", path, "': ", + lua_tostring(L_, -1)); + lua_pop(L_, 1); + return false; + } + return true; +} + +bool LuaEngine::saveSavedVariables(const std::string& path, const std::vector& varNames) { + if (!L_ || varNames.empty()) return false; + std::string output; + for (const auto& name : varNames) { + lua_getglobal(L_, name.c_str()); + if (!lua_isnil(L_, -1)) { + output += name + " = "; + serializeLuaValue(L_, lua_gettop(L_), output, 0); + output += "\n"; + } + lua_pop(L_, 1); + } + if (output.empty()) return true; + + // Ensure directory exists + size_t lastSlash = path.find_last_of("/\\"); + if (lastSlash != std::string::npos) { + std::error_code ec; + std::filesystem::create_directories(path.substr(0, lastSlash), ec); + } + + std::ofstream f(path); + if (!f.is_open()) { + LOG_WARNING("LuaEngine: cannot write saved variables to '", path, "'"); + return false; + } + f << output; + LOG_INFO("LuaEngine: saved variables to '", path, "' (", output.size(), " bytes)"); + return true; +} + bool LuaEngine::executeFile(const std::string& path) { if (!L_) return false; diff --git a/src/addons/toc_parser.cpp b/src/addons/toc_parser.cpp index 33feac39..3b5c03ab 100644 --- a/src/addons/toc_parser.cpp +++ b/src/addons/toc_parser.cpp @@ -19,6 +19,27 @@ bool TocFile::isLoadOnDemand() const { return (it != directives.end()) && it->second == "1"; } +std::vector TocFile::getSavedVariables() const { + std::vector result; + auto it = directives.find("SavedVariables"); + if (it == directives.end()) return result; + // Parse comma-separated variable names + std::string val = it->second; + size_t pos = 0; + while (pos <= val.size()) { + size_t comma = val.find(',', pos); + std::string name = (comma != std::string::npos) ? val.substr(pos, comma - pos) : val.substr(pos); + // Trim whitespace + size_t start = name.find_first_not_of(" \t"); + size_t end = name.find_last_not_of(" \t"); + if (start != std::string::npos) + result.push_back(name.substr(start, end - start + 1)); + if (comma == std::string::npos) break; + pos = comma + 1; + } + return result; +} + std::optional parseTocFile(const std::string& tocPath) { std::ifstream f(tocPath); if (!f.is_open()) return std::nullopt; diff --git a/src/core/application.cpp b/src/core/application.cpp index 3f199678..9c286d0a 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -693,6 +693,7 @@ void Application::setState(AppState newState) { // If we reuse a previously spawned instance without forcing a respawn, appearance (notably hair) can desync. if (addonManager_ && addonsLoaded_) { addonManager_->fireEvent("PLAYER_LEAVING_WORLD"); + addonManager_->saveAllSavedVariables(); } npcsSpawned = false; playerCharacterSpawned = false;