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 <AddonDir>/<AddonName>.lua.saved

Updated HelloWorld addon to track login count across sessions.
This commit is contained in:
Kelsi 2026-03-20 12:22:50 -07:00
parent 5ea5588c14
commit 062cfd1e4a
9 changed files with 177 additions and 2 deletions

View file

@ -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 <message>"))
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.")

View file

@ -1,4 +1,5 @@
## Interface: 30300
## Title: Hello World
## Notes: Test addon for the WoWee addon system
## SavedVariables: HelloWorldDB
HelloWorld.lua

View file

@ -25,11 +25,14 @@ public:
LuaEngine* getLuaEngine() { return &luaEngine_; }
bool isInitialized() const { return luaEngine_.isInitialized(); }
void saveAllSavedVariables();
private:
LuaEngine luaEngine_;
std::vector<TocFile> addons_;
bool loadAddon(const TocFile& addon);
std::string getSavedVariablesPath(const TocFile& addon) const;
};
} // namespace wowee::addons

View file

@ -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<std::string>& varNames);
lua_State* getState() { return L_; }
bool isInitialized() const { return L_ != nullptr; }

View file

@ -17,6 +17,7 @@ struct TocFile {
std::string getTitle() const;
std::string getInterface() const;
bool isLoadOnDemand() const;
std::vector<std::string> getSavedVariables() const;
};
std::optional<TocFile> parseTocFile(const std::string& tocPath);

View file

@ -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<char>(std::tolower(static_cast<unsigned char>(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();
}

View file

@ -3,6 +3,8 @@
#include "game/entity.hpp"
#include "core/logger.hpp"
#include <cstring>
#include <fstream>
#include <filesystem>
extern "C" {
#include <lua.h>
@ -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<long long>(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<char>(f)), std::istreambuf_iterator<char>());
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<std::string>& 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;

View file

@ -19,6 +19,27 @@ bool TocFile::isLoadOnDemand() const {
return (it != directives.end()) && it->second == "1";
}
std::vector<std::string> TocFile::getSavedVariables() const {
std::vector<std::string> 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<TocFile> parseTocFile(const std::string& tocPath) {
std::ifstream f(tocPath);
if (!f.is_open()) return std::nullopt;

View file

@ -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;