Details
Expansion
Developers
Having issues?

A lightweight framework for building WoW Classic addons.
Embeddable LibStub library with dependency injection, an addon container, client detection for every Classic client, logging, locales, timers and a complete UI toolkit in a purple and gold style. Register models, services and views and build features instead of boilerplate.
The library is embedded in the addons using it, so players never install it themselves. When several addons ship a copy, the newest one wins and serves them all.
Supported clients: Classic Era, Season of Discovery, Anniversary, TBC, Wrath, Cataclysm and Mists of Pandaria Classic.
Dependency injection
The heart of the library. Instead of globals and load order juggling you register three kinds of building blocks by name and let the container wire them together.
| Kind | Register | Resolve | Lives |
|---|---|---|---|
| Service | addon:CreateService(name) | addon:GetService(name) | One lazy singleton per addon |
| View | addon:CreateView(name) | addon:NewView(name) | A fresh instance per call |
| Model | addon:CreateModel(name[, data]) | addon:GetModel(name) | One shared table |
Registration order does not matter. Nothing runs until something actually asks for it, so a service that is never used costs nothing but its file.
What gets injected
Every service, view and class model is created with these members already on it:
self.addon -- the addon container (name, version, client flags, Log, ...)
self:GetService(name) -- resolve another service
self:GetModel(name) -- resolve a model
self:HandleEvent(...) -- register a game event (services only)You never pass dependencies around and you never store references at file scope. Ask for what you need at the moment you need it.
Services
A service is a lazy singleton. The first GetService call creates it and calls Initialize once, which is the place for constants, storage and events.
local PriceService = _G.myAddon:CreateService("price");
function PriceService:Initialize()
-- constants belong here, not at file scope
self.MaxAge = 3600;
self.prices = self.addon:GetSavedTable("Prices");
-- events are registered through the container's dispatcher
self:HandleEvent("AUCTION_HOUSE_SHOW", function()
self:Scan();
end);
self.addon:Log("PriceService", "Initialize", "ready, %d cached prices", self:Count());
end
function PriceService:GetPrice(itemId)
local entry = self.prices[itemId];
if (not entry or time() - entry.timestamp > self.MaxAge) then
return nil;
end
return entry.price;
endServices may use each other freely, because resolution happens at call time and not at load time:
function PriceService:Scan()
local locale = self:GetService("locale");
print(locale:Get("ScanStarted"));
endViews
A view is a type you can instantiate more than once, which is what you want for a dialog or list that exists per tab. NewView returns a fresh table each time.
local ListView = _G.myAddon:CreateView("list");
function ListView:Setup(parent, filterType)
self.filterType = filterType;
self.frame = self:GetService("ui"):CreatePanel(parent);
self:Refresh();
end
function ListView:Refresh()
local entries = self:GetService("price"):GetAll(self.filterType);
-- ... render rows
end-- two independent instances of the same type
local requests = _G.myAddon:NewView("list");
local offers = _G.myAddon:NewView("list");
requests:Setup(window, "request");
offers:Setup(window, "offer");Views get self.addon, self:GetService and self:GetModel. They deliberately have no HandleEvent: let a service own the events and call into the view, so views can be created and dropped freely.
Models
Models hold data. Pass a table to CreateModel and it is stored as is:
_G.myAddon:CreateModel("profession-icons", {
[171] = "Interface\Icons\Trade_BrewPoison",
[164] = "Interface\Icons\Trade_BlackSmithing",
});
local icon = _G.myAddon:GetModel("profession-icons")[171];Leave the second argument out and you get a class model with self.addon, self:GetService and self:GetModel injected, which suits data that has to be built at runtime:
local LocalesModel = _G.myAddon:CreateModel("locales");
function LocalesModel:Create()
return {
["en"] = { ["Greeting"] = "Hello %s" },
["de"] = { ["Greeting"] = "Hallo %s" },
};
endOverriding a built-in service
log, locale, timer and ui are resolved by name like everything else. Register your own service under one of those names and your addon uses yours, while other addons sharing the embedded library keep the original.
local UiService = _G.myAddon:CreateService("ui"); -- replaces the built-in ui serviceEmbedding
Copy the single LibMasterAddons-<version>.lua file from the package straight into your libs folder. No subfolder, no XML, no extra files: the whole library is that one compiled file.
MyAddon/
libs/
LibStub.lua
CallbackHandler-1.0.lua
LibDataBroker-1.1.lua
LibDBIcon-1.0.lua
LibMasterAddons-1.0.1.lua
MyAddon.lua
MyAddon.tocLoad it after LibStub and the LibDBIcon stack. The file name carries the library version, so updating means dropping in the new file and changing that one TOC line:
## OptionalDeps: LibMasterAddons
## X-Embeds: LibMasterAddons
libsLibStub.lua
libsCallbackHandler-1.0.lua
libsLibDataBroker-1.1.lua
libsLibDBIcon-1.0.lua
libsLibMasterAddons-1.0.1.lua
MyAddon.luaBecause the version sits in the file name, two addons can never overwrite each other's copy, and LibStub hands the newest of all loaded copies to everyone.
Quick start
local Lib = LibStub("LibMasterAddons-1.0");
local addon = Lib:CreateAddon({
id = "MyAddon", -- folder and TOC name
name = "My Addon",
shortcut = "|cffDA8CFF[MA]|r ", -- prefix of window titles and chat output
storagePrefix = "MA", -- MA_Logs, MA_Frames, MA_Settings
});
-- SavedVariables are available from here on
addon:OnLoaded(function()
addon:GetService("startup");
end);
_G.myAddon = addon;The container also gives you:
addon.version -- from the TOC
addon.devMode / addon.isDebug -- true while the TOC still says 0.0.1
addon.inCombat -- kept current by the container
addon:GetSavedTable("Settings") -- MA_Settings, created when missing
addon:GenerateString(10) -- random id, e.g. for frame names
addon:HandleEvent("BAG_UPDATE", function(bagId) ... end)
addon:OnCombatStart(function() myWindow:Hide(); end)
addon:OnCombatEnd(function() myWindow:Show(); end)Client detection
Every container carries the flags, so you never parse a build string yourself:
if (addon.isSod) then ... end
if (addon.isWrathAtLeast) then ... end -- Wrath, Cata and MoP
local id = addon.expansionId; -- 1 to 5, Season of Discovery = 102Available: isVanilla, isSod, isBcc, isWrath, isCata, isMop, plus isBccAtLeast, isWrathAtLeast, isCataAtLeast and isMopAtLeast.
Built-in services
Log service
A persistent log grouped by day in <storagePrefix>_Logs, purged after 7 days. Log through the container, read it back for a support window.
self.addon:Log("SyncService", "SendData", "sent %d skills to %s", count, playerName);local log = addon:GetService("log");
log:GetLogText(); -- whole log as text, newest day first
log:ClearLogs(); -- for a clear button
log:GetLogs(); -- raw table, keyed by "2026-08-31"Locale service
Add a model named locales whose Create() returns one table per language. The service picks the full client locale first (zhCN), then the short one (de), then English.
local LocalesModel = _G.myAddon:CreateModel("locales");
function LocalesModel:Create()
return {
["en"] = { ["ItemsFound"] = "%d items found", ["Title"] = "My Addon" },
["de"] = { ["ItemsFound"] = "%d Gegenstände gefunden", ["Title"] = "Mein Addon" },
};
endlocal locale = self:GetService("locale");
locale:Get("Title"); -- "My Addon"
locale:Get("ItemsFound", 12); -- "12 items found"
locale:GetBare("Title"); -- nil when the key is missing
locale:Has("Title"); -- true
locale.localeName; -- "deDE"Get returns the key itself when it is missing, so a forgotten translation shows up in the UI instead of erroring. Without format arguments the text is returned untouched, which keeps placeholders like %player% intact.
Timer service
Named countdowns on a one second ticker, so you need no OnUpdate handler of your own.
local timer = self:GetService("timer");
-- countdown, called immediately and then every second
timer:Start("scan", 30, function(secondsLeft)
button:SetText(timer:FormatTime(secondsLeft));
end);
timer:IsRunning("scan"); -- true
timer:Stop("scan"); -- stop early
-- one shot, replaces a pending wait of the same name
timer:Wait("refresh", 5, function()
self:Refresh();
end);FormatTime renders durations the way cooldown lists want them:
timer:FormatTime(45); -- "45s"
timer:FormatTime(300); -- "5m"
timer:FormatTime(180000); -- "2d 2h 0m"
timer:FormatTime(180000, true); -- "2d 2h" (zero parts left out)
timer:FormatTime(0); -- green "Ready" (locale key CooldownReady)UI service
The complete toolkit behind the Master addon windows. Everything below is themed, so a window built from these parts looks finished without a single texture of your own.
Windows
local ui = self:GetService("ui");
-- name, width, height, title, prefix the title with addon.shortcut, resizable
local window = ui:CreateView("main", 800, 500, "My Addon", true, true);
ui:CreateFlatCloseButton(window, function() window:Hide(); end)
:SetPoint("TOPRIGHT", -12, -8);Movable, resizable from every edge and corner, with a gradient border, ElvUI scale awareness and an automatic clamp back onto the screen. Position and size are stored per window name in <storagePrefix>_Frames and restored on the next login. Optional arguments after resizable are minWidth, minHeight and a table of allowed resize directions. The frame exposes titleLabel and SetMinSize(width, height).
Tabs
CreateTabStrip builds a real tab control that sits on the top border of a content frame. Tabs are clipped to the window width, and when they no longer fit, two arrows appear on the right and the mouse wheel scrolls through them.
local content = ui:CreatePanel(window);
content:SetPoint("TOPLEFT", 12, -70);
content:SetPoint("BOTTOMRIGHT", -12, 12);
local strip = ui:CreateTabStrip(window, content);
local guildTab = strip:AddTab("Guild", function() self:ShowGuild(); end);
strip:AddTab("Requests", function() self:ShowRequests(); end);
strip:AddTab("Offers", function() self:ShowOffers(); end);
strip:SetActive(guildTab);
strip:Layout();The strip takes an optional config table: tabWidth (125), tabGap (4), activeHeight (26), inactiveHeight (24), sideInset (6) and glowTexture for the glow behind the active tab. strip:ScrollToTab(button) brings a tab into view, for example after activating it from code. ui:CreateTab(container, caption) is the plain single tab button when you do not want a whole strip.
Dropdowns
local dropdown = ui:CreateDropdown(window, 200, {
{ value = 0, text = "All professions" },
{ value = 333, text = "Enchanting" },
{ value = 755, text = "Jewelcrafting" },
}, function(value, text)
self.professionId = value;
self:Refresh();
end);
dropdown:SetPoint("TOPLEFT", 12, -40);
dropdown:SetValue(333); -- select from code
dropdown:GetValue(); -- 333
dropdown:SetItems(newItems); -- rebuild the list, e.g. after a filter changeItem text may contain escape sequences, so an icon in front of an entry is simply "|T" .. icon .. ":16|t " .. name. Opening a dropdown closes any other open one automatically.
Tooltips
-- static text
ui:BindTooltip(button, "Scan the auction house");
-- computed on hover, so it always shows the current state
ui:BindTooltip(button, function()
return self.isScanning and "Scanning..." or "Scan the auction house";
end);
-- custom anchor
ui:BindTooltip(icon, "Requires Mining", "ANCHOR_RIGHT");BindTooltip hooks the scripts instead of replacing them, so existing OnEnter and OnLeave handlers keep working. Returning nil or an empty string suppresses the tooltip for that hover.
Buttons
ui:CreateButton(window, "Apply", function() self:Apply(); end); -- classic
ui:CreateFlatButton(window, "Cancel", function() window:Hide(); end); -- flat, themed
ui:CreateFlatSquareButton(box, "OK", function() self:Confirm(); end, 18); -- small square
ui:CreateFlatCloseButton(window, function() window:Hide(); end); -- X
ui:CreateHeaderIconButton(window, "Interface\Icons\INV_Misc_Note_01",
"Settings", function() self:OpenSettings(); end); -- icon with tooltipLists and scrolling
local panel, content, scrollFrame = ui:CreateScrollFrame(window);
panel:SetPoint("TOPLEFT", 12, -70);
panel:SetPoint("BOTTOMRIGHT", -12, 12);
for index, entry in ipairs(entries) do
local row = ui:CreatePanel(content);
row:SetHeight(20);
ui:SetRowColor(row, index); -- alternating background
ui:AttachHoverGlow(row); -- purple hover border and backdrop
endAttachHoverGlow takes a second argument with extra mouse enabled child frames that should keep the glow alive, for example per column hover regions. Set row.hoverGlowDisabled = true on rows without a click action.
Input
local search = ui:CreateEditBox(window, 200);
search:SetPlaceholder("Search");
search:SetText("");
search:GetText();
search.editBox:SetScript("OnEnterPressed", function(box) box:ClearFocus(); end);
local quantity = ui:CreateNumberEditBox(window, 60); -- right aligned, for numbersMinimap button
ui:CreateMinimapIcon({
icon = "Interface\Icons\INV_Misc_Book_09",
overlayText = "MA",
onClick = function(button)
if (button == "LeftButton" and IsShiftKeyDown()) then
self:OpenSettings();
elseif (button == "LeftButton") then
self.addon.mainView:Toggle();
end
end,
onTooltip = { "My Addon", "|cff999999Left Click:|r toggle the window" },
onHidden = function() settingsCheckbox:SetChecked(false); end,
});
ui:SetMinimapIconShown(false); -- from a settings checkbox
ui:IsMinimapIconShown();Ctrl and right click hides the button, and position and hide state persist in <storagePrefix>_Settings.minimapButton. onTooltip also accepts a function receiving the tooltip.
Compatibility
LibMasterAddons-1.0 is the API contract: members are only ever added, never removed or changed in meaning. The minor version in the file name is stamped by the build, and the newest embedded copy serves every addon that ships one. Breaking changes would ship as a new major that can be embedded side by side.
Feedback
Comments on the CurseForge project page are welcome.
You must be logged in to leave a comment.